diff --git a/docs/architecture-cercles.md b/docs/architecture-cercles.md
index 7e462ab27b12337cf64377e499cc7b5b5bda4819..4f0a224e462e3aaa1f1d8f5647c204321b064778 100644
--- a/docs/architecture-cercles.md
+++ b/docs/architecture-cercles.md
@@ -156,17 +156,40 @@ elles-mêmes dans `report/views/`, donc Cercle 2).
## Distinguer un module Cercle 1 vs Cercle 2
-Test concret : si on supprime ce module, est-ce que la phrase
-*« Picarones est un banc d'essai pour pipelines OCR/HTR/VLM »* reste
-vraie ?
-
-- **Oui** → Cercle 2 (le produit existe sans ce module).
-- **Non** → Cercle 1 (le module participe à la définition même).
+Critère **corrigé** (alignement architecture hexagonale / DDD) :
+
+> **Cercle 1 = abstractions et logique métier du domaine,
+> indépendantes de l'interface utilisateur. Stables entre versions
+> mineures.**
+>
+> **Cercle 2 = adapters concrets (engines, LLM, modules de référence),
+> couches d'interface (report, cli, web), et mesures au-delà du noyau
+> (measurements). Maintenus mais peuvent évoluer.**
+
+Le critère « si on supprime ce module, le produit reste viable »
+mélange deux questions distinctes (« est-ce indispensable ? » et
+« est-ce une abstraction stable ? »). On préfère le critère DDD :
+
+- **Cercle 1** : abstractions et orchestration qui définissent ce
+ que Picarones *est* logiquement (corpus, BaseModule, registres,
+ runner). Indépendant de l'interface utilisateur.
+- **Cercle 2** : ce qui rend le domaine utilisable concrètement
+ (adapters, mesures, présentation HTML, CLI).
Exemple :
-- Sans `corpus.py` : impossible de charger un corpus → Cercle 1.
-- Sans `confusion.py` : on a toujours un bench fonctionnel sans
- matrice de confusion → Cercle 2.
+- `corpus.py` → Cercle 1 (abstraction du domaine).
+- `runner.py` → Cercle 1 (orchestration du domaine).
+- `confusion.py` → Cercle 2 (mesure au-delà du noyau, dans
+ ``measurements/``).
+- `report/generator.py` → Cercle 2 (couche de présentation, même si
+ essentielle à l'usage pratique).
+- `engines/tesseract.py` → Cercle 2 (adapter concret).
+
+> Note : la convention « `base.py` dans le dossier du concept »
+> (`engines/base.py`, `llm/base.py`) reste dans son dossier d'origine.
+> Ces contrats sont logiquement Cercle 1 (API publique stable) mais
+> physiquement co-localisés avec leurs implémentations, comme dans
+> Django, SQLAlchemy, FastAPI. Convention universelle Python.
- Sans `taxonomy_intra_doc.py` : on a toujours un bench complet et
utile → Cercle 3.
diff --git a/picarones/core/baseline_comparison.py b/picarones/core/baseline_comparison.py
index 22f021aaceb4952d7f96271e325b6864b50a9258..25de1acf7ab62e1b774ea64f0c3334e25d1bca3b 100644
--- a/picarones/core/baseline_comparison.py
+++ b/picarones/core/baseline_comparison.py
@@ -1,229 +1,19 @@
-"""Comparaison à la baseline historique — Sprint 73 (A.I.3).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.baseline_comparison`.
-Sprint 73 — chantier 2 d'A.I.3 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.baseline_comparison import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-L'historique SQLite (``picarones/core/history.py``, Sprint 8)
-existe mais aucun détecteur narratif ne le lit. Ce module fournit
-la couche de calcul qui répond à *« comment ce moteur se
-comporte-t-il sur ce corpus, **par rapport à ses runs précédents
-de mon institution** ? »*.
-
-Sortie typique
---------------
-Un dict par moteur :
-
-.. code-block:: python
-
- {
- "engine_name": "tesseract",
- "cer_current": 0.052,
- "cer_historical_mean": 0.041,
- "cer_historical_median": 0.040,
- "n_runs": 12,
- "absolute_delta": 0.011,
- "relative_delta": 0.268, # +26,8 % vs moyenne
- "off_baseline": True,
- }
-
-Le détecteur narratif ``engine_off_baseline`` (Sprint 73)
-consomme cette structure pour émettre des Facts.
-
-Garde-fous
-----------
-- ``min_runs`` (défaut 5) : si l'historique pour le moteur×corpus
- contient moins de runs, on retourne ``None`` plutôt que de
- comparer à un échantillon trop petit.
-- ``corpus_name`` est utilisé pour ne comparer qu'aux runs **du
- même corpus** (sinon on compare des pommes et des oranges :
- registres paroissiaux vs imprimés modernes).
-- Le run courant lui-même n'est pas inclus dans la baseline (on
- passe le ``current_run_id`` à exclure).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import statistics
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-def compute_engine_baseline(
- history,
- engine_name: str,
- corpus_name: str,
- current_cer: float,
- *,
- current_run_id: Optional[str] = None,
- min_runs: int = 5,
- relative_delta_threshold: float = 0.20,
-) -> Optional[dict]:
- """Compare le CER courant d'un moteur à sa moyenne historique
- sur le **même corpus**.
-
- Parameters
- ----------
- history:
- Instance de ``BenchmarkHistory`` (ou compatible : doit
- exposer une méthode ``query(engine, corpus, limit)``
- retournant une liste d'``HistoryEntry`` avec attribut
- ``cer_mean`` et ``run_id``).
- engine_name:
- Nom du moteur dont on calcule la baseline.
- corpus_name:
- Nom du corpus — limite la comparaison aux runs antérieurs
- sur ce même corpus.
- current_cer:
- CER moyen observé dans le run courant.
- current_run_id:
- Si fourni, le run portant cet identifiant est exclu de la
- baseline (utile quand le run courant est déjà enregistré
- dans l'historique avant d'appeler ce calcul).
- min_runs:
- Nombre minimum de runs historiques pour que la
- comparaison soit considérée fiable. Sous ce seuil, on
- retourne ``None``.
- relative_delta_threshold:
- Seuil au-delà duquel ``off_baseline`` vaut ``True``
- (défaut : 0,20 = 20 % d'écart relatif).
-
- Returns
- -------
- Optional[dict]
- ``None`` si :
- - moins de ``min_runs`` runs historiques disponibles
- - ``current_cer`` est ``None`` ou négatif
- - tous les CER historiques sont ``None``
-
- Sinon, dict avec les champs documentés dans le module.
- """
- if current_cer is None or current_cer < 0:
- return None
- try:
- entries = history.query(
- engine=engine_name, corpus=corpus_name, limit=1000,
- )
- except Exception as exc: # pragma: no cover — défense
- logger.warning(
- "[baseline_comparison] query history a levé : %s", exc,
- )
- return None
-
- historical_cers: list[float] = []
- for entry in entries:
- if current_run_id is not None and entry.run_id == current_run_id:
- continue
- cer = entry.cer_mean
- if cer is None or cer < 0:
- continue
- historical_cers.append(float(cer))
-
- if len(historical_cers) < min_runs:
- return None
-
- mean = statistics.fmean(historical_cers)
- median = statistics.median(historical_cers)
- absolute_delta = current_cer - mean
- if mean > 0:
- relative_delta = absolute_delta / mean
- elif current_cer == 0:
- relative_delta = 0.0
- else:
- # Baseline à 0 mais CER courant > 0 : écart infini —
- # convention : on signale comme off_baseline avec
- # relative_delta = None.
- relative_delta = None
-
- off_baseline = (
- relative_delta is not None
- and abs(relative_delta) > relative_delta_threshold
- )
-
- return {
- "engine_name": engine_name,
- "corpus_name": corpus_name,
- "cer_current": float(current_cer),
- "cer_historical_mean": mean,
- "cer_historical_median": median,
- "n_runs": len(historical_cers),
- "absolute_delta": absolute_delta,
- "relative_delta": relative_delta,
- "off_baseline": off_baseline,
- }
-
-
-def compute_corpus_difficulty_percentile(
- history,
- current_difficulty: float,
- *,
- min_runs: int = 5,
-) -> Optional[dict]:
- """Place la difficulté du corpus courant dans la distribution
- des difficultés historiques.
-
- Lit les difficultés stockées dans ``HistoryEntry.metadata``
- sous la clé ``difficulty`` (convention de
- ``picarones/core/difficulty.py``).
-
- Returns
- -------
- Optional[dict]
- ``{
- "current_difficulty": float,
- "percentile": float, # 0..100
- "n_runs": int,
- "median_historical": float,
- "harder_than_usual": bool, # percentile > 75
- "easier_than_usual": bool, # percentile < 25
- }``
- ou ``None`` si moins de ``min_runs`` runs historiques ont
- une difficulté enregistrée.
- """
- if current_difficulty is None:
- return None
- try:
- entries = history.query(limit=1000)
- except Exception as exc: # pragma: no cover
- logger.warning(
- "[baseline_comparison] query history a levé : %s", exc,
- )
- return None
-
- historical_difficulties: list[float] = []
- for entry in entries:
- diff = entry.metadata.get("difficulty") if entry.metadata else None
- if diff is None:
- continue
- try:
- historical_difficulties.append(float(diff))
- except (TypeError, ValueError):
- continue
-
- if len(historical_difficulties) < min_runs:
- return None
-
- sorted_diff = sorted(historical_difficulties)
- n = len(sorted_diff)
- # Percentile = % de corpus historiques de difficulté ≤
- # current_difficulty. Convention courante (P_i = i/n × 100).
- n_below = sum(1 for d in sorted_diff if d <= current_difficulty)
- percentile = (n_below / n) * 100.0
- median = statistics.median(sorted_diff)
-
- return {
- "current_difficulty": float(current_difficulty),
- "percentile": percentile,
- "n_runs": n,
- "median_historical": median,
- "harder_than_usual": percentile > 75.0,
- "easier_than_usual": percentile < 25.0,
- }
-
+from picarones.measurements.baseline_comparison import * # noqa: F401, F403
-__all__ = [
- "compute_engine_baseline",
- "compute_corpus_difficulty_percentile",
-]
+import picarones.measurements.baseline_comparison as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/builtin_hooks.py b/picarones/core/builtin_hooks.py
index ebb3481d460e89241bf2b02dc77930c7dc1f7743..f76ce39b1da0a70b0574fa0e29f28b3e256c3239 100644
--- a/picarones/core/builtin_hooks.py
+++ b/picarones/core/builtin_hooks.py
@@ -1,582 +1,19 @@
-"""Enregistrement des hooks de métriques natifs de Picarones.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.builtin_hooks`.
-Chantier 2 du plan d'évolution post-Sprint 97.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.builtin_hooks import ...``) de continuer
+à fonctionner sans modification.
-Ce module **migre** les 12 hooks document-level et 12 agrégateurs
-corpus-level qui étaient codés en dur dans
-``picarones.core.runner._compute_document_result`` et autour de la
-boucle d'agrégation (lignes 794-827 du runner pré-chantier-2).
-
-Approche additive — rétrocompat stricte
----------------------------------------
-Tous les hooks sont enregistrés sur les profils ``standard``,
-``philological``, ``diagnostics`` et ``full`` (i.e. activés par
-défaut quand le runner est appelé sans paramètre ``profile``). Le
-profil ``minimal`` n'active aucun hook (pour bench massif où seul
-CER/WER comptent). Les profils ``economics`` et ``pipeline`` sont
-réservés pour des hooks futurs.
-
-L'import de ce module **suffit** à peupler les registres :
-:mod:`picarones.core.metric_hooks` se contente d'exposer les
-décorateurs ; le runner ne dépend que d'une seule fonction —
-``select_document_hooks(profile)`` — pour découvrir les hooks actifs.
-
-Liste complète des hooks (Sprint d'origine)
--------------------------------------------
-**Document-level** (12) :
-
-- ``confusion`` (Sprint 5) — ``confusion_matrix``
-- ``char_scores`` (Sprint 5) — ``char_scores``
-- ``taxonomy`` (Sprint 5) — ``taxonomy``
-- ``structure`` (Sprint 5) — ``structure``
-- ``image_quality`` (Sprint 5) — ``image_quality``
-- ``line_metrics`` (Sprint 10) — ``line_metrics``
-- ``hallucination`` (Sprint 10) — ``hallucination_metrics``
-- ``calibration`` (Sprint 42) — ``calibration_metrics``
-- ``philological`` (Sprint 61) — ``philological_metrics``
-- ``searchability`` (Sprint 86) — ``searchability_metrics``
-- ``numerical_sequences`` (Sprint 86) — ``numerical_sequence_metrics``
-- ``readability`` (Sprint 87) — ``readability_metrics``
-
-**Corpus-level** (12) : un agrégateur par hook documentaire,
-remplissant le champ ``aggregated_*`` correspondant du
-``EngineReport``.
-
-Le hook ``ner`` (Sprint 40) reste hors de ce mécanisme : il dépend
-d'un ``EntityExtractor`` injecté à la main par l'utilisateur, ce
-qui n'entre pas dans la sémantique des profils.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from collections import Counter
-from typing import Any, Optional
-
-from picarones.core.metric_hooks import (
- PROFILE_DIAGNOSTICS,
- PROFILE_FULL,
- PROFILE_PHILOLOGICAL,
- PROFILE_STANDARD,
- register_corpus_aggregator,
- register_document_metric,
-)
-
-logger = logging.getLogger(__name__)
-
-
-# Profils dans lesquels les 12 hooks "standard" s'activent. Égalent
-# par construction le comportement runner pré-chantier-2 ; le profil
-# ``minimal`` est volontairement absent.
-_STANDARD_PROFILES = (
- PROFILE_STANDARD,
- PROFILE_PHILOLOGICAL,
- PROFILE_DIAGNOSTICS,
- PROFILE_FULL,
-)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Helper de calibration (déplacé depuis runner.py — chantier 2)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def calibration_from_engine_result(
- ground_truth: str,
- token_confidences: list,
-) -> Optional[dict]:
- """Aligne les ``token_confidences`` du moteur sur la GT (bag-of-words)
- pour produire les listes parallèles ``confidences`` / ``is_correct``,
- puis appelle ``compute_calibration_metrics`` (Sprint 39).
-
- Convention d'alignement (proxy bag-of-words avec multiplicité, comme
- ``oracle_token_recall`` du Sprint 35) : un token de l'hypothèse est
- "correct" si la GT contient encore une occurrence de ce token.
-
- Les confidences ``> 1.0`` sont supposées en pourcentage et
- normalisées à ``[0, 1]``. Les confidences négatives (Tesseract met
- -1 pour les non-mots) sont ignorées.
- """
- from picarones.core.calibration import compute_calibration_metrics
-
- if not token_confidences:
- return None
-
- gt_counter = Counter((ground_truth or "").split())
- confidences: list[float] = []
- is_correct: list[int] = []
-
- for tc in token_confidences:
- if not isinstance(tc, dict):
- continue
- token = str(tc.get("token", ""))
- if not token:
- continue
- try:
- conf = float(tc.get("confidence"))
- except (TypeError, ValueError):
- continue
- if conf < 0:
- continue
- if conf > 1.0:
- conf = conf / 100.0
- if not 0.0 <= conf <= 1.0:
- continue
- if gt_counter[token] > 0:
- is_correct.append(1)
- gt_counter[token] -= 1
- else:
- is_correct.append(0)
- confidences.append(conf)
-
- if not confidences:
- return None
- return compute_calibration_metrics(confidences, is_correct)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Document-level hooks (12)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@register_document_metric(
- name="confusion",
- attribute="confusion_matrix",
- profiles=_STANDARD_PROFILES,
- requires_success=True,
-)
-def _confusion_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.confusion import build_confusion_matrix
- return build_confusion_matrix(ground_truth, hypothesis).as_dict()
-
-
-@register_document_metric(
- name="char_scores",
- attribute="char_scores",
- profiles=_STANDARD_PROFILES,
- requires_success=True,
-)
-def _char_scores_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.char_scores import (
- compute_diacritic_score,
- compute_ligature_score,
- )
- lig = compute_ligature_score(ground_truth, hypothesis)
- diac = compute_diacritic_score(ground_truth, hypothesis)
- return {"ligature": lig.as_dict(), "diacritic": diac.as_dict()}
-
-
-@register_document_metric(
- name="taxonomy",
- attribute="taxonomy",
- profiles=_STANDARD_PROFILES,
- requires_success=True,
-)
-def _taxonomy_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.taxonomy import classify_errors
- return classify_errors(ground_truth, hypothesis).as_dict()
-
-
-@register_document_metric(
- name="structure",
- attribute="structure",
- profiles=_STANDARD_PROFILES,
- requires_success=True,
-)
-def _structure_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.structure import analyze_structure
- return analyze_structure(ground_truth, hypothesis).as_dict()
-
-
-@register_document_metric(
- name="line_metrics",
- attribute="line_metrics",
- profiles=_STANDARD_PROFILES,
- requires_success=True,
-)
-def _line_metrics_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.line_metrics import compute_line_metrics
- return compute_line_metrics(ground_truth, hypothesis).as_dict()
-
-
-@register_document_metric(
- name="hallucination",
- attribute="hallucination_metrics",
- profiles=_STANDARD_PROFILES,
- requires_success=True,
-)
-def _hallucination_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.hallucination import compute_hallucination_metrics
- return compute_hallucination_metrics(ground_truth, hypothesis).as_dict()
-
-
-@register_document_metric(
- name="calibration",
- attribute="calibration_metrics",
- profiles=_STANDARD_PROFILES,
- requires_token_confidences=True,
-)
-def _calibration_hook(*, ground_truth, ocr_result, **_):
- return calibration_from_engine_result(
- ground_truth, ocr_result.token_confidences,
- )
-
-
-@register_document_metric(
- name="image_quality",
- attribute="image_quality",
- profiles=_STANDARD_PROFILES,
- # Pas de requires_success : on analyse l'image quel que soit le
- # résultat OCR (pour comparer un échec OCR à la qualité image).
-)
-def _image_quality_hook(*, image_path, **_):
- from picarones.core.image_quality import analyze_image_quality
- iq = analyze_image_quality(image_path)
- if iq.error is not None:
- return None
- return iq.as_dict()
-
-
-@register_document_metric(
- name="philological",
- attribute="philological_metrics",
- profiles=_STANDARD_PROFILES,
- # Pas de requires_success : le runner pré-chantier-2 calculait
- # même sur échec OCR (avec hyp=""). Les modules philologiques
- # retournent ``None`` quand la GT n'a pas de signal exploitable
- # — comportement adaptive intact.
-)
-def _philological_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.philological_runner import compute_philological_metrics
- return compute_philological_metrics(ground_truth, hypothesis)
-
-
-@register_document_metric(
- name="searchability",
- attribute="searchability_metrics",
- profiles=_STANDARD_PROFILES,
-)
-def _searchability_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.searchability_runner import compute_searchability_metrics
- return compute_searchability_metrics(ground_truth, hypothesis)
-
-
-@register_document_metric(
- name="numerical_sequences",
- attribute="numerical_sequence_metrics",
- profiles=_STANDARD_PROFILES,
-)
-def _numerical_sequences_hook(*, ground_truth, hypothesis, **_):
- from picarones.core.numerical_sequences_runner import (
- compute_numerical_sequence_metrics_adaptive,
- )
- return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis)
-
-
-@register_document_metric(
- name="readability",
- attribute="readability_metrics",
- profiles=_STANDARD_PROFILES,
-)
-def _readability_hook(*, ground_truth, hypothesis, corpus_lang, **_):
- from picarones.core.readability_runner import compute_readability_metrics
- return compute_readability_metrics(ground_truth, hypothesis, lang=corpus_lang)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Corpus-level aggregators (12)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@register_corpus_aggregator(
- name="confusion",
- attribute="aggregated_confusion",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_confusion(doc_results: list) -> Optional[dict]:
- from picarones.core.confusion import (
- ConfusionMatrix, aggregate_confusion_matrices,
- )
- matrices = [
- ConfusionMatrix(**dr.confusion_matrix)
- for dr in doc_results
- if dr.confusion_matrix is not None
- ]
- if not matrices:
- return None
- return aggregate_confusion_matrices(matrices).as_compact_dict(min_count=2)
-
-
-@register_corpus_aggregator(
- name="char_scores",
- attribute="aggregated_char_scores",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_char_scores(doc_results: list) -> Optional[dict]:
- from picarones.core.char_scores import (
- DiacriticScore,
- LigatureScore,
- aggregate_diacritic_scores,
- aggregate_ligature_scores,
- )
- lig_scores = [
- LigatureScore(**dr.char_scores["ligature"])
- for dr in doc_results
- if dr.char_scores is not None
- ]
- diac_scores = [
- DiacriticScore(**dr.char_scores["diacritic"])
- for dr in doc_results
- if dr.char_scores is not None
- ]
- if not lig_scores:
- return None
- return {
- "ligature": aggregate_ligature_scores(lig_scores),
- "diacritic": aggregate_diacritic_scores(diac_scores),
- }
-
-
-@register_corpus_aggregator(
- name="taxonomy",
- attribute="aggregated_taxonomy",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_taxonomy(doc_results: list) -> Optional[dict]:
- from picarones.core.taxonomy import TaxonomyResult, aggregate_taxonomy
- results = [
- TaxonomyResult.from_dict(dr.taxonomy)
- for dr in doc_results
- if dr.taxonomy is not None
- ]
- if not results:
- return None
- return aggregate_taxonomy(results)
-
-
-@register_corpus_aggregator(
- name="structure",
- attribute="aggregated_structure",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_structure(doc_results: list) -> Optional[dict]:
- from picarones.core.structure import StructureResult, aggregate_structure
- results = [
- StructureResult.from_dict(dr.structure)
- for dr in doc_results
- if dr.structure is not None
- ]
- if not results:
- return None
- return aggregate_structure(results)
-
-
-@register_corpus_aggregator(
- name="image_quality",
- attribute="aggregated_image_quality",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_image_quality(doc_results: list) -> Optional[dict]:
- from picarones.core.image_quality import (
- ImageQualityResult, aggregate_image_quality,
- )
- results = [
- ImageQualityResult.from_dict(dr.image_quality)
- for dr in doc_results
- if dr.image_quality is not None
- ]
- if not results:
- return None
- return aggregate_image_quality(results)
-
-
-@register_corpus_aggregator(
- name="line_metrics",
- attribute="aggregated_line_metrics",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_line_metrics(doc_results: list) -> Optional[dict]:
- from picarones.core.line_metrics import (
- LineMetrics, aggregate_line_metrics,
- )
- results = [
- LineMetrics.from_dict(dr.line_metrics)
- for dr in doc_results
- if dr.line_metrics is not None
- ]
- if not results:
- return None
- return aggregate_line_metrics(results)
-
-
-@register_corpus_aggregator(
- name="hallucination",
- attribute="aggregated_hallucination",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_hallucination(doc_results: list) -> Optional[dict]:
- from picarones.core.hallucination import (
- HallucinationMetrics, aggregate_hallucination_metrics,
- )
- results = [
- HallucinationMetrics.from_dict(dr.hallucination_metrics)
- for dr in doc_results
- if dr.hallucination_metrics is not None
- ]
- if not results:
- return None
- return aggregate_hallucination_metrics(results)
-
-
-@register_corpus_aggregator(
- name="calibration",
- attribute="aggregated_calibration",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_calibration(doc_results: list) -> Optional[dict]:
- """Agrège la calibration micro sur tous les docs.
-
- Recalcule ECE/MCE à partir de la **somme des bins** de chaque
- document : pour chaque bin, on additionne ``count``, on agrège la
- confiance moyenne pondérée par count, et on agrège l'accuracy
- pondérée par count. L'ECE micro est ensuite la moyenne pondérée
- par bin de ``|conf - acc|``.
-
- Comportement déplacé verbatim depuis ``runner._aggregate_calibration``
- (chantier 2 — rétrocompat octet par octet du sérialisé).
- """
- relevant = [
- dr for dr in doc_results
- if dr.calibration_metrics is not None
- and (dr.calibration_metrics.get("bins") or [])
- ]
- if not relevant:
- return None
-
- n_bins = relevant[0].calibration_metrics.get("n_bins", 10)
- sum_conf: list[float] = [0.0] * n_bins
- sum_acc: list[float] = [0.0] * n_bins
- counts: list[int] = [0] * n_bins
- bin_lows: list[float] = [
- b["bin_low"] for b in relevant[0].calibration_metrics["bins"]
- ]
- bin_highs: list[float] = [
- b["bin_high"] for b in relevant[0].calibration_metrics["bins"]
- ]
-
- for dr in relevant:
- m = dr.calibration_metrics
- if m.get("n_bins") != n_bins:
- logger.warning(
- "[aggregate_calibration] %s : n_bins=%s ≠ %s — ignoré",
- dr.doc_id, m.get("n_bins"), n_bins,
- )
- continue
- for k, b in enumerate(m["bins"]):
- n = int(b.get("count") or 0)
- if n == 0:
- continue
- counts[k] += n
- sum_conf[k] += float(b.get("avg_confidence") or 0.0) * n
- sum_acc[k] += float(b.get("accuracy") or 0.0) * n
-
- total = sum(counts)
- if total == 0:
- return None
-
- bins: list[dict] = []
- ece = 0.0
- mce = 0.0
- for k in range(n_bins):
- n = counts[k]
- if n == 0:
- bins.append({
- "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
- "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
- "avg_confidence": None,
- "accuracy": None,
- "count": 0,
- "gap": None,
- })
- continue
- avg_conf = sum_conf[k] / n
- accuracy = sum_acc[k] / n
- gap = abs(avg_conf - accuracy)
- bins.append({
- "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
- "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
- "avg_confidence": avg_conf,
- "accuracy": accuracy,
- "count": n,
- "gap": gap,
- })
- ece += (n / total) * gap
- if gap > mce:
- mce = gap
-
- overall_acc = sum(sum_acc) / total
- overall_conf = sum(sum_conf) / total
-
- return {
- "ece": ece,
- "mce": mce,
- "n_bins": n_bins,
- "n_predictions": total,
- "overall_accuracy": overall_acc,
- "overall_confidence": overall_conf,
- "bins": bins,
- "doc_count": len(relevant),
- }
-
-
-@register_corpus_aggregator(
- name="philological",
- attribute="aggregated_philological",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_philological(doc_results: list) -> Optional[dict]:
- from picarones.core.philological_runner import aggregate_philological_metrics
- return aggregate_philological_metrics(
- [dr.philological_metrics for dr in doc_results],
- )
-
-
-@register_corpus_aggregator(
- name="searchability",
- attribute="aggregated_searchability",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_searchability(doc_results: list) -> Optional[dict]:
- from picarones.core.searchability_runner import aggregate_searchability_metrics
- return aggregate_searchability_metrics(
- [dr.searchability_metrics for dr in doc_results],
- )
-
-
-@register_corpus_aggregator(
- name="numerical_sequences",
- attribute="aggregated_numerical_sequences",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]:
- from picarones.core.numerical_sequences_runner import (
- aggregate_numerical_sequence_metrics,
- )
- return aggregate_numerical_sequence_metrics(
- [dr.numerical_sequence_metrics for dr in doc_results],
- )
-
-
-@register_corpus_aggregator(
- name="readability",
- attribute="aggregated_readability",
- profiles=_STANDARD_PROFILES,
-)
-def _aggregate_readability(doc_results: list) -> Optional[dict]:
- from picarones.core.readability_runner import aggregate_readability_metrics
- return aggregate_readability_metrics(
- [dr.readability_metrics for dr in doc_results],
- )
-
+from picarones.measurements.builtin_hooks import * # noqa: F401, F403
-__all__ = ["calibration_from_engine_result"]
+import picarones.measurements.builtin_hooks as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/calibration.py b/picarones/core/calibration.py
index 35819b20332e0b915b4cb13a5b9c55555f50c392..030a9d90f78b6fbc26ab7a399347efa01123e560 100644
--- a/picarones/core/calibration.py
+++ b/picarones/core/calibration.py
@@ -1,323 +1,19 @@
-"""Calibration des moteurs : ECE, MCE, reliability diagram.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.calibration`.
-Sprint 39 — A.II.1.b du plan d'évolution 2026 : couche de calcul pure.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.calibration import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Tous les moteurs OCR cibles fournissent une confidence par token ou par
-ligne (Tesseract via le ``tsv``, Pero OCR via le ``PageLayout``,
-Mistral OCR via ``confidence``, Google Vision via ``Word.confidence``).
-La question naturelle pour un workflow patrimonial est : *« quand le
-moteur dit qu'il est sûr, est-il vraiment sûr ? »*. Pour une équipe
-qui doit vérifier humainement un corpus de 50 000 pages, la différence
-entre vérifier 100 % vs 15 % du volume est l'effet de la calibration.
-
-Ce module fournit les trois mesures classiques :
-
-- **Expected Calibration Error (ECE)** — moyenne pondérée par bin de
- l'écart absolu entre confiance moyenne et précision moyenne.
- ``ECE = 0`` ↔ moteur parfaitement calibré ; ``ECE`` élevé ↔ écart
- systématique entre confiance affichée et fiabilité réelle.
-- **Maximum Calibration Error (MCE)** — max de cet écart sur les bins.
- Utile pour repérer le pire mensonge du moteur (ex. il dit toujours
- 95 % de confiance et il a tort une fois sur deux).
-- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf,
- accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en
- Chart.js côté navigateur dans un sprint suivant.
-
-Stratégie de découpage
-----------------------
-Comme pour le NER (Sprint 38) et la divergence (Sprints 35-37),
-on découpe :
-
-- **Sprint 39** (ici) — couche de calcul pure : entrée = deux listes
- parallèles ``confidences`` (∈ [0, 1]) et ``is_correct`` (bool/0-1).
- Aucune dépendance externe.
-- **Sprint à venir** — exposition de ``token_confidences`` sur
- ``EngineResult``, alignement caractère/token avec la GT pour produire
- ``is_correct``, intégration dans le runner et vue HTML reliability.
-
-Ce qui est explicitement hors scope
------------------------------------
-Ce sprint ne touche **aucun adaptateur OCR**. Aucune confiance n'est
-extraite ; on calcule uniquement à partir de séquences de prédictions
-fournies en entrée. C'est ce qui permet de tester rigoureusement les
-invariants mathématiques (ECE = 0 ↔ calibré, ECE = |bias| pour bias
-constant, etc.) sans dépendre d'un backend.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-from typing import Iterable
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Modèle de données
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@dataclass(frozen=True)
-class CalibrationBin:
- """Un bin du reliability diagram.
-
- Attributs
- ---------
- bin_low, bin_high:
- Bornes du bin sur l'axe de confiance (``[bin_low, bin_high)`` —
- sauf le dernier bin qui inclut ``1.0``).
- avg_confidence:
- Moyenne des confidences des prédictions tombées dans le bin.
- ``None`` si le bin est vide.
- accuracy:
- Fraction de prédictions correctes dans le bin (``∈ [0, 1]``).
- ``None`` si le bin est vide.
- count:
- Nombre de prédictions dans le bin.
- """
-
- bin_low: float
- bin_high: float
- avg_confidence: float | None
- accuracy: float | None
- count: int
-
- @property
- def gap(self) -> float | None:
- """Écart absolu ``|confidence - accuracy|`` ou ``None`` si vide."""
- if self.avg_confidence is None or self.accuracy is None:
- return None
- return abs(self.avg_confidence - self.accuracy)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Validation
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _validate_inputs(
- confidences: list[float],
- is_correct: list[bool | int],
-) -> None:
- if len(confidences) != len(is_correct):
- raise ValueError(
- f"Longueurs incompatibles : confidences={len(confidences)} "
- f"vs is_correct={len(is_correct)}"
- )
- for i, c in enumerate(confidences):
- if not (0.0 <= float(c) <= 1.0):
- raise ValueError(
- f"Confiance hors [0, 1] à l'index {i} : {c!r}"
- )
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Reliability diagram (binning)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def reliability_diagram(
- confidences: Iterable[float],
- is_correct: Iterable[bool | int],
- n_bins: int = 10,
-) -> list[CalibrationBin]:
- """Découpe les prédictions en ``n_bins`` bins équidistants par confiance
- et calcule pour chacun la confiance moyenne, la précision et le compte.
-
- Parameters
- ----------
- confidences:
- Confidences des prédictions, ``∈ [0, 1]``.
- is_correct:
- Indicateur booléen (1 = prédiction correcte, 0 = incorrecte).
- n_bins:
- Nombre de bins (défaut : 10). Bornes : ``[k/n_bins, (k+1)/n_bins)``
- sauf le dernier bin qui inclut ``1.0``.
-
- Returns
- -------
- list[CalibrationBin]
- Liste de ``n_bins`` bins, dans l'ordre croissant des confidences.
- """
- if n_bins < 1:
- raise ValueError(f"n_bins doit être ≥ 1 — reçu {n_bins}")
-
- confs = [float(c) for c in confidences]
- correct = [int(bool(x)) for x in is_correct]
- _validate_inputs(confs, correct)
-
- bin_width = 1.0 / n_bins
- sums: list[float] = [0.0] * n_bins
- correct_counts: list[int] = [0] * n_bins
- counts: list[int] = [0] * n_bins
-
- for c, ok in zip(confs, correct):
- # Calcul du bin index par multiplication ``c * n_bins`` plutôt que
- # division ``c / bin_width`` pour éviter les pièges de
- # représentation flottante (ex. ``0.6 / 0.1 = 5.999…`` en IEEE 754
- # qui placerait 0.6 dans le bin [0.5, 0.6) au lieu de [0.6, 0.7)).
- if c >= 1.0:
- idx = n_bins - 1
- else:
- idx = int(c * n_bins)
- # Garde-fou en cas d'arrondi flottant
- if idx >= n_bins:
- idx = n_bins - 1
- elif idx < 0:
- idx = 0
- sums[idx] += c
- correct_counts[idx] += ok
- counts[idx] += 1
-
- bins: list[CalibrationBin] = []
- for k in range(n_bins):
- low = k * bin_width
- high = (k + 1) * bin_width
- n = counts[k]
- if n == 0:
- bins.append(CalibrationBin(low, high, None, None, 0))
- else:
- bins.append(CalibrationBin(
- bin_low=low,
- bin_high=high,
- avg_confidence=sums[k] / n,
- accuracy=correct_counts[k] / n,
- count=n,
- ))
- return bins
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# ECE et MCE
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def expected_calibration_error(
- confidences: Iterable[float],
- is_correct: Iterable[bool | int],
- n_bins: int = 10,
-) -> float:
- """Expected Calibration Error : moyenne pondérée par bin de l'écart
- absolu confiance ↔ précision.
-
- ``ECE = sum_k (n_k / N) * |avg_conf_k - accuracy_k|``
-
- où la somme porte sur les bins non vides.
-
- Returns
- -------
- float
- ``∈ [0, 1]``. ``0`` ↔ calibration parfaite.
- """
- bins = reliability_diagram(confidences, is_correct, n_bins=n_bins)
- total = sum(b.count for b in bins)
- if total == 0:
- return 0.0
- ece = 0.0
- for b in bins:
- if b.count == 0 or b.gap is None:
- continue
- ece += (b.count / total) * b.gap
- return ece
-
-
-def maximum_calibration_error(
- confidences: Iterable[float],
- is_correct: Iterable[bool | int],
- n_bins: int = 10,
-) -> float:
- """Maximum Calibration Error : pire écart confiance ↔ précision sur
- tous les bins non vides.
-
- Utile pour repérer un mensonge ponctuel du moteur (ex. il dit 95 %
- de confiance et il a tort une fois sur deux dans ce bin).
-
- Returns
- -------
- float
- ``∈ [0, 1]``. ``0`` ↔ calibration parfaite.
- """
- bins = reliability_diagram(confidences, is_correct, n_bins=n_bins)
- gaps = [b.gap for b in bins if b.gap is not None]
- return max(gaps) if gaps else 0.0
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Vue agrégée
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def compute_calibration_metrics(
- confidences: Iterable[float],
- is_correct: Iterable[bool | int],
- n_bins: int = 10,
-) -> dict:
- """Calcule l'ensemble des métriques de calibration en un appel.
-
- Returns
- -------
- dict
- ``{
- "ece": float,
- "mce": float,
- "n_bins": int,
- "n_predictions": int,
- "overall_accuracy": float,
- "overall_confidence": float,
- "bins": [
- {"bin_low", "bin_high", "avg_confidence",
- "accuracy", "count", "gap"},
- ...
- ],
- }``
- """
- confs = list(confidences)
- correct = list(is_correct)
- bins = reliability_diagram(confs, correct, n_bins=n_bins)
- total = sum(b.count for b in bins)
- overall_acc = (
- sum(int(bool(x)) for x in correct) / total if total > 0 else 0.0
- )
- overall_conf = (
- sum(float(c) for c in confs) / total if total > 0 else 0.0
- )
-
- ece = 0.0
- if total > 0:
- for b in bins:
- if b.gap is None:
- continue
- ece += (b.count / total) * b.gap
- mce = max((b.gap for b in bins if b.gap is not None), default=0.0)
-
- return {
- "ece": ece,
- "mce": mce,
- "n_bins": n_bins,
- "n_predictions": total,
- "overall_accuracy": overall_acc,
- "overall_confidence": overall_conf,
- "bins": [
- {
- "bin_low": b.bin_low,
- "bin_high": b.bin_high,
- "avg_confidence": b.avg_confidence,
- "accuracy": b.accuracy,
- "count": b.count,
- "gap": b.gap,
- }
- for b in bins
- ],
- }
-
+from picarones.measurements.calibration import * # noqa: F401, F403
-__all__ = [
- "CalibrationBin",
- "reliability_diagram",
- "expected_calibration_error",
- "maximum_calibration_error",
- "compute_calibration_metrics",
-]
+import picarones.measurements.calibration as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/char_scores.py b/picarones/core/char_scores.py
index e390462b14d027212f05f765d6691b8fe8542aa3..582a90fcfdc73194ba47417c062b5abfc8c61902 100644
--- a/picarones/core/char_scores.py
+++ b/picarones/core/char_scores.py
@@ -1,370 +1,19 @@
-"""Scores de reconnaissance des ligatures et des diacritiques.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.char_scores`.
-Ces métriques sont spécifiques aux documents patrimoniaux (manuscrits, imprimés
-anciens) où ligatures et diacritiques jouent un rôle paléographique essentiel.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.char_scores import ...``) de continuer
+à fonctionner sans modification.
-Ligatures
----------
-Caractères encodés comme une séquence unique dans Unicode mais représentant
-deux ou plusieurs glyphes fusionnés : fi (fi), fl (fl), œ, æ, etc.
-
-Pour chaque ligature présente dans le GT, on vérifie si l'OCR a produit
-soit le caractère Unicode équivalent, soit la séquence décomposée équivalente.
-
-Diacritiques
------------
-Accents, cédilles, trémas et autres signes diacritiques. Pour chaque caractère
-accentué dans le GT, on vérifie si l'OCR a conservé le diacritique ou l'a
-remplacé par la lettre de base.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from typing import Optional
-
-import unicodedata
-
-
-# ---------------------------------------------------------------------------
-# Tables de ligatures (char ligature → séquences équivalentes acceptées)
-# ---------------------------------------------------------------------------
-
-#: Table principale des ligatures et leurs équivalents acceptés.
-#: Clé = caractère ligature Unicode ; valeur = liste de séquences équivalentes.
-LIGATURE_TABLE: dict[str, list[str]] = {
- # Ligatures typographiques latines (Unicode Letterlike Symbols / Alphabetic Presentation Forms)
- "\uFB00": ["ff"], # ff ff
- "\uFB01": ["fi"], # fi fi
- "\uFB02": ["fl"], # fl fl
- "\uFB03": ["ffi"], # ffi ffi
- "\uFB04": ["ffl"], # ffl ffl
- "\uFB05": ["st", "\u017Ft"], # ſt st / ſt
- "\uFB06": ["st"], # st st (variante)
- # Ligatures latines patrimoniales (Unicode Latin Extended Additional)
- "\u0153": ["oe"], # œ oe
- "\u00E6": ["ae"], # æ ae
- "\u0152": ["OE"], # Œ OE
- "\u00C6": ["AE"], # Æ AE
- # Abréviations latines / médiévales
- "\uA751": ["per", "p\u0332"], # ꝑ per / p̲
- "\uA753": ["pro"], # ꝓ pro
- "\uA757": ["que"], # ꝗ que
- # Ligatures germaniques
- "\u00DF": ["ss"], # ß ss
- "\u1E9E": ["SS"], # ẞ SS
-}
-
-# Ensemble de toutes les ligatures pour recherche rapide
-_ALL_LIGATURES: frozenset[str] = frozenset(LIGATURE_TABLE)
-
-# Mapping inverse : séquence → ligature
-_SEQ_TO_LIGATURE: dict[str, str] = {}
-for _lig, _seqs in LIGATURE_TABLE.items():
- for _seq in _seqs:
- _SEQ_TO_LIGATURE[_seq] = _lig
-
-
-# ---------------------------------------------------------------------------
-# Table des caractères diacritiques
-# ---------------------------------------------------------------------------
-
-def _build_diacritic_map() -> dict[str, str]:
- """Construit automatiquement la table diacritique depuis l'Unicode."""
- table: dict[str, str] = {}
- for codepoint in range(0x00C0, 0x0250): # Latin Étendu A + B
- ch = chr(codepoint)
- nfd = unicodedata.normalize("NFD", ch)
- if len(nfd) > 1: # le caractère est décomposable
- base = nfd[0] # lettre de base
- if base.isalpha() and base != ch:
- table[ch] = base
- # Compléments manuels
- table.update({
- "\u0107": "c", # ć
- "\u0119": "e", # ę
- "\u0142": "l", # ł
- "\u0144": "n", # ń
- "\u015B": "s", # ś
- "\u017A": "z", # ź
- "\u017C": "z", # ż
- })
- return table
-
-
-DIACRITIC_MAP: dict[str, str] = _build_diacritic_map()
-_ALL_DIACRITICS: frozenset[str] = frozenset(DIACRITIC_MAP)
-
-# Ligatures qui NE sont PAS des diacritiques (pour éviter les doublons)
-_LIGATURE_SET: frozenset[str] = frozenset(LIGATURE_TABLE)
-
-
-# ---------------------------------------------------------------------------
-# Résultats structurés
-# ---------------------------------------------------------------------------
-
-@dataclass
-class LigatureScore:
- """Score de reconnaissance des ligatures pour une paire (GT, OCR)."""
-
- total_in_gt: int = 0
- """Nombre de ligatures présentes dans le GT."""
- correctly_recognized: int = 0
- """Nombre de ligatures correctement transcrites (unicode ou équivalent)."""
- score: float = 0.0
- """Taux de reconnaissance = correctly_recognized / total_in_gt. 1.0 si total=0."""
- per_ligature: dict[str, dict] = field(default_factory=dict)
- """Détail par ligature : {'fi': {'gt_count': 5, 'ocr_correct': 3, 'score': 0.6}}"""
-
- def as_dict(self) -> dict:
- return {
- "total_in_gt": self.total_in_gt,
- "correctly_recognized": self.correctly_recognized,
- "score": round(self.score, 4),
- "per_ligature": {
- k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()}
- for k, v in self.per_ligature.items()
- },
- }
-
-
-@dataclass
-class DiacriticScore:
- """Score de conservation des diacritiques pour une paire (GT, OCR)."""
-
- total_in_gt: int = 0
- """Nombre de caractères accentués dans le GT."""
- correctly_recognized: int = 0
- """Nombre de diacritiques correctement conservés."""
- score: float = 0.0
- """Taux de conservation = correctly_recognized / total_in_gt. 1.0 si total=0."""
- per_diacritic: dict[str, dict] = field(default_factory=dict)
- """Détail par caractère diacritique."""
-
- def as_dict(self) -> dict:
- return {
- "total_in_gt": self.total_in_gt,
- "correctly_recognized": self.correctly_recognized,
- "score": round(self.score, 4),
- "per_diacritic": {
- k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()}
- for k, v in self.per_diacritic.items()
- },
- }
-
-
-# ---------------------------------------------------------------------------
-# Calcul des scores
-# ---------------------------------------------------------------------------
-
-def compute_ligature_score(ground_truth: str, hypothesis: str) -> LigatureScore:
- """Calcule le score de reconnaissance des ligatures.
-
- Pour chaque ligature dans le GT, on vérifie si l'OCR a produit :
- - Exactement le même caractère ligature Unicode (ex. fi → fi)
- - Ou la séquence de lettres équivalente (ex. fi → fi)
-
- Les deux sont considérés comme corrects — ce qui correspond à la pratique
- éditoriale patrimoniaux (certains éditeurs développent les ligatures).
-
- Parameters
- ----------
- ground_truth:
- Texte de référence.
- hypothesis:
- Texte produit par l'OCR.
-
- Returns
- -------
- LigatureScore
- """
- if not ground_truth:
- return LigatureScore(score=1.0)
-
- # Construire un index de position dans l'hypothèse pour recherche rapide
- hyp_norm = unicodedata.normalize("NFC", hypothesis)
- gt_norm = unicodedata.normalize("NFC", ground_truth)
-
- per_lig: dict[str, dict] = {}
- total = 0
- correct = 0
-
- # Trouver toutes les ligatures dans le GT
- i = 0
- while i < len(gt_norm):
- ch = gt_norm[i]
- if ch in _ALL_LIGATURES:
- total += 1
- equivalents = [ch] + LIGATURE_TABLE[ch] # unicode direct ou séquences équivalentes
-
- # Vérifier si la position correspondante dans l'OCR contient l'équivalent
- is_correct = _check_char_at_context(gt_norm, hyp_norm, i, ch, equivalents)
- if is_correct:
- correct += 1
-
- if ch not in per_lig:
- per_lig[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0}
- per_lig[ch]["gt_count"] += 1
- if is_correct:
- per_lig[ch]["ocr_correct"] += 1
- i += 1
-
- # Calculer les scores individuels
- for lig_data in per_lig.values():
- lig_data["score"] = (
- lig_data["ocr_correct"] / lig_data["gt_count"]
- if lig_data["gt_count"] > 0
- else 1.0
- )
-
- score = correct / total if total > 0 else 1.0
- return LigatureScore(
- total_in_gt=total,
- correctly_recognized=correct,
- score=score,
- per_ligature=per_lig,
- )
-
-
-def compute_diacritic_score(ground_truth: str, hypothesis: str) -> DiacriticScore:
- """Calcule le score de conservation des diacritiques.
-
- Pour chaque caractère accentué dans le GT, on vérifie si l'OCR a produit
- le même caractère (conservation) ou a substitué la lettre de base (perte).
- On accepte aussi les formes NFD équivalentes.
-
- Parameters
- ----------
- ground_truth:
- Texte de référence.
- hypothesis:
- Texte produit par l'OCR.
-
- Returns
- -------
- DiacriticScore
- """
- if not ground_truth:
- return DiacriticScore(score=1.0)
-
- gt_norm = unicodedata.normalize("NFC", ground_truth)
- hyp_norm = unicodedata.normalize("NFC", hypothesis)
-
- per_diac: dict[str, dict] = {}
- total = 0
- correct = 0
-
- # Utiliser difflib pour l'alignement
- import difflib
- matcher = difflib.SequenceMatcher(None, gt_norm, hyp_norm, autojunk=False)
- gt_to_hyp: dict[int, Optional[int]] = {}
-
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
- if tag == "equal":
- for k in range(i2 - i1):
- gt_to_hyp[i1 + k] = j1 + k
- elif tag == "replace" and (i2 - i1) == (j2 - j1):
- for k in range(i2 - i1):
- gt_to_hyp[i1 + k] = j1 + k
- else:
- # delete ou replace de longueurs différentes
- for k in range(i1, i2):
- gt_to_hyp[k] = None
-
- for i, ch in enumerate(gt_norm):
- if ch in _ALL_DIACRITICS and ch not in _LIGATURE_SET:
- total += 1
- hyp_pos = gt_to_hyp.get(i)
- is_correct = False
- if hyp_pos is not None and hyp_pos < len(hyp_norm):
- hyp_ch = hyp_norm[hyp_pos]
- is_correct = (hyp_ch == ch)
- if is_correct:
- correct += 1
-
- if ch not in per_diac:
- per_diac[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0}
- per_diac[ch]["gt_count"] += 1
- if is_correct:
- per_diac[ch]["ocr_correct"] += 1
-
- for diac_data in per_diac.values():
- diac_data["score"] = (
- diac_data["ocr_correct"] / diac_data["gt_count"]
- if diac_data["gt_count"] > 0
- else 1.0
- )
-
- score = correct / total if total > 0 else 1.0
- return DiacriticScore(
- total_in_gt=total,
- correctly_recognized=correct,
- score=score,
- per_diacritic=per_diac,
- )
-
-
-def _check_char_at_context(
- gt: str,
- hyp: str,
- gt_pos: int,
- gt_char: str,
- equivalents: list[str],
-) -> bool:
- """Vérifie si la position correspondante dans l'hypothèse contient un équivalent.
-
- Cherche dans une fenêtre de ±5 caractères autour de la position estimée
- pour tolérer les décalages d'alignement OCR.
- """
- # Position estimée dans l'hypothèse (ratio proportionnel)
- if len(gt) == 0:
- return False
- est_pos = int(gt_pos * len(hyp) / len(gt)) if len(gt) > 0 else 0
- window = 5
- start = max(0, est_pos - window)
- end = min(len(hyp), est_pos + window + len(gt_char))
- context = hyp[start:end]
- for equiv in equivalents:
- if equiv in context:
- return True
- return False
-
-
-def aggregate_ligature_scores(scores: list[LigatureScore]) -> dict:
- """Agrège les scores de ligatures sur un corpus."""
- total_gt = sum(s.total_in_gt for s in scores)
- total_correct = sum(s.correctly_recognized for s in scores)
- score = total_correct / total_gt if total_gt > 0 else 1.0
-
- # Agrégation par ligature
- per_lig: dict[str, dict] = {}
- for s in scores:
- for lig, data in s.per_ligature.items():
- if lig not in per_lig:
- per_lig[lig] = {"gt_count": 0, "ocr_correct": 0}
- per_lig[lig]["gt_count"] += data["gt_count"]
- per_lig[lig]["ocr_correct"] += data["ocr_correct"]
- for lig_data in per_lig.values():
- lig_data["score"] = (
- lig_data["ocr_correct"] / lig_data["gt_count"]
- if lig_data["gt_count"] > 0 else 1.0
- )
-
- return {
- "score": round(score, 4),
- "total_in_gt": total_gt,
- "correctly_recognized": total_correct,
- "per_ligature": per_lig,
- }
-
+from picarones.measurements.char_scores import * # noqa: F401, F403
-def aggregate_diacritic_scores(scores: list[DiacriticScore]) -> dict:
- """Agrège les scores diacritiques sur un corpus."""
- total_gt = sum(s.total_in_gt for s in scores)
- total_correct = sum(s.correctly_recognized for s in scores)
- score = total_correct / total_gt if total_gt > 0 else 1.0
- return {
- "score": round(score, 4),
- "total_in_gt": total_gt,
- "correctly_recognized": total_correct,
- }
+import picarones.measurements.char_scores as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/confusion.py b/picarones/core/confusion.py
index a90d9ebb9b3eb6a5585e4f172a0a6bbf4be79689..4c59906188a415a8097ba7378dd0422084543ac5 100644
--- a/picarones/core/confusion.py
+++ b/picarones/core/confusion.py
@@ -1,268 +1,19 @@
-"""Matrice de confusion unicode pour l'analyse fine des erreurs OCR.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.confusion`.
-Pour chaque moteur, on calcule quels caractères du GT sont transcrits par
-quels caractères OCR (substitutions). Cette "empreinte d'erreur" est
-caractéristique de chaque moteur ou pipeline.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.confusion import ...``) de continuer
+à fonctionner sans modification.
-Méthode
--------
-L'alignement caractère par caractère utilise les opérations d'édition
-de la distance de Levenshtein (via difflib.SequenceMatcher), ce qui permet
-d'identifier les substitutions, insertions et suppressions.
-
-La matrice est stockée comme un dict de dict :
- ``{gt_char: {ocr_char: count}}``
-
-La valeur spéciale ``"∅"`` (U+2205) représente un caractère vide :
-- ``{"a": {"∅": 3}}`` → 'a' supprimé 3 fois dans l'OCR
-- ``{"∅": {"x": 2}}`` → 'x' inséré 2 fois dans l'OCR (absent du GT)
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import difflib
-from collections import defaultdict
-from dataclasses import dataclass, field
-
-# Symbole représentant un caractère absent (insertion / suppression)
-EMPTY_CHAR = "∅"
-
-# Caractères non pertinents à ignorer dans la matrice (espaces, sauts de ligne)
-_WHITESPACE = set(" \t\n\r")
-
-
-@dataclass
-class ConfusionMatrix:
- """Matrice de confusion unicode pour une paire (GT, OCR)."""
-
- matrix: dict[str, dict[str, int]] = field(default_factory=dict)
- """Clé externe = char GT ; clé interne = char OCR ; valeur = count."""
-
- total_substitutions: int = 0
- total_insertions: int = 0
- total_deletions: int = 0
-
- @property
- def total_errors(self) -> int:
- return self.total_substitutions + self.total_insertions + self.total_deletions
-
- def top_confusions(self, n: int = 20) -> list[dict]:
- """Retourne les n confusions les plus fréquentes (substitutions uniquement)."""
- pairs: list[tuple[str, str, int]] = []
- for gt_char, ocr_counts in self.matrix.items():
- if gt_char == EMPTY_CHAR:
- continue # insertions
- for ocr_char, count in ocr_counts.items():
- if ocr_char == EMPTY_CHAR:
- continue # suppressions
- if gt_char != ocr_char:
- pairs.append((gt_char, ocr_char, count))
- pairs.sort(key=lambda x: -x[2])
- return [
- {"gt": gt, "ocr": ocr, "count": cnt}
- for gt, ocr, cnt in pairs[:n]
- ]
-
- def as_compact_dict(self, min_count: int = 1) -> dict:
- """Sérialise la matrice en éliminant les entrées rares."""
- compact: dict[str, dict[str, int]] = {}
- for gt_char, ocr_counts in self.matrix.items():
- filtered = {
- oc: cnt for oc, cnt in ocr_counts.items()
- if cnt >= min_count
- }
- if filtered:
- compact[gt_char] = filtered
- return {
- "matrix": compact,
- "total_substitutions": self.total_substitutions,
- "total_insertions": self.total_insertions,
- "total_deletions": self.total_deletions,
- }
-
- def as_dict(self) -> dict:
- return self.as_compact_dict(min_count=1)
-
-
-def build_confusion_matrix(
- ground_truth: str,
- hypothesis: str,
- ignore_whitespace: bool = True,
- ignore_correct: bool = True,
-) -> ConfusionMatrix:
- """Construit la matrice de confusion unicode pour une paire GT/OCR.
-
- Parameters
- ----------
- ground_truth:
- Texte de référence (vérité terrain).
- hypothesis:
- Texte produit par l'OCR.
- ignore_whitespace:
- Si True, ignore les espaces, tabulations et sauts de ligne.
- ignore_correct:
- Si True, n'enregistre pas les paires identiques (gt_char == ocr_char).
- Par défaut True pour réduire la taille de la matrice.
-
- Returns
- -------
- ConfusionMatrix
- """
- matrix: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
- n_subs = n_ins = n_dels = 0
-
- if not ground_truth and not hypothesis:
- return ConfusionMatrix(dict(matrix), 0, 0, 0)
-
- # SequenceMatcher sur listes de chars pour un alignement précis
- matcher = difflib.SequenceMatcher(None, ground_truth, hypothesis, autojunk=False)
-
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
- if tag == "equal":
- if not ignore_correct:
- for ch in ground_truth[i1:i2]:
- if ignore_whitespace and ch in _WHITESPACE:
- continue
- matrix[ch][ch] += 1
- elif tag == "replace":
- # Aligner char par char les séquences de longueurs différentes
- gt_seg = ground_truth[i1:i2]
- oc_seg = hypothesis[j1:j2]
- _align_segments(gt_seg, oc_seg, matrix, ignore_whitespace)
- # Substitutions = longueur commune, surplus = insertions ou suppressions
- n_subs += min(len(gt_seg), len(oc_seg))
- surplus = abs(len(gt_seg) - len(oc_seg))
- if len(gt_seg) > len(oc_seg):
- n_dels += surplus
- else:
- n_ins += surplus
- elif tag == "delete":
- for ch in ground_truth[i1:i2]:
- if ignore_whitespace and ch in _WHITESPACE:
- continue
- matrix[ch][EMPTY_CHAR] += 1
- n_dels += 1
- elif tag == "insert":
- for ch in hypothesis[j1:j2]:
- if ignore_whitespace and ch in _WHITESPACE:
- continue
- matrix[EMPTY_CHAR][ch] += 1
- n_ins += 1
-
- # Convertir defaultdict en dict normal
- result_matrix: dict[str, dict[str, int]] = {
- k: dict(v) for k, v in matrix.items()
- }
-
- return ConfusionMatrix(
- matrix=result_matrix,
- total_substitutions=n_subs,
- total_insertions=n_ins,
- total_deletions=n_dels,
- )
-
-
-def _align_segments(
- gt_seg: str,
- oc_seg: str,
- matrix: dict,
- ignore_whitespace: bool,
-) -> None:
- """Aligne deux segments de longueurs potentiellement différentes."""
- if not gt_seg:
- for ch in oc_seg:
- if ignore_whitespace and ch in _WHITESPACE:
- continue
- matrix[EMPTY_CHAR][ch] += 1
- return
- if not oc_seg:
- for ch in gt_seg:
- if ignore_whitespace and ch in _WHITESPACE:
- continue
- matrix[ch][EMPTY_CHAR] += 1
- return
-
- if len(gt_seg) == len(oc_seg):
- # Substitutions 1-pour-1
- for g, o in zip(gt_seg, oc_seg):
- if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE):
- continue
- matrix[g][o] += 1
- else:
- # Longueurs différentes : utiliser SequenceMatcher récursif sur segments courts
- sub = difflib.SequenceMatcher(None, gt_seg, oc_seg, autojunk=False)
- for tag2, i1, i2, j1, j2 in sub.get_opcodes():
- if tag2 == "equal":
- pass
- elif tag2 == "replace":
- # Régression simple : aligner par troncature
- for g, o in zip(gt_seg[i1:i2], oc_seg[j1:j2]):
- if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE):
- continue
- matrix[g][o] += 1
- elif tag2 == "delete":
- for g in gt_seg[i1:i2]:
- if ignore_whitespace and g in _WHITESPACE:
- continue
- matrix[g][EMPTY_CHAR] += 1
- elif tag2 == "insert":
- for o in oc_seg[j1:j2]:
- if ignore_whitespace and o in _WHITESPACE:
- continue
- matrix[EMPTY_CHAR][o] += 1
-
-
-def aggregate_confusion_matrices(matrices: list[ConfusionMatrix]) -> ConfusionMatrix:
- """Agrège plusieurs matrices de confusion en une seule.
-
- Utile pour obtenir la matrice agrégée sur l'ensemble du corpus.
- """
- combined: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
- total_subs = total_ins = total_dels = 0
-
- for cm in matrices:
- for gt_char, ocr_counts in cm.matrix.items():
- for ocr_char, count in ocr_counts.items():
- combined[gt_char][ocr_char] += count
- total_subs += cm.total_substitutions
- total_ins += cm.total_insertions
- total_dels += cm.total_deletions
-
- return ConfusionMatrix(
- matrix={k: dict(v) for k, v in combined.items()},
- total_substitutions=total_subs,
- total_insertions=total_ins,
- total_deletions=total_dels,
- )
-
-
-def top_confused_chars(
- matrix: ConfusionMatrix,
- n: int = 15,
- exclude_empty: bool = True,
-) -> list[dict]:
- """Retourne les caractères GT les plus souvent confondus.
-
- Retourne une liste triée par nombre total d'erreurs décroissant :
- ``[{"char": "ſ", "total_errors": 47, "top_substitutes": [...]}, ...]``
- """
- char_stats: dict[str, dict] = {}
- for gt_char, ocr_counts in matrix.matrix.items():
- if exclude_empty and gt_char == EMPTY_CHAR:
- continue
- error_count = sum(
- cnt for oc, cnt in ocr_counts.items()
- if (oc != gt_char) and (not exclude_empty or oc != EMPTY_CHAR)
- )
- if error_count > 0:
- top_subs = sorted(
- [{"ocr": oc, "count": cnt} for oc, cnt in ocr_counts.items() if oc != gt_char],
- key=lambda x: -x["count"],
- )[:5]
- char_stats[gt_char] = {
- "char": gt_char,
- "total_errors": error_count,
- "top_substitutes": top_subs,
- }
+from picarones.measurements.confusion import * # noqa: F401, F403
- return sorted(char_stats.values(), key=lambda x: -x["total_errors"])[:n]
+import picarones.measurements.confusion as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/cost_projection.py b/picarones/core/cost_projection.py
index f9eab7a6d47731e7b6b0722cb86ad9b1aae0729b..6e76ffcac346ef2b2a97f31da58abc7a4ae6706b 100644
--- a/picarones/core/cost_projection.py
+++ b/picarones/core/cost_projection.py
@@ -1,169 +1,19 @@
-"""Projection de coût en volume cible — Sprint 79 (A.I.6).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.cost_projection`.
-Sprint 79 — A.I.6 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.cost_projection import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par
-unité (1 000 pages). Pour décider business-side, il faut projeter
-ce coût sur le **volume cible** que l'utilisateur prévoit de
-traiter — payer 50 € de plus sur 50 pages est trivial, sur
-5 millions ça change tout.
-
-Sortie typique
---------------
-*« Pour vos 80 000 pages BMS — Tesseract = 3 €, Pero = 0 € (local
-amorti), Mistral OCR = 280 €, GPT-4o post-correction = 600 €. »*
-
-Aucun seuil arbitraire imposé : le module fournit les chiffres,
-le chercheur arbitre selon son budget.
-
-Dépendance
-----------
-S'appuie sur ``picarones.core.pricing`` (Sprint 20) qui expose
-``EngineCost.cost_per_1k_pages_eur`` et
-``co2_per_1k_pages_g``.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-from typing import Optional
-
-from picarones.core.pricing import EngineCost
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True)
-class ProjectedCost:
- """Coût total projeté d'un moteur pour un volume cible."""
- engine_key: str
- target_pages: int
- cost_total_eur: Optional[float]
- co2_total_g: Optional[float]
- cost_per_1k_pages_eur: Optional[float]
- co2_per_1k_pages_g: Optional[float]
- type: str # "local" / "cloud_api" / "unknown"
-
- def as_dict(self) -> dict:
- return {
- "engine_key": self.engine_key,
- "target_pages": self.target_pages,
- "cost_total_eur": self.cost_total_eur,
- "co2_total_g": self.co2_total_g,
- "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur,
- "co2_per_1k_pages_g": self.co2_per_1k_pages_g,
- "type": self.type,
- }
-
-
-def project_cost_total(
- engine_cost: EngineCost, target_pages: int,
-) -> Optional[float]:
- """Coût total projeté en euros pour ``target_pages`` pages.
-
- Retourne ``None`` si ``cost_per_1k_pages_eur`` est ``None``
- (données insuffisantes) ou si ``target_pages`` est négatif.
- """
- if target_pages < 0:
- return None
- if engine_cost.cost_per_1k_pages_eur is None:
- return None
- return engine_cost.cost_per_1k_pages_eur * target_pages / 1000.0
-
-
-def project_co2_total(
- engine_cost: EngineCost, target_pages: int,
-) -> Optional[float]:
- """Empreinte CO₂ totale en grammes pour ``target_pages`` pages."""
- if target_pages < 0:
- return None
- if engine_cost.co2_per_1k_pages_g is None:
- return None
- return engine_cost.co2_per_1k_pages_g * target_pages / 1000.0
-
-
-def project_engine(
- engine_cost: EngineCost, target_pages: int,
-) -> ProjectedCost:
- """Retourne le ``ProjectedCost`` complet pour un moteur."""
- return ProjectedCost(
- engine_key=engine_cost.engine_key,
- target_pages=int(target_pages),
- cost_total_eur=project_cost_total(engine_cost, target_pages),
- co2_total_g=project_co2_total(engine_cost, target_pages),
- cost_per_1k_pages_eur=engine_cost.cost_per_1k_pages_eur,
- co2_per_1k_pages_g=engine_cost.co2_per_1k_pages_g,
- type=engine_cost.type,
- )
-
-
-def project_all_engines(
- engine_costs: dict[str, EngineCost],
- target_pages: int,
-) -> dict[str, ProjectedCost]:
- """Projette les coûts de plusieurs moteurs sur le volume cible.
-
- Retourne un dict ``{engine_name: ProjectedCost}`` avec entrée
- pour chaque moteur, y compris ceux sans données de coût (où
- ``cost_total_eur`` sera ``None``).
- """
- if target_pages < 0:
- raise ValueError("target_pages doit être ≥ 0")
- return {
- name: project_engine(cost, target_pages)
- for name, cost in engine_costs.items()
- }
-
-
-def cost_gap_table(
- projections: dict[str, ProjectedCost],
- baseline_engine: str,
-) -> dict[str, dict[str, Optional[float]]]:
- """Pour chaque moteur, écart de coût total vs baseline.
-
- Retourne ``{engine: {"total": float, "delta_abs": float,
- "delta_rel": float}}`` où :
-
- - ``delta_abs`` = ``cost - cost_baseline`` (None si l'un des
- deux est None)
- - ``delta_rel`` = ``delta_abs / cost_baseline`` (None si
- baseline = 0 ou None)
-
- Lève ``KeyError`` si la baseline est inconnue.
- """
- if baseline_engine not in projections:
- raise KeyError(
- f"baseline {baseline_engine!r} absente des projections",
- )
- baseline_total = projections[baseline_engine].cost_total_eur
- out: dict[str, dict[str, Optional[float]]] = {}
- for name, proj in projections.items():
- total = proj.cost_total_eur
- if total is None or baseline_total is None:
- delta_abs: Optional[float] = None
- delta_rel: Optional[float] = None
- else:
- delta_abs = total - baseline_total
- if baseline_total != 0:
- delta_rel = delta_abs / baseline_total
- else:
- delta_rel = None
- out[name] = {
- "total": total,
- "delta_abs": delta_abs,
- "delta_rel": delta_rel,
- }
- return out
-
+from picarones.measurements.cost_projection import * # noqa: F401, F403
-__all__ = [
- "ProjectedCost",
- "project_cost_total",
- "project_co2_total",
- "project_engine",
- "project_all_engines",
- "cost_gap_table",
-]
+import picarones.measurements.cost_projection as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/difficulty.py b/picarones/core/difficulty.py
index 7f037a48d4f67d06e7162473b901fc261681373a..f1153aebe7b3ecac19f86967a6140843a2e50b64 100644
--- a/picarones/core/difficulty.py
+++ b/picarones/core/difficulty.py
@@ -1,202 +1,19 @@
-"""Score de difficulté intrinsèque par document.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.difficulty`.
-Le score est indépendant des moteurs OCR : il mesure la difficulté
-*objective* d'un document, indépendamment de la qualité des transcriptions.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.difficulty import ...``) de continuer
+à fonctionner sans modification.
-Formule
--------
- difficulty = w_variance * variance_norm
- + w_quality * (1 - image_quality_score)
- + w_density * special_char_density
-
-où :
- - variance_norm : variance inter-moteurs du CER, normalisée [0, 1]
- - image_quality : score de qualité image [0, 1] (netteté, contraste…)
- - special_chars : densité de caractères spéciaux dans la GT [0, 1]
-
-Les poids sont configurables (défaut : 0.4 / 0.35 / 0.25).
-
-Score final : [0, 1] — 0 = document facile, 1 = très difficile.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import re
-from dataclasses import dataclass
-from typing import Optional
-
-
-# Poids par défaut
-_W_VARIANCE = 0.40
-_W_QUALITY = 0.35
-_W_DENSITY = 0.25
-
-# Caractères spéciaux patrimoniaux (ligatures, abréviations, diacritiques rares)
-_SPECIAL_CHARS_RE = re.compile(
- r"[ſœæꝑꝓ&]" # ligatures / abréviations médiévales
- r"|[ḁ-ỿ]" # Latin Étendu Additionnel (diacritiques rares)
- r"|[\u0300-\u036f]" # Diacritiques combinants
- r"|[\ufb00-\ufb06]" # Formes de présentation latines (fi, fl…)
- r"|[IVXLCDM]{3,}" # Chiffres romains (3+ caractères)
-)
-
-
-@dataclass
-class DifficultyScore:
- """Score de difficulté intrinsèque d'un document."""
- doc_id: str
- score: float
- """Score global [0, 1] — plus élevé = plus difficile."""
- variance_component: float
- """Composante variance inter-moteurs [0, 1]."""
- quality_component: float
- """Composante qualité image inversée [0, 1]."""
- density_component: float
- """Composante densité caractères spéciaux [0, 1]."""
- cer_variance: float
- """Variance brute du CER entre moteurs."""
- image_quality_score: float
- """Score de qualité image (si disponible, sinon 0.5)."""
- special_char_ratio: float
- """Ratio caractères spéciaux / longueur GT."""
-
- def as_dict(self) -> dict:
- return {
- "doc_id": self.doc_id,
- "score": round(self.score, 4),
- "variance_component": round(self.variance_component, 4),
- "quality_component": round(self.quality_component, 4),
- "density_component": round(self.density_component, 4),
- "cer_variance": round(self.cer_variance, 6),
- "image_quality_score": round(self.image_quality_score, 4),
- "special_char_ratio": round(self.special_char_ratio, 4),
- }
-
-
-def _special_char_density(text: str) -> float:
- """Ratio de caractères spéciaux patrimoniaux dans le texte."""
- if not text:
- return 0.0
- matches = len(_SPECIAL_CHARS_RE.findall(text))
- return min(1.0, matches / len(text))
-
-
-def _variance(values: list[float]) -> float:
- """Variance d'une liste de valeurs."""
- if len(values) < 2:
- return 0.0
- mu = sum(values) / len(values)
- return sum((v - mu) ** 2 for v in values) / len(values)
-
-
-def compute_difficulty_score(
- doc_id: str,
- ground_truth: str,
- cer_per_engine: list[float],
- image_quality_score: Optional[float] = None,
- weights: tuple[float, float, float] = (_W_VARIANCE, _W_QUALITY, _W_DENSITY),
-) -> DifficultyScore:
- """Calcule le score de difficulté intrinsèque pour un document.
-
- Parameters
- ----------
- doc_id : identifiant du document
- ground_truth : texte de référence
- cer_per_engine : liste des CER (un par moteur concurrent)
- image_quality_score: score de qualité image [0, 1] (None → 0.5 neutre)
- weights : (w_variance, w_quality, w_density)
-
- Returns
- -------
- DifficultyScore
- """
- w_var, w_qual, w_den = weights
-
- # 1. Variance inter-moteurs (normalisée sur [0, 1] — variance max ≈ 0.25)
- cer_var = _variance(cer_per_engine)
- variance_norm = min(1.0, cer_var / 0.25)
-
- # 2. Qualité image inversée
- iq = image_quality_score if image_quality_score is not None else 0.5
- iq = max(0.0, min(1.0, iq))
- quality_component = 1.0 - iq
-
- # 3. Densité de caractères spéciaux
- density = _special_char_density(ground_truth)
- # Amplifier légèrement (la densité brute est souvent faible)
- density_component = min(1.0, density * 3.0)
-
- # Score combiné
- score = (
- w_var * variance_norm
- + w_qual * quality_component
- + w_den * density_component
- )
- score = max(0.0, min(1.0, score))
-
- return DifficultyScore(
- doc_id=doc_id,
- score=score,
- variance_component=variance_norm,
- quality_component=quality_component,
- density_component=density_component,
- cer_variance=cer_var,
- image_quality_score=iq,
- special_char_ratio=density,
- )
-
-
-def compute_all_difficulties(
- doc_ids: list[str],
- ground_truths: dict[str, str],
- cer_map: dict[str, dict[str, float]],
- image_quality_map: Optional[dict[str, float]] = None,
-) -> dict[str, DifficultyScore]:
- """Calcule les scores de difficulté pour tous les documents d'un corpus.
-
- Parameters
- ----------
- doc_ids : liste des identifiants de documents
- ground_truths : {doc_id → gt_text}
- cer_map : {doc_id → {engine_name → cer}}
- image_quality_map : {doc_id → quality_score} (facultatif)
-
- Returns
- -------
- {doc_id → DifficultyScore}
- """
- result = {}
- for doc_id in doc_ids:
- gt = ground_truths.get(doc_id, "")
- engine_cers = list(cer_map.get(doc_id, {}).values())
- iq = (image_quality_map or {}).get(doc_id)
- result[doc_id] = compute_difficulty_score(
- doc_id=doc_id,
- ground_truth=gt,
- cer_per_engine=engine_cers,
- image_quality_score=iq,
- )
- return result
-
-
-def difficulty_label(score: float) -> str:
- """Retourne un label lisible pour un score de difficulté."""
- if score < 0.25:
- return "Facile"
- if score < 0.50:
- return "Modéré"
- if score < 0.75:
- return "Difficile"
- return "Très difficile"
-
+from picarones.measurements.difficulty import * # noqa: F401, F403
-def difficulty_color(score: float) -> str:
- """Retourne une couleur CSS pour un score de difficulté."""
- from picarones.core.colors import COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_RED
- if score < 0.25:
- return COLOR_GREEN
- if score < 0.50:
- return COLOR_YELLOW
- if score < 0.75:
- return COLOR_ORANGE
- return COLOR_RED
+import picarones.measurements.difficulty as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/equivalence_profile.py b/picarones/core/equivalence_profile.py
index e3c9adf1cb24c3e73567733ac16c883ba8396e81..07d7dc76786a04bb57daf9881cd90869321752c2 100644
--- a/picarones/core/equivalence_profile.py
+++ b/picarones/core/equivalence_profile.py
@@ -1,199 +1,19 @@
-"""Équivalences diplomatiques granulaires — Sprint 78 (A.I.5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.equivalence_profile`.
-Sprint 78 — A.I.5 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.equivalence_profile import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Aujourd'hui les profils de ``picarones/core/normalization.py``
-(``medieval_french``, ``early_modern_french``, etc.) appliquent un
-**bloc entier** de transformations. Mais un éditeur peut vouloir
-nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par
-exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont
-distinctes mais où le s long doit être normalisé.
-
-Ce module **éclate** chaque profil en règles d'équivalence
-**nommées et indépendantes** que l'utilisateur peut activer ou
-désactiver une par une. La couche de calcul retourne le CER
-recalculé avec un sous-ensemble personnalisé.
-
-Format
-------
-Chaque règle a :
-
-- ``name`` : identifiant stable utilisé dans les URLs et l'UX
- (ex. ``"longs_s"``, ``"u_eq_v"``)
-- ``source`` : caractère ou séquence à remplacer
-- ``target`` : caractère ou séquence cible
-- ``description`` : phrase courte FR destinée à l'utilisateur
-- ``profile_tag`` : nom du profil dont elle est issue (utile pour
- grouper dans l'UX)
-
-Stratégie de découpage
-----------------------
-Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau
-avancé (cases à cocher + recalcul JS client + URL state) suivra
-dans un sprint dédié — la couche calcul livrée ici est une
-fondation suffisante pour qu'un développeur frontend câble la vue.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-from typing import Iterable, Optional
-
-from picarones.core.normalization import (
- DIPLOMATIC_EN_EARLY_MODERN,
- DIPLOMATIC_FR_EARLY_MODERN,
- DIPLOMATIC_LATIN_MEDIEVAL,
- DIPLOMATIC_MINIMAL,
-)
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True)
-class EquivalenceRule:
- """Une équivalence diplomatique nommée et indépendante."""
- name: str
- source: str
- target: str
- description: str
- profile_tag: str
-
-
-# Catalogue : on dérive des profils existants en attribuant un nom
-# stable à chaque transformation. Les doublons (ex. ``ſ → s``
-# présent dans plusieurs profils) sont fusionnés sous un nom unique
-# (le premier rencontré).
-def _build_catalog() -> dict[str, EquivalenceRule]:
- catalog: dict[str, EquivalenceRule] = {}
-
- # Noms canoniques pour les transformations courantes
- canonical_names: dict[tuple[str, str], tuple[str, str]] = {
- ("ſ", "s"): ("longs_s", "s long ſ → s"),
- ("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"),
- ("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"),
- ("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"),
- ("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"),
- ("æ", "ae"): ("ae_ligature", "æ → ae"),
- ("œ", "oe"): ("oe_ligature", "œ → oe"),
- ("þ", "th"): ("thorn_th", "þ (thorn) → th"),
- ("ð", "th"): ("eth_th", "ð (eth) → th"),
- ("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"),
- ("&", "et"): ("ampersand_et", "& → et (esperluette)"),
- ("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"),
- ("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"),
- ("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"),
- ("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"),
- }
-
- sources = [
- ("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL),
- ("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN),
- ("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN),
- ("minimal", DIPLOMATIC_MINIMAL),
- ]
-
- for profile_tag, profile_dict in sources:
- for source, target in profile_dict.items():
- key = (source, target)
- if key in canonical_names:
- name, desc = canonical_names[key]
- else:
- # Fallback : générer un nom à partir des codepoints
- name = f"{source}_to_{target}".replace(" ", "_")
- desc = f"{source} → {target}"
- if name in catalog:
- # On garde le profile_tag du premier rencontré, mais
- # on note que la règle est partagée.
- continue
- catalog[name] = EquivalenceRule(
- name=name,
- source=source,
- target=target,
- description=desc,
- profile_tag=profile_tag,
- )
- return catalog
-
-
-BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog()
-
-
-def list_equivalences_by_profile(
- profile_name: Optional[str] = None,
-) -> list[EquivalenceRule]:
- """Liste les règles d'équivalence disponibles.
-
- Si ``profile_name`` est fourni, ne retourne que les règles dont
- ``profile_tag == profile_name`` (ou les règles dérivées de
- plusieurs profils dont au moins un est ``profile_name``).
- """
- if profile_name is None:
- return list(BUILTIN_EQUIVALENCES.values())
- return [
- rule for rule in BUILTIN_EQUIVALENCES.values()
- if rule.profile_tag == profile_name
- ]
-
-
-def apply_selected_equivalences(
- text: Optional[str],
- selected_names: Iterable[str],
-) -> str:
- """Applique uniquement les règles dont le nom est dans
- ``selected_names``.
-
- L'ordre d'application est l'ordre du catalogue interne — les
- transformations sont appliquées séquentiellement sur le texte.
- Les règles inconnues sont silencieusement ignorées (avec
- warning).
- """
- if not text:
- return text or ""
- selected_set = set(selected_names)
- if not selected_set:
- return text
- out = text
- for name, rule in BUILTIN_EQUIVALENCES.items():
- if name not in selected_set:
- continue
- out = out.replace(rule.source, rule.target)
- # Détection des règles inconnues (pour logger explicite)
- unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys())
- if unknown:
- logger.warning(
- "[equivalence_profile] règles inconnues ignorées : %s",
- sorted(unknown),
- )
- return out
-
-
-def compute_cer_with_equivalences(
- reference: Optional[str],
- hypothesis: Optional[str],
- selected_names: Iterable[str],
-) -> float:
- """Calcule le CER après application des équivalences sélectionnées
- sur les **deux** côtés (GT et hypothèse).
-
- Utilise ``picarones.core.metrics.compute_metrics`` et extrait
- le champ ``cer`` du résultat.
- """
- from picarones.core.metrics import compute_metrics
-
- selected_list = list(selected_names)
- ref = apply_selected_equivalences(reference or "", selected_list)
- hyp = apply_selected_equivalences(hypothesis or "", selected_list)
- result = compute_metrics(ref, hyp)
- return result.cer
-
+from picarones.measurements.equivalence_profile import * # noqa: F401, F403
-__all__ = [
- "EquivalenceRule",
- "BUILTIN_EQUIVALENCES",
- "list_equivalences_by_profile",
- "apply_selected_equivalences",
- "compute_cer_with_equivalences",
-]
+import picarones.measurements.equivalence_profile as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/error_absorption.py b/picarones/core/error_absorption.py
index ce1021d64b625397fd5c3dca1d15475d6d83477b..f06a093ecb07e5f9a3301bd3303e4262faa3bf16 100644
--- a/picarones/core/error_absorption.py
+++ b/picarones/core/error_absorption.py
@@ -1,276 +1,19 @@
-"""Métrique d'absorption d'erreur — Sprint 94 (B.3).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.error_absorption`.
-Sprint 94 — B.3 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.error_absorption import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Quand un module de post-correction LLM aplatit les différences
-entre OCR amont, ce n'est pas qu'il « améliore » tous les
-moteurs — c'est qu'il introduit ses propres biais qui dominent
-ceux de l'OCR. Mesurer la dégradation par étape ne suffit
-pas : il faut **séparer** les deux flux.
-
-À chaque jonction où un module transforme un artefact, on
-mesure :
-
-- **Taux de correction** : parmi les erreurs présentes en
- entrée du module, combien sont corrigées en sortie ?
-- **Taux d'introduction** : parmi les erreurs présentes en
- sortie, combien sont **nouvelles** (absentes en entrée) ?
-
-C'est la généralisation du score de sur-normalisation
-(chantier A.I.7) à toute jonction. La formule s'applique
-uniformément à OCR→LLM, OCR→reconstructor, VLM→ALTO_mapper —
-toute jonction qui transforme un artefact en un autre du même
-type.
-
-Méthode (token-level)
----------------------
-On split en tokens whitespace ``reference``, ``before``,
-``after``. On compare en **multiset** (un token GT consommé
-au plus une fois) :
-
-- ``errors_before`` = tokens GT non retrouvés dans ``before``
-- ``errors_after`` = tokens GT non retrouvés dans ``after``
-- ``corrected`` = ``errors_before \\ errors_after``
- (présents avant, absents après → corrigés)
-- ``introduced`` = ``errors_after \\ errors_before``
- (absents avant, présents après → introduits)
-
-Garde-fou : le module ne classe pas les erreurs (visuelles,
-abréviations, etc.) — c'est une métrique d'**absorption de
-volume**, pas de qualité éditoriale. L'intersection sémantique
-avec ``taxonomy`` (Sprint 5) est documentée dans le glossaire.
-
-Sortie
-------
-``compute_error_absorption(reference, before, after)`` retourne :
-
-.. code-block:: text
-
- {
- "n_gt_tokens": int,
- "n_errors_before": int,
- "n_errors_after": int,
- "n_corrected": int,
- "n_introduced": int,
- "n_kept_wrong": int,
- "correction_rate": float | None, # n_corrected / n_errors_before
- "introduction_rate": float | None, # n_introduced / n_errors_after
- "net_improvement": int, # n_corrected - n_introduced
- "corrected_tokens": list[str],
- "introduced_tokens": list[str],
- }
-
-``aggregate_error_absorption(per_doc_results)`` somme les
-compteurs corpus-wide et recalcule les taux *micro*.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from collections import Counter
-from typing import Iterable, Optional
-
-logger = logging.getLogger(__name__)
-
-
-def _split_words(text: Optional[str]) -> list[str]:
- if not text:
- return []
- return text.split()
-
-
-def _missing_tokens(
- reference: list[str], hypothesis: list[str],
-) -> Counter:
- """Tokens GT manquants en hypothèse au sens multiset.
-
- Un token GT compte plusieurs fois s'il apparaît plusieurs
- fois ; chaque occurrence en hypothèse en absorbe au plus
- une. Retourne un Counter ``{token: nb_occurrences_manquees}``.
- """
- ref_count = Counter(reference)
- hyp_count = Counter(hypothesis)
- missing: Counter = Counter()
- for token, n_ref in ref_count.items():
- n_hyp = hyp_count.get(token, 0)
- if n_hyp < n_ref:
- missing[token] = n_ref - n_hyp
- return missing
-
-
-def compute_error_absorption(
- reference: Optional[str],
- before: Optional[str],
- after: Optional[str],
- *,
- case_sensitive: bool = False,
-) -> Optional[dict]:
- """Mesure l'absorption d'erreur entre ``before`` et ``after``.
-
- Parameters
- ----------
- reference:
- GT (vérité terrain).
- before:
- Sortie de l'étape précédente (typiquement OCR amont).
- after:
- Sortie de l'étape courante (typiquement post-correction LLM).
- case_sensitive:
- Si False (défaut), match case-insensitive — la sortie
- ``corrected_tokens``/``introduced_tokens`` reste en casse
- GT originale.
-
- Returns
- -------
- dict | None
- ``None`` si la GT est vide ou ne contient aucun token.
- """
- ref_tokens = _split_words(reference)
- if not ref_tokens:
- return None
- before_tokens = _split_words(before)
- after_tokens = _split_words(after)
-
- if case_sensitive:
- ref_match = list(ref_tokens)
- before_match = list(before_tokens)
- after_match = list(after_tokens)
- else:
- ref_match = [t.lower() for t in ref_tokens]
- before_match = [t.lower() for t in before_tokens]
- after_match = [t.lower() for t in after_tokens]
-
- # Map case-insensitive token → liste de casses GT originales
- ref_orig_by_match: dict[str, list[str]] = {}
- for orig, m in zip(ref_tokens, ref_match):
- ref_orig_by_match.setdefault(m, []).append(orig)
-
- missing_before = _missing_tokens(ref_match, before_match)
- missing_after = _missing_tokens(ref_match, after_match)
-
- n_errors_before = sum(missing_before.values())
- n_errors_after = sum(missing_after.values())
-
- # Calcul corrigé / introduit en multiset
- corrected_counter: Counter = Counter()
- introduced_counter: Counter = Counter()
- kept_wrong_counter: Counter = Counter()
- all_tokens = set(missing_before) | set(missing_after)
- for tok in all_tokens:
- nb = missing_before.get(tok, 0)
- na = missing_after.get(tok, 0)
- if nb > na:
- corrected_counter[tok] = nb - na
- kept_wrong_counter[tok] = na
- elif na > nb:
- introduced_counter[tok] = na - nb
- kept_wrong_counter[tok] = nb
- else:
- kept_wrong_counter[tok] = nb
-
- n_corrected = sum(corrected_counter.values())
- n_introduced = sum(introduced_counter.values())
- n_kept_wrong = sum(kept_wrong_counter.values())
-
- correction_rate = (
- n_corrected / n_errors_before
- if n_errors_before > 0 else None
- )
- introduction_rate = (
- n_introduced / n_errors_after
- if n_errors_after > 0 else None
- )
-
- def _expand(counter: Counter) -> list[str]:
- out: list[str] = []
- for tok, count in counter.items():
- origs = ref_orig_by_match.get(tok, [tok])
- # Ne renvoie que la casse représentative GT
- display = origs[0] if origs else tok
- out.extend([display] * count)
- return out
-
- return {
- "n_gt_tokens": len(ref_tokens),
- "n_errors_before": n_errors_before,
- "n_errors_after": n_errors_after,
- "n_corrected": n_corrected,
- "n_introduced": n_introduced,
- "n_kept_wrong": n_kept_wrong,
- "correction_rate": correction_rate,
- "introduction_rate": introduction_rate,
- "net_improvement": n_corrected - n_introduced,
- "corrected_tokens": _expand(corrected_counter),
- "introduced_tokens": _expand(introduced_counter),
- }
-
-
-def aggregate_error_absorption(
- per_doc: Iterable[Optional[dict]],
- *,
- sample_tokens: int = 50,
-) -> Optional[dict]:
- """Agrège les compteurs corpus-wide et recalcule les taux
- *micro*.
-
- Parameters
- ----------
- per_doc:
- Itérable de sorties de ``compute_error_absorption`` (ou
- ``None`` pour les docs sans GT).
- sample_tokens:
- Nombre maximal de tokens corrigés/introduits gardés dans
- l'échantillon (cap pour ne pas exploser le JSON).
-
- Returns
- -------
- dict | None
- ``None`` si aucune entry valide.
- """
- docs = [d for d in per_doc if d]
- if not docs:
- return None
- n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
- n_errors_before = sum(int(d.get("n_errors_before") or 0) for d in docs)
- n_errors_after = sum(int(d.get("n_errors_after") or 0) for d in docs)
- n_corrected = sum(int(d.get("n_corrected") or 0) for d in docs)
- n_introduced = sum(int(d.get("n_introduced") or 0) for d in docs)
- n_kept_wrong = sum(int(d.get("n_kept_wrong") or 0) for d in docs)
- correction_rate = (
- n_corrected / n_errors_before if n_errors_before > 0 else None
- )
- introduction_rate = (
- n_introduced / n_errors_after if n_errors_after > 0 else None
- )
- corrected_sample: list[str] = []
- introduced_sample: list[str] = []
- for d in docs:
- corrected_sample.extend(d.get("corrected_tokens") or [])
- introduced_sample.extend(d.get("introduced_tokens") or [])
- if (
- len(corrected_sample) >= sample_tokens
- and len(introduced_sample) >= sample_tokens
- ):
- break
- return {
- "n_docs": len(docs),
- "n_gt_tokens": n_gt,
- "n_errors_before": n_errors_before,
- "n_errors_after": n_errors_after,
- "n_corrected": n_corrected,
- "n_introduced": n_introduced,
- "n_kept_wrong": n_kept_wrong,
- "correction_rate": correction_rate,
- "introduction_rate": introduction_rate,
- "net_improvement": n_corrected - n_introduced,
- "corrected_tokens_sample": corrected_sample[:sample_tokens],
- "introduced_tokens_sample": introduced_sample[:sample_tokens],
- }
-
+from picarones.measurements.error_absorption import * # noqa: F401, F403
-__all__ = [
- "compute_error_absorption",
- "aggregate_error_absorption",
-]
+import picarones.measurements.error_absorption as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/hallucination.py b/picarones/core/hallucination.py
index 07eda573ca8d1b4e659600482d3af3e87f245c21..a84914afd096d6d73aeee9d9746061360bc87a39 100644
--- a/picarones/core/hallucination.py
+++ b/picarones/core/hallucination.py
@@ -1,331 +1,19 @@
-"""Détection des hallucinations VLM/LLM — Sprint 10.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.hallucination`.
-Métriques calculées
--------------------
-- Taux d'insertion net : mots/caractères ajoutés absents du GT, distinct du WIL existant
-- Ratio de longueur : len(hyp) / len(gt) — ratio > 1.2 → hallucination potentielle
-- Score d'ancrage : proportion des n-grammes (trigrammes) de la sortie présents dans le GT
-- Blocs hallucinés : segments continus de la sortie sans correspondance GT au-delà d'un seuil
-- Badge hallucination : True si ancrage faible ou ratio de longueur anormal
-"""
-
-from __future__ import annotations
-
-import re
-from dataclasses import dataclass
-
-
-# ---------------------------------------------------------------------------
-# Helpers texte
-# ---------------------------------------------------------------------------
-
-def _tokenize(text: str) -> list[str]:
- """Découpe en mots (minuscules, sans ponctuation)."""
- return re.findall(r"[^\s]+", text.lower())
-
-
-def _ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]:
- """Génère les n-grammes d'une liste de tokens."""
- if len(tokens) < n:
- return [tuple(tokens)] if tokens else []
- return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]
-
-
-# ---------------------------------------------------------------------------
-# Blocs hallucinés (segments continus sans ancrage)
-# ---------------------------------------------------------------------------
-
-@dataclass
-class HallucinatedBlock:
- """Segment continu de la sortie sans correspondance dans le GT."""
- start_token: int
- end_token: int
- text: str
- length: int # nombre de tokens
-
- def as_dict(self) -> dict:
- return {
- "start_token": self.start_token,
- "end_token": self.end_token,
- "text": self.text,
- "length": self.length,
- }
-
-
-def _detect_hallucinated_blocks(
- hyp_tokens: list[str],
- gt_token_set: set[str],
- tolerance: int = 3,
- min_block_length: int = 4,
-) -> list[HallucinatedBlock]:
- """Détecte les blocs de tokens hypothèse sans correspondance dans le GT.
-
- Un bloc est un segment contigu de tokens hypothèse dont aucun n'est présent
- dans le vocabulaire GT. Une tolérance de ``tolerance`` tokens connus interrompus
- est acceptée avant de clore un bloc.
-
- Parameters
- ----------
- hyp_tokens:
- Tokens de la sortie OCR/VLM.
- gt_token_set:
- Ensemble des tokens du GT (pour recherche O(1)).
- tolerance:
- Nombre de tokens connus consécutifs interrompant un bloc avant de le clore.
- min_block_length:
- Longueur minimale (tokens) pour qu'un bloc soit signalé.
-
- Returns
- -------
- list[HallucinatedBlock]
- """
- blocks: list[HallucinatedBlock] = []
- if not hyp_tokens:
- return blocks
-
- in_block = False
- block_start = 0
- consecutive_known = 0
-
- for i, tok in enumerate(hyp_tokens):
- is_unknown = tok not in gt_token_set
- if is_unknown:
- if not in_block:
- in_block = True
- block_start = i
- consecutive_known = 0
- else:
- consecutive_known = 0
- else:
- if in_block:
- consecutive_known += 1
- if consecutive_known >= tolerance:
- # Clore le bloc
- end = i - consecutive_known
- length = end - block_start + 1
- if length >= min_block_length:
- text = " ".join(hyp_tokens[block_start:end + 1])
- blocks.append(HallucinatedBlock(
- start_token=block_start,
- end_token=end,
- text=text,
- length=length,
- ))
- in_block = False
- consecutive_known = 0
-
- # Bloc non terminé
- if in_block:
- end = len(hyp_tokens) - 1
- length = end - block_start + 1
- if length >= min_block_length:
- text = " ".join(hyp_tokens[block_start:end + 1])
- blocks.append(HallucinatedBlock(
- start_token=block_start,
- end_token=end,
- text=text,
- length=length,
- ))
-
- return blocks
-
-
-# ---------------------------------------------------------------------------
-# Résultat structuré
-# ---------------------------------------------------------------------------
-
-@dataclass
-class HallucinationMetrics:
- """Métriques de détection des hallucinations pour une paire (GT, hypothèse)."""
-
- net_insertion_rate: float
- """Taux d'insertion nette : tokens hypothèse absents du GT / total tokens hypothèse."""
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.hallucination import ...``) de continuer
+à fonctionner sans modification.
- length_ratio: float
- """Ratio de longueur : len(hyp) / len(gt) en caractères. > 1.2 = signal d'hallucination."""
-
- anchor_score: float
- """Score d'ancrage : proportion des trigrammes hypothèse présents dans les trigrammes GT.
- Score élevé → l'hypothèse s'ancre bien dans le GT. Score faible → hallucinations probables."""
-
- hallucinated_blocks: list[HallucinatedBlock]
- """Segments continus de la sortie sans correspondance GT (au-dessus du seuil de tolérance)."""
-
- is_hallucinating: bool
- """True si anchor_score < anchor_threshold OU length_ratio > length_ratio_threshold."""
-
- # Détails supplémentaires
- gt_word_count: int = 0
- hyp_word_count: int = 0
- net_inserted_words: int = 0
- anchor_threshold_used: float = 0.5
- length_ratio_threshold_used: float = 1.2
- ngram_size_used: int = 3
-
- def as_dict(self) -> dict:
- return {
- "net_insertion_rate": round(self.net_insertion_rate, 6),
- "length_ratio": round(self.length_ratio, 6),
- "anchor_score": round(self.anchor_score, 6),
- "hallucinated_blocks": [b.as_dict() for b in self.hallucinated_blocks],
- "is_hallucinating": self.is_hallucinating,
- "gt_word_count": self.gt_word_count,
- "hyp_word_count": self.hyp_word_count,
- "net_inserted_words": self.net_inserted_words,
- "anchor_threshold_used": self.anchor_threshold_used,
- "length_ratio_threshold_used": self.length_ratio_threshold_used,
- "ngram_size_used": self.ngram_size_used,
- }
-
- @classmethod
- def from_dict(cls, d: dict) -> "HallucinationMetrics":
- blocks = [
- HallucinatedBlock(**b) for b in d.get("hallucinated_blocks", [])
- ]
- return cls(
- net_insertion_rate=d.get("net_insertion_rate", 0.0),
- length_ratio=d.get("length_ratio", 1.0),
- anchor_score=d.get("anchor_score", 1.0),
- hallucinated_blocks=blocks,
- is_hallucinating=d.get("is_hallucinating", False),
- gt_word_count=d.get("gt_word_count", 0),
- hyp_word_count=d.get("hyp_word_count", 0),
- net_inserted_words=d.get("net_inserted_words", 0),
- anchor_threshold_used=d.get("anchor_threshold_used", 0.5),
- length_ratio_threshold_used=d.get("length_ratio_threshold_used", 1.2),
- ngram_size_used=d.get("ngram_size_used", 3),
- )
-
-
-# ---------------------------------------------------------------------------
-# Calcul principal
-# ---------------------------------------------------------------------------
-
-def compute_hallucination_metrics(
- reference: str,
- hypothesis: str,
- n: int = 3,
- length_ratio_threshold: float = 1.2,
- anchor_threshold: float = 0.5,
- block_tolerance: int = 3,
- min_block_length: int = 4,
-) -> HallucinationMetrics:
- """Calcule les métriques de détection des hallucinations VLM/LLM.
-
- Parameters
- ----------
- reference:
- Texte de vérité terrain (GT).
- hypothesis:
- Texte produit par le modèle.
- n:
- Taille des n-grammes pour le score d'ancrage (défaut : trigrammes).
- length_ratio_threshold:
- Seuil de ratio de longueur au-dessus duquel on signale une hallucination potentielle.
- anchor_threshold:
- Seuil de score d'ancrage en dessous duquel on signale une hallucination potentielle.
- block_tolerance:
- Nombre de tokens connus consécutifs acceptés dans un bloc halluciné.
- min_block_length:
- Longueur minimale (tokens) pour signaler un bloc halluciné.
-
- Returns
- -------
- HallucinationMetrics
- """
- gt_tokens = _tokenize(reference)
- hyp_tokens = _tokenize(hypothesis)
-
- gt_len_chars = len(reference.strip())
- hyp_len_chars = len(hypothesis.strip())
-
- # ── Ratio de longueur ────────────────────────────────────────────────
- if gt_len_chars == 0:
- length_ratio = 1.0 if hyp_len_chars == 0 else float("inf")
- else:
- length_ratio = hyp_len_chars / gt_len_chars
-
- # ── Taux d'insertion nette ───────────────────────────────────────────
- gt_token_set = set(gt_tokens)
- hyp_token_count = len(hyp_tokens)
-
- if hyp_token_count == 0:
- net_insertion_rate = 0.0
- net_inserted_words = 0
- else:
- net_inserted = [t for t in hyp_tokens if t not in gt_token_set]
- net_inserted_words = len(net_inserted)
- net_insertion_rate = net_inserted_words / hyp_token_count
-
- # ── Score d'ancrage (n-grammes) ──────────────────────────────────────
- gt_ngrams = set(_ngrams(gt_tokens, n))
- hyp_ngrams = _ngrams(hyp_tokens, n)
-
- if not hyp_ngrams:
- # Pas de n-grammes dans l'hypothèse → ancrage parfait (hypothèse vide ou trop courte)
- anchor_score = 1.0 if not gt_ngrams else 0.0
- elif not gt_ngrams:
- anchor_score = 0.0
- else:
- anchored = sum(1 for ng in hyp_ngrams if ng in gt_ngrams)
- anchor_score = anchored / len(hyp_ngrams)
-
- # ── Blocs hallucinés ─────────────────────────────────────────────────
- blocks = _detect_hallucinated_blocks(
- hyp_tokens=hyp_tokens,
- gt_token_set=gt_token_set,
- tolerance=block_tolerance,
- min_block_length=min_block_length,
- )
-
- # ── Badge hallucination ──────────────────────────────────────────────
- is_hallucinating = (
- anchor_score < anchor_threshold
- or length_ratio > length_ratio_threshold
- )
-
- return HallucinationMetrics(
- net_insertion_rate=net_insertion_rate,
- length_ratio=min(length_ratio, 9.99), # plafonner pour la sérialisation
- anchor_score=anchor_score,
- hallucinated_blocks=blocks,
- is_hallucinating=is_hallucinating,
- gt_word_count=len(gt_tokens),
- hyp_word_count=hyp_token_count,
- net_inserted_words=net_inserted_words,
- anchor_threshold_used=anchor_threshold,
- length_ratio_threshold_used=length_ratio_threshold,
- ngram_size_used=n,
- )
-
-
-# ---------------------------------------------------------------------------
-# Agrégation sur un corpus
-# ---------------------------------------------------------------------------
-
-def aggregate_hallucination_metrics(results: list[HallucinationMetrics]) -> dict:
- """Agrège les métriques d'hallucination sur un corpus.
-
- Returns
- -------
- dict
- Statistiques agrégées : anchor_score moyen, taux de documents hallucinés…
- """
- if not results:
- return {}
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
+"""
- n = len(results)
- anchor_values = [r.anchor_score for r in results]
- ratio_values = [r.length_ratio for r in results]
- insertion_values = [r.net_insertion_rate for r in results]
- hallucinating_count = sum(1 for r in results if r.is_hallucinating)
+from picarones.measurements.hallucination import * # noqa: F401, F403
- return {
- "anchor_score_mean": round(sum(anchor_values) / n, 6),
- "anchor_score_min": round(min(anchor_values), 6),
- "length_ratio_mean": round(sum(ratio_values) / n, 6),
- "net_insertion_rate_mean": round(sum(insertion_values) / n, 6),
- "hallucinating_doc_count": hallucinating_count,
- "hallucinating_doc_rate": round(hallucinating_count / n, 6),
- "document_count": n,
- }
+import picarones.measurements.hallucination as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/history.py b/picarones/core/history.py
index 9a360851ae767a99635a18eadae3fa0dcd9fb411..e0134ea445c4435553272c152362ff83c21bed02 100644
--- a/picarones/core/history.py
+++ b/picarones/core/history.py
@@ -1,615 +1,19 @@
-"""Suivi longitudinal des benchmarks — base SQLite optionnelle.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.history`.
-Fonctionnement
---------------
-- Chaque run de benchmark est enregistré dans une table SQLite avec horodatage,
- corpus, moteurs, métriques agrégées.
-- L'historique permet de tracer des courbes d'évolution du CER dans le temps.
-- La détection de régression compare le dernier run à une baseline configurable.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.history import ...``) de continuer
+à fonctionner sans modification.
-Structure de la base
---------------------
-Table ``runs`` :
- run_id TEXT PRIMARY KEY — UUID ou hash du run
- timestamp TEXT — ISO 8601
- corpus_name TEXT
- engine_name TEXT
- cer_mean REAL
- wer_mean REAL
- doc_count INTEGER
- metadata TEXT — JSON
-
-Usage
------
->>> from picarones.core.history import BenchmarkHistory
->>> history = BenchmarkHistory("~/.picarones/history.db")
->>> history.record(benchmark_result)
->>> df = history.query(engine="tesseract", corpus="chroniques")
->>> regression = history.detect_regression(engine="tesseract", threshold=0.02)
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import json
-import logging
-import sqlite3
-import uuid
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import TYPE_CHECKING, Optional
-
-if TYPE_CHECKING:
- from picarones.core.results import BenchmarkResult
-
-logger = logging.getLogger(__name__)
-
-
-# ---------------------------------------------------------------------------
-# Structures de données
-# ---------------------------------------------------------------------------
-
-@dataclass
-class HistoryEntry:
- """Un enregistrement dans l'historique des benchmarks."""
- run_id: str
- timestamp: str
- corpus_name: str
- engine_name: str
- cer_mean: Optional[float]
- wer_mean: Optional[float]
- doc_count: int
- metadata: dict = field(default_factory=dict)
-
- @property
- def cer_percent(self) -> Optional[float]:
- return self.cer_mean * 100 if self.cer_mean is not None else None
-
- def as_dict(self) -> dict:
- return {
- "run_id": self.run_id,
- "timestamp": self.timestamp,
- "corpus_name": self.corpus_name,
- "engine_name": self.engine_name,
- "cer_mean": self.cer_mean,
- "wer_mean": self.wer_mean,
- "doc_count": self.doc_count,
- "metadata": self.metadata,
- }
-
-
-@dataclass
-class RegressionResult:
- """Résultat d'une détection de régression."""
- engine_name: str
- corpus_name: str
- baseline_run_id: str
- baseline_timestamp: str
- baseline_cer: Optional[float]
- current_run_id: str
- current_timestamp: str
- current_cer: Optional[float]
- delta_cer: Optional[float]
- """Delta CER (current - baseline). Positif = régression."""
- is_regression: bool
- threshold: float
-
- def as_dict(self) -> dict:
- return {
- "engine_name": self.engine_name,
- "corpus_name": self.corpus_name,
- "baseline_run_id": self.baseline_run_id,
- "baseline_timestamp": self.baseline_timestamp,
- "baseline_cer": self.baseline_cer,
- "current_run_id": self.current_run_id,
- "current_timestamp": self.current_timestamp,
- "current_cer": self.current_cer,
- "delta_cer": self.delta_cer,
- "is_regression": self.is_regression,
- "threshold": self.threshold,
- }
-
-
-# ---------------------------------------------------------------------------
-# BenchmarkHistory
-# ---------------------------------------------------------------------------
-
-class BenchmarkHistory:
- """Gestionnaire de l'historique des benchmarks dans SQLite.
-
- Parameters
- ----------
- db_path:
- Chemin vers le fichier SQLite. Utiliser ``":memory:"`` pour les tests.
-
- Examples
- --------
- >>> history = BenchmarkHistory("~/.picarones/history.db")
- >>> history.record(benchmark)
- >>> entries = history.query(engine="tesseract")
- >>> for e in entries:
- ... print(e.timestamp, f"CER={e.cer_percent:.2f}%")
- """
-
- _CREATE_TABLE = """
- CREATE TABLE IF NOT EXISTS runs (
- run_id TEXT PRIMARY KEY,
- timestamp TEXT NOT NULL,
- corpus_name TEXT NOT NULL,
- engine_name TEXT NOT NULL,
- cer_mean REAL,
- wer_mean REAL,
- doc_count INTEGER,
- metadata TEXT
- );
- CREATE INDEX IF NOT EXISTS idx_engine ON runs (engine_name);
- CREATE INDEX IF NOT EXISTS idx_corpus ON runs (corpus_name);
- CREATE INDEX IF NOT EXISTS idx_timestamp ON runs (timestamp);
- """
-
- def __init__(self, db_path: str = "~/.picarones/history.db") -> None:
- if db_path != ":memory:":
- path = Path(db_path).expanduser()
- path.parent.mkdir(parents=True, exist_ok=True)
- self.db_path = str(path)
- else:
- self.db_path = ":memory:"
- self._conn: Optional[sqlite3.Connection] = None
- self._init_db()
-
- def _connect(self) -> sqlite3.Connection:
- if self._conn is None:
- self._conn = sqlite3.connect(self.db_path)
- self._conn.row_factory = sqlite3.Row
- return self._conn
-
- def _init_db(self) -> None:
- conn = self._connect()
- conn.executescript(self._CREATE_TABLE)
- conn.commit()
-
- def close(self) -> None:
- """Ferme la connexion SQLite."""
- if self._conn:
- self._conn.close()
- self._conn = None
-
- # ------------------------------------------------------------------
- # Enregistrement
- # ------------------------------------------------------------------
-
- def record(
- self,
- benchmark_result: "BenchmarkResult",
- run_id: Optional[str] = None,
- extra_metadata: Optional[dict] = None,
- ) -> str:
- """Enregistre les résultats d'un benchmark dans l'historique.
-
- Parameters
- ----------
- benchmark_result:
- Résultats à enregistrer (``BenchmarkResult``).
- run_id:
- Identifiant du run (auto-généré si None).
- extra_metadata:
- Métadonnées supplémentaires à stocker.
-
- Returns
- -------
- str
- L'identifiant du run enregistré.
- """
- if run_id is None:
- run_id = str(uuid.uuid4())
-
- timestamp = datetime.now(timezone.utc).isoformat()
- conn = self._connect()
-
- for report in benchmark_result.engine_reports:
- ranking = benchmark_result.ranking()
- engine_entry = next(
- (r for r in ranking if r["engine"] == report.engine_name),
- None,
- )
- cer_mean = engine_entry["mean_cer"] if engine_entry else None
- wer_mean = engine_entry["mean_wer"] if engine_entry else None
-
- meta = {
- "engine_version": report.engine_version,
- "engine_config": report.engine_config,
- "picarones_version": benchmark_result.metadata.get("picarones_version", ""),
- **(extra_metadata or {}),
- }
-
- conn.execute(
- """
- INSERT OR REPLACE INTO runs
- (run_id, timestamp, corpus_name, engine_name,
- cer_mean, wer_mean, doc_count, metadata)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- f"{run_id}_{report.engine_name}",
- timestamp,
- benchmark_result.corpus_name,
- report.engine_name,
- cer_mean,
- wer_mean,
- benchmark_result.document_count,
- json.dumps(meta, ensure_ascii=False),
- ),
- )
-
- conn.commit()
- logger.info("Benchmark enregistré dans l'historique : run_id=%s", run_id)
- return run_id
-
- def record_single(
- self,
- run_id: str,
- corpus_name: str,
- engine_name: str,
- cer_mean: Optional[float],
- wer_mean: Optional[float],
- doc_count: int,
- timestamp: Optional[str] = None,
- metadata: Optional[dict] = None,
- ) -> str:
- """Enregistre manuellement une entrée dans l'historique.
-
- Utile pour les tests, les imports de données externes, ou pour
- enregistrer des résultats calculés en dehors de Picarones.
-
- Returns
- -------
- str
- run_id enregistré.
- """
- if timestamp is None:
- timestamp = datetime.now(timezone.utc).isoformat()
-
- conn = self._connect()
- conn.execute(
- """
- INSERT OR REPLACE INTO runs
- (run_id, timestamp, corpus_name, engine_name,
- cer_mean, wer_mean, doc_count, metadata)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- run_id,
- timestamp,
- corpus_name,
- engine_name,
- cer_mean,
- wer_mean,
- doc_count,
- json.dumps(metadata or {}, ensure_ascii=False),
- ),
- )
- conn.commit()
- return run_id
-
- # ------------------------------------------------------------------
- # Requêtes
- # ------------------------------------------------------------------
-
- def query(
- self,
- engine: Optional[str] = None,
- corpus: Optional[str] = None,
- since: Optional[str] = None,
- limit: int = 100,
- ) -> list[HistoryEntry]:
- """Retourne l'historique des runs, avec filtres optionnels.
-
- Parameters
- ----------
- engine:
- Filtre sur le nom du moteur.
- corpus:
- Filtre sur le nom du corpus.
- since:
- Date ISO 8601 minimale (``"2025-01-01"``).
- limit:
- Nombre maximum d'entrées retournées.
-
- Returns
- -------
- list[HistoryEntry]
- Entrées triées par timestamp croissant.
- """
- clauses: list[str] = []
- params: list = []
-
- if engine:
- clauses.append("engine_name = ?")
- params.append(engine)
- if corpus:
- clauses.append("corpus_name = ?")
- params.append(corpus)
- if since:
- clauses.append("timestamp >= ?")
- params.append(since)
-
- where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
- params.append(limit)
-
- conn = self._connect()
- rows = conn.execute(
- f"SELECT * FROM runs {where} ORDER BY timestamp ASC LIMIT ?",
- params,
- ).fetchall()
-
- return [
- HistoryEntry(
- run_id=row["run_id"],
- timestamp=row["timestamp"],
- corpus_name=row["corpus_name"],
- engine_name=row["engine_name"],
- cer_mean=row["cer_mean"],
- wer_mean=row["wer_mean"],
- doc_count=row["doc_count"],
- metadata=json.loads(row["metadata"] or "{}"),
- )
- for row in rows
- ]
-
- def list_engines(self) -> list[str]:
- """Retourne la liste des moteurs présents dans l'historique."""
- conn = self._connect()
- rows = conn.execute(
- "SELECT DISTINCT engine_name FROM runs ORDER BY engine_name"
- ).fetchall()
- return [row[0] for row in rows]
-
- def list_corpora(self) -> list[str]:
- """Retourne la liste des corpus présents dans l'historique."""
- conn = self._connect()
- rows = conn.execute(
- "SELECT DISTINCT corpus_name FROM runs ORDER BY corpus_name"
- ).fetchall()
- return [row[0] for row in rows]
-
- def count(self) -> int:
- """Nombre total d'entrées dans l'historique."""
- conn = self._connect()
- return conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
-
- # ------------------------------------------------------------------
- # Courbes d'évolution
- # ------------------------------------------------------------------
-
- def get_cer_curve(
- self,
- engine: str,
- corpus: Optional[str] = None,
- ) -> list[dict]:
- """Retourne les données pour tracer la courbe d'évolution du CER.
-
- Parameters
- ----------
- engine:
- Nom du moteur.
- corpus:
- Corpus spécifique (None = tous les corpus pour ce moteur).
-
- Returns
- -------
- list[dict]
- Chaque dict contient ``{"timestamp": str, "cer": float, "run_id": str}``.
- """
- entries = self.query(engine=engine, corpus=corpus, limit=1000)
- return [
- {
- "timestamp": e.timestamp,
- "cer": e.cer_mean,
- "cer_percent": e.cer_percent,
- "run_id": e.run_id,
- "corpus_name": e.corpus_name,
- }
- for e in entries
- if e.cer_mean is not None
- ]
-
- # ------------------------------------------------------------------
- # Détection de régression
- # ------------------------------------------------------------------
-
- def detect_regression(
- self,
- engine: str,
- corpus: Optional[str] = None,
- threshold: float = 0.01,
- baseline_run_id: Optional[str] = None,
- ) -> Optional[RegressionResult]:
- """Détecte une régression du CER entre deux runs.
-
- Compare le run le plus récent à une baseline (le run précédent ou
- un run spécifique).
-
- Parameters
- ----------
- engine:
- Nom du moteur à surveiller.
- corpus:
- Corpus spécifique (None = tous).
- threshold:
- Seuil de régression en points absolus de CER (ex : 0.01 = 1%).
- Si delta_cer > threshold → régression détectée.
- baseline_run_id:
- run_id de référence. Si None, utilise l'avant-dernier run.
-
- Returns
- -------
- RegressionResult | None
- None si moins de 2 runs disponibles.
- """
- entries = self.query(engine=engine, corpus=corpus, limit=1000)
- if len(entries) < 2:
- logger.info("Pas assez de runs pour détecter une régression (moteur=%s)", engine)
- return None
-
- current = entries[-1]
-
- if baseline_run_id:
- baseline_list = [e for e in entries[:-1] if e.run_id == baseline_run_id]
- baseline = baseline_list[0] if baseline_list else entries[-2]
- else:
- baseline = entries[-2]
-
- delta = None
- is_regression = False
- if current.cer_mean is not None and baseline.cer_mean is not None:
- delta = current.cer_mean - baseline.cer_mean
- is_regression = delta > threshold
-
- return RegressionResult(
- engine_name=engine,
- corpus_name=corpus or "tous",
- baseline_run_id=baseline.run_id,
- baseline_timestamp=baseline.timestamp,
- baseline_cer=baseline.cer_mean,
- current_run_id=current.run_id,
- current_timestamp=current.timestamp,
- current_cer=current.cer_mean,
- delta_cer=delta,
- is_regression=is_regression,
- threshold=threshold,
- )
-
- def detect_all_regressions(
- self,
- threshold: float = 0.01,
- ) -> list[RegressionResult]:
- """Détecte les régressions pour tous les moteurs et corpus connus.
-
- Parameters
- ----------
- threshold:
- Seuil de régression.
-
- Returns
- -------
- list[RegressionResult]
- Uniquement les moteurs où une régression est détectée.
- """
- results: list[RegressionResult] = []
- engines = self.list_engines()
- corpora = self.list_corpora()
-
- for engine in engines:
- for corpus in corpora:
- result = self.detect_regression(engine, corpus, threshold)
- if result and result.is_regression:
- results.append(result)
-
- return results
-
- # ------------------------------------------------------------------
- # Export
- # ------------------------------------------------------------------
-
- def export_json(self, output_path: str) -> Path:
- """Exporte l'historique complet en JSON.
-
- Parameters
- ----------
- output_path:
- Chemin du fichier JSON de sortie.
-
- Returns
- -------
- Path
- Chemin vers le fichier créé.
- """
- entries = self.query(limit=100_000)
- path = Path(output_path)
- data = {
- "picarones_history": True,
- "exported_at": datetime.now(timezone.utc).isoformat(),
- "total_runs": len(entries),
- "engines": self.list_engines(),
- "corpora": self.list_corpora(),
- "runs": [e.as_dict() for e in entries],
- }
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
- return path
-
- def __repr__(self) -> str:
- return f"BenchmarkHistory(db='{self.db_path}', runs={self.count()})"
-
-
-# ---------------------------------------------------------------------------
-# Données de démonstration longitudinale
-# ---------------------------------------------------------------------------
-
-def generate_demo_history(
- db: BenchmarkHistory,
- n_runs: int = 8,
- seed: int = 42,
-) -> None:
- """Insère des données fictives de suivi longitudinal pour la démo.
-
- Simule l'amélioration progressive d'un modèle tesseract sur 8 runs,
- avec une légère régression au run 5.
-
- Parameters
- ----------
- db:
- Base d'historique à remplir.
- n_runs:
- Nombre de runs à générer.
- seed:
- Graine aléatoire.
- """
- import random
- rng = random.Random(seed)
-
- engines = ["tesseract", "pero_ocr", "ancien_moteur"]
- corpus = "Chroniques médiévales"
-
- # Trajectoires de CER simulées (amélioration progressive + bruit)
- base_cers = {
- "tesseract": 0.15,
- "pero_ocr": 0.09,
- "ancien_moteur": 0.28,
- }
- improvements = {
- "tesseract": -0.008, # améliore de ~0.8% par run
- "pero_ocr": -0.005, # améliore de ~0.5% par run
- "ancien_moteur": -0.003,
- }
-
- from datetime import timedelta
- base_date = datetime(2024, 9, 1, tzinfo=timezone.utc)
-
- for run_idx in range(n_runs):
- run_date = base_date + timedelta(weeks=run_idx * 2)
- run_id = f"demo_run_{run_idx + 1:02d}"
-
- for engine in engines:
- cer = base_cers[engine] + improvements[engine] * run_idx
- # Ajouter du bruit + régression au run 5
- noise = rng.gauss(0, 0.005)
- if run_idx == 4 and engine == "tesseract":
- noise += 0.02 # régression simulée
- cer = max(0.01, min(0.5, cer + noise))
-
- wer = cer * 1.8 + rng.gauss(0, 0.01)
- wer = max(0.01, min(0.9, wer))
+from picarones.measurements.history import * # noqa: F401, F403
- db.record_single(
- run_id=f"{run_id}_{engine}",
- corpus_name=corpus,
- engine_name=engine,
- cer_mean=round(cer, 4),
- wer_mean=round(wer, 4),
- doc_count=12,
- timestamp=run_date.isoformat(),
- metadata={
- "note": f"Run de démonstration #{run_idx + 1}",
- "engine_version": f"5.{run_idx}.0" if engine == "tesseract" else "0.7.2",
- },
- )
+import picarones.measurements.history as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/image_quality.py b/picarones/core/image_quality.py
index 929bf67f7a4c0a60d2f7029ebdba72a6d665e1fb..4e3a80d1acbcbf6bcea786f415cbdddc6a100abc 100644
--- a/picarones/core/image_quality.py
+++ b/picarones/core/image_quality.py
@@ -1,391 +1,19 @@
-"""Analyse automatique de la qualité des images de documents numérisés.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.image_quality`.
-Métriques
----------
-- **Score de netteté** : variance du laplacien (plus élevé = plus net)
-- **Niveau de bruit** : écart-type des résidus haute-fréquence
-- **Angle de rotation résiduel** : estimé par projection horizontale
-- **Score de contraste** : ratio Michelson entre zones sombres (encre) et claires (fond)
-- **Score de qualité global** : combinaison normalisée des métriques ci-dessus
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.image_quality import ...``) de continuer
+à fonctionner sans modification.
-Ces calculs sont réalisés en pur Python + bibliothèques stdlib ou Pillow.
-NumPy est utilisé si disponible (calculs plus rapides), mais les méthodes
-de fallback n'en dépendent pas.
-
-Note
-----
-Pour les images placeholder (fixtures), des valeurs fictives cohérentes
-sont générées via `generate_mock_quality_scores()`.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import math
-import statistics
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class ImageQualityResult:
- """Métriques de qualité d'une image de document."""
-
- sharpness_score: float = 0.0
- """Score de netteté [0, 1]. Basé sur la variance du laplacien normalisée."""
-
- noise_level: float = 0.0
- """Niveau de bruit [0, 1]. 0 = pas de bruit, 1 = très bruité."""
-
- rotation_degrees: float = 0.0
- """Angle de rotation résiduel estimé en degrés (positif = sens horaire)."""
-
- contrast_score: float = 0.0
- """Score de contraste [0, 1]. Ratio Michelson encre/fond."""
-
- quality_score: float = 0.0
- """Score de qualité global [0, 1]. Combinaison pondérée des autres métriques."""
-
- analysis_method: str = "none"
- """Méthode d'analyse utilisée : 'pillow', 'numpy', 'mock'."""
-
- error: Optional[str] = None
- """Erreur si l'analyse a échoué."""
-
- @property
- def is_good_quality(self) -> bool:
- """Vrai si le score de qualité global est ≥ 0.7."""
- return self.quality_score >= 0.7
-
- @property
- def quality_tier(self) -> str:
- """Catégorie de qualité : 'good', 'medium', 'poor'."""
- if self.quality_score >= 0.7:
- return "good"
- elif self.quality_score >= 0.4:
- return "medium"
- return "poor"
-
- def as_dict(self) -> dict:
- d = {
- "sharpness_score": round(self.sharpness_score, 4),
- "noise_level": round(self.noise_level, 4),
- "rotation_degrees": round(self.rotation_degrees, 2),
- "contrast_score": round(self.contrast_score, 4),
- "quality_score": round(self.quality_score, 4),
- "quality_tier": self.quality_tier,
- "analysis_method": self.analysis_method,
- }
- if self.error:
- d["error"] = self.error
- return d
-
- @classmethod
- def from_dict(cls, data: dict) -> "ImageQualityResult":
- return cls(
- sharpness_score=data.get("sharpness_score", 0.0),
- noise_level=data.get("noise_level", 0.0),
- rotation_degrees=data.get("rotation_degrees", 0.0),
- contrast_score=data.get("contrast_score", 0.0),
- quality_score=data.get("quality_score", 0.0),
- analysis_method=data.get("analysis_method", "none"),
- error=data.get("error"),
- )
-
-
-def analyze_image_quality(image_path: str | Path) -> ImageQualityResult:
- """Analyse la qualité d'une image de document numérisé.
-
- Essaie successivement :
- 1. Pillow + NumPy (méthode complète)
- 2. Pillow seul (méthode simplifiée)
- 3. Fallback : retourne un résultat vide avec erreur
-
- Parameters
- ----------
- image_path:
- Chemin vers l'image (JPG, PNG, TIFF…).
-
- Returns
- -------
- ImageQualityResult
- """
- path = Path(image_path)
- if not path.exists():
- return ImageQualityResult(
- error=f"Fichier image introuvable : {image_path}",
- analysis_method="none",
- )
-
- # Essai avec Pillow + NumPy
- try:
- import numpy as np
- from PIL import Image
- return _analyze_with_numpy(path, np, Image)
- except ImportError:
- pass
-
- # Essai avec Pillow seul
- try:
- from PIL import Image
- return _analyze_with_pillow(path, Image)
- except ImportError:
- pass
-
- return ImageQualityResult(
- error="Pillow non disponible (pip install Pillow)",
- analysis_method="none",
- quality_score=0.5, # valeur neutre
- )
-
-
-def _analyze_with_numpy(path: Path, np, Image) -> ImageQualityResult:
- """Analyse complète avec NumPy."""
- img = Image.open(path).convert("L") # niveaux de gris
- arr = np.array(img, dtype=np.float32)
-
- # 1. Netteté : variance du laplacien
- laplacian = _laplacian_variance_numpy(arr, np)
- # Normalisation empirique : variance > 500 = très net, < 50 = flou
- sharpness = min(1.0, laplacian / 500.0)
-
- # 2. Bruit : écart-type des résidus (différence image - image lissée)
- noise = _noise_level_numpy(arr, np)
-
- # 3. Rotation : angle d'inclinaison estimé
- rotation = _estimate_rotation_numpy(arr, np)
-
- # 4. Contraste : ratio Michelson
- contrast = _contrast_score_numpy(arr, np)
-
- # 5. Score global pondéré
- quality = _global_quality_score(sharpness, noise, abs(rotation), contrast)
-
- return ImageQualityResult(
- sharpness_score=float(sharpness),
- noise_level=float(noise),
- rotation_degrees=float(rotation),
- contrast_score=float(contrast),
- quality_score=float(quality),
- analysis_method="numpy",
- )
-
-
-def _analyze_with_pillow(path: Path, Image) -> ImageQualityResult:
- """Analyse simplifiée avec Pillow seul (sans NumPy)."""
- img = Image.open(path).convert("L")
- pixels = list(img.tobytes()) # mode "L" = 1 byte/pixel
- w, h = img.size
-
- if not pixels:
- return ImageQualityResult(quality_score=0.5, analysis_method="pillow")
-
- # Contraste : étendue des valeurs
- min_val = min(pixels)
- max_val = max(pixels)
- if max_val + min_val > 0:
- contrast = (max_val - min_val) / (max_val + min_val)
- else:
- contrast = 0.0
-
- # Netteté approximée : variance globale des pixels
- try:
- variance = statistics.variance(pixels)
- except statistics.StatisticsError:
- variance = 0.0
- sharpness = min(1.0, math.sqrt(variance) / 128.0)
-
- # Bruit : approximation grossière
- noise = min(1.0, statistics.stdev(pixels[:min(1000, len(pixels))]) / 64.0) if len(pixels) > 1 else 0.0
-
- quality = _global_quality_score(sharpness, noise, 0.0, contrast)
-
- return ImageQualityResult(
- sharpness_score=sharpness,
- noise_level=noise,
- rotation_degrees=0.0, # non calculé sans NumPy
- contrast_score=contrast,
- quality_score=quality,
- analysis_method="pillow",
- )
-
-
-def _laplacian_variance_numpy(arr, np) -> float:
- """Calcule la variance du laplacien (mesure de netteté)."""
- # Convolution laplacien 3x3 via slicing (bordures ignorées)
- h, w = arr.shape
- if h < 3 or w < 3:
- return float(np.var(arr))
-
- # Utiliser une convolution rapide avec slicing
- center = arr[1:-1, 1:-1]
- top = arr[:-2, 1:-1]
- bottom = arr[2:, 1:-1]
- left = arr[1:-1, :-2]
- right = arr[1:-1, 2:]
- lap = top + bottom + left + right - 4 * center
-
- return float(np.var(lap))
-
-
-def _noise_level_numpy(arr, np) -> float:
- """Estime le niveau de bruit par la MAD (Median Absolute Deviation) des gradients."""
- h, w = arr.shape
- if h < 2 or w < 2:
- return 0.0
- # Différences horizontales et verticales
- diff_h = np.abs(arr[:, 1:] - arr[:, :-1])
- diff_v = np.abs(arr[1:, :] - arr[:-1, :])
- noise_std = float(np.median(np.concatenate([diff_h.ravel(), diff_v.ravel()])))
- # Normaliser : 0 = pas de bruit, 1 = très bruité (seuil à ~30)
- return min(1.0, noise_std / 30.0)
-
-
-def _estimate_rotation_numpy(arr, np) -> float:
- """Estime l'angle de rotation par projection horizontale simplifiée.
-
- Retourne l'angle estimé en degrés [-45, 45].
- """
- # Méthode simplifiée : analyse de la variance des projections à différents angles
- # Limiter à quelques angles pour la performance
- h, w = arr.shape
- if h < 20 or w < 20:
- return 0.0
-
- # Sous-échantillonnage pour la performance
- step = max(1, h // 100)
- sample = arr[::step, :]
-
- best_angle = 0.0
- best_var = -1.0
-
- for angle_deg in range(-5, 6): # ±5 degrés, pas de 1°
- angle_rad = math.radians(angle_deg)
- # Projection horizontale après rotation approximative
- # (approximation linéaire rapide)
- offsets = np.round(
- np.arange(sample.shape[0]) * math.tan(angle_rad)
- ).astype(int)
- offsets = np.clip(offsets, 0, w - 1)
-
- # Variance des sommes de lignes décalées
- try:
- row_sums = np.array([
- float(np.sum(sample[i, max(0, offsets[i]):min(w, offsets[i]+w)]))
- for i in range(sample.shape[0])
- ])
- var = float(np.var(row_sums))
- if var > best_var:
- best_var = var
- best_angle = float(angle_deg)
- except Exception as e:
- logger.warning(
- "[image_quality] projection à %d° indisponible : %s",
- angle_deg, e,
- )
-
- return best_angle
-
-
-def _contrast_score_numpy(arr, np) -> float:
- """Score de contraste Michelson [0, 1]."""
- p5 = float(np.percentile(arr, 5)) # fond clair
- p95 = float(np.percentile(arr, 95)) # encre sombre
- if p5 + p95 == 0:
- return 0.0
- # Michelson : (Imax - Imin) / (Imax + Imin)
- return float((p95 - p5) / (p95 + p5))
-
-
-def _global_quality_score(
- sharpness: float,
- noise: float,
- rotation_abs: float,
- contrast: float,
-) -> float:
- """Calcule le score de qualité global pondéré."""
- # Poids : netteté (40%), contraste (30%), bruit (20%), rotation (10%)
- score = (
- 0.40 * sharpness
- + 0.30 * contrast
- + 0.20 * (1.0 - noise) # moins de bruit = mieux
- + 0.10 * max(0.0, 1.0 - rotation_abs / 10.0) # ±10° max
- )
- return round(min(1.0, max(0.0, score)), 4)
-
-
-# ---------------------------------------------------------------------------
-# Données fictives pour les fixtures de démo
-# ---------------------------------------------------------------------------
-
-def generate_mock_quality_scores(
- doc_id: str,
- seed: Optional[int] = None,
-) -> ImageQualityResult:
- """Génère des métriques de qualité fictives mais cohérentes pour un document.
-
- Utilisé par les fixtures de démo pour simuler une diversité réaliste
- de qualités d'image (bonne, moyenne, dégradée).
-
- Parameters
- ----------
- doc_id:
- Identifiant du document (utilisé pour la reproductibilité).
- seed:
- Graine aléatoire optionnelle.
- """
- import random
- rng = random.Random(seed or hash(doc_id) % 2**32)
-
- # Générer une qualité cohérente : certains docs sont plus difficiles
- base_quality = 0.3 + rng.random() * 0.6 # 0.3 à 0.9
-
- sharpness = max(0.1, min(1.0, base_quality + rng.gauss(0, 0.1)))
- noise = max(0.0, min(1.0, (1.0 - base_quality) * 0.8 + rng.gauss(0, 0.05)))
- rotation = rng.gauss(0, 1.5) # ±1.5° typique
- contrast = max(0.2, min(1.0, base_quality + rng.gauss(0, 0.15)))
-
- quality = _global_quality_score(sharpness, noise, abs(rotation), contrast)
-
- return ImageQualityResult(
- sharpness_score=round(sharpness, 4),
- noise_level=round(noise, 4),
- rotation_degrees=round(rotation, 2),
- contrast_score=round(contrast, 4),
- quality_score=round(quality, 4),
- analysis_method="mock",
- )
-
-
-def aggregate_image_quality(results: list[ImageQualityResult]) -> dict:
- """Agrège les métriques de qualité image sur un corpus."""
- if not results:
- return {}
-
- valid = [r for r in results if r.error is None]
- if not valid:
- return {"error": "Aucune analyse réussie"}
-
- def _mean(vals: list[float]) -> float:
- return round(statistics.mean(vals), 4) if vals else 0.0
-
- quality_scores = [r.quality_score for r in valid]
- sharpness_scores = [r.sharpness_score for r in valid]
- noise_levels = [r.noise_level for r in valid]
-
- # Distribution par tier
- tiers = {"good": 0, "medium": 0, "poor": 0}
- for r in valid:
- tiers[r.quality_tier] += 1
+from picarones.measurements.image_quality import * # noqa: F401, F403
- return {
- "mean_quality_score": _mean(quality_scores),
- "mean_sharpness": _mean(sharpness_scores),
- "mean_noise_level": _mean(noise_levels),
- "quality_distribution": tiers,
- "document_count": len(valid),
- "scores": [r.quality_score for r in valid], # pour scatter plot
- }
+import picarones.measurements.image_quality as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/incremental_comparison.py b/picarones/core/incremental_comparison.py
index 03f3ea0ee8a3b3eb7ed90af59349f49cb95386e1..3b747cf5003096d3a64b816975e4897aafe79439 100644
--- a/picarones/core/incremental_comparison.py
+++ b/picarones/core/incremental_comparison.py
@@ -1,253 +1,19 @@
-"""Comparaison incrémentale de pipelines composées — Sprint 96 (B.5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.incremental_comparison`.
-Sprint 96 — B.5 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.incremental_comparison import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Avec 5 OCR × 3 reconstructeurs × 4 post-correcteurs × 3
-mappeurs = 180 pipelines à comparer, le rapport noie
-l'information. Il faut un mécanisme de **comparaison
-contrôlée** type design d'expérience.
-
-Méthode
--------
-Pour mesurer l'effet isolé d'un slot ``varying`` :
-
-1. Fixer les valeurs des autres slots (``fixed``).
-2. Pour chaque combinaison des fixed, comparer les pipelines
- qui ne diffèrent que sur le slot varying.
-3. Agréger : pour chaque valeur du slot varying, calculer
- sa moyenne, son écart-type, son rang moyen sur les groupes.
-
-C'est presque un Latin square automatisé. Sans ça, le
-rapport sur 180 pipelines est inutilisable.
-
-Pas de tests statistiques scipy
--------------------------------
-On ne reconstruit pas Friedman/Nemenyi (déjà dans Sprint 18) ;
-on agrège ici les données nécessaires pour qu'un
-tests statistique externe puisse les consommer. Le rapport
-existant reste libre de brancher
-``picarones.core.statistics.friedman_test`` sur la sortie de
-ce module.
-
-Sortie
-------
-``compare_isolated_effect(runs, varying_slot)`` retourne :
-
-.. code-block:: text
-
- {
- "varying_slot": str,
- "n_runs": int,
- "n_groups": int, # combinaisons fixed distinctes
- "values": list[str], # valeurs distinctes du slot
- "per_value": {value: {
- "n_observations": int,
- "mean": float | None,
- "stdev": float | None,
- "min": float, "max": float,
- "mean_rank": float | None,
- }},
- "best_value": str | None,
- "worst_value": str | None,
- "groups": list[dict], # détail par groupe
- }
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import statistics
-from dataclasses import dataclass
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass(frozen=True)
-class PipelineRun:
- """Un run de pipeline composée pour la comparaison contrôlée.
-
- Attributes
- ----------
- name:
- Nom du run (libre — informatif uniquement).
- slots:
- Map ``{slot_name: module_name}`` décrivant la pipeline
- (ex. ``{"ocr": "tess", "llm": "gpt-4o"}``).
- score:
- Métrique numérique à comparer (CER moyen typiquement).
- Plus bas = meilleur par convention sauf si
- ``higher_is_better=True`` est passé à
- ``compare_isolated_effect``.
- """
-
- name: str
- slots: dict[str, str]
- score: float
-
- def as_dict(self) -> dict:
- return {
- "name": self.name,
- "slots": dict(self.slots),
- "score": self.score,
- }
-
-
-def _normalise_runs(runs) -> list[PipelineRun]:
- """Accepte une liste de ``PipelineRun`` ou de dicts compatibles."""
- out: list[PipelineRun] = []
- for r in runs:
- if isinstance(r, PipelineRun):
- out.append(r)
- continue
- if not isinstance(r, dict):
- continue
- slots = r.get("slots") or {}
- if not isinstance(slots, dict):
- continue
- try:
- score = float(r.get("score"))
- except (TypeError, ValueError):
- continue
- out.append(PipelineRun(
- name=str(r.get("name") or ""),
- slots={str(k): str(v) for k, v in slots.items()},
- score=score,
- ))
- return out
-
-
-def compare_isolated_effect(
- runs,
- varying_slot: str,
- *,
- higher_is_better: bool = False,
-) -> Optional[dict]:
- """Mesure l'effet isolé du slot ``varying_slot``.
-
- Parameters
- ----------
- runs:
- Liste de ``PipelineRun`` (ou dicts compatibles).
- varying_slot:
- Nom du slot dont on veut isoler l'effet. Les autres
- slots constituent les groupes de contrôle.
- higher_is_better:
- Si ``True``, on inverse la convention de classement
- (rang 1 = score le plus haut). Défaut ``False`` =
- rang 1 = score le plus bas (CER).
-
- Returns
- -------
- dict | None
- ``None`` si moins de 2 runs ou si ``varying_slot``
- n'est présent dans aucun run.
- """
- runs_list = _normalise_runs(runs)
- if len(runs_list) < 2:
- return None
- runs_list = [r for r in runs_list if varying_slot in r.slots]
- if not runs_list:
- return None
-
- # Constitue les groupes par valeurs des slots fixed
- groups: dict[tuple, list[PipelineRun]] = {}
- fixed_slot_names: list[str] = []
- for r in runs_list:
- other_slots = sorted(k for k in r.slots if k != varying_slot)
- if not fixed_slot_names:
- fixed_slot_names = other_slots
- # Skip runs avec un schéma de slots incompatible
- if other_slots != fixed_slot_names:
- continue
- key = tuple((k, r.slots[k]) for k in other_slots)
- groups.setdefault(key, []).append(r)
-
- if not groups:
- return None
-
- # Pour chaque groupe : ranking des runs par score
- per_value: dict[str, dict] = {}
- group_details: list[dict] = []
- for key, members in groups.items():
- members_sorted = sorted(
- members, key=lambda x: x.score, reverse=higher_is_better,
- )
- # Rangs : runs ex aequo partagent la moyenne des rangs
- ranks: dict[str, float] = {}
- i = 0
- while i < len(members_sorted):
- j = i
- while (
- j + 1 < len(members_sorted)
- and members_sorted[j + 1].score == members_sorted[i].score
- ):
- j += 1
- avg_rank = (i + 1 + j + 1) / 2
- for k in range(i, j + 1):
- value = members_sorted[k].slots[varying_slot]
- ranks[value] = avg_rank
- i = j + 1
-
- for r in members:
- value = r.slots[varying_slot]
- slot = per_value.setdefault(value, {
- "scores": [],
- "ranks": [],
- })
- slot["scores"].append(r.score)
- slot["ranks"].append(ranks[value])
- group_details.append({
- "fixed_slots": dict(key),
- "n_members": len(members),
- "values": [r.slots[varying_slot] for r in members_sorted],
- "scores": [r.score for r in members_sorted],
- })
-
- # Calcul mean/stdev/min/max + rang moyen par valeur
- summary: dict[str, dict] = {}
- for value, slot in per_value.items():
- scores = slot["scores"]
- ranks = slot["ranks"]
- summary[value] = {
- "n_observations": len(scores),
- "mean": statistics.fmean(scores) if scores else None,
- "stdev": (
- statistics.stdev(scores) if len(scores) >= 2 else None
- ),
- "min": min(scores),
- "max": max(scores),
- "mean_rank": (
- statistics.fmean(ranks) if ranks else None
- ),
- }
-
- # Best/worst : sur la mean (convention CER : plus bas = meilleur)
- by_mean = sorted(
- ((v, d["mean"]) for v, d in summary.items()
- if d["mean"] is not None),
- key=lambda kv: kv[1],
- reverse=higher_is_better,
- )
- best_value = by_mean[0][0] if by_mean else None
- worst_value = by_mean[-1][0] if by_mean else None
-
- return {
- "varying_slot": varying_slot,
- "n_runs": len(runs_list),
- "n_groups": len(groups),
- "values": sorted(per_value.keys()),
- "per_value": summary,
- "best_value": best_value,
- "worst_value": worst_value,
- "groups": group_details,
- "higher_is_better": higher_is_better,
- }
-
+from picarones.measurements.incremental_comparison import * # noqa: F401, F403
-__all__ = [
- "PipelineRun",
- "compare_isolated_effect",
-]
+import picarones.measurements.incremental_comparison as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/inter_engine.py b/picarones/core/inter_engine.py
index 68576f0ef9792451092a94aadeafb2c9aea4cf97..27cc04dc89de133da77300b22d39b7c264fab96a 100644
--- a/picarones/core/inter_engine.py
+++ b/picarones/core/inter_engine.py
@@ -1,484 +1,19 @@
-"""Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.inter_engine`.
-Deux familles de mesures qui répondent à des questions différentes mais
-liées :
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.inter_engine import ...``) de continuer
+à fonctionner sans modification.
-1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`,
- `taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des
- erreurs de natures différentes ?* Une divergence élevée signale des
- moteurs spécialisés sur des classes d'erreurs distinctes (visual vs
- abréviation vs casse) et donc des candidats pour un voting ensemble.
-
-2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`,
- `pairwise_disagreement_rate`) — *quel CER serait atteignable si on
- combinait les moteurs ?* La borne inférieure du CER atteignable par
- un voting majoritaire token-level est ``1 - oracle_token_recall``.
- Si elle est très inférieure au CER du meilleur moteur seul, l'effort
- d'un pipeline d'ensemble se justifie. Sinon non.
-
-Convention de typage
---------------------
-Toutes les fonctions sont enregistrables dans le registre Sprint 34 si
-on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour
-limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont
-des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne
-correspondent pas au modèle « une jonction = une métrique » du runner.
-Elles sont consommées par les détecteurs narratifs et le rapport HTML.
-
-Note sur l'oracle
------------------
-La métrique ``oracle_token_recall`` retournée ici utilise un alignement
-bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie
-borne atteignable par voting majoritaire séquentiel — c'est une borne
-supérieure (proxy optimiste). La vraie borne demanderait un
-alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour
-le diagnostic « ensemble vaut-il le coup ? », le proxy suffit
-largement ; on documente clairement la limite dans le glossaire et le
-rapport.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import math
-from collections import Counter
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Divergence taxonomique (KL / Jensen-Shannon)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _smoothed_distribution(
- distribution: dict[str, float],
- keys: list[str],
- epsilon: float = 1e-12,
-) -> list[float]:
- """Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros.
-
- Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement
- minuscule pour ne pas modifier le résultat de manière sensible.
- """
- smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys]
- total = sum(smoothed)
- return [v / total for v in smoothed]
-
-
-def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float:
- """KL-divergence ``D(P||Q)`` en bits, sur l'union des clés.
-
- Les distributions n'ont pas besoin de partager exactement les mêmes
- clés ; les clés manquantes sont lissées à ``epsilon`` puis
- renormalisées.
-
- Returns
- -------
- float
- ``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas
- symétrique : ``kl(p, q) != kl(q, p)`` en général.
- """
- keys = sorted(set(p.keys()) | set(q.keys()))
- if not keys:
- return 0.0
- p_vec = _smoothed_distribution(p, keys)
- q_vec = _smoothed_distribution(q, keys)
- return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec))
-
-
-def jensen_shannon_divergence(
- p: dict[str, float],
- q: dict[str, float],
-) -> float:
- """JS-divergence symétrique en bits, bornée dans ``[0, 1]``.
-
- ``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``.
- Symétrique et bornée — préférable à la KL pour construire une
- matrice triangulaire de divergences entre moteurs.
- """
- keys = sorted(set(p.keys()) | set(q.keys()))
- if not keys:
- return 0.0
- p_vec = _smoothed_distribution(p, keys)
- q_vec = _smoothed_distribution(q, keys)
- m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)]
-
- def _kl(a: list[float], b: list[float]) -> float:
- return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0)
-
- js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec)
- # Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les
- # erreurs d'arrondi flottant.
- return max(0.0, min(1.0, js))
-
-
-def taxonomy_divergence_matrix(
- distributions: dict[str, dict[str, float]],
- metric: str = "js",
-) -> dict[str, dict[str, float]]:
- """Construit la matrice de divergence triangulaire entre moteurs.
-
- Parameters
- ----------
- distributions:
- ``{engine_name: {error_class: probability}}``. Chaque
- distribution doit sommer à environ 1 (pas de validation stricte
- — les distributions taxonomiques de Picarones sont déjà
- normalisées par ``aggregate_taxonomy``).
- metric:
- ``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique).
-
- Returns
- -------
- dict[str, dict[str, float]]
- Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour
- ``js``, asymétrique pour ``kl``. La diagonale vaut 0.
- """
- if metric not in ("js", "kl"):
- raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}")
- fn = jensen_shannon_divergence if metric == "js" else kl_divergence
-
- engines = sorted(distributions.keys())
- matrix: dict[str, dict[str, float]] = {a: {} for a in engines}
- for a in engines:
- for b in engines:
- if a == b:
- matrix[a][b] = 0.0
- elif metric == "js" and b in matrix and a in matrix[b]:
- # Symétrique : recopie pour éviter de recalculer
- matrix[a][b] = matrix[b][a]
- else:
- matrix[a][b] = fn(distributions[a], distributions[b])
- return matrix
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Complémentarité (oracle token recall)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _word_multiset(text: str) -> Counter[str]:
- """Décomposition en multiset de tokens (séparateur whitespace)."""
- return Counter(tok for tok in text.split() if tok)
-
-
-def oracle_token_recall(
- reference: str,
- hypotheses: dict[str, str],
-) -> float:
- """Borne supérieure (proxy bag-of-words) du token-recall atteignable
- par un voting majoritaire entre tous les moteurs fournis.
-
- Pour chaque token de la référence (avec sa multiplicité), on
- considère qu'il est "préservé" par l'ensemble si au moins un moteur
- en produit une occurrence non encore comptée. Le score est le ratio
- d'occurrences GT préservées sur le total.
-
- Parameters
- ----------
- reference:
- Texte GT.
- hypotheses:
- ``{engine_name: hypothesis_text}``.
-
- Returns
- -------
- float
- Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent
- dans au moins une hypothèse à hauteur de sa multiplicité.
-
- Note
- ----
- Cette borne est **optimiste** (supérieure à la vraie borne par
- voting séquentiel) car elle ignore l'ordre d'apparition. Pour le
- diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour
- une vraie borne il faudrait un alignement séquentiel.
- """
- ref_counter = _word_multiset(reference)
- if not ref_counter or not hypotheses:
- return 1.0 if not ref_counter else 0.0
-
- hyp_counters = [_word_multiset(h) for h in hypotheses.values()]
- total_ref = sum(ref_counter.values())
- preserved = 0
- for token, gt_count in ref_counter.items():
- # Pour chaque moteur, le nombre d'occurrences disponibles, plafonné
- # à la multiplicité GT. L'oracle prend le max sur les moteurs.
- best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0)
- preserved += best
- return preserved / total_ref
-
-
-def complementarity_gap(
- reference: str,
- hypotheses: dict[str, str],
-) -> dict[str, float]:
- """Compare l'oracle au meilleur moteur seul.
-
- Returns
- -------
- dict
- ``{
- "oracle_recall": float, # bag-of-words recall de l'oracle
- "best_single_recall": float, # meilleur recall token d'un moteur seul
- "best_engine": str, # nom du moteur correspondant
- "absolute_gap": float, # oracle - best_single (toujours ≥ 0)
- "relative_gap": float, # absolute_gap / (1 - best_single + ε)
- # = fraction des erreurs encore évitables
- # par un ensemble
- }``
- """
- ref_counter = _word_multiset(reference)
- total = sum(ref_counter.values())
- if not total:
- return {
- "oracle_recall": 1.0,
- "best_single_recall": 1.0,
- "best_engine": "",
- "absolute_gap": 0.0,
- "relative_gap": 0.0,
- }
-
- def _single_recall(hyp_text: str) -> float:
- hc = _word_multiset(hyp_text)
- preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items())
- return preserved / total
-
- if not hypotheses:
- return {
- "oracle_recall": 0.0,
- "best_single_recall": 0.0,
- "best_engine": "",
- "absolute_gap": 0.0,
- "relative_gap": 0.0,
- }
-
- per_engine = {name: _single_recall(h) for name, h in hypotheses.items()}
- best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1])
- oracle = oracle_token_recall(reference, hypotheses)
-
- absolute_gap = max(0.0, oracle - best_recall)
- # relative_gap : fraction des erreurs du meilleur moteur que l'ensemble
- # serait théoriquement capable de récupérer (∈ [0, 1])
- headroom = max(1.0 - best_recall, 1e-12)
- relative_gap = min(1.0, absolute_gap / headroom)
-
- return {
- "oracle_recall": oracle,
- "best_single_recall": best_recall,
- "best_engine": best_engine,
- "absolute_gap": absolute_gap,
- "relative_gap": relative_gap,
- }
-
-
-def pairwise_disagreement_rate(
- reference: str,
- hyp_a: str,
- hyp_b: str,
-) -> float:
- """Fraction de tokens GT pour lesquels A et B sont en désaccord.
-
- Un désaccord = (l'un préserve le token, l'autre non) OU
- (les deux le ratent mais avec des substitutions différentes — non
- capturé ici, on reste sur la version simple présence/absence).
-
- Returns
- -------
- float
- Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix
- (pas de gain d'ensemble). ``1`` = A et B sont toujours en
- désaccord (gain d'ensemble maximal).
- """
- ref_counter = _word_multiset(reference)
- if not ref_counter:
- return 0.0
- a = _word_multiset(hyp_a)
- b = _word_multiset(hyp_b)
- total = sum(ref_counter.values())
- disagree = 0
- for tok, gt_count in ref_counter.items():
- a_pres = min(gt_count, a.get(tok, 0))
- b_pres = min(gt_count, b.get(tok, 0))
- # Compte les positions où A et B donnent une réponse différente
- disagree += abs(a_pres - b_pres)
- return disagree / total
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Agrégation au niveau benchmark (Sprint 36)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def compute_inter_engine_analysis(
- *,
- per_engine_outputs: dict[str, dict[str, str]],
- ground_truths: dict[str, str],
- taxonomy_distributions: dict[str, dict[str, float]] | None = None,
- divergence_metric: str = "js",
-) -> dict:
- """Agrège les métriques inter-moteurs sur l'ensemble du corpus.
-
- Parameters
- ----------
- per_engine_outputs:
- ``{engine_name: {doc_id: hypothesis_text}}``. Une entrée par
- moteur, avec une hypothèse par document. Les documents absents
- d'un moteur (échecs, timeouts) sont simplement ignorés pour ce
- moteur — l'oracle est calculé sur les moteurs qui ont produit
- une sortie pour le doc.
- ground_truths:
- ``{doc_id: ground_truth_text}``. La GT est la même pour tous
- les moteurs ; on la passe une seule fois.
- taxonomy_distributions:
- ``{engine_name: {error_class: probability}}`` — typiquement
- ``EngineReport.aggregated_taxonomy["class_distribution"]``. Si
- ``None`` ou vide, la divergence taxonomique n'est pas calculée.
- divergence_metric:
- ``"js"`` (défaut, symétrique) ou ``"kl"``.
-
- Returns
- -------
- dict
- Structure stable consommable par les détecteurs narratifs et le
- rapport HTML :
- ``{
- "complementarity": {
- "oracle_recall": float,
- "best_single_recall": float,
- "best_engine": str,
- "absolute_gap": float,
- "relative_gap": float,
- "doc_count": int,
- "per_doc": [{doc_id, oracle, best, gap}, ...] # max 50 docs
- },
- "taxonomy_divergence": {
- "metric": "js"|"kl",
- "matrix": {engine_a: {engine_b: divergence}},
- "max_pair": [engine_a, engine_b, value] # paire la plus divergente
- } | None,
- "engines": [...], # liste des moteurs analysés (ordre stable)
- }``
- """
- engines = sorted(per_engine_outputs.keys())
- result: dict = {"engines": engines}
-
- # ── Complémentarité agrégée doc par doc ──────────────────────────────
- if not engines:
- result["complementarity"] = None
- else:
- total_oracle_preserved = 0
- total_ref_tokens = 0
- per_engine_preserved: dict[str, int] = {name: 0 for name in engines}
- per_doc_records: list[dict] = []
-
- for doc_id, gt in ground_truths.items():
- ref_counter = _word_multiset(gt)
- ref_total = sum(ref_counter.values())
- if not ref_total:
- continue
- total_ref_tokens += ref_total
-
- doc_hyps: dict[str, str] = {}
- for name in engines:
- hyp = per_engine_outputs.get(name, {}).get(doc_id)
- if hyp is not None:
- doc_hyps[name] = hyp
-
- if not doc_hyps:
- continue
-
- hyp_counters = {n: _word_multiset(h) for n, h in doc_hyps.items()}
-
- doc_oracle = 0
- doc_best_per_engine: dict[str, int] = {n: 0 for n in doc_hyps}
- for tok, gt_count in ref_counter.items():
- # Oracle : meilleur des moteurs sur ce token
- best_for_token = 0
- for name, hc in hyp_counters.items():
- preserved = min(gt_count, hc.get(tok, 0))
- doc_best_per_engine[name] += preserved
- if preserved > best_for_token:
- best_for_token = preserved
- doc_oracle += best_for_token
-
- total_oracle_preserved += doc_oracle
- for name, count in doc_best_per_engine.items():
- per_engine_preserved[name] += count
-
- doc_best = max(doc_best_per_engine.values()) if doc_best_per_engine else 0
- per_doc_records.append({
- "doc_id": doc_id,
- "oracle_recall": doc_oracle / ref_total,
- "best_single_recall": doc_best / ref_total,
- "absolute_gap": (doc_oracle - doc_best) / ref_total,
- })
-
- if total_ref_tokens == 0:
- result["complementarity"] = None
- else:
- oracle_recall = total_oracle_preserved / total_ref_tokens
- recalls = {
- name: per_engine_preserved[name] / total_ref_tokens
- for name in engines
- }
- best_engine, best_recall = max(recalls.items(), key=lambda kv: kv[1])
- absolute_gap = max(0.0, oracle_recall - best_recall)
- headroom = max(1.0 - best_recall, 1e-12)
- relative_gap = min(1.0, absolute_gap / headroom)
-
- # Garder les ``per_doc_records`` les plus instructifs : tri par
- # gap absolu décroissant, top 50. Les détecteurs narratifs
- # n'en consomment que quelques-uns.
- per_doc_records.sort(key=lambda r: r["absolute_gap"], reverse=True)
- per_doc_top = per_doc_records[:50]
-
- result["complementarity"] = {
- "oracle_recall": oracle_recall,
- "best_single_recall": best_recall,
- "best_engine": best_engine,
- "absolute_gap": absolute_gap,
- "relative_gap": relative_gap,
- "doc_count": len(per_doc_records),
- "per_engine_recall": recalls,
- "per_doc": per_doc_top,
- }
-
- # ── Divergence taxonomique ─────────────────────────────────────────
- if not taxonomy_distributions:
- result["taxonomy_divergence"] = None
- else:
- matrix = taxonomy_divergence_matrix(
- taxonomy_distributions,
- metric=divergence_metric,
- )
- # Cherche la paire la plus divergente (utile pour la synthèse
- # narrative qui veut nommer les deux moteurs candidats à
- # l'ensemble).
- max_pair: tuple[str, str, float] = ("", "", 0.0)
- names = sorted(matrix.keys())
- for i, a in enumerate(names):
- for b in names[i + 1:]:
- v = matrix[a][b]
- if v > max_pair[2]:
- max_pair = (a, b, v)
-
- result["taxonomy_divergence"] = {
- "metric": divergence_metric,
- "matrix": matrix,
- "max_pair": list(max_pair) if max_pair[2] > 0 else None,
- }
-
- return result
-
+from picarones.measurements.inter_engine import * # noqa: F401, F403
-__all__ = [
- "kl_divergence",
- "jensen_shannon_divergence",
- "taxonomy_divergence_matrix",
- "oracle_token_recall",
- "complementarity_gap",
- "pairwise_disagreement_rate",
- "compute_inter_engine_analysis",
-]
+import picarones.measurements.inter_engine as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/layout.py b/picarones/core/layout.py
index 477d247e8b531c1aeafa97ee6b76ac064479904b..2b4a3a5c1924dfc38277de9d7699feaa92a53685 100644
--- a/picarones/core/layout.py
+++ b/picarones/core/layout.py
@@ -1,280 +1,19 @@
-"""Layout F1 par type de région — Sprint 54.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.layout`.
-Sprint 54 — A.II.2.2 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.layout import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Un médiéviste qui édite un manuscrit glosé veut savoir : *« le moteur
-sépare-t-il bien le texte principal de la glose ? »*. Le score de
-structure global de Picarones (Sprint 5) agrège fusion/fragmentation
-de lignes en un seul nombre — utile mais non typé. Ce module
-discrimine par **type de région** ALTO/PAGE (``TextRegion``,
-``MarginNote``, ``Header``, ``Footer``, ``Drop-Cap``...) en
-appliquant le pattern ICDAR layout standard :
-
-- **TP** : région GT et région hypothèse de **même type** avec
- chevauchement IoU ≥ seuil (alignement greedy par IoU décroissant),
-- **FN** : région GT non matchée,
-- **FP** : région hypothèse non matchée,
-- F1 calculé global et par type.
-
-Le pattern d'alignement est le même que pour le NER (Sprint 38) — on
-réutilise une approche éprouvée plutôt que d'en inventer une nouvelle.
-
-Stratégie de découpage
-----------------------
-Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
-(Sprint 53) : couche de calcul pure d'abord. L'utilisateur fournit
-deux listes de ``Region`` (typiquement extraites de ALTO/PAGE par un
-parser amont — le parser ALTO/PAGE standard de Picarones suivra
-dans un sprint dédié). Pas de câblage runner ni de vue HTML ici.
-
-Convention de coordonnées
--------------------------
-Une bbox est un tuple ``(x, y, width, height)`` en pixels (origine
-en haut à gauche, axe y vers le bas — convention ALTO et PAGE
-standard). L'IoU est calculée sur l'aire d'intersection / union des
-rectangles.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-from typing import Iterable
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Modèle de données
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@dataclass(frozen=True)
-class Region:
- """Une région ALTO/PAGE alignable sur sa GT.
-
- Attributs
- ---------
- id:
- Identifiant unique au sein de la séquence (ex. ``"r_1"``,
- ``"region_main"``). Informatif — l'alignement se fait par IoU,
- pas par ID.
- type:
- Catégorie de la région (``"TextRegion"``, ``"MarginNote"``,
- ``"Header"``, etc.). Comparaison **case-insensitive**.
- bbox:
- Rectangle ``(x, y, width, height)`` en pixels, origine en haut
- à gauche. Doit avoir width > 0 et height > 0.
- """
-
- id: str
- type: str
- bbox: tuple[int, int, int, int]
-
- def __post_init__(self) -> None:
- x, y, w, h = self.bbox
- if w <= 0 or h <= 0:
- raise ValueError(
- f"Region {self.id!r} : bbox invalide (w={w}, h={h}). "
- "width et height doivent être strictement positifs."
- )
-
- @property
- def area(self) -> int:
- _, _, w, h = self.bbox
- return w * h
-
-
-def _to_region(obj: Region | dict) -> Region:
- """Coerce un dict en ``Region`` (clés ``id``, ``type``, ``bbox``)."""
- if isinstance(obj, Region):
- return obj
- return Region(
- id=str(obj["id"]),
- type=str(obj["type"]),
- bbox=tuple(obj["bbox"]), # type: ignore[arg-type]
- )
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# IoU + alignement greedy
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _iou_bbox(a: Region, b: Region) -> float:
- """Intersection-over-Union de deux bboxes ``(x, y, w, h)``."""
- ax, ay, aw, ah = a.bbox
- bx, by, bw, bh = b.bbox
- inter_x = max(ax, bx)
- inter_y = max(ay, by)
- inter_x_end = min(ax + aw, bx + bw)
- inter_y_end = min(ay + ah, by + bh)
- inter_w = max(0, inter_x_end - inter_x)
- inter_h = max(0, inter_y_end - inter_y)
- inter = inter_w * inter_h
- if inter == 0:
- return 0.0
- union = a.area + b.area - inter
- if union <= 0:
- return 0.0
- return inter / union
-
-
-def _align_regions(
- references: list[Region],
- hypotheses: list[Region],
- iou_threshold: float,
-) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
- """Appareillage greedy par IoU décroissant ; same type requis.
-
- Renvoie ``(matches, unmatched_refs, unmatched_hyps)`` —
- ``matches`` est une liste de ``(idx_ref, idx_hyp, iou)``.
- """
- candidates: list[tuple[float, int, int]] = []
- for i, r in enumerate(references):
- for j, h in enumerate(hypotheses):
- if r.type.casefold() != h.type.casefold():
- continue
- iou = _iou_bbox(r, h)
- if iou >= iou_threshold:
- candidates.append((iou, i, j))
-
- # Tri stable : IoU décroissant, puis indices croissants pour
- # déterminisme sur égalités.
- candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
-
- matched_refs: set[int] = set()
- matched_hyps: set[int] = set()
- matches: list[tuple[int, int, float]] = []
- for iou, i, j in candidates:
- if i in matched_refs or j in matched_hyps:
- continue
- matched_refs.add(i)
- matched_hyps.add(j)
- matches.append((i, j, iou))
-
- unmatched_refs = set(range(len(references))) - matched_refs
- unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
- return matches, unmatched_refs, unmatched_hyps
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Métrique principale
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
- p = tp / (tp + fp) if (tp + fp) > 0 else 0.0
- r = tp / (tp + fn) if (tp + fn) > 0 else 0.0
- f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
- return {"precision": p, "recall": r, "f1": f1, "support": tp + fn}
-
-
-def compute_layout_metrics(
- reference_regions: Iterable[Region | dict] | None,
- hypothesis_regions: Iterable[Region | dict] | None,
- iou_threshold: float = 0.5,
-) -> dict:
- """Calcule precision/recall/F1 sur le layout par type de région.
-
- Parameters
- ----------
- reference_regions:
- Liste de régions GT (``Region`` ou dict ``{id, type, bbox}``).
- hypothesis_regions:
- Liste de régions produites par le moteur OCR/HTR ou un
- layout-detector.
- iou_threshold:
- Seuil de chevauchement minimal pour déclarer un appariement
- (défaut : 0,5 — convention ICDAR).
-
- Returns
- -------
- dict
- ``{
- "global": {"precision", "recall", "f1", "support"},
- "per_type": {type_name: {"precision", ...}},
- "true_positives": int,
- "false_positives": int,
- "false_negatives": int,
- "missed_regions": list[dict], # GT non matchées
- "hallucinated_regions": list[dict], # hyp non matchées
- "iou_threshold": float,
- }``
-
- Cas dégénérés
- -------------
- - Deux listes vides → F1 = 0 et tous compteurs à 0.
- - GT vide + hyp non-vide → F1 = 0 (toutes hyp = FP).
- - hyp vide + GT non-vide → F1 = 0 (toutes GT = FN).
- """
- refs = [_to_region(r) for r in (reference_regions or [])]
- hyps = [_to_region(h) for h in (hypothesis_regions or [])]
-
- matches, unmatched_refs, unmatched_hyps = _align_regions(
- refs, hyps, iou_threshold,
- )
-
- tp = len(matches)
- fn = len(unmatched_refs)
- fp = len(unmatched_hyps)
-
- cat_tp: dict[str, int] = {}
- cat_fn: dict[str, int] = {}
- cat_fp: dict[str, int] = {}
- for i, _j, _iou in matches:
- cat = refs[i].type
- cat_tp[cat] = cat_tp.get(cat, 0) + 1
- for i in unmatched_refs:
- cat = refs[i].type
- cat_fn[cat] = cat_fn.get(cat, 0) + 1
- for j in unmatched_hyps:
- cat = hyps[j].type
- cat_fp[cat] = cat_fp.get(cat, 0) + 1
-
- all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
- per_type = {
- cat: _prf(
- cat_tp.get(cat, 0),
- cat_fp.get(cat, 0),
- cat_fn.get(cat, 0),
- )
- for cat in all_categories
- }
-
- return {
- "global": _prf(tp, fp, fn),
- "per_type": per_type,
- "true_positives": tp,
- "false_positives": fp,
- "false_negatives": fn,
- "missed_regions": [
- {"id": refs[i].id, "type": refs[i].type, "bbox": list(refs[i].bbox)}
- for i in sorted(unmatched_refs)
- ],
- "hallucinated_regions": [
- {"id": hyps[j].id, "type": hyps[j].type, "bbox": list(hyps[j].bbox)}
- for j in sorted(unmatched_hyps)
- ],
- "iou_threshold": iou_threshold,
- }
-
-
-def layout_f1(
- reference_regions: Iterable[Region | dict] | None,
- hypothesis_regions: Iterable[Region | dict] | None,
- iou_threshold: float = 0.5,
-) -> float:
- """Raccourci : F1 global du layout."""
- return compute_layout_metrics(
- reference_regions, hypothesis_regions, iou_threshold,
- )["global"]["f1"]
-
+from picarones.measurements.layout import * # noqa: F401, F403
-__all__ = [
- "Region",
- "compute_layout_metrics",
- "layout_f1",
-]
+import picarones.measurements.layout as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/levers.py b/picarones/core/levers.py
index 47ba0ab9d665f6eb35d0572fdb4c07a2d7b4ea44..7706d6fd69631dd71d914e4ed4eab32ce347f239 100644
--- a/picarones/core/levers.py
+++ b/picarones/core/levers.py
@@ -1,561 +1,19 @@
-"""Section « Leviers d'amélioration » — Sprint 82 (A.I.9).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.levers`.
-Sprint 82 — A.I.9 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.levers import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce
-qui s'est passé** dans le benchmark : qui gagne, qui s'effondre,
-qui est fragile. Ce sprint répond à une question
-complémentaire : **sur quelle dimension le bénéfice attendu d'une
-amélioration serait-il le plus visible ?**
-
-Pas de prescription
--------------------
-Picarones est un **outil de recherche**, pas un atelier de
-production. Le module ne dit jamais *« faites X »* ni
-*« utilisez le moteur Y »* ; il agrège des **observations
-factuelles** déjà calculées dans d'autres modules (Sprints 75-81)
-et les présente comme un récapitulatif compact en bas du rapport.
-Le chercheur lit, juge et arbitre.
-
-Exemples de leviers émis
-------------------------
-- *« 65 % des erreurs de Tesseract sont de classe récupérable
- (case_error, ligature_error, abbreviation_error) — un
- post-processing trivial absorberait une partie. »*
-- *« 12 % de vos documents concentrent 78 % du CER total
- (Pareto-CER). »*
-- *« Le déficit projeté du moteur le plus fragile sur le corpus
- réel est de 4,2 points de CER (Sprint 81). »*
-- *« Le top-3 des tokens GT systématiquement modernisés est
- maistre, nostre, veoir (Sprint 80). »*
-
-Structure
----------
-Module parallèle au registre narratif Sprint 19 : `Lever` est la
-dataclass équivalente à `Fact`, `LeverImportance` reprend la
-sémantique de `FactImportance`, `@register_lever` indexe les
-détecteurs. Garde-fou anti-hallucination identique : chaque
-nombre rendu doit être présent dans le `payload` du `Lever`.
-
-Les détecteurs lisent **uniquement** des structures déjà
-construites par le pipeline du benchmark — ils ne calculent rien
-de nouveau, ils synthétisent. C'est pourquoi le module est
-résolument optionnel : si un benchmark n'expose pas
-`taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`,
-`lexical_modernization` ou `robustness_projection`, le détecteur
-correspondant retourne tout simplement `[]`.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import threading
-from dataclasses import dataclass
-from enum import Enum
-from typing import Callable
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Modèle
-# ──────────────────────────────────────────────────────────────────────────
-
-
-class LeverType(str, Enum):
- """Types de leviers détectés."""
-
- DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class"
- """Une part importante des erreurs d'un moteur est dans des classes
- catégorisées « récupérables » (Sprint 77)."""
-
- PARETO_CONCENTRATION = "pareto_concentration"
- """Une fraction minoritaire de documents concentre une fraction
- majoritaire du CER total — l'inspection ciblée est rentable."""
-
- COMPLEMENTARITY_OBSERVATION = "complementarity_observation"
- """Le `complementarity_gap` (Sprint 35) entre l'oracle et le
- meilleur moteur seul est non négligeable — observation factuelle,
- aucune recommandation d'ensemble."""
-
- LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation"
- """Top-N des tokens GT systématiquement modernisés (Sprint 80)."""
-
- ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation"
- """Déficit projeté global le plus important pour un moteur sur
- le corpus réel (Sprint 81)."""
-
-
-class LeverImportance(int, Enum):
- """Importance éditoriale d'un levier."""
-
- HIGH = 70
- MEDIUM = 40
- LOW = 10
-
-
-@dataclass
-class Lever:
- """Observation factuelle synthétisable en encart « Leviers ».
-
- Attributes
- ----------
- type:
- Le type de levier (voir `LeverType`).
- importance:
- Score qui décide l'ordre d'affichage.
- payload:
- Données brutes — **tout chiffre rendu dans le HTML doit
- provenir d'ici**, jamais d'un calcul du renderer.
- engines_involved:
- Noms des moteurs concernés (peut être vide pour un levier
- corpus-wide).
- """
-
- type: LeverType
- importance: LeverImportance
- payload: dict
- engines_involved: tuple[str, ...] = ()
-
- def as_dict(self) -> dict:
- return {
- "type": self.type.value,
- "importance": int(self.importance),
- "payload": self.payload,
- "engines_involved": list(self.engines_involved),
- }
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Registre
-# ──────────────────────────────────────────────────────────────────────────
-
-
-LeverDetectorFn = Callable[[dict], list[Lever]]
-
-
-@dataclass(frozen=True)
-class LeverDetectorEntry:
- lever_type: LeverType
- fn: LeverDetectorFn
- priority: int
-
-
-_LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {}
-_LEVER_REGISTRY_LOCK = threading.Lock()
-
-
-def register_lever(
- lever_type: LeverType,
- *,
- priority: int,
-) -> Callable[[LeverDetectorFn], LeverDetectorFn]:
- """Décorateur : enregistre un détecteur de levier.
-
- Une seule fonction par type — réenregistrer lève `ValueError`.
- """
- def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn:
- with _LEVER_REGISTRY_LOCK:
- if lever_type in _LEVER_REGISTRY:
- raise ValueError(
- f"Détecteur déjà enregistré pour {lever_type.value!r} : "
- f"{_LEVER_REGISTRY[lever_type].fn.__name__}."
- )
- _LEVER_REGISTRY[lever_type] = LeverDetectorEntry(
- lever_type=lever_type, fn=fn, priority=int(priority),
- )
- return fn
- return _decorator
-
-
-def unregister_lever(lever_type: LeverType) -> None:
- with _LEVER_REGISTRY_LOCK:
- _LEVER_REGISTRY.pop(lever_type, None)
-
-
-def iter_lever_detectors() -> list[LeverDetectorEntry]:
- with _LEVER_REGISTRY_LOCK:
- entries = list(_LEVER_REGISTRY.values())
- entries.sort(key=lambda e: e.priority)
- return entries
-
-
-def detect_levers(benchmark_data: dict) -> list[Lever]:
- """Applique tous les détecteurs enregistrés et trie par importance
- décroissante puis priorité d'enregistrement croissante."""
- levers: list[Lever] = []
- for entry in iter_lever_detectors():
- try:
- result = entry.fn(benchmark_data)
- except Exception as e:
- logger.warning(
- "[levers.detector.%s] fonctionnalité dégradée : %s",
- entry.lever_type.value, e,
- )
- continue
- if result:
- levers.extend(result)
- # Tri stable : importance décroissante d'abord
- levers.sort(key=lambda lv: -int(lv.importance))
- return levers
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Détecteurs
-# ──────────────────────────────────────────────────────────────────────────
-
-
-# Catégorisation reprise du Sprint 77 (taxonomy_comparison.py).
-# Volontairement dupliquée ici pour ne pas introduire d'import
-# circulaire — la sémantique est gelée.
-_RECOVERABILITY: dict[str, str] = {
- "case_error": "recoverable",
- "ligature_error": "recoverable",
- "abbreviation_error": "recoverable",
- "diacritic_error": "difficult",
- "visual_confusion": "difficult",
- "hapax": "difficult",
- "lacuna": "irrecoverable",
- "oov_character": "irrecoverable",
- "segmentation_error": "irrecoverable",
-}
-
-
-@register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10)
-def detect_dominant_recoverable_class(
- benchmark_data: dict,
- *,
- threshold: float = 0.30,
-) -> list[Lever]:
- """Émet un levier si ≥ `threshold` des erreurs d'un moteur sont
- classifiées récupérables (catégorisation Sprint 77).
-
- Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` —
- structure produite par le runner historique. Si absent, retourne
- [].
- """
- engines = benchmark_data.get("engines") or []
- out: list[Lever] = []
- for engine in engines:
- taxonomy = engine.get("aggregated_taxonomy")
- if not taxonomy:
- continue
- # `taxonomy` peut être {class_name: int} ou un dict avec une
- # sous-clé "counts" — on accepte les deux conventions.
- counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy
- if not isinstance(counts, dict) or not counts:
- continue
- try:
- int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))}
- except (TypeError, ValueError):
- continue
- total = sum(int_counts.values())
- if total <= 0:
- continue
- recoverable_total = sum(
- v for k, v in int_counts.items()
- if _RECOVERABILITY.get(k) == "recoverable"
- )
- share = recoverable_total / total
- if share < threshold:
- continue
- # Classes récupérables non vides triées par count décroissant
- breakdown = sorted(
- (
- (k, v) for k, v in int_counts.items()
- if _RECOVERABILITY.get(k) == "recoverable" and v > 0
- ),
- key=lambda kv: -kv[1],
- )
- importance = (
- LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM
- )
- out.append(Lever(
- type=LeverType.DOMINANT_RECOVERABLE_CLASS,
- importance=importance,
- payload={
- "engine": engine.get("name") or "?",
- "share_recoverable": share,
- "share_recoverable_pct": round(share * 100, 1),
- "n_recoverable": recoverable_total,
- "n_total_errors": total,
- "top_classes": [
- {"class": k, "count": v} for k, v in breakdown[:3]
- ],
- },
- engines_involved=(engine.get("name") or "?",),
- ))
- return out
-
-
-@register_lever(LeverType.PARETO_CONCENTRATION, priority=20)
-def detect_pareto_concentration(
- benchmark_data: dict,
- *,
- top_share: float = 0.20,
- cer_share_threshold: float = 0.50,
-) -> list[Lever]:
- """Émet un levier si une fraction minoritaire de documents
- (`top_share`) concentre plus de `cer_share_threshold` du CER
- total cumulé sur le moteur leader.
-
- Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de
- reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`.
- Si rien d'exploitable, retourne [].
- """
- ranking = benchmark_data.get("ranking") or []
- if not ranking:
- return []
- leader = ranking[0]
- leader_name = leader.get("engine")
- if not leader_name:
- return []
-
- per_doc_cer: list[float] = []
- # Voie 1 : structure plate "per_doc_cer"
- flat = benchmark_data.get("per_doc_cer") or {}
- if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list):
- per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))]
- else:
- # Voie 2 : engine.per_doc liste de dicts {cer: float}
- for engine in benchmark_data.get("engines") or []:
- if engine.get("name") != leader_name:
- continue
- per_doc = engine.get("per_doc") or []
- for entry in per_doc:
- if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)):
- per_doc_cer.append(float(entry["cer"]))
- break
-
- if not per_doc_cer:
- return []
- total_cer = sum(per_doc_cer)
- if total_cer <= 0:
- return []
-
- sorted_cer = sorted(per_doc_cer, reverse=True)
- n = len(sorted_cer)
- n_top = max(1, int(round(top_share * n)))
- top_cer_sum = sum(sorted_cer[:n_top])
- share_of_total = top_cer_sum / total_cer
- if share_of_total < cer_share_threshold:
- return []
- importance = (
- LeverImportance.HIGH if share_of_total >= 0.75
- else LeverImportance.MEDIUM
- )
- return [Lever(
- type=LeverType.PARETO_CONCENTRATION,
- importance=importance,
- payload={
- "engine": leader_name,
- "n_docs": n,
- "n_docs_top": n_top,
- "top_share_pct": round((n_top / n) * 100, 1),
- "cer_share_of_total": share_of_total,
- "cer_share_pct": round(share_of_total * 100, 1),
- },
- engines_involved=(leader_name,),
- )]
-
-
-@register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30)
-def detect_complementarity_observation(
- benchmark_data: dict,
- *,
- min_relative_gap: float = 0.20,
-) -> list[Lever]:
- """Reformule factuellement le `complementarity_gap` (Sprint 35).
-
- Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne
- déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune
- recommandation d'ensemble** — le levier dit factuellement
- « X points séparent l'oracle du meilleur moteur », c'est tout.
- """
- inter = benchmark_data.get("inter_engine_analysis") or {}
- cgap = inter.get("complementarity_gap") or {}
- relative_gap = cgap.get("relative_gap")
- absolute_gap = cgap.get("absolute_gap")
- if relative_gap is None or absolute_gap is None:
- return []
- try:
- rg = float(relative_gap)
- ag = float(absolute_gap)
- except (TypeError, ValueError):
- return []
- if rg < min_relative_gap:
- return []
- importance = (
- LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM
- )
- payload: dict = {
- "absolute_gap": ag,
- "absolute_gap_pct": round(ag * 100, 1),
- "relative_gap": rg,
- "relative_gap_pct": round(rg * 100, 1),
- }
- best_engine = cgap.get("best_engine") or inter.get("best_engine")
- best_recall = cgap.get("best_recall") or inter.get("best_engine_recall")
- oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall")
- engines_involved: tuple[str, ...] = ()
- if best_engine:
- payload["best_engine"] = str(best_engine)
- engines_involved = (str(best_engine),)
- if isinstance(best_recall, (int, float)):
- payload["best_recall"] = float(best_recall)
- if isinstance(oracle_recall, (int, float)):
- payload["oracle_recall"] = float(oracle_recall)
- return [Lever(
- type=LeverType.COMPLEMENTARITY_OBSERVATION,
- importance=importance,
- payload=payload,
- engines_involved=engines_involved,
- )]
-
-
-@register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40)
-def detect_lexical_modernization_observation(
- benchmark_data: dict,
- *,
- top_n: int = 3,
- min_total: int = 3,
- min_rate: float = 0.50,
-) -> list[Lever]:
- """Pour chaque moteur disposant de `lexical_modernization`,
- émet un levier listant les `top_n` tokens GT les plus modernisés.
-
- Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui
- suit la forme produite par `compute_lexical_modernization` du
- Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`).
- """
- out: list[Lever] = []
- for engine in benchmark_data.get("engines") or []:
- data = engine.get("lexical_modernization")
- if not isinstance(data, dict):
- continue
- tokens = data.get("tokens") or {}
- if not isinstance(tokens, dict) or not tokens:
- continue
- candidates: list[tuple[str, dict]] = []
- for gt_token, slot in tokens.items():
- if not isinstance(slot, dict):
- continue
- n_total = slot.get("n_total")
- rate = slot.get("rate_modernized")
- if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)):
- continue
- if int(n_total) < min_total:
- continue
- if float(rate) < min_rate:
- continue
- candidates.append((gt_token, dict(slot)))
- if not candidates:
- continue
- candidates.sort(
- key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)),
- -int(kv[1].get("n_total", 0)),
- kv[0]),
- )
- top = candidates[:top_n]
- engine_name = engine.get("name") or "?"
- max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top)
- importance = (
- LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM
- )
- out.append(Lever(
- type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION,
- importance=importance,
- payload={
- "engine": engine_name,
- "top_tokens": [
- {
- "gt_token": gt,
- "n_total": int(slot.get("n_total", 0)),
- "rate_modernized": float(slot.get("rate_modernized", 0.0)),
- "rate_modernized_pct": round(
- float(slot.get("rate_modernized", 0.0)) * 100, 1,
- ),
- }
- for gt, slot in top
- ],
- },
- engines_involved=(engine_name,),
- ))
- return out
-
-
-@register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50)
-def detect_robustness_projection_observation(
- benchmark_data: dict,
- *,
- min_total_deficit: float = 0.02,
-) -> list[Lever]:
- """Lit l'agrégation par moteur de la projection de robustesse
- (Sprint 81). Émet le levier pour le moteur dont
- `total_expected_deficit` est ≥ `min_total_deficit` (par défaut
- 2 points de CER).
-
- Lit `benchmark_data["robustness_projection_aggregated"]` —
- structure produite par `aggregate_projection_per_engine`.
- """
- agg = benchmark_data.get("robustness_projection_aggregated") or {}
- if not isinstance(agg, dict) or not agg:
- return []
- out: list[Lever] = []
- for engine_name, info in agg.items():
- if not isinstance(info, dict):
- continue
- total_deficit = info.get("total_expected_deficit")
- worst_type = info.get("worst_degradation_type")
- worst_deficit = info.get("worst_degradation_deficit")
- if not isinstance(total_deficit, (int, float)):
- continue
- if float(total_deficit) < min_total_deficit:
- continue
- importance = (
- LeverImportance.HIGH if float(total_deficit) >= 0.05
- else LeverImportance.MEDIUM
- )
- payload: dict = {
- "engine": engine_name,
- "total_expected_deficit": float(total_deficit),
- "total_expected_deficit_pct": round(float(total_deficit) * 100, 1),
- "n_degradation_types": int(info.get("n_degradation_types") or 0),
- }
- if isinstance(worst_type, str):
- payload["worst_degradation_type"] = worst_type
- if isinstance(worst_deficit, (int, float)):
- payload["worst_degradation_deficit"] = float(worst_deficit)
- payload["worst_degradation_deficit_pct"] = round(
- float(worst_deficit) * 100, 1,
- )
- out.append(Lever(
- type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION,
- importance=importance,
- payload=payload,
- engines_involved=(engine_name,),
- ))
- # Tri par déficit décroissant pour stabilité d'affichage.
- out.sort(
- key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0),
- )
- return out
-
+from picarones.measurements.levers import * # noqa: F401, F403
-__all__ = [
- "Lever",
- "LeverImportance",
- "LeverType",
- "LeverDetectorEntry",
- "register_lever",
- "unregister_lever",
- "iter_lever_detectors",
- "detect_levers",
- "detect_dominant_recoverable_class",
- "detect_pareto_concentration",
- "detect_complementarity_observation",
- "detect_lexical_modernization_observation",
- "detect_robustness_projection_observation",
-]
+import picarones.measurements.levers as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/line_metrics.py b/picarones/core/line_metrics.py
index 5204decce03afa16ce9d4fc93e8bbb973d77f475..4de862ecaaacf2d7dd1b41eeacb74a6183432b88 100644
--- a/picarones/core/line_metrics.py
+++ b/picarones/core/line_metrics.py
@@ -1,286 +1,19 @@
-"""Distribution des erreurs CER par ligne — Sprint 10.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.line_metrics`.
-Métriques calculées
--------------------
-- CER par ligne : distance d'édition caractère/longueur GT sur chaque paire de lignes
-- Percentiles : p50, p75, p90, p95, p99 sur la distribution des CER ligne
-- Taux catastrophiques : % de lignes dépassant des seuils configurables (30 %, 50 %, 100 %)
-- Coefficient de Gini : concentration des erreurs (0 = uniformes, 1 = toutes concentrées)
-- Carte thermique : CER moyen par tranche de position dans le document
-"""
-
-from __future__ import annotations
-
-import unicodedata
-from dataclasses import dataclass
-from typing import Optional
-
-
-# ---------------------------------------------------------------------------
-# CER d'une paire de lignes (distance d'édition Levenshtein normalisée)
-# ---------------------------------------------------------------------------
-
-def _edit_distance(a: str, b: str) -> int:
- """Distance de Levenshtein entre deux chaînes."""
- if not a:
- return len(b)
- if not b:
- return len(a)
- prev = list(range(len(b) + 1))
- for i, ca in enumerate(a, 1):
- curr = [i]
- for j, cb in enumerate(b, 1):
- cost = 0 if ca == cb else 1
- curr.append(min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost))
- prev = curr
- return prev[-1]
-
-
-def _line_cer(ref_line: str, hyp_line: str) -> float:
- """CER pour une paire de lignes. Retourne 1.0 si le GT est vide et que l'hyp ne l'est pas."""
- ref = unicodedata.normalize("NFC", ref_line.strip())
- hyp = unicodedata.normalize("NFC", hyp_line.strip())
- if not ref:
- return 0.0 if not hyp else 1.0
- dist = _edit_distance(ref, hyp)
- return dist / len(ref)
-
-
-# ---------------------------------------------------------------------------
-# Percentiles (implémentation pur-Python, sans numpy)
-# ---------------------------------------------------------------------------
-
-def _percentile(sorted_values: list[float], p: float) -> float:
- """Retourne le p-ième percentile (0 ≤ p ≤ 100) d'une liste triée."""
- if not sorted_values:
- return 0.0
- n = len(sorted_values)
- index = p / 100 * (n - 1)
- lo = int(index)
- hi = min(lo + 1, n - 1)
- frac = index - lo
- return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo])
-
-
-# ---------------------------------------------------------------------------
-# Coefficient de Gini
-# ---------------------------------------------------------------------------
-
-def _gini(values: list[float]) -> float:
- """Coefficient de Gini des erreurs (0 = uniformes, 1 = toutes concentrées).
-
- Formule : G = (2 * Σ i*x_i) / (n * Σ x_i) - (n+1)/n
- sur les valeurs triées par ordre croissant.
- """
- if not values:
- return 0.0
- xs = sorted(max(v, 0.0) for v in values)
- n = len(xs)
- total = sum(xs)
- if total == 0.0:
- return 0.0
- weighted_sum = sum((i + 1) * x for i, x in enumerate(xs))
- return (2.0 * weighted_sum) / (n * total) - (n + 1) / n
-
-
-# ---------------------------------------------------------------------------
-# Résultat structuré
-# ---------------------------------------------------------------------------
-
-@dataclass
-class LineMetrics:
- """Distribution des erreurs CER par ligne pour une paire (GT, hypothèse)."""
-
- cer_per_line: list[float]
- """CER de chaque ligne (longueur = nombre de lignes GT)."""
-
- percentiles: dict[str, float]
- """Percentiles : p50, p75, p90, p95, p99."""
-
- catastrophic_rate: dict[str, float]
- """Taux de lignes catastrophiques pour chaque seuil (ex. {0.3: 0.12, 0.5: 0.07, 1.0: 0.02})."""
-
- gini: float
- """Coefficient de Gini des erreurs (0 → uniforme, 1 → concentrées)."""
-
- heatmap: list[float]
- """CER moyen par tranche de position dans le document (longueur = heatmap_bins)."""
-
- line_count: int
- """Nombre de lignes GT traitées."""
-
- mean_cer: float
- """CER moyen sur l'ensemble des lignes."""
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.line_metrics import ...``) de continuer
+à fonctionner sans modification.
- def as_dict(self) -> dict:
- return {
- "cer_per_line": [round(v, 6) for v in self.cer_per_line],
- "percentiles": {k: round(v, 6) for k, v in self.percentiles.items()},
- "catastrophic_rate": {str(k): round(v, 6) for k, v in self.catastrophic_rate.items()},
- "gini": round(self.gini, 6),
- "heatmap": [round(v, 6) for v in self.heatmap],
- "line_count": self.line_count,
- "mean_cer": round(self.mean_cer, 6),
- }
-
- @classmethod
- def from_dict(cls, d: dict) -> "LineMetrics":
- return cls(
- cer_per_line=d.get("cer_per_line", []),
- percentiles=d.get("percentiles", {}),
- catastrophic_rate={float(k): v for k, v in d.get("catastrophic_rate", {}).items()},
- gini=d.get("gini", 0.0),
- heatmap=d.get("heatmap", []),
- line_count=d.get("line_count", 0),
- mean_cer=d.get("mean_cer", 0.0),
- )
-
-
-# ---------------------------------------------------------------------------
-# Calcul principal
-# ---------------------------------------------------------------------------
-
-def compute_line_metrics(
- reference: str,
- hypothesis: str,
- thresholds: Optional[list[float]] = None,
- heatmap_bins: int = 10,
-) -> LineMetrics:
- """Calcule la distribution des erreurs CER ligne par ligne.
-
- Parameters
- ----------
- reference:
- Texte de vérité terrain (GT) avec sauts de ligne.
- hypothesis:
- Texte produit par le moteur OCR.
- thresholds:
- Seuils CER pour le taux catastrophique. Défaut : [0.30, 0.50, 1.00].
- heatmap_bins:
- Nombre de tranches de position pour la carte thermique.
-
- Returns
- -------
- LineMetrics
- """
- if thresholds is None:
- thresholds = [0.30, 0.50, 1.00]
-
- ref_lines = reference.splitlines()
- hyp_lines = hypothesis.splitlines()
-
- # Aligner les lignes GT / hypothèse — on prend au moins autant de lignes que le GT
- n = len(ref_lines)
- if n == 0:
- # Pas de lignes : retourner des métriques neutres
- return LineMetrics(
- cer_per_line=[],
- percentiles={f"p{p}": 0.0 for p in (50, 75, 90, 95, 99)},
- catastrophic_rate={t: 0.0 for t in thresholds},
- gini=0.0,
- heatmap=[0.0] * heatmap_bins,
- line_count=0,
- mean_cer=0.0,
- )
-
- # Aligner en ignorant les lignes d'hypothèse supplémentaires
- # Si l'hypothèse a moins de lignes, les lignes manquantes comptent comme supprimées (CER = 1.0)
- cer_per_line: list[float] = []
- for i, ref_line in enumerate(ref_lines):
- hyp_line = hyp_lines[i] if i < len(hyp_lines) else ""
- cer_per_line.append(min(_line_cer(ref_line, hyp_line), 1.0))
-
- sorted_cer = sorted(cer_per_line)
-
- # Percentiles
- percentiles = {
- f"p{p}": _percentile(sorted_cer, p)
- for p in (50, 75, 90, 95, 99)
- }
-
- # Taux catastrophiques
- catastrophic_rate: dict[float, float] = {}
- for t in thresholds:
- count = sum(1 for v in cer_per_line if v > t)
- catastrophic_rate[t] = count / n
-
- # Gini
- gini = _gini(cer_per_line)
-
- # Carte thermique par tranche de position
- bins = heatmap_bins
- heatmap: list[float] = []
- for b in range(bins):
- start = int(b * n / bins)
- end = int((b + 1) * n / bins)
- slice_ = cer_per_line[start:end]
- heatmap.append(sum(slice_) / len(slice_) if slice_ else 0.0)
-
- mean_cer = sum(cer_per_line) / n
-
- return LineMetrics(
- cer_per_line=cer_per_line,
- percentiles=percentiles,
- catastrophic_rate=catastrophic_rate,
- gini=gini,
- heatmap=heatmap,
- line_count=n,
- mean_cer=mean_cer,
- )
-
-
-# ---------------------------------------------------------------------------
-# Agrégation sur un corpus
-# ---------------------------------------------------------------------------
-
-def aggregate_line_metrics(results: list[LineMetrics]) -> dict:
- """Agrège les métriques de distribution par ligne sur un corpus.
-
- Returns
- -------
- dict
- Statistiques agrégées : Gini moyen, percentiles moyens, taux catastrophiques moyens.
- """
- if not results:
- return {}
-
- import statistics as _stats
-
- gini_values = [r.gini for r in results]
- mean_cer_values = [r.mean_cer for r in results]
-
- # Percentiles moyens
- pct_keys = ["p50", "p75", "p90", "p95", "p99"]
- avg_percentiles = {}
- for k in pct_keys:
- vals = [r.percentiles.get(k, 0.0) for r in results]
- avg_percentiles[k] = round(sum(vals) / len(vals), 6) if vals else 0.0
-
- # Taux catastrophiques moyens (union des seuils)
- all_thresholds: set[float] = set()
- for r in results:
- all_thresholds.update(r.catastrophic_rate.keys())
- avg_catastrophic: dict[str, float] = {}
- for t in sorted(all_thresholds):
- vals = [r.catastrophic_rate.get(t, 0.0) for r in results]
- avg_catastrophic[str(t)] = round(sum(vals) / len(vals), 6) if vals else 0.0
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
+"""
- # Heatmap moyenne (longueur = max des longueurs)
- if results and results[0].heatmap:
- n_bins = len(results[0].heatmap)
- heatmap_avg = []
- for b in range(n_bins):
- vals = [r.heatmap[b] for r in results if b < len(r.heatmap)]
- heatmap_avg.append(round(sum(vals) / len(vals), 6) if vals else 0.0)
- else:
- heatmap_avg = []
+from picarones.measurements.line_metrics import * # noqa: F401, F403
- return {
- "gini_mean": round(sum(gini_values) / len(gini_values), 6),
- "gini_stdev": round(_stats.stdev(gini_values), 6) if len(gini_values) > 1 else 0.0,
- "mean_cer_mean": round(sum(mean_cer_values) / len(mean_cer_values), 6),
- "percentiles": avg_percentiles,
- "catastrophic_rate": avg_catastrophic,
- "heatmap": heatmap_avg,
- "document_count": len(results),
- }
+import picarones.measurements.line_metrics as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/longitudinal.py b/picarones/core/longitudinal.py
index 26fe91c4530a99793c87e35fef81ffb5716df174..edf827cf317be40df36e072daf6952a9a12d97e2 100644
--- a/picarones/core/longitudinal.py
+++ b/picarones/core/longitudinal.py
@@ -1,373 +1,19 @@
-"""Métriques longitudinales — Sprint 92 (A.II.9).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.longitudinal`.
-Sprint 92 — A.II.9 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.longitudinal import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-L'historique SQLite (`core/history.py`, Sprint 8) collecte les
-résultats de chaque run de benchmark, mais aucune métrique
-n'en sortait dans le rapport. Ce module exploite la série
-temporelle des CER d'un moteur pour répondre à deux
-questions :
-
-1. **Y a-t-il une tendance ?** Régression linéaire simple
- (méthode des moindres carrés) sur ``(t, CER)`` — pente,
- ordonnée à l'origine, R², n_runs. Une pente > 0 signale
- une régression progressive ; une pente < 0 une amélioration.
-
-2. **Y a-t-il un point de rupture ?** Algorithme de
- change-point pur Python (différence de moyennes maximale,
- variante de Pettitt simplifiée). Identifie l'index où la
- série se sépare en deux segments avec moyennes les plus
- différentes — typiquement le run où un modèle a changé de
- comportement.
-
-Pas de scipy
-------------
-Pour rester sans dépendance lourde, on implémente :
-- la régression linéaire en pur Python (closed-form OLS) ;
-- le change-point par balayage exhaustif (O(N) pour de petits
- N — l'historique d'une institution dépasse rarement quelques
- centaines de runs).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import math
-import statistics
-from dataclasses import dataclass
-from datetime import datetime
-from typing import Iterable, Optional
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class LinearTrend:
- """Résultat d'une régression linéaire sur une série CER."""
- slope: float
- """Pente (CER par jour). Positif = régression."""
- intercept: float
- """Ordonnée à l'origine."""
- r_squared: float
- """Qualité de l'ajustement, ∈ [0, 1]."""
- n_runs: int
- """Nombre de points utilisés."""
-
- def as_dict(self) -> dict:
- return {
- "slope": self.slope,
- "intercept": self.intercept,
- "r_squared": self.r_squared,
- "n_runs": self.n_runs,
- }
-
-
-@dataclass
-class ChangePointResult:
- """Résultat d'une détection de point de rupture."""
- index: int
- """Index de la rupture (0-based, le segment 1 est [0:index],
- le segment 2 est [index:N])."""
- timestamp: str
- """Timestamp du run à la rupture."""
- mean_before: float
- mean_after: float
- delta: float
- """``mean_after - mean_before``. Positif = régression."""
- n_before: int
- n_after: int
-
- def as_dict(self) -> dict:
- return {
- "index": self.index,
- "timestamp": self.timestamp,
- "mean_before": self.mean_before,
- "mean_after": self.mean_after,
- "delta": self.delta,
- "n_before": self.n_before,
- "n_after": self.n_after,
- }
-
-
-def _parse_timestamp(ts: str) -> Optional[float]:
- """Parse un ISO timestamp en jour ordinal float.
-
- Tolère ``YYYY-MM-DD`` et ``YYYY-MM-DDTHH:MM:SS``. Retourne
- ``None`` si non parsable.
- """
- if not ts:
- return None
- formats = (
- "%Y-%m-%dT%H:%M:%S.%f",
- "%Y-%m-%dT%H:%M:%S",
- "%Y-%m-%d %H:%M:%S",
- "%Y-%m-%d",
- )
- for fmt in formats:
- try:
- dt = datetime.strptime(ts.split("+")[0].split("Z")[0], fmt)
- return dt.toordinal() + (
- dt.hour * 3600 + dt.minute * 60 + dt.second
- ) / 86400.0
- except ValueError:
- continue
- return None
-
-
-def compute_linear_trend(
- cer_series: Iterable[tuple[str, float]],
-) -> Optional[LinearTrend]:
- """Régression linéaire OLS sur une série temporelle de CER.
-
- Parameters
- ----------
- cer_series:
- Itérable de ``(timestamp_iso, cer)``. Au moins 2 points
- valides requis.
-
- Returns
- -------
- LinearTrend | None
- ``None`` si moins de 2 points ou si tous les timestamps
- sont identiques (variance nulle sur t).
- """
- points: list[tuple[float, float]] = []
- for ts, cer in cer_series:
- t = _parse_timestamp(ts)
- if t is None or cer is None:
- continue
- try:
- cer_f = float(cer)
- except (TypeError, ValueError):
- continue
- points.append((t, cer_f))
- n = len(points)
- if n < 2:
- return None
- xs = [p[0] for p in points]
- ys = [p[1] for p in points]
- x_mean = statistics.fmean(xs)
- y_mean = statistics.fmean(ys)
- sxx = sum((x - x_mean) ** 2 for x in xs)
- sxy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys))
- if sxx == 0:
- return None
- slope = sxy / sxx
- intercept = y_mean - slope * x_mean
- syy = sum((y - y_mean) ** 2 for y in ys)
- if syy == 0:
- # Tous les CER sont égaux → R² mathématiquement indéfini ;
- # on retourne 1.0 (parfaite "non-tendance").
- r_squared = 1.0
- else:
- ss_res = sum(
- (y - (slope * x + intercept)) ** 2
- for x, y in zip(xs, ys)
- )
- r_squared = max(0.0, 1.0 - ss_res / syy)
- return LinearTrend(
- slope=slope,
- intercept=intercept,
- r_squared=r_squared,
- n_runs=n,
- )
-
-
-def detect_change_point(
- cer_series: Iterable[tuple[str, float]],
- min_segment_size: int = 3,
-) -> Optional[ChangePointResult]:
- """Détecte le point de rupture maximisant l'écart de moyennes.
-
- Algorithme : balayage des indices ``i`` où la série se
- sépare en deux segments d'au moins ``min_segment_size``
- points chacun ; on retient l'index où ``|mean_after -
- mean_before|`` est maximal. Variante simplifiée de Pettitt.
-
- Parameters
- ----------
- cer_series:
- Itérable de ``(timestamp_iso, cer)``.
- min_segment_size:
- Taille minimale des deux segments. Défaut 3.
-
- Returns
- -------
- ChangePointResult | None
- ``None`` si la série a moins de ``2 × min_segment_size``
- points valides.
- """
- points: list[tuple[str, float, float]] = []
- for ts, cer in cer_series:
- t = _parse_timestamp(ts)
- if t is None or cer is None:
- continue
- try:
- cer_f = float(cer)
- except (TypeError, ValueError):
- continue
- points.append((ts, t, cer_f))
- if len(points) < 2 * min_segment_size:
- return None
- points.sort(key=lambda p: p[1])
- n = len(points)
- best_index = -1
- best_abs_delta = -1.0
- best_delta = 0.0
- best_mean_before = 0.0
- best_mean_after = 0.0
- for i in range(min_segment_size, n - min_segment_size + 1):
- before = [p[2] for p in points[:i]]
- after = [p[2] for p in points[i:]]
- mean_b = statistics.fmean(before)
- mean_a = statistics.fmean(after)
- delta = mean_a - mean_b
- abs_delta = abs(delta)
- if abs_delta > best_abs_delta:
- best_abs_delta = abs_delta
- best_index = i
- best_delta = delta
- best_mean_before = mean_b
- best_mean_after = mean_a
- if best_index < 0:
- return None
- return ChangePointResult(
- index=best_index,
- timestamp=points[best_index][0],
- mean_before=best_mean_before,
- mean_after=best_mean_after,
- delta=best_delta,
- n_before=best_index,
- n_after=n - best_index,
- )
-
-
-def compute_engine_longitudinal(
- history_entries: Iterable,
- engine_name: str,
- corpus_name: Optional[str] = None,
- *,
- min_runs_for_trend: int = 3,
- min_segment_size: int = 3,
- change_point_threshold: float = 0.01,
-) -> Optional[dict]:
- """Calcule trend + change_point pour un moteur.
-
- Parameters
- ----------
- history_entries:
- Liste de ``HistoryEntry`` (ou dicts compatibles).
- engine_name:
- Filtre sur le nom du moteur.
- corpus_name:
- Filtre optionnel sur le corpus. ``None`` (défaut) : tous
- les corpus.
- min_runs_for_trend:
- Minimum de runs pour calculer une tendance.
- min_segment_size:
- Taille minimale des segments pour le change-point.
- change_point_threshold:
- Magnitude absolue minimale du delta (en CER) pour
- retenir le change-point. Défaut 0.01 (1 point de CER).
-
- Returns
- -------
- dict | None
- ``{
- "engine_name", "corpus_name", "n_runs", "trend",
- "change_point", # ou None
- "first_timestamp", "last_timestamp",
- "first_cer", "last_cer", "absolute_delta_pct",
- }`` ou ``None`` si moins de ``min_runs_for_trend`` runs.
- """
- series: list[tuple[str, float]] = []
- for entry in history_entries:
- if hasattr(entry, "as_dict"):
- data = entry.as_dict()
- else:
- data = entry
- if data.get("engine_name") != engine_name:
- continue
- if corpus_name is not None and data.get("corpus_name") != corpus_name:
- continue
- cer = data.get("cer_mean")
- ts = data.get("timestamp")
- if cer is None or ts is None:
- continue
- series.append((ts, float(cer)))
- if len(series) < min_runs_for_trend:
- return None
- series.sort(key=lambda p: _parse_timestamp(p[0]) or 0.0)
- trend = compute_linear_trend(series)
- cp = detect_change_point(series, min_segment_size=min_segment_size)
- if cp is not None and abs(cp.delta) < change_point_threshold:
- cp = None
- first_ts, first_cer = series[0]
- last_ts, last_cer = series[-1]
- return {
- "engine_name": engine_name,
- "corpus_name": corpus_name,
- "n_runs": len(series),
- "trend": trend.as_dict() if trend else None,
- "change_point": cp.as_dict() if cp else None,
- "first_timestamp": first_ts,
- "last_timestamp": last_ts,
- "first_cer": first_cer,
- "last_cer": last_cer,
- "absolute_delta": last_cer - first_cer,
- "absolute_delta_pct": round((last_cer - first_cer) * 100, 2),
- }
-
-
-def compute_corpus_longitudinal(
- history_entries: Iterable,
- corpus_name: Optional[str] = None,
- *,
- min_runs_for_trend: int = 3,
- min_segment_size: int = 3,
- change_point_threshold: float = 0.01,
-) -> list[dict]:
- """Pour chaque moteur présent dans l'historique sur ``corpus_name``,
- calcule trend + change_point.
-
- Returns
- -------
- list[dict]
- Une entrée par moteur (filtrée), liste vide si rien.
- """
- entries = list(history_entries)
- engines: set[str] = set()
- for entry in entries:
- data = entry.as_dict() if hasattr(entry, "as_dict") else entry
- if corpus_name is not None and data.get("corpus_name") != corpus_name:
- continue
- name = data.get("engine_name")
- if name:
- engines.add(name)
- out: list[dict] = []
- for engine in sorted(engines):
- result = compute_engine_longitudinal(
- entries, engine, corpus_name=corpus_name,
- min_runs_for_trend=min_runs_for_trend,
- min_segment_size=min_segment_size,
- change_point_threshold=change_point_threshold,
- )
- if result is not None:
- out.append(result)
- return out
-
-
-__all__ = [
- "LinearTrend",
- "ChangePointResult",
- "compute_linear_trend",
- "detect_change_point",
- "compute_engine_longitudinal",
- "compute_corpus_longitudinal",
-]
-
+from picarones.measurements.longitudinal import * # noqa: F401, F403
-# Marqueur d'évitement d'import inutilisé (math)
-_ = math
+import picarones.measurements.longitudinal as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/marginal_cost.py b/picarones/core/marginal_cost.py
index 4d1c59bf324ede3d6bf0e2fcf91c59d9dae9d0de..a02620857ff9f800f60811c67acc1064cc0fa5dc 100644
--- a/picarones/core/marginal_cost.py
+++ b/picarones/core/marginal_cost.py
@@ -1,142 +1,19 @@
-"""Coût marginal par erreur évitée — Sprint 91 (A.II.6 chantier 2).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.marginal_cost`.
-Sprint 91 — A.II.6 chantier 2 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.marginal_cost import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-La vue Pareto (Sprint 20) trace CER vs coût mais n'arbitre pas
-quel surcoût est *raisonnable* pour quelle réduction d'erreur.
-Une institution avec un budget contraint a besoin d'une
-réponse opérationnelle :
-
- *« Passer de Tesseract à Mistral OCR coûte 0,83 € par
- erreur évitée — décider selon votre budget par millier
- d'erreurs corrigées. »*
-
-Formule
--------
-Pour deux moteurs A et B où B fait **moins** d'erreurs que A
-(donc B est plus précis) :
-
-.. code::
-
- coût_marginal = (coût_B − coût_A) / (errors_A − errors_B)
-
-- Si ``cost_B > cost_A`` et ``errors_B < errors_A`` :
- ``cost_per_avoided_error > 0`` (cas standard, B coûte plus
- pour moins d'erreurs).
-- Si ``cost_B ≤ cost_A`` et ``errors_B < errors_A`` :
- ``cost_per_avoided_error ≤ 0`` (cas idéal, B est strictement
- meilleur).
-- Si ``errors_B ≥ errors_A`` : non comparable dans ce sens
- (B n'évite pas d'erreur), retourne ``None``.
-
-Sortie
-------
-``compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)``
-retourne ``{cost_per_avoided_error, n_errors_avoided,
-cost_delta, dominated}`` ou ``None`` si non comparable.
-
-``compute_marginal_cost_matrix(per_engine)`` retourne, pour
-chaque paire ordonnée ``(A → B)`` où B est plus précis, le
-coût marginal correspondant. Trié par coût marginal croissant
-(meilleur ratio en tête).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-def compute_marginal_cost(
- cost_a: float,
- errors_a: float,
- cost_b: float,
- errors_b: float,
-) -> Optional[dict]:
- """Coût marginal du passage A → B (B plus précis).
-
- Retourne ``None`` si :
- - ``errors_b >= errors_a`` (B n'évite pas d'erreur) ;
- - les valeurs ne sont pas finies.
- """
- try:
- ca = float(cost_a)
- cb = float(cost_b)
- ea = float(errors_a)
- eb = float(errors_b)
- except (TypeError, ValueError):
- return None
- if ea <= eb:
- # B ne fait pas mieux que A → pas de gain à mesurer.
- return None
- n_avoided = ea - eb
- cost_delta = cb - ca
- cost_per_avoided = cost_delta / n_avoided
- dominated = cost_delta <= 0 # B aussi cher ou moins → cas idéal
- return {
- "cost_per_avoided_error": cost_per_avoided,
- "n_errors_avoided": n_avoided,
- "cost_delta": cost_delta,
- "dominated": dominated,
- }
-
-
-def compute_marginal_cost_matrix(
- per_engine: dict[str, dict],
-) -> Optional[dict]:
- """Pour chaque paire A → B où B fait moins d'erreurs, calcule
- le coût marginal.
-
- Parameters
- ----------
- per_engine:
- Map ``{engine_name: {"cost": float, "errors": float}}``.
-
- Returns
- -------
- dict | None
- ``{
- "pairs": list[
- {"engine_a", "engine_b", "cost_per_avoided_error",
- "n_errors_avoided", "cost_delta", "dominated"}
- ], # triée par cost_per_avoided_error croissant
- }``
- ou ``None`` si moins de 2 moteurs.
- """
- if not per_engine or len(per_engine) < 2:
- return None
- engines = sorted(per_engine.keys())
- pairs: list[dict] = []
- for a in engines:
- for b in engines:
- if a == b:
- continue
- data_a = per_engine[a]
- data_b = per_engine[b]
- try:
- ca = float(data_a.get("cost"))
- ea = float(data_a.get("errors"))
- cb = float(data_b.get("cost"))
- eb = float(data_b.get("errors"))
- except (TypeError, ValueError):
- continue
- result = compute_marginal_cost(ca, ea, cb, eb)
- if result is None:
- continue
- entry = {"engine_a": a, "engine_b": b}
- entry.update(result)
- pairs.append(entry)
- if not pairs:
- return None
- pairs.sort(key=lambda p: p["cost_per_avoided_error"])
- return {"pairs": pairs}
-
+from picarones.measurements.marginal_cost import * # noqa: F401, F403
-__all__ = [
- "compute_marginal_cost",
- "compute_marginal_cost_matrix",
-]
+import picarones.measurements.marginal_cost as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/__init__.py b/picarones/core/narrative/__init__.py
index 69fb6492d069f7fa24dc5fce781a3e1eddf42e9f..2b1de8881e36eb31acc32ea7c56de4e5ebaa62b0 100644
--- a/picarones/core/narrative/__init__.py
+++ b/picarones/core/narrative/__init__.py
@@ -1,78 +1,15 @@
-"""Moteur narratif factuel — génération de synthèse déterministe.
+"""Alias rétrocompat — package déplacé dans :mod:`picarones.measurements.narrative`.
-Extrait des faits saillants d'un ``BenchmarkResult`` et les rend en phrases
-courtes via des templates externes YAML. Aucun LLM : chaque nombre ou nom
-apparaissant dans la synthèse est traçable au JSON de résultats en entrée.
-
-API publique
-------------
-- ``Fact``, ``FactType``, ``FactImportance`` : modèle de données
-- ``DetectorRegistry`` : registre des détecteurs
-- ``detect_all(data)`` : applique le registre par défaut
-- ``select_facts(facts, max_facts=5)`` : arbitre de sélection
-- ``render_synthesis(facts, lang="fr")`` : rend en liste de phrases
-- ``build_synthesis(data, lang="fr")`` : pipeline complet (Sprint 4)
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2) vit désormais dans ``picarones.measurements.narrative``.
+Cet alias maintient la rétrocompat des imports historiques :
+``from picarones.core.narrative import build_synthesis``,
+``from picarones.core.narrative.facts import Fact``, etc.
"""
-from picarones.core.narrative.facts import (
- Fact,
- FactType,
- FactImportance,
- DetectorRegistry,
- detect_all,
- _DEFAULT_REGISTRY,
-)
-from picarones.core.narrative.arbiter import select_facts
-from picarones.core.narrative.renderer import (
- render_fact,
- render_synthesis,
- extract_numbers,
-)
-from picarones.core.narrative.detectors import (
- register_default_detectors,
- DETECTORS_BY_TYPE,
-)
-
-
-# Activer le registre par défaut — Sprint 4
-register_default_detectors(_DEFAULT_REGISTRY)
-
-
-def build_synthesis(
- benchmark_data: dict,
- lang: str = "fr",
- max_facts: int = 5,
-) -> dict:
- """Pipeline complet : détection → arbitre → rendu.
-
- Returns
- -------
- dict avec :
- - ``sentences`` : liste de phrases prêtes à l'affichage
- - ``facts`` : liste de dicts ``Fact.as_dict()`` pour traçabilité
- - ``lang`` : langue utilisée
- """
- all_facts = detect_all(benchmark_data)
- selected = select_facts(all_facts, max_facts=max_facts)
- sentences = render_synthesis(selected, lang=lang)
- return {
- "sentences": sentences,
- "facts": [f.as_dict() for f in selected],
- "lang": lang,
- }
-
+from picarones.measurements.narrative import * # noqa: F401, F403
-__all__ = [
- "Fact",
- "FactType",
- "FactImportance",
- "DetectorRegistry",
- "detect_all",
- "select_facts",
- "render_fact",
- "render_synthesis",
- "extract_numbers",
- "build_synthesis",
- "register_default_detectors",
- "DETECTORS_BY_TYPE",
-]
+import picarones.measurements.narrative as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/arbiter.py b/picarones/core/narrative/arbiter.py
index 16a0b43c59008ed595009fc4fdcc9df35a889f9c..c419584f8098d9f919ee62def6e0f95589f1b5ed 100644
--- a/picarones/core/narrative/arbiter.py
+++ b/picarones/core/narrative/arbiter.py
@@ -1,227 +1,13 @@
-"""Arbitre de sélection des faits narratifs.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.arbiter`.
-L'arbitre transforme une liste potentiellement longue de ``Fact`` détectés
-en une synthèse courte (3 à 5 phrases) adaptée à l'ouverture du rapport.
-
-Règles de sélection :
- 1. Tri par importance décroissante, puis par type (ordre canonique).
- 2. Non-redondance : un seul fait par moteur, sauf si les types sont
- complémentaires (ex. ``GLOBAL_LEADER_CER`` + ``SIGNIFICANT_GAP``
- concernent le leader mais apportent une information différente).
- 3. Limite : au maximum ``max_facts`` faits retenus (défaut 5).
- 4. Déterminisme : tri stable sur (−importance, ordre canonique du type,
- noms des moteurs) pour garantir une sortie bit-à-bit identique.
-
-Les détecteurs peuvent émettre plusieurs faits du même type (ex. plusieurs
-``STATISTICAL_TIE`` si plusieurs groupes distincts). L'arbitre ne fusionne
-pas mais peut limiter par type.
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-from typing import Iterable, Sequence
-
-from picarones.core.narrative.facts import Fact, FactImportance, FactType
-
-
-# Ordre canonique des types pour départager les ex-aequo à l'importance égale.
-#
-# Politique éditoriale — exposée et documentée dans
-# ``docs/developer/narrative-engine.md`` § Editorial policy.
-# L'ordre encode quels faits sont remontés en priorité quand plusieurs ont
-# la même ``FactImportance``. Surchargeable via le paramètre ``type_order``
-# de ``select_facts`` sans patcher le code.
-#
-# Sprint 29 : la valeur n'est plus codée en dur ici — elle est dérivée du
-# registre déclaratif (``@register_detector(..., priority=N)``). Ajouter
-# un détecteur en bonne position se fait donc en éditant **un seul**
-# fichier (``detectors.py``) au lieu de quatre comme avant.
-def _compute_default_type_order() -> tuple[FactType, ...]:
- # Import local pour éviter la dépendance circulaire au chargement.
- from picarones.core.narrative.registry import default_type_order
- order = default_type_order()
- # Filet de sécurité : tant que les détecteurs n'ont pas été importés
- # (cas des tests qui mockent le registre), on retombe sur un ordre
- # canonique gravé pour ne pas planter ``select_facts``.
- if not order:
- return _FALLBACK_TYPE_ORDER
- return order
-
-
-# Ordre statique gardé en mémoire : utilisé si jamais le registre est vide
-# au moment où ``arbiter`` est chargé (chargement partiel par les tests).
-_FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
- FactType.GLOBAL_LEADER_CER,
- FactType.STATISTICAL_TIE,
- FactType.SIGNIFICANT_GAP,
- FactType.STRATUM_WINNER,
- # Sprint 46 — priority 45, juste après STRATUM_WINNER (40),
- # avant STRATUM_COLLAPSE (50). La recommandation de stratification
- # nuance directement les autres faits par strate.
- FactType.STRATIFICATION_RECOMMENDED,
- FactType.STRATUM_COLLAPSE,
- FactType.ERROR_PROFILE_OUTLIER,
- FactType.LLM_HALLUCINATION_FLAG,
- FactType.ROBUSTNESS_FRAGILE,
- FactType.PARETO_ALTERNATIVE,
- FactType.SPEED_WINNER,
- FactType.COST_OUTLIER,
- FactType.CONFIDENCE_WARNING,
- FactType.ENSEMBLE_OPPORTUNITY,
- FactType.MEDIAN_MEAN_GAP_WARNING,
- # Sprint 73 — priority 150, après MEDIAN_MEAN_GAP_WARNING (140).
- # Le détecteur off-baseline donne le contexte historique, qui
- # vient en fin de synthèse comme « note ».
- FactType.ENGINE_OFF_BASELINE,
- # Sprint 90 — priority 160, ferme la synthèse avec la mise en
- # garde sur la reproductibilité. Une instabilité multi-runs
- # discrédite toute autre conclusion sur ce moteur ; on la
- # remonte en dernier pour ne pas l'enterrer.
- FactType.ENGINE_UNSTABLE,
- # Sprint 92 — priority 170, après ENGINE_UNSTABLE. La
- # régression historique complète A.I.3 (off-baseline) en
- # caractérisant la tendance : l'écart courant est-il une
- # dégradation graduelle, une rupture brutale, ou un bruit ?
- FactType.REGRESSION_IN_HISTORY,
-)
-
-
-# ``DEFAULT_TYPE_ORDER`` reste un attribut module accessible. On le calcule
-# à l'import si possible, sinon on prend le fallback ; ``select_facts``
-# recalcule à chaque appel pour absorber les ajouts de détecteurs après
-# l'import initial (extensions tierces).
-DEFAULT_TYPE_ORDER: tuple[FactType, ...] = _compute_default_type_order()
-
-# Alias rétro-compatible.
-_TYPE_ORDER = DEFAULT_TYPE_ORDER
-_TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)}
-
-
-# Paires de types qui ne sont PAS considérées comme redondantes même quand
-# elles concernent le même moteur. Tout autre couple → un seul fait retenu
-# pour le moteur (le plus important).
-_COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({
- frozenset({FactType.GLOBAL_LEADER_CER, FactType.SIGNIFICANT_GAP}),
- frozenset({FactType.GLOBAL_LEADER_CER, FactType.SPEED_WINNER}),
- frozenset({FactType.GLOBAL_LEADER_CER, FactType.CONFIDENCE_WARNING}),
- frozenset({FactType.STATISTICAL_TIE, FactType.SPEED_WINNER}),
- # Sprint 44 — l'avertissement d'asymétrie nuance le leader
- # plutôt que de le doubler : on veut les deux phrases ensemble.
- frozenset({FactType.GLOBAL_LEADER_CER, FactType.MEDIAN_MEAN_GAP_WARNING}),
- # Sprint 46 — la recommandation de stratification est un méta-conseil
- # qui s'ajoute au leader sans le contredire ; les deux peuvent
- # cohabiter même quand ils concernent le même moteur.
- frozenset({FactType.GLOBAL_LEADER_CER, FactType.STRATIFICATION_RECOMMENDED}),
- # Sprint 90 — l'instabilité multi-runs nuance les conclusions
- # sur le moteur leader sans les contredire : un moteur peut être
- # leader **et** instable, et c'est précisément l'information
- # critique pour la reproductibilité scientifique.
- frozenset({FactType.GLOBAL_LEADER_CER, FactType.ENGINE_UNSTABLE}),
- # Sprint 92 — la régression historique caractérise la tendance
- # du leader : un leader peut être en régression progressive,
- # info critique pour décider quand re-tester.
- frozenset({FactType.GLOBAL_LEADER_CER, FactType.REGRESSION_IN_HISTORY}),
- # Off-baseline (Sprint 73) dit "écart anormal sur ce corpus" ;
- # regression-in-history (Sprint 92) dit "tendance dans le
- # temps" — les deux se complètent sans se redonder.
- frozenset({FactType.ENGINE_OFF_BASELINE, FactType.REGRESSION_IN_HISTORY}),
-})
-
-
-def _sort_key(fact: Fact, type_index: dict[FactType, int]) -> tuple:
- """Clé de tri stable : importance (desc), type canonique, moteurs."""
- return (
- -int(fact.importance),
- type_index.get(fact.type, len(type_index)),
- tuple(sorted(fact.engines_involved)),
- fact.stratum or "",
- )
-
-
-def _is_redundant(candidate: Fact, kept: Fact) -> bool:
- """Vrai si ``candidate`` apporte trop peu par rapport à ``kept``.
-
- Deux faits sont redondants s'ils concernent exactement le même moteur,
- ont le même type, et la même strate (s'il y en a une). Des types
- différents sur le même moteur ne sont considérés redondants que s'ils
- n'appartiennent pas aux paires complémentaires (ex : un leader peut
- aussi être rapide ; c'est complémentaire).
- """
- if candidate.type == kept.type and candidate.stratum == kept.stratum:
- return set(candidate.engines_involved) == set(kept.engines_involved)
- if set(candidate.engines_involved) == set(kept.engines_involved):
- pair = frozenset({candidate.type, kept.type})
- return pair not in _COMPLEMENTARY_PAIRS
- return False
-
-
-def _remove_contradictions(facts: list[Fact]) -> list[Fact]:
- """Supprime les faits incohérents sur le plan statistique.
-
- Règle centrale : si Nemenyi (post-hoc corrigé pour comparaisons multiples)
- place deux moteurs dans le même groupe d'ex-aequo, alors un ``SIGNIFICANT_GAP``
- basé sur Wilcoxon non corrigé entre ces deux mêmes moteurs est trompeur
- pour un lecteur non statisticien. Nemenyi l'emporte.
- """
- tied_groups: list[set[str]] = []
- for f in facts:
- if f.type == FactType.STATISTICAL_TIE:
- tied_groups.append(set(f.engines_involved))
-
- def _is_contradicted(fact: Fact) -> bool:
- if fact.type != FactType.SIGNIFICANT_GAP:
- return False
- pair = set(fact.engines_involved)
- return any(pair <= group for group in tied_groups)
-
- return [f for f in facts if not _is_contradicted(f)]
-
-
-def select_facts(
- facts: Iterable[Fact],
- max_facts: int = 5,
- min_importance: FactImportance = FactImportance.MEDIUM,
- type_order: Sequence[FactType] | None = None,
-) -> list[Fact]:
- """Sélectionne la synthèse finale à partir d'une liste brute de faits.
-
- Parameters
- ----------
- facts:
- Liste de ``Fact`` brute issue de ``DetectorRegistry.run``.
- max_facts:
- Nombre maximal de faits retenus (défaut : 5).
- min_importance:
- Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut.
- type_order:
- Surcharge optionnelle de l'ordre canonique des types pour départager
- les faits d'égale importance. ``None`` (défaut) utilise
- ``DEFAULT_TYPE_ORDER``. Une institution peut passer son propre ordre
- sans patcher le code — voir ``docs/developer/narrative-engine.md``.
-
- Returns
- -------
- Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
- """
- if type_order is None:
- # Sprint 29 — recalcul à chaque appel pour absorber les détecteurs
- # enregistrés après l'import d'arbiter (extensions tierces qui
- # font ``@register_detector`` dans un module utilisateur).
- from picarones.core.narrative.registry import default_type_order
- live_order = default_type_order() or _FALLBACK_TYPE_ORDER
- type_index = {t: i for i, t in enumerate(live_order)}
- else:
- type_index = {t: i for i, t in enumerate(type_order)}
-
- facts_list = [f for f in facts if int(f.importance) >= int(min_importance)]
- facts_list = _remove_contradictions(facts_list)
- ranked = sorted(facts_list, key=lambda f: _sort_key(f, type_index))
+from picarones.measurements.narrative.arbiter import * # noqa: F401, F403
- selected: list[Fact] = []
- for fact in ranked:
- if any(_is_redundant(fact, kept) for kept in selected):
- continue
- selected.append(fact)
- if len(selected) >= max_facts:
- break
- return selected
+import picarones.measurements.narrative.arbiter as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/__init__.py b/picarones/core/narrative/detectors/__init__.py
index eab4983817d14f4ca2a168166dc2f7503bb7b82b..9d677e61151ff05be9f2d9e5bd0942a2fc7d883e 100644
--- a/picarones/core/narrative/detectors/__init__.py
+++ b/picarones/core/narrative/detectors/__init__.py
@@ -1,129 +1,13 @@
-"""Détecteurs narratifs — package thématique (chantier 5 post-Sprint 97).
+"""Alias rétrocompat — package déplacé dans :mod:`picarones.measurements.narrative.detectors`.
-Avant le chantier 5, ce module était un fichier monolithe de 1229 lignes
-(``narrative/detectors.py``) contenant 18 détecteurs. Pour aligner la
-structure de code avec celle du registre déclaratif (Sprint 29), les
-détecteurs ont été regroupés par **famille thématique** :
-
-- :mod:`ranking` — global leader, statistical tie, significant gap,
- speed winner, median/mean gap warning (5 détecteurs)
-- :mod:`pareto` — Pareto alternative, cost outlier (2 détecteurs)
-- :mod:`stratum` — stratum winner / collapse, stratification
- recommended (3 détecteurs)
-- :mod:`quality` — error profile outlier, LLM hallucination flag,
- robustness fragile, confidence warning (4 détecteurs)
-- :mod:`history` — engine off baseline, engine unstable, regression
- in history (3 détecteurs)
-- :mod:`ensemble` — ensemble opportunity (1 détecteur)
-
-Total : 18 détecteurs (≠ "12" mentionné dans CLAUDE.md historique —
-le chantier 5 corrige ce comptage).
-
-Rétrocompatibilité absolue
---------------------------
-Tous les noms exportés par l'ancien fichier ``detectors.py``
-(``detect_*``, ``DETECTORS_BY_TYPE``, ``register_default_detectors``)
-restent accessibles via ``from picarones.core.narrative.detectors
-import ...``. Les tests Sprints 20, 23, 29, 36, 44, 46, 73 importent
-directement ces noms et continuent à fonctionner sans modification.
-
-L'enregistrement automatique des détecteurs via ``@register_detector``
-se fait à l'import de ce package — chaque sous-module est importé ici
-en cascade.
+Phase E du chantier de refonte. Les 18 détecteurs en 6 familles
+(ranking, pareto, stratum, quality, history, ensemble) vivent
+désormais dans ``picarones.measurements.narrative.detectors/``.
"""
-from __future__ import annotations
-
-# Imports en cascade des 6 sous-modules : déclenche l'enregistrement
-# automatique via les décorateurs ``@register_detector`` au chargement.
-from picarones.core.narrative.detectors.ranking import (
- detect_global_leader_cer,
- detect_median_mean_gap_warning,
- detect_significant_gap,
- detect_speed_winner,
- detect_statistical_tie,
-)
-from picarones.core.narrative.detectors.pareto import (
- detect_cost_outlier,
- detect_pareto_alternative,
-)
-from picarones.core.narrative.detectors.stratum import (
- detect_stratification_recommended,
- detect_stratum_collapse,
- detect_stratum_winner,
-)
-from picarones.core.narrative.detectors.quality import (
- detect_confidence_warning,
- detect_error_profile_outlier,
- detect_llm_hallucination_flag,
- detect_robustness_fragile,
-)
-from picarones.core.narrative.detectors.history import (
- detect_engine_off_baseline,
- detect_engine_unstable,
- detect_regression_in_history,
-)
-from picarones.core.narrative.detectors.ensemble import (
- detect_ensemble_opportunity,
-)
-
-# Snapshot du registre + helper d'enregistrement legacy — déplacés
-# verbatim depuis l'ancien ``detectors.py`` (lignes 1193-1229).
-from picarones.core.narrative.facts import DetectorFn, FactType
-from picarones.core.narrative.registry import (
- iter_detectors as _iter_detectors,
- populate_legacy_registry as _populate_legacy_registry,
-)
-
-
-def _build_detectors_by_type() -> dict[FactType, DetectorFn]:
- """Snapshot du registre déclaratif vers un dict ``{type: fn}``."""
- return {entry.fact_type: entry.fn for entry in _iter_detectors()}
-
-
-# Vue figée à l'import — utile pour les tests qui parcourent les types
-# enregistrés sans instancier un ``DetectorRegistry``.
-DETECTORS_BY_TYPE = _build_detectors_by_type()
-
-
-def register_default_detectors(registry) -> None:
- """Enregistre les détecteurs du registre déclaratif dans un
- ``DetectorRegistry`` historique.
-
- Sprint 29 : la source de vérité est maintenant le décorateur
- ``@register_detector`` ; cette fonction se contente de pousser
- le contenu du registre vers l'objet ``DetectorRegistry`` que les
- consommateurs externes (``DetectorRegistry.run``) instancient.
- """
- _populate_legacy_registry(registry)
-
+from picarones.measurements.narrative.detectors import * # noqa: F401, F403
-__all__ = [
- # ranking
- "detect_global_leader_cer",
- "detect_median_mean_gap_warning",
- "detect_significant_gap",
- "detect_speed_winner",
- "detect_statistical_tie",
- # pareto
- "detect_cost_outlier",
- "detect_pareto_alternative",
- # stratum
- "detect_stratification_recommended",
- "detect_stratum_collapse",
- "detect_stratum_winner",
- # quality
- "detect_confidence_warning",
- "detect_error_profile_outlier",
- "detect_llm_hallucination_flag",
- "detect_robustness_fragile",
- # history
- "detect_engine_off_baseline",
- "detect_engine_unstable",
- "detect_regression_in_history",
- # ensemble
- "detect_ensemble_opportunity",
- # legacy
- "DETECTORS_BY_TYPE",
- "register_default_detectors",
-]
+import picarones.measurements.narrative.detectors as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/_helpers.py b/picarones/core/narrative/detectors/_helpers.py
index 124324c361400d2f8a36a89bf03885f3af1d1579..0465d072fb081bc2307a71fbdb86ab236d4691fd 100644
--- a/picarones/core/narrative/detectors/_helpers.py
+++ b/picarones/core/narrative/detectors/_helpers.py
@@ -1,31 +1,13 @@
-"""Helpers internes partagés par les détecteurs narratifs.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors._helpers`.
-Chantier 5 du plan d'évolution post-Sprint 97 — découpage de
-``picarones/core/narrative/detectors.py`` (1229 lignes, 18 détecteurs)
-en 6 sous-modules thématiques + ce module d'helpers communs.
-
-Ces fonctions étaient privées (préfixe ``_``) au module historique.
-Elles sont conservées telles quelles ici ; les sous-modules les
-importent.
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-from typing import Optional
-
-
-def _engines_summary(data: dict) -> list[dict]:
- """Accès normalisé à la liste des résumés moteur."""
- return data.get("engines", []) or []
-
-
-def _engine_by_name(data: dict, name: str) -> Optional[dict]:
- for e in _engines_summary(data):
- if e.get("name") == name:
- return e
- return None
-
+from picarones.measurements.narrative.detectors._helpers import * # noqa: F401, F403
-def _n_docs(data: dict) -> int:
- meta = data.get("meta", {}) or {}
- return int(meta.get("document_count") or 0)
+import picarones.measurements.narrative.detectors._helpers as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/ensemble.py b/picarones/core/narrative/detectors/ensemble.py
index 31ce6ec7d33899f6bb7261805180870751ecddc1..d736ba5bd3b1527f1e49deeeb4911edecdb91cfa 100644
--- a/picarones/core/narrative/detectors/ensemble.py
+++ b/picarones/core/narrative/detectors/ensemble.py
@@ -1,96 +1,13 @@
-"""Détecteurs narratifs liés à l'*opportunité d'ensemble inter-moteurs* (chantier 5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.ensemble`.
-1 détecteur déplacé depuis ``narrative/detectors.py`` :
-
-- :func:`detect_ensemble_opportunity` (Sprint 36)
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-import statistics as _stats
-from typing import Optional
-
-from picarones.core.narrative.facts import Fact, FactImportance, FactType
-from picarones.core.narrative.registry import register_detector
-
-from picarones.core.narrative.detectors._helpers import (
- _engine_by_name,
- _engines_summary,
- _n_docs,
-)
-
-
-@register_detector(
- FactType.ENSEMBLE_OPPORTUNITY,
- priority=130,
- importance=FactImportance.MEDIUM,
-)
-def detect_ensemble_opportunity(benchmark_data: dict) -> list[Fact]:
- """Deux moteurs très complémentaires : un voting majoritaire entre eux
- pourrait améliorer significativement le CER token-level.
-
- Lit la structure ``inter_engine_analysis`` produite par le runner
- (Sprint 35-36) et déclenche si la fraction d'erreurs du meilleur
- moteur récupérable par un ensemble dépasse 25 %.
-
- L'importance monte à ``HIGH`` quand le gap relatif dépasse 50 %
- (ensemble franchement profitable) — sinon reste à ``MEDIUM``.
- """
- iea = benchmark_data.get("inter_engine_analysis") or {}
- comp = iea.get("complementarity") or {}
- if not comp:
- return []
-
- relative_gap = float(comp.get("relative_gap") or 0.0)
- if relative_gap < 0.25:
- # En deçà de 25 %, l'ensemble n'apporterait quasi rien — on ne
- # remonte pas le fait pour ne pas bruiter la synthèse.
- return []
-
- best_engine = comp.get("best_engine") or ""
- if not best_engine:
- return []
-
- payload: dict = {
- "best_engine": best_engine,
- "best_recall_pct": round(float(comp.get("best_single_recall") or 0.0) * 100, 2),
- "oracle_recall_pct": round(float(comp.get("oracle_recall") or 0.0) * 100, 2),
- "absolute_gap_pct": round(float(comp.get("absolute_gap") or 0.0) * 100, 2),
- "relative_gap_pct": round(relative_gap * 100, 1),
- "doc_count": int(comp.get("doc_count") or 0),
- }
-
- # Paire la plus complémentaire — la divergence taxonomique, quand
- # disponible, fournit deux moteurs « candidats naturels ». Sinon on
- # tombe sur le best + le second-best en recall individuel.
- div = iea.get("taxonomy_divergence") or {}
- pair = div.get("max_pair") or []
- pair_a = ""
- pair_b = ""
- divergence_value: Optional[float] = None
- if pair and len(pair) >= 3 and isinstance(pair[2], (int, float)) and pair[2] > 0:
- pair_a, pair_b, divergence_value = str(pair[0]), str(pair[1]), float(pair[2])
- else:
- # Fallback : best engine + second-best engine par recall individuel
- per_engine = comp.get("per_engine_recall") or {}
- if len(per_engine) >= 2:
- ranked = sorted(per_engine.items(), key=lambda kv: kv[1], reverse=True)
- pair_a, pair_b = ranked[0][0], ranked[1][0]
-
- payload["pair_a"] = pair_a
- payload["pair_b"] = pair_b
- payload["divergence"] = round(divergence_value, 3) if divergence_value is not None else 0.0
- payload["divergence_metric"] = (div.get("metric") or "js")
+from picarones.measurements.narrative.detectors.ensemble import * # noqa: F401, F403
- importance = (
- FactImportance.HIGH if relative_gap >= 0.5 else FactImportance.MEDIUM
- )
- engines_involved: tuple[str, ...] = (
- (pair_a, pair_b) if pair_a and pair_b else (best_engine,)
- )
- return [Fact(
- type=FactType.ENSEMBLE_OPPORTUNITY,
- importance=importance,
- payload=payload,
- engines_involved=engines_involved,
- )]
+import picarones.measurements.narrative.detectors.ensemble as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/history.py b/picarones/core/narrative/detectors/history.py
index de1fa94835ead21cb101107bddd2bf6921a660d6..13431b2f261aa4fda0ecbbf666fbe17e3b394dc7 100644
--- a/picarones/core/narrative/detectors/history.py
+++ b/picarones/core/narrative/detectors/history.py
@@ -1,280 +1,13 @@
-"""Détecteurs narratifs liés à *l'historique SQLite + multi-runs* (chantier 5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.history`.
-3 détecteurs déplacés depuis ``narrative/detectors.py`` :
-
-- :func:`detect_engine_off_baseline` (Sprint 73)
-- :func:`detect_engine_unstable` (Sprint 90)
-- :func:`detect_regression_in_history` (Sprint 92)
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-import statistics as _stats
-from typing import Optional
-
-from picarones.core.narrative.facts import Fact, FactImportance, FactType
-from picarones.core.narrative.registry import register_detector
-
-from picarones.core.narrative.detectors._helpers import (
- _engine_by_name,
- _engines_summary,
- _n_docs,
-)
-
-
-@register_detector(
- FactType.ENGINE_OFF_BASELINE,
- priority=150,
- importance=FactImportance.MEDIUM,
-)
-def detect_engine_off_baseline(benchmark_data: dict) -> list[Fact]:
- """Émet un Fact pour chaque moteur dont le CER courant s'écarte
- significativement de sa moyenne historique sur le **même corpus**.
-
- Lit ``benchmark_data["baseline_comparisons"]`` (liste de dicts
- produits par ``compute_engine_baseline`` du module
- ``baseline_comparison`` Sprint 73). Si la clé est absente ou
- vide, le détecteur reste silencieux — typiquement le cas quand
- aucun historique SQLite n'a été chargé.
-
- Garde-fous :
-
- - Si ``n_runs < 5`` (déjà filtré par ``compute_engine_baseline``
- qui retourne ``None`` dans ce cas).
- - Si ``relative_delta`` n'est pas calculable (baseline = 0).
- - Importance ``HIGH`` si ``|relative_delta| ≥ 50 %``, sinon
- ``MEDIUM``.
- """
- comparisons = benchmark_data.get("baseline_comparisons") or []
- if not isinstance(comparisons, (list, tuple)):
- return []
- facts: list[Fact] = []
- for comp in comparisons:
- if not isinstance(comp, dict):
- continue
- if not comp.get("off_baseline"):
- continue
- rel = comp.get("relative_delta")
- if rel is None:
- continue
- engine = comp.get("engine_name")
- cer_current = comp.get("cer_current")
- cer_hist_mean = comp.get("cer_historical_mean")
- n_runs = comp.get("n_runs")
- if engine is None or cer_current is None or cer_hist_mean is None:
- continue
- importance = (
- FactImportance.HIGH if abs(float(rel)) >= 0.50
- else FactImportance.MEDIUM
- )
- facts.append(Fact(
- type=FactType.ENGINE_OFF_BASELINE,
- importance=importance,
- payload={
- "engine": engine,
- "cer_current_pct": round(float(cer_current) * 100, 2),
- "cer_historical_mean_pct": round(
- float(cer_hist_mean) * 100, 2,
- ),
- "n_runs": int(n_runs or 0),
- "relative_delta_pct": round(float(rel) * 100, 1),
- "direction": "higher" if float(rel) > 0 else "lower",
- },
- engines_involved=(engine,),
- ))
- return facts
-
-
-@register_detector(
- FactType.ENGINE_UNSTABLE,
- priority=160,
- importance=FactImportance.HIGH,
-)
-def detect_engine_unstable(benchmark_data: dict) -> list[Fact]:
- """Émet un Fact pour chaque moteur dont la stabilité multi-runs
- est insuffisante (Sprint 83 + 90).
-
- Lit ``benchmark_data["multirun_stability"]`` : liste de dicts
- avec ``engine_name`` + champs de ``compute_multirun_stability``
- (cer_cv, identical_run_rate, n_runs, etc.). Si la clé est
- absente ou vide, le détecteur reste silencieux — typiquement
- le cas quand l'utilisateur n'a pas exécuté `--repeats N`.
-
- Garde-fous :
-
- - ``n_runs ≥ 2`` (déjà filtré par
- ``compute_multirun_stability`` qui retourne ``None``).
- - Déclenche si ``cer_cv > 0.10`` (variance relative > 10 % du
- CER moyen) **ou** ``identical_run_rate < 0.50`` (moins
- d'une paire de runs sur deux est identique).
- - Importance ``HIGH`` (l'instabilité discrédite les
- conclusions).
- """
- stabilities = benchmark_data.get("multirun_stability") or []
- if not isinstance(stabilities, (list, tuple)):
- return []
- facts: list[Fact] = []
- for stab in stabilities:
- if not isinstance(stab, dict):
- continue
- engine = stab.get("engine_name") or stab.get("engine")
- if not engine:
- continue
- n_runs = stab.get("n_runs")
- if not isinstance(n_runs, int) or n_runs < 2:
- continue
- cer_cv = stab.get("cer_cv")
- identical_rate = stab.get("identical_run_rate")
- # Critères de déclenchement
- cv_high = (
- isinstance(cer_cv, (int, float)) and float(cer_cv) > 0.10
- )
- runs_diverge = (
- isinstance(identical_rate, (int, float))
- and float(identical_rate) < 0.50
- )
- if not (cv_high or runs_diverge):
- continue
- payload: dict = {
- "engine": engine,
- "n_runs": int(n_runs),
- }
- if isinstance(cer_cv, (int, float)):
- payload["cer_cv"] = float(cer_cv)
- payload["cer_cv_pct"] = round(float(cer_cv) * 100, 1)
- if isinstance(identical_rate, (int, float)):
- payload["identical_run_rate"] = float(identical_rate)
- payload["identical_run_rate_pct"] = round(
- float(identical_rate) * 100, 1,
- )
- # Champs additionnels pour la phrase de synthèse
- cer_mean = stab.get("cer_mean")
- cer_stdev = stab.get("cer_stdev")
- if isinstance(cer_mean, (int, float)):
- payload["cer_mean_pct"] = round(float(cer_mean) * 100, 2)
- if isinstance(cer_stdev, (int, float)):
- payload["cer_stdev_pct"] = round(float(cer_stdev) * 100, 2)
- n_distinct = stab.get("n_distinct_outputs")
- if isinstance(n_distinct, int):
- payload["n_distinct_outputs"] = int(n_distinct)
- facts.append(Fact(
- type=FactType.ENGINE_UNSTABLE,
- importance=FactImportance.HIGH,
- payload=payload,
- engines_involved=(engine,),
- ))
- return facts
-
-
-@register_detector(
- FactType.REGRESSION_IN_HISTORY,
- priority=170,
- importance=FactImportance.MEDIUM,
-)
-def detect_regression_in_history(benchmark_data: dict) -> list[Fact]:
- """Émet un Fact pour chaque moteur dont l'historique montre
- une dégradation : pente positive significative ou rupture
- brutale (Sprint 92).
-
- Lit ``benchmark_data["longitudinal_trends"]`` : liste de
- dicts produits par ``compute_corpus_longitudinal`` du module
- ``longitudinal``. Si la clé est absente ou vide, le
- détecteur reste silencieux — typiquement le cas quand
- aucun historique n'a été chargé ou que la série est trop
- courte.
-
- Garde-fous :
+from picarones.measurements.narrative.detectors.history import * # noqa: F401, F403
- - ``n_runs ≥ 3`` (déjà filtré par
- ``compute_engine_longitudinal``).
- - Déclenche si **soit** ``trend.slope`` traduit une
- régression d'au moins ``slope_threshold`` (en CER/jour,
- défaut équivalent à +1 point CER sur 365 jours), **soit**
- ``change_point.delta > change_threshold`` (défaut
- 0.01 = +1 point de CER d'un segment à l'autre).
- - Importance ``HIGH`` si la dégradation cumulée
- ``absolute_delta`` ≥ 5 points de CER.
- """
- trends = benchmark_data.get("longitudinal_trends") or []
- if not isinstance(trends, (list, tuple)):
- return []
- slope_threshold = (
- 0.01 / 365.0 # +1 point de CER sur 365 jours minimum
- )
- change_threshold = 0.01
- facts: list[Fact] = []
- for entry in trends:
- if not isinstance(entry, dict):
- continue
- engine = entry.get("engine_name")
- if not engine:
- continue
- n_runs = entry.get("n_runs")
- if not isinstance(n_runs, int) or n_runs < 3:
- continue
- trend = entry.get("trend") or {}
- cp = entry.get("change_point")
- slope = trend.get("slope")
- slope_high = (
- isinstance(slope, (int, float))
- and float(slope) > slope_threshold
- )
- cp_high = (
- isinstance(cp, dict)
- and isinstance(cp.get("delta"), (int, float))
- and float(cp["delta"]) > change_threshold
- )
- if not (slope_high or cp_high):
- continue
- absolute_delta = entry.get("absolute_delta") or 0.0
- importance = (
- FactImportance.HIGH
- if isinstance(absolute_delta, (int, float))
- and abs(float(absolute_delta)) >= 0.05
- else FactImportance.MEDIUM
- )
- payload: dict = {
- "engine": engine,
- "n_runs": int(n_runs),
- "absolute_delta_pct": round(
- float(absolute_delta) * 100, 2,
- ) if isinstance(absolute_delta, (int, float)) else 0.0,
- "first_cer_pct": round(
- float(entry.get("first_cer") or 0.0) * 100, 2,
- ),
- "last_cer_pct": round(
- float(entry.get("last_cer") or 0.0) * 100, 2,
- ),
- }
- if slope_high:
- payload["slope_per_year_pct"] = round(
- float(slope) * 365 * 100, 2,
- )
- payload["r_squared"] = round(
- float(trend.get("r_squared") or 0.0), 3,
- )
- payload["pattern"] = "trend"
- if cp_high:
- payload["change_point_timestamp"] = str(
- cp.get("timestamp") or "?",
- )
- payload["change_delta_pct"] = round(
- float(cp["delta"]) * 100, 2,
- )
- payload["mean_before_pct"] = round(
- float(cp.get("mean_before") or 0.0) * 100, 2,
- )
- payload["mean_after_pct"] = round(
- float(cp.get("mean_after") or 0.0) * 100, 2,
- )
- # Si on a aussi une rupture, le pattern domine
- payload["pattern"] = (
- "trend_and_change_point" if slope_high else "change_point"
- )
- facts.append(Fact(
- type=FactType.REGRESSION_IN_HISTORY,
- importance=importance,
- payload=payload,
- engines_involved=(engine,),
- ))
- return facts
+import picarones.measurements.narrative.detectors.history as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/pareto.py b/picarones/core/narrative/detectors/pareto.py
index fd246c7cf6536bc8351ff85d6351fc2ee51a8280..75075af1112b097bdfb2cdd1e3466efa2038f74b 100644
--- a/picarones/core/narrative/detectors/pareto.py
+++ b/picarones/core/narrative/detectors/pareto.py
@@ -1,136 +1,13 @@
-"""Détecteurs narratifs orientés *coût/performance Pareto* (chantier 5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.pareto`.
-2 détecteurs déplacés depuis ``narrative/detectors.py`` :
-
-- :func:`detect_pareto_alternative` (Sprint 19) — alternative coût/qualité
-- :func:`detect_cost_outlier` (Sprint 19) — moteur dont le coût est aberrant
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-import statistics as _stats
-from typing import Optional
-
-from picarones.core.narrative.facts import Fact, FactImportance, FactType
-from picarones.core.narrative.registry import register_detector
-
-from picarones.core.narrative.detectors._helpers import (
- _engine_by_name,
- _engines_summary,
- _n_docs,
-)
-
-
-@register_detector(
- FactType.PARETO_ALTERNATIVE,
- priority=90,
- importance=FactImportance.HIGH,
-)
-def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]:
- """Moteur Pareto-dominant différent du leader CER.
-
- Lit ``benchmark_data["pareto"]["cost"]`` (Sprint 19) et émet un Fact si
- la frontière contient un moteur autre que le leader CER, pour souligner
- l'existence d'un compromis coût/qualité intéressant.
- """
- pareto = (benchmark_data.get("pareto") or {}).get("cost") or {}
- front = pareto.get("front") or []
- points = pareto.get("points") or []
- if len(front) < 2:
- return []
-
- ranking = benchmark_data.get("ranking") or []
- if not ranking:
- return []
- leader = ranking[0].get("engine")
-
- # Le moteur le moins cher sur le front (hors leader)
- alt: Optional[dict] = None
- for p in points:
- if p.get("engine") == leader:
- continue
- if p.get("engine") not in front:
- continue
- if alt is None or float(p.get("cost") or 0.0) < float(alt.get("cost") or 0.0):
- alt = p
- if alt is None:
- return []
-
- leader_point = next((p for p in points if p.get("engine") == leader), None)
- if leader_point is None:
- return []
-
- alt_cer = float(alt.get("cer") or 0.0)
- alt_cost = float(alt.get("cost") or 0.0)
- leader_cer = float(leader_point.get("cer") or 0.0)
- leader_cost = float(leader_point.get("cost") or 0.0)
- if alt_cost >= leader_cost or alt_cost <= 0:
- return [] # pas réellement moins cher — pas intéressant à remonter
-
- return [Fact(
- type=FactType.PARETO_ALTERNATIVE,
- importance=FactImportance.HIGH,
- payload={
- "engine": alt["engine"],
- "leader": leader,
- "cer": round(alt_cer, 4),
- "cer_pct": round(alt_cer * 100, 2),
- "cost": round(alt_cost, 2),
- "leader_cer": round(leader_cer, 4),
- "leader_cer_pct": round(leader_cer * 100, 2),
- "leader_cost": round(leader_cost, 2),
- "cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None,
- "delta_cer_pct": round((alt_cer - leader_cer) * 100, 2),
- # Unité du coût — propagée pour traçabilité (le template ne
- # hardcode plus "1000 pages").
- "cost_unit_pages": 1000,
- },
- engines_involved=(alt["engine"],),
- )]
-
-
-@register_detector(
- FactType.COST_OUTLIER,
- priority=110,
- importance=FactImportance.MEDIUM,
-)
-def detect_cost_outlier(benchmark_data: dict) -> list[Fact]:
- """Moteur dont le coût est très disproportionné par rapport à son apport.
-
- Flag un moteur dont le coût ≥ 5× la médiane ET qui n'est pas sur le
- front Pareto (donc dominé par moins cher OU meilleur CER).
- """
- pareto = (benchmark_data.get("pareto") or {}).get("cost") or {}
- points = pareto.get("points") or []
- front = set(pareto.get("front") or [])
- if len(points) < 3:
- return []
-
- costs = [float(p["cost"]) for p in points if p.get("cost") is not None]
- if not costs:
- return []
- median_cost = _stats.median(costs)
- if median_cost <= 0:
- return []
+from picarones.measurements.narrative.detectors.pareto import * # noqa: F401, F403
- facts: list[Fact] = []
- for p in points:
- c = float(p.get("cost") or 0.0)
- if c < 5.0 * median_cost:
- continue
- if p["engine"] in front:
- continue # sur le front → coût justifié par une qualité unique
- facts.append(Fact(
- type=FactType.COST_OUTLIER,
- importance=FactImportance.MEDIUM,
- payload={
- "engine": p["engine"],
- "cost": round(c, 2),
- "median_cost": round(median_cost, 2),
- "ratio_to_median": round(c / median_cost, 1),
- "cer_pct": round(float(p.get("cer") or 0.0) * 100, 2),
- "cost_unit_pages": 1000,
- },
- engines_involved=(p["engine"],),
- ))
- return facts
+import picarones.measurements.narrative.detectors.pareto as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/quality.py b/picarones/core/narrative/detectors/quality.py
index 70cd29f14f0f3feacf71c8c030e4c50ada9cde94..acdb59e182e1b84da6b3948788e293b1f82f7e7f 100644
--- a/picarones/core/narrative/detectors/quality.py
+++ b/picarones/core/narrative/detectors/quality.py
@@ -1,251 +1,13 @@
-"""Détecteurs narratifs liés à la *qualité texte / fiabilité* (chantier 5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.quality`.
-4 détecteurs déplacés depuis ``narrative/detectors.py`` :
-
-- :func:`detect_error_profile_outlier` (Sprint 4)
-- :func:`detect_llm_hallucination_flag` (Sprint 4)
-- :func:`detect_robustness_fragile` (Sprint 4)
-- :func:`detect_confidence_warning` (Sprint 4)
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-import statistics as _stats
-from typing import Optional
-
-from picarones.core.narrative.facts import Fact, FactImportance, FactType
-from picarones.core.narrative.registry import register_detector
-
-from picarones.core.narrative.detectors._helpers import (
- _engine_by_name,
- _engines_summary,
- _n_docs,
-)
-
-
-@register_detector(
- FactType.ERROR_PROFILE_OUTLIER,
- priority=60,
- importance=FactImportance.MEDIUM,
-)
-def detect_error_profile_outlier(benchmark_data: dict) -> list[Fact]:
- """Moteur au profil taxonomique atypique.
-
- Émet un Fact si, pour un moteur et une classe d'erreur, la part relative
- est au moins 2× plus élevée que la médiane des autres moteurs (et > 15 %
- du total pour éviter les strates marginales).
- """
- engines = _engines_summary(benchmark_data)
- # {engine: {class_name: proportion}}
- profiles: dict[str, dict[str, float]] = {}
- for e in engines:
- tax = e.get("aggregated_taxonomy") or {}
- distribution = tax.get("distribution") or tax.get("proportions") or {}
- if not distribution:
- continue
- profiles[e["name"]] = {k: float(v) for k, v in distribution.items()}
- if len(profiles) < 2:
- return []
-
- # Collecter toutes les classes rencontrées
- all_classes: set[str] = set()
- for p in profiles.values():
- all_classes.update(p.keys())
-
- facts: list[Fact] = []
- for cls in all_classes:
- values = [(name, p.get(cls, 0.0)) for name, p in profiles.items()]
- props = [v for _, v in values]
- if not props:
- continue
- median_prop = _stats.median(props)
- for name, v in values:
- if v < 0.15: # trop marginal pour être notable
- continue
- if median_prop <= 0:
- continue
- if v >= 2.0 * median_prop:
- facts.append(Fact(
- type=FactType.ERROR_PROFILE_OUTLIER,
- importance=FactImportance.HIGH,
- payload={
- "engine": name,
- "error_class": cls,
- "proportion": round(v, 4),
- "proportion_pct": round(v * 100, 1),
- "median_proportion": round(median_prop, 4),
- "median_proportion_pct": round(median_prop * 100, 1),
- "ratio_to_median": round(v / median_prop, 2) if median_prop else None,
- },
- engines_involved=(name,),
- ))
- return facts
-
-
-@register_detector(
- FactType.LLM_HALLUCINATION_FLAG,
- priority=70,
- importance=FactImportance.HIGH,
-)
-def detect_llm_hallucination_flag(benchmark_data: dict) -> list[Fact]:
- """LLM/VLM au taux d'hallucination notablement élevé.
-
- Déclenché si ``hallucinating_doc_rate`` > 30 % OU ``anchor_score_mean`` < 0,6
- pour un moteur dont le champ ``is_pipeline`` ou ``is_vlm`` est ``True``.
- """
- facts: list[Fact] = []
- for e in _engines_summary(benchmark_data):
- agg = e.get("aggregated_hallucination") or {}
- if not agg:
- continue
- rate = agg.get("hallucinating_doc_rate")
- anchor = agg.get("anchor_score_mean")
- length_ratio = agg.get("length_ratio_mean")
- # Signal seulement si c'est un pipeline LLM ou un VLM
- is_llm = bool(e.get("is_pipeline")) or bool(e.get("is_vlm"))
- if not is_llm:
- continue
-
- flagged = False
- reasons = []
- if rate is not None and float(rate) > 0.30:
- flagged = True
- reasons.append("taux de documents hallucinés")
- if anchor is not None and float(anchor) < 0.60:
- flagged = True
- reasons.append("ancrage faible")
- if length_ratio is not None and float(length_ratio) > 1.30:
- flagged = True
- reasons.append("sortie anormalement longue")
- if not flagged:
- continue
-
- facts.append(Fact(
- type=FactType.LLM_HALLUCINATION_FLAG,
- importance=FactImportance.HIGH,
- payload={
- "engine": e["name"],
- "hallucinating_rate": round(float(rate or 0.0), 4),
- "hallucinating_rate_pct": round(float(rate or 0.0) * 100, 1),
- "anchor_score": round(float(anchor), 3) if anchor is not None else None,
- "length_ratio": round(float(length_ratio), 3) if length_ratio is not None else None,
- "reasons": reasons,
- "reasons_list": ", ".join(reasons),
- },
- engines_involved=(e["name"],),
- ))
- return facts
-
-
-@register_detector(
- FactType.ROBUSTNESS_FRAGILE,
- priority=80,
- importance=FactImportance.MEDIUM,
-)
-def detect_robustness_fragile(benchmark_data: dict) -> list[Fact]:
- """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou.
-
- Activé si les données de robustesse sont embarquées dans
- ``benchmark_data["robustness"]`` (hors scope du benchmark classique,
- produit par ``picarones robustness`` et injecté optionnellement).
- """
- robustness = benchmark_data.get("robustness")
- if not robustness:
- return []
-
- facts: list[Fact] = []
- curves = robustness.get("curves") or robustness.get("engines") or []
- # Structure attendue : [{engine, degradation_type, points: [{level, cer}]}]
- # Flag : CER à niveau max > 3× CER au niveau min.
- for entry in curves:
- engine = entry.get("engine")
- dtype = entry.get("degradation_type")
- points = entry.get("points") or []
- if not engine or not points or len(points) < 2:
- continue
- try:
- sorted_pts = sorted(points, key=lambda p: float(p["level"]))
- except (KeyError, TypeError, ValueError):
- continue
- first, last = sorted_pts[0], sorted_pts[-1]
- c0 = float(first.get("cer") or 0.0)
- c1 = float(last.get("cer") or 0.0)
- if c0 <= 0.01: # éviter division par quasi-zéro
- continue
- if c1 >= 3.0 * c0 and c1 > 0.15:
- facts.append(Fact(
- type=FactType.ROBUSTNESS_FRAGILE,
- importance=FactImportance.HIGH,
- payload={
- "engine": engine,
- "degradation": dtype,
- "cer_baseline": round(c0, 4),
- "cer_baseline_pct": round(c0 * 100, 1),
- "cer_degraded": round(c1, 4),
- "cer_degraded_pct": round(c1 * 100, 1),
- "ratio": round(c1 / c0, 1),
- "level_max": float(last.get("level") or 0),
- },
- engines_involved=(engine,),
- ))
- return facts
-
-
-@register_detector(
- FactType.CONFIDENCE_WARNING,
- priority=120,
- importance=FactImportance.MEDIUM,
-)
-def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
- """Intervalle de confiance large → classement peu fiable.
-
- Déclenché si, pour le leader ou le runner-up, la largeur de l'IC 95 %
- est plus du triple de l'écart |leader − runner-up| OU > 5 points de CER.
- """
- stats = benchmark_data.get("statistics", {}) or {}
- cis = stats.get("bootstrap_cis") or []
- if len(cis) < 2:
- return []
-
- ranking = benchmark_data.get("ranking") or []
- valid = [r for r in ranking if r.get("mean_cer") is not None]
- if len(valid) < 2:
- return []
-
- by_name = {c["engine"]: c for c in cis if "engine" in c}
- leader = valid[0]["engine"]
- runner_up = valid[1]["engine"]
- leader_ci = by_name.get(leader)
- runner_ci = by_name.get(runner_up)
- if not leader_ci or not runner_ci:
- return []
+from picarones.measurements.narrative.detectors.quality import * # noqa: F401, F403
- gap = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"]))
- facts: list[Fact] = []
- for engine_name, ci in ((leader, leader_ci), (runner_up, runner_ci)):
- lo = float(ci.get("ci_lower") or 0.0)
- hi = float(ci.get("ci_upper") or 0.0)
- width = hi - lo
- wide_vs_gap = gap > 0 and width > 3.0 * gap
- wide_absolute = width > 0.05
- if wide_vs_gap or wide_absolute:
- facts.append(Fact(
- type=FactType.CONFIDENCE_WARNING,
- importance=FactImportance.MEDIUM,
- payload={
- "engine": engine_name,
- "ci_lower": round(lo, 4),
- "ci_upper": round(hi, 4),
- "ci_width": round(width, 4),
- "ci_width_pct": round(width * 100, 2),
- "mean_cer": round(float(ci.get("mean") or 0.0), 4),
- "mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2),
- "gap_to_runner_up_pct": round(gap * 100, 2),
- # Niveau de confiance des bornes — propagé pour traçabilité
- # anti-hallucination (le template ne hardcode plus "95 %").
- "confidence_level": 95,
- },
- engines_involved=(engine_name,),
- ))
- break # un seul avertissement suffit
- return facts
+import picarones.measurements.narrative.detectors.quality as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/ranking.py b/picarones/core/narrative/detectors/ranking.py
index 987ac82a2f8c0dac55b81e029c1fa42bcd984b08..f152130934a793aec8c67de7acafd087de464674 100644
--- a/picarones/core/narrative/detectors/ranking.py
+++ b/picarones/core/narrative/detectors/ranking.py
@@ -1,279 +1,13 @@
-"""Détecteurs narratifs orientés *classement* (chantier 5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.ranking`.
-5 détecteurs déplacés depuis ``narrative/detectors.py`` :
-
-- :func:`detect_global_leader_cer` (Sprint 4)
-- :func:`detect_statistical_tie` (Sprint 18)
-- :func:`detect_significant_gap` (Sprint 4)
-- :func:`detect_speed_winner` (Sprint 4)
-- :func:`detect_median_mean_gap_warning` (Sprint 44)
-
-Comportement et signature inchangés. Tous restent enregistrés
-automatiquement via ``@register_detector`` à l'import.
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-import statistics as _stats
-from typing import Optional
-
-from picarones.core.narrative.facts import Fact, FactImportance, FactType
-from picarones.core.narrative.registry import register_detector
-
-from picarones.core.narrative.detectors._helpers import (
- _engine_by_name,
- _engines_summary,
- _n_docs,
-)
-
-
-@register_detector(
- FactType.GLOBAL_LEADER_CER,
- priority=10,
- importance=FactImportance.CRITICAL,
-)
-def detect_global_leader_cer(benchmark_data: dict) -> list[Fact]:
- """Moteur avec le CER moyen le plus bas sur l'ensemble du corpus.
-
- Émet un Fact CRITICAL si au moins 2 moteurs sont comparés, en attachant
- aussi le 2ᵉ pour permettre à l'arbitre de fusionner avec ``significant_gap``.
- """
- ranking = benchmark_data.get("ranking") or []
- # Éliminer les entrées sans CER calculé
- valid = [r for r in ranking if r.get("mean_cer") is not None]
- if len(valid) < 1:
- return []
-
- leader = valid[0]
- runner_up = valid[1] if len(valid) >= 2 else None
-
- payload = {
- "engine": leader["engine"],
- "cer": float(leader["mean_cer"]),
- "cer_pct": round(float(leader["mean_cer"]) * 100, 2),
- "n_engines": len(valid),
- "n_docs": _n_docs(benchmark_data),
- }
- if runner_up is not None:
- payload["runner_up"] = runner_up["engine"]
- payload["runner_up_cer"] = float(runner_up["mean_cer"])
- payload["runner_up_cer_pct"] = round(float(runner_up["mean_cer"]) * 100, 2)
-
- return [Fact(
- type=FactType.GLOBAL_LEADER_CER,
- importance=FactImportance.CRITICAL,
- payload=payload,
- engines_involved=(leader["engine"],),
- )]
-
-
-@register_detector(
- FactType.STATISTICAL_TIE,
- priority=20,
- importance=FactImportance.CRITICAL,
-)
-def detect_statistical_tie(benchmark_data: dict) -> list[Fact]:
- """Groupes de moteurs statistiquement indiscernables (Nemenyi)."""
- nemenyi = benchmark_data.get("statistics", {}).get("nemenyi", {})
- if not nemenyi or nemenyi.get("error"):
- return []
-
- tied_groups = nemenyi.get("tied_groups", [])
- mean_ranks = nemenyi.get("mean_ranks", {})
- cd = nemenyi.get("critical_distance", 0.0)
- alpha = nemenyi.get("alpha", 0.05)
- n_blocks = nemenyi.get("n_blocks", 0)
-
- facts: list[Fact] = []
- for group in tied_groups:
- if len(group) < 2:
- continue
- is_leader_tie = min(mean_ranks.get(n, 999) for n in group) == min(
- mean_ranks.values(), default=0
- )
- importance = FactImportance.CRITICAL if is_leader_tie else FactImportance.HIGH
-
- facts.append(Fact(
- type=FactType.STATISTICAL_TIE,
- importance=importance,
- payload={
- "engines": list(group),
- "engines_list": ", ".join(group),
- "mean_ranks": {n: mean_ranks.get(n) for n in group},
- "critical_distance": round(cd, 3),
- "alpha": alpha,
- "n_blocks": n_blocks,
- "includes_leader": is_leader_tie,
- "n_tied": len(group),
- },
- engines_involved=tuple(group),
- ))
- return facts
-
-
-@register_detector(
- FactType.SIGNIFICANT_GAP,
- priority=30,
- importance=FactImportance.HIGH,
-)
-def detect_significant_gap(benchmark_data: dict) -> list[Fact]:
- """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement.
-
- Lit la matrice de Wilcoxon pairwise et vérifie si la paire (leader,
- runner-up) y apparaît avec ``significant = True``.
- """
- ranking = benchmark_data.get("ranking") or []
- valid = [r for r in ranking if r.get("mean_cer") is not None]
- if len(valid) < 2:
- return []
-
- leader = valid[0]["engine"]
- runner_up = valid[1]["engine"]
-
- pairwise = benchmark_data.get("statistics", {}).get("pairwise_wilcoxon") or []
- match = None
- for p in pairwise:
- names = {p.get("engine_a"), p.get("engine_b")}
- if names == {leader, runner_up}:
- match = p
- break
- if match is None:
- return []
-
- if not match.get("significant"):
- return [] # pas d'écart significatif — rien à signaler ici
-
- delta_cer = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"]))
- return [Fact(
- type=FactType.SIGNIFICANT_GAP,
- importance=FactImportance.CRITICAL,
- payload={
- "leader": leader,
- "runner_up": runner_up,
- "p_value": float(match.get("p_value", 0.0)),
- "delta_cer": round(delta_cer, 4),
- "delta_cer_pct": round(delta_cer * 100, 2),
- "n_pairs": int(match.get("n_pairs", 0)),
- },
- engines_involved=(leader, runner_up),
- )]
-
-
-@register_detector(
- FactType.SPEED_WINNER,
- priority=100,
- importance=FactImportance.MEDIUM,
-)
-def detect_speed_winner(benchmark_data: dict) -> list[Fact]:
- """Moteur significativement plus rapide pour une qualité comparable.
-
- Déclenché si un moteur est au moins 3× plus rapide que la médiane ET que
- son CER n'est pas significativement pire (dans le même groupe Nemenyi que
- le leader OU CER ≤ 1,1 × CER du leader).
- """
- durations = _mean_duration_per_engine(benchmark_data)
- if len(durations) < 2:
- return []
-
- values = list(durations.values())
- median_dur = _stats.median(values)
- if median_dur <= 0:
- return []
-
- ranking = benchmark_data.get("ranking") or []
- valid = [r for r in ranking if r.get("mean_cer") is not None]
- if not valid:
- return []
- leader_cer = float(valid[0]["mean_cer"])
- quality_ceiling = max(0.01, leader_cer * 1.10)
-
- tied_groups = benchmark_data.get("statistics", {}).get("nemenyi", {}).get("tied_groups") or []
- leader_group: set[str] = set()
- for g in tied_groups:
- if valid[0]["engine"] in g:
- leader_group = set(g)
- break
-
- facts: list[Fact] = []
- candidates = sorted(durations.items(), key=lambda kv: kv[1])
- for engine, dur in candidates:
- if dur * 3.0 > median_dur:
- break # les suivants sont encore plus lents
- summary = _engine_by_name(benchmark_data, engine) or {}
- engine_cer = summary.get("cer")
- if engine_cer is None:
- continue
- acceptable_quality = (
- engine in leader_group or float(engine_cer) <= quality_ceiling
- )
- if not acceptable_quality:
- continue
- facts.append(Fact(
- type=FactType.SPEED_WINNER,
- importance=FactImportance.MEDIUM,
- payload={
- "engine": engine,
- "mean_duration": round(dur, 3),
- "median_duration": round(median_dur, 3),
- "speedup": round(median_dur / dur, 1) if dur > 0 else None,
- "cer": round(float(engine_cer), 4),
- "cer_pct": round(float(engine_cer) * 100, 2),
- },
- engines_involved=(engine,),
- ))
- return facts[:1] # seulement le plus rapide — éviter le bruit
-
-
-@register_detector(
- FactType.MEDIAN_MEAN_GAP_WARNING,
- priority=140,
- importance=FactImportance.MEDIUM,
-)
-def detect_median_mean_gap_warning(benchmark_data: dict) -> list[Fact]:
- """Avertit quand le ratio ``|moyenne - médiane| / médiane`` du leader
- dépasse 30 %, ce qui indique une distribution fortement asymétrique
- où la moyenne masque les performances réelles.
-
- Sprint 44 — A.I.2 du plan d'évolution. Cohérent avec le passage du
- tri par défaut sur la médiane : si la moyenne du leader diverge
- fortement de la médiane, l'utilisateur doit le savoir pour
- interpréter correctement les chiffres.
- """
- ranking = benchmark_data.get("ranking") or []
- valid = [
- r for r in ranking
- if r.get("median_cer") is not None
- and r.get("mean_cer") is not None
- ]
- if not valid:
- return []
-
- leader = valid[0]
- median_cer = float(leader["median_cer"])
- mean_cer = float(leader["mean_cer"])
-
- if median_cer <= 0:
- # Médiane nulle (corpus très facile pour ce moteur) — l'écart
- # relatif n'est pas calculable de manière utile, on s'abstient.
- return []
-
- relative_gap = abs(mean_cer - median_cer) / median_cer
- if relative_gap < 0.30:
- return []
-
- importance = (
- FactImportance.HIGH if relative_gap >= 1.0 else FactImportance.MEDIUM
- )
+from picarones.measurements.narrative.detectors.ranking import * # noqa: F401, F403
- return [Fact(
- type=FactType.MEDIAN_MEAN_GAP_WARNING,
- importance=importance,
- payload={
- "engine": leader["engine"],
- "median_cer_pct": round(median_cer * 100, 2),
- "mean_cer_pct": round(mean_cer * 100, 2),
- "relative_gap_pct": round(relative_gap * 100, 1),
- "n_docs": int(leader.get("documents") or 0),
- },
- engines_involved=(leader["engine"],),
- )]
+import picarones.measurements.narrative.detectors.ranking as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/detectors/stratum.py b/picarones/core/narrative/detectors/stratum.py
index 525aad71a12597d84ba2c05fcfd1895fe4460db9..f7dd96cd9cbbc3b4a83f5b74fa9213422d7f6ea2 100644
--- a/picarones/core/narrative/detectors/stratum.py
+++ b/picarones/core/narrative/detectors/stratum.py
@@ -1,203 +1,13 @@
-"""Détecteurs narratifs liés à la *stratification corpus* (chantier 5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.detectors.stratum`.
-3 détecteurs + 1 helper (``_stratum_cer_by_engine``) déplacés depuis
-``narrative/detectors.py`` :
-
-- :func:`detect_stratum_winner` (Sprint 4)
-- :func:`detect_stratum_collapse` (Sprint 4)
-- :func:`detect_stratification_recommended` (Sprint 45)
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-import statistics as _stats
-from typing import Optional
-
-from picarones.core.narrative.facts import Fact, FactImportance, FactType
-from picarones.core.narrative.registry import register_detector
-
-from picarones.core.narrative.detectors._helpers import (
- _engine_by_name,
- _engines_summary,
- _n_docs,
-)
-
-
-def _stratum_cer_by_engine(benchmark_data: dict) -> dict[str, dict[str, list[float]]]:
- """Agrège les CER par (moteur, strate).
-
- Strate = ``document["script_type"]`` si présent. Retourne ``{}`` si aucun
- document n'expose de strate (pas d'émission possible).
- """
- out: dict[str, dict[str, list[float]]] = {}
- for doc in benchmark_data.get("documents") or []:
- stratum = doc.get("script_type")
- if not stratum:
- continue
- for er in doc.get("engine_results") or []:
- if er.get("error"):
- continue
- cer = er.get("cer")
- if cer is None:
- continue
- name = er.get("engine")
- out.setdefault(name, {}).setdefault(stratum, []).append(float(cer))
- return out
-
-
-@register_detector(
- FactType.STRATUM_WINNER,
- priority=40,
- importance=FactImportance.MEDIUM,
-)
-def detect_stratum_winner(benchmark_data: dict) -> list[Fact]:
- """Moteur qui domine nettement sur une strate (≥ 3 documents, CER
- au moins 25 % plus bas que le second sur cette strate).
- """
- agg = _stratum_cer_by_engine(benchmark_data)
- if not agg:
- return []
-
- # Inverser : {stratum: {engine: mean_cer}}
- by_stratum: dict[str, dict[str, float]] = {}
- for engine, strata in agg.items():
- for stratum, vals in strata.items():
- if len(vals) < 3:
- continue
- by_stratum.setdefault(stratum, {})[engine] = sum(vals) / len(vals)
-
- facts: list[Fact] = []
- for stratum, engine_cer in by_stratum.items():
- if len(engine_cer) < 2:
- continue
- ordered = sorted(engine_cer.items(), key=lambda kv: kv[1])
- best_name, best_cer = ordered[0]
- second_cer = ordered[1][1]
- if second_cer == 0:
- continue
- if best_cer < second_cer * 0.75: # dominance ≥ 25 %
- facts.append(Fact(
- type=FactType.STRATUM_WINNER,
- importance=FactImportance.HIGH,
- payload={
- "engine": best_name,
- "stratum": stratum,
- "cer": round(best_cer, 4),
- "cer_pct": round(best_cer * 100, 2),
- "second_engine": ordered[1][0],
- "second_cer": round(second_cer, 4),
- "second_cer_pct": round(second_cer * 100, 2),
- "n_docs_stratum": len(agg[best_name][stratum]),
- },
- engines_involved=(best_name,),
- stratum=stratum,
- ))
- return facts
-
-
-@register_detector(
- FactType.STRATUM_COLLAPSE,
- priority=50,
- importance=FactImportance.HIGH,
-)
-def detect_stratum_collapse(benchmark_data: dict) -> list[Fact]:
- """Moteur globalement compétitif qui s'effondre sur une strate.
-
- Déclenché si, pour un moteur, le CER moyen sur une strate ≥ 3 documents
- est plus du double du CER global du même moteur.
- """
- agg = _stratum_cer_by_engine(benchmark_data)
- if not agg:
- return []
-
- facts: list[Fact] = []
- for engine_name, strata in agg.items():
- summary = _engine_by_name(benchmark_data, engine_name) or {}
- global_cer = summary.get("cer")
- if global_cer is None:
- continue
- global_cer = float(global_cer)
- if global_cer <= 0:
- continue
- for stratum, vals in strata.items():
- if len(vals) < 3:
- continue
- local_cer = sum(vals) / len(vals)
- if local_cer > 2.0 * global_cer and (local_cer - global_cer) > 0.05:
- facts.append(Fact(
- type=FactType.STRATUM_COLLAPSE,
- importance=FactImportance.HIGH,
- payload={
- "engine": engine_name,
- "stratum": stratum,
- "local_cer": round(local_cer, 4),
- "local_cer_pct": round(local_cer * 100, 2),
- "global_cer": round(global_cer, 4),
- "global_cer_pct": round(global_cer * 100, 2),
- "delta_cer_pct": round((local_cer - global_cer) * 100, 2),
- "n_docs_stratum": len(vals),
- },
- engines_involved=(engine_name,),
- stratum=stratum,
- ))
- return facts
-
-
-@register_detector(
- FactType.STRATIFICATION_RECOMMENDED,
- priority=45, # juste après STRATUM_WINNER (40), avant STRATUM_COLLAPSE (50)
- importance=FactImportance.HIGH,
-)
-def detect_stratification_recommended(benchmark_data: dict) -> list[Fact]:
- """Avertit quand le corpus est hétérogène et que la vue stratifiée
- apporte un éclairage qualitativement différent du classement global.
-
- Critère : ``corpus_homogeneity.max_inter_strata_gap > 5 points`` de
- CER médian sur le moteur leader. Au-delà de 10 points, importance
- ``HIGH`` (situation très hétérogène où le seul classement global
- serait trompeur).
-
- Lit ``benchmark_data["corpus_homogeneity"]`` exposé par
- ``BenchmarkResult.as_dict()`` (Sprint 45).
- """
- homog = benchmark_data.get("corpus_homogeneity")
- if not homog:
- return []
-
- gap = homog.get("max_inter_strata_gap")
- if gap is None:
- return []
-
- gap = float(gap)
- if gap < 0.05:
- return [] # 5 points de CER : seuil de pertinence éditoriale
-
- leader = str(homog.get("leader") or "")
- n_strata = int(homog.get("n_strata") or 0)
- pair = homog.get("leader_max_gap_strata") or ["", ""]
- if len(pair) < 2:
- return []
- min_strat, max_strat = str(pair[0]), str(pair[1])
-
- leader_per_stratum = homog.get("leader_per_stratum_median") or {}
- min_med = float(leader_per_stratum.get(min_strat, 0.0))
- max_med = float(leader_per_stratum.get(max_strat, 0.0))
-
- importance = (
- FactImportance.HIGH if gap >= 0.10 else FactImportance.MEDIUM
- )
+from picarones.measurements.narrative.detectors.stratum import * # noqa: F401, F403
- return [Fact(
- type=FactType.STRATIFICATION_RECOMMENDED,
- importance=importance,
- payload={
- "leader": leader,
- "n_strata": n_strata,
- "gap_pct": round(gap * 100, 1),
- "min_stratum": min_strat,
- "max_stratum": max_strat,
- "min_stratum_cer_pct": round(min_med * 100, 2),
- "max_stratum_cer_pct": round(max_med * 100, 2),
- },
- engines_involved=(leader,) if leader else (),
- )]
+import picarones.measurements.narrative.detectors.stratum as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/facts.py b/picarones/core/narrative/facts.py
index f06c2289173a203616ea64b658a4ae21368b15af..3365bdf3ca3c66463e6b9a6ec4c4252ae3315e4a 100644
--- a/picarones/core/narrative/facts.py
+++ b/picarones/core/narrative/facts.py
@@ -1,212 +1,13 @@
-"""Modèle de données du moteur narratif.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.facts`.
-Un ``Fact`` est une observation structurée extraite d'un ``BenchmarkResult``.
-Chaque détecteur retourne zéro, un ou plusieurs ``Fact`` typés. L'arbitre
-(Sprint 4) trie par ``importance`` et sélectionne les faits à afficher.
-
-Règle d'or (à vérifier par tests) : chaque valeur numérique ou nom d'entité
-présent dans ``payload`` doit provenir directement du JSON d'entrée, jamais
-d'une génération. C'est ce qui rend la synthèse reproductible bit-à-bit et
-immune à l'hallucination par construction.
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from enum import Enum
-from typing import Callable, Optional
-
-
-class FactType(str, Enum):
- """Types de faits détectables.
-
- L'ajout d'un nouveau type se fait ici + un détecteur dans ``detectors.py``
- + un template dans ``narrative/templates_{lang}.yaml`` (Sprint 4).
- """
-
- GLOBAL_LEADER_CER = "global_leader_cer"
- """Moteur avec le CER médian le plus bas sur l'ensemble du corpus."""
-
- STATISTICAL_TIE = "statistical_tie"
- """Top-N moteurs statistiquement indiscernables (Nemenyi, Sprint 3)."""
-
- SIGNIFICANT_GAP = "significant_gap"
- """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement."""
-
- PARETO_ALTERNATIVE = "pareto_alternative"
- """Moteur sur la frontière Pareto différent du leader CER pur (Sprint 5)."""
-
- STRATUM_WINNER = "stratum_winner"
- """Moteur qui domine sur une strate spécifique (siècle, langue, type)."""
-
- STRATUM_COLLAPSE = "stratum_collapse"
- """Moteur globalement bon qui s'effondre sur une strate spécifique."""
-
- ERROR_PROFILE_OUTLIER = "error_profile_outlier"
- """Moteur avec un profil taxonomique atypique (ex : 3× plus d'erreurs d'abréviation)."""
-
- LLM_HALLUCINATION_FLAG = "llm_hallucination_flag"
- """LLM avec un taux d'hallucination notablement supérieur aux autres."""
-
- ROBUSTNESS_FRAGILE = "robustness_fragile"
- """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou."""
-
- COST_OUTLIER = "cost_outlier"
- """Moteur au ratio coût/qualité très défavorable (Sprint 5)."""
-
- SPEED_WINNER = "speed_winner"
- """Moteur significativement plus rapide pour une qualité comparable."""
-
- CONFIDENCE_WARNING = "confidence_warning"
- """Intervalle de confiance très large : classement peu fiable."""
-
- ENSEMBLE_OPPORTUNITY = "ensemble_opportunity"
- """Deux moteurs sont fortement complémentaires : un voting majoritaire
- pourrait améliorer significativement le CER (Sprint 36)."""
-
- MEDIAN_MEAN_GAP_WARNING = "median_mean_gap_warning"
- """Distribution des CER fortement asymétrique sur le corpus —
- la moyenne du leader est tirée par quelques documents catastrophiques
- et masque les performances réelles. La médiane (utilisée pour le tri
- par défaut depuis Sprint 44) est plus représentative."""
-
- STRATIFICATION_RECOMMENDED = "stratification_recommended"
- """Le corpus est hétérogène du point de vue script_type : le moteur
- leader varie fortement selon la strate. Le lecteur doit consulter
- la vue stratifiée plutôt que de se fier au seul classement global
- (Sprint 46)."""
-
- ENGINE_OFF_BASELINE = "engine_off_baseline"
- """Le CER courant d'un moteur s'écarte significativement de sa
- moyenne historique sur le même corpus (lue depuis l'historique
- SQLite, Sprint 8). Lit ``BenchmarkHistory`` via le module
- ``baseline_comparison`` (Sprint 73). Garde-fous : ≥ 5 runs
- historiques même corpus + |delta_relatif| > 20 %."""
-
- ENGINE_UNSTABLE = "engine_unstable"
- """Un moteur LLM/VLM exécuté plusieurs fois sur les mêmes
- documents produit des sorties différentes au-delà d'un seuil
- de variance (Sprint 90). Lit ``compute_multirun_stability``
- (Sprint 83). Garde-fous : ≥ 2 runs et seuil sur le coefficient
- de variation du CER (>10 % par défaut) ou sur le rappel de
- runs identiques (<50 %)."""
-
- REGRESSION_IN_HISTORY = "regression_in_history"
- """Un moteur montre une tendance ou une rupture défavorable
- sur l'historique SQLite : son CER moyen s'est dégradé sur
- les N derniers runs (Sprint 92). Lit
- ``compute_corpus_longitudinal`` du module ``longitudinal``.
- Garde-fous : ≥ 3 runs historiques et soit pente > seuil
- (régression progressive), soit change-point avec delta >
- seuil (rupture brutale)."""
-
-
-class FactImportance(int, Enum):
- """Score d'importance d'un fait — décide l'ordre et la sélection."""
-
- CRITICAL = 100
- """À remonter systématiquement en synthèse (ex : leader + écart significatif)."""
-
- HIGH = 70
- """À remonter sauf si déjà redondant avec un fait critique."""
-
- MEDIUM = 40
- """À remonter si la synthèse a encore de la place."""
-
- LOW = 10
- """Informatif, remonté uniquement en vue détaillée."""
-
-
-@dataclass
-class Fact:
- """Observation structurée extraite d'un benchmark.
-
- Attributes
- ----------
- type:
- Type de fait (voir ``FactType``).
- importance:
- Priorité de sélection (voir ``FactImportance``).
- payload:
- Dict de données brutes sérialisables. **Toutes les valeurs doivent
- provenir du JSON d'entrée** — c'est le garde-fou anti-hallucination.
- engines_involved:
- Noms des moteurs concernés. Utilisé par l'arbitre pour détecter
- les redondances (deux faits sur le même moteur = fusion ou sélection).
- stratum:
- Strate concernée (ex : "XVIIe siècle", "latin médiéval") ou None.
- """
-
- type: FactType
- importance: FactImportance
- payload: dict
- engines_involved: tuple[str, ...] = ()
- stratum: Optional[str] = None
-
- def as_dict(self) -> dict:
- return {
- "type": self.type.value,
- "importance": int(self.importance),
- "payload": self.payload,
- "engines_involved": list(self.engines_involved),
- "stratum": self.stratum,
- }
-
-
-# ---------------------------------------------------------------------------
-# Registre de détecteurs
-# ---------------------------------------------------------------------------
-
-# Signature d'un détecteur : prend le dict JSON du benchmark, retourne une liste
-# de Fact (potentiellement vide). Doit être pure et déterministe.
-DetectorFn = Callable[[dict], list[Fact]]
-
-
-@dataclass
-class DetectorRegistry:
- """Registre central des détecteurs de faits.
-
- Un détecteur est enregistré via ``register(fact_type, fn)``. ``detect_all``
- appelle tous les détecteurs enregistrés et renvoie la liste consolidée.
- """
-
- _detectors: dict[FactType, DetectorFn] = field(default_factory=dict)
-
- def register(self, fact_type: FactType, fn: DetectorFn) -> None:
- self._detectors[fact_type] = fn
-
- def unregister(self, fact_type: FactType) -> None:
- self._detectors.pop(fact_type, None)
-
- def registered_types(self) -> tuple[FactType, ...]:
- return tuple(self._detectors.keys())
-
- def run(self, benchmark_data: dict) -> list[Fact]:
- facts: list[Fact] = []
- for fact_type, fn in self._detectors.items():
- try:
- result = fn(benchmark_data)
- except Exception as e:
- import logging
- logging.getLogger(__name__).warning(
- "[narrative.detector.%s] fonctionnalité dégradée : %s",
- fact_type.value, e,
- )
- continue
- if result:
- facts.extend(result)
- return facts
-
-
-def detect_all(benchmark_data: dict, registry: Optional[DetectorRegistry] = None) -> list[Fact]:
- """Applique tous les détecteurs enregistrés au benchmark donné.
-
- Point d'entrée du Sprint 4. Pour Sprint 1, le registre par défaut est vide :
- les détecteurs concrets sont ajoutés sprint par sprint.
- """
- if registry is None:
- registry = _DEFAULT_REGISTRY
- return registry.run(benchmark_data)
-
+from picarones.measurements.narrative.facts import * # noqa: F401, F403
-_DEFAULT_REGISTRY = DetectorRegistry()
+import picarones.measurements.narrative.facts as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/registry.py b/picarones/core/narrative/registry.py
index c38b566280eed5560628e8a93d9210f34cf1d838..d400fd4c614e0142162b00056a167cb9074ce940 100644
--- a/picarones/core/narrative/registry.py
+++ b/picarones/core/narrative/registry.py
@@ -1,217 +1,13 @@
-"""Registre déclaratif des détecteurs narratifs (Sprint 29).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.registry`.
-Avant le Sprint 29, ajouter un nouveau type de fait imposait de toucher
-**quatre** fichiers :
-
- 1. ``facts.py`` — ajouter une valeur à ``FactType`` ;
- 2. ``detectors.py`` — écrire ``def detect_xxx(data) -> list[Fact]`` ;
- 3. ``detectors.py`` — l'inscrire dans le dict ``DETECTORS_BY_TYPE`` ;
- 4. ``arbiter.py`` — ajouter le type à la séquence ``DEFAULT_TYPE_ORDER``
- au bon endroit pour la priorité éditoriale.
-
-Sprint 29 ramène le nombre de modifications à **deux** :
-
- 1. ``facts.py`` — toujours nécessaire pour le type énuméré ;
- 2. ``detectors.py`` — décorer la fonction avec ``@register_detector(...)``.
-
-Le décorateur :
- - enregistre la fonction dans un registre global trié par ``priority`` ;
- - vérifie qu'aucun détecteur ne se réenregistre sur le même ``FactType`` ;
- - laisse la fonction utilisable telle quelle (rétrocompatibilité) ;
- - alimente automatiquement ``arbiter.DEFAULT_TYPE_ORDER``.
-
-Conventions de priorité (« politique éditoriale » du rapport)
--------------------------------------------------------------
-Plus la valeur est petite, plus le fait remonte tôt en synthèse à
-importance égale. Pour conserver l'ordre historique du Sprint 23, on
-utilise un pas de 10 pour laisser de la place à des insertions futures :
-
- 10 GLOBAL_LEADER_CER qui gagne globalement
- 20 STATISTICAL_TIE y a-t-il un ex-aequo
- 30 SIGNIFICANT_GAP à quel point l'écart est solide
- 40 STRATUM_WINNER qui domine sur quel sous-corpus
- 50 STRATUM_COLLAPSE qui s'effondre sur quoi
- 60 ERROR_PROFILE_OUTLIER qui se trompe différemment
- 70 LLM_HALLUCINATION_FLAG hallucinations VLM
- 80 ROBUSTNESS_FRAGILE sensibilité aux dégradations
- 90 PARETO_ALTERNATIVE compromis coût/qualité
- 100 SPEED_WINNER vitesse
- 110 COST_OUTLIER coût aberrant
- 120 CONFIDENCE_WARNING mise en garde sur la fiabilité
-
-Le décorateur n'impose **pas** de pas — un détecteur tiers peut très
-bien utiliser ``priority=42`` pour s'insérer entre STRATUM_WINNER et
-STRATUM_COLLAPSE par exemple.
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
-
-import logging
-import threading
-from dataclasses import dataclass
-from typing import Callable, Optional
-
-from picarones.core.narrative.facts import (
- DetectorFn,
- DetectorRegistry,
- FactImportance,
- FactType,
-)
-
-logger = logging.getLogger(__name__)
-
-
-# ---------------------------------------------------------------------------
-# Métadonnées d'un détecteur
-# ---------------------------------------------------------------------------
-
-@dataclass(frozen=True)
-class DetectorEntry:
- """Métadonnées d'un détecteur enregistré."""
- fact_type: FactType
- fn: DetectorFn
- priority: int
- importance: FactImportance
-
-
-# ---------------------------------------------------------------------------
-# Registre global
-# ---------------------------------------------------------------------------
-
-_REGISTRY: dict[FactType, DetectorEntry] = {}
-_REGISTRY_LOCK = threading.Lock()
-
-
-def register_detector(
- fact_type: FactType,
- *,
- priority: int,
- importance: FactImportance = FactImportance.MEDIUM,
-) -> Callable[[DetectorFn], DetectorFn]:
- """Décorateur d'enregistrement.
-
- Usage::
-
- @register_detector(FactType.GLOBAL_LEADER_CER, priority=10,
- importance=FactImportance.CRITICAL)
- def detect_global_leader_cer(data: dict) -> list[Fact]:
- ...
-
- Le décorateur :
- - vérifie qu'aucun autre détecteur n'est déjà enregistré sur
- ``fact_type`` (sinon ``ValueError``) ;
- - vérifie que ``priority`` est un entier ;
- - retourne la fonction inchangée pour ne pas casser les imports
- existants.
-
- L'``importance`` mémorisée ici sert de **métadonnée** au registre :
- chaque détecteur reste libre d'émettre des ``Fact`` avec une
- importance différente selon le contexte (ex. CRITICAL si l'écart
- est gigantesque, HIGH sinon).
- """
- def _decorator(fn: DetectorFn) -> DetectorFn:
- with _REGISTRY_LOCK:
- if fact_type in _REGISTRY:
- raise ValueError(
- f"Détecteur déjà enregistré pour {fact_type.value!r} : "
- f"{_REGISTRY[fact_type].fn.__name__}. Désenregistrer "
- "explicitement avant de réassigner."
- )
- entry = DetectorEntry(
- fact_type=fact_type,
- fn=fn,
- priority=int(priority),
- importance=importance,
- )
- _REGISTRY[fact_type] = entry
- logger.debug(
- "[narrative.registry] enregistré %s priority=%s importance=%s",
- fact_type.value, priority, importance.name,
- )
- return fn
-
- return _decorator
-
-
-def unregister(fact_type: FactType) -> None:
- """Retire un détecteur du registre — utilisé par les tests."""
- with _REGISTRY_LOCK:
- _REGISTRY.pop(fact_type, None)
-
-
-def iter_detectors() -> list[DetectorEntry]:
- """Retourne tous les détecteurs enregistrés, triés par ``priority``.
-
- Le tri est stable : à ``priority`` égale, l'ordre d'enregistrement
- est préservé (utile en présence d'extensions tierces).
- """
- with _REGISTRY_LOCK:
- entries = list(_REGISTRY.values())
- entries.sort(key=lambda e: e.priority)
- return entries
-
-
-def detector_for(fact_type: FactType) -> Optional[DetectorEntry]:
- with _REGISTRY_LOCK:
- return _REGISTRY.get(fact_type)
-
-
-def clear_registry() -> None:
- """Vide le registre — réservé aux tests d'isolation."""
- with _REGISTRY_LOCK:
- _REGISTRY.clear()
-
-
-def default_type_order() -> tuple[FactType, ...]:
- """Calcule l'ordre canonique des types depuis le registre courant.
-
- Source de vérité de ``arbiter.DEFAULT_TYPE_ORDER`` depuis le Sprint 29.
- """
- return tuple(e.fact_type for e in iter_detectors())
-
-
-# ---------------------------------------------------------------------------
-# Pont avec ``DetectorRegistry`` historique
-# ---------------------------------------------------------------------------
-
-def populate_legacy_registry(registry: DetectorRegistry) -> None:
- """Synchronise le ``DetectorRegistry`` historique depuis le décorateur.
-
- L'objet ``DetectorRegistry`` reste l'API publique pour les
- consommateurs externes (cf. ``DetectorRegistry.run``) ; cette
- fonction l'alimente depuis le registre déclaratif courant.
- """
- for entry in iter_detectors():
- registry.register(entry.fact_type, entry.fn)
-
-
-__all__ = [
- "DetectorEntry",
- "register_detector",
- "unregister",
- "iter_detectors",
- "detector_for",
- "clear_registry",
- "default_type_order",
- "populate_legacy_registry",
-]
-
-
-# ---------------------------------------------------------------------------
-# Sentinel — sans usage direct ; vérifie au build qu'on n'introduit pas
-# de valeur ``priority`` dupliquée par accident parmi les builtins.
-# ---------------------------------------------------------------------------
+from picarones.measurements.narrative.registry import * # noqa: F401, F403
-def _verify_unique_priorities() -> None:
- seen: dict[int, FactType] = {}
- for entry in iter_detectors():
- if entry.priority in seen:
- logger.warning(
- "[narrative.registry] priority %s dupliquée : "
- "%s et %s — ordre indéterministe à priorité égale.",
- entry.priority,
- seen[entry.priority].value,
- entry.fact_type.value,
- )
- else:
- seen[entry.priority] = entry.fact_type
+import picarones.measurements.narrative.registry as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/narrative/renderer.py b/picarones/core/narrative/renderer.py
index a201560c682f25a1611359477509c08e75f83e8b..15fa8454067ee95553e5243ec4d5932b6fce0367 100644
--- a/picarones/core/narrative/renderer.py
+++ b/picarones/core/narrative/renderer.py
@@ -1,105 +1,13 @@
-"""Rendu des faits narratifs en texte lisible.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.narrative.renderer`.
-Les templates sont chargés depuis ``templates/{lang}.yaml`` au premier accès.
-Le rendu utilise ``str.format_map`` sur le ``payload`` du ``Fact``. Aucun LLM,
-aucune génération : la sortie est la concaténation de templates remplis avec
-des valeurs venant strictement du JSON d'entrée.
+Phase E du chantier de refonte en 3 cercles. Le moteur narratif
+(Cercle 2 — measurements/) a quitté ``picarones.core.narrative``.
+Cet alias maintient la rétrocompat des imports historiques.
"""
-from __future__ import annotations
+from picarones.measurements.narrative.renderer import * # noqa: F401, F403
-import logging
-import re
-from pathlib import Path
-from typing import Iterable
-
-import yaml
-
-from picarones.core.narrative.facts import Fact
-
-logger = logging.getLogger(__name__)
-
-_TEMPLATES_DIR = Path(__file__).parent / "templates"
-_TEMPLATES_CACHE: dict[str, dict[str, str]] = {}
-
-
-def _load_templates(lang: str) -> dict[str, str]:
- """Charge et met en cache les templates de la langue demandée.
-
- Fallback : si la langue n'existe pas, retourne les templates FR. Si FR
- est également absent (incident d'installation), retourne un dict vide.
- """
- if lang in _TEMPLATES_CACHE:
- return _TEMPLATES_CACHE[lang]
-
- path = _TEMPLATES_DIR / f"{lang}.yaml"
- if not path.exists():
- if lang != "fr":
- return _load_templates("fr")
- _TEMPLATES_CACHE[lang] = {}
- return _TEMPLATES_CACHE[lang]
-
- try:
- with path.open(encoding="utf-8") as fh:
- data = yaml.safe_load(fh) or {}
- if not isinstance(data, dict):
- logger.warning("[narrative] %s n'est pas un dict YAML — ignoré", path)
- _TEMPLATES_CACHE[lang] = {}
- else:
- _TEMPLATES_CACHE[lang] = {str(k): str(v).strip() for k, v in data.items()}
- except yaml.YAMLError as e:
- logger.warning("[narrative] échec parsing %s : %s", path, e)
- _TEMPLATES_CACHE[lang] = {}
-
- return _TEMPLATES_CACHE[lang]
-
-
-class _SafeFormatMap(dict):
- """Dict qui retourne ``'?'`` pour les clés manquantes dans un template.
-
- Évite qu'un détecteur mal documenté fasse crasher le rendu. En pratique
- les tests couvrent les clés attendues, mais la robustesse prévaut.
- """
-
- def __missing__(self, key: str) -> str:
- logger.warning("[narrative] clé manquante dans payload : %r", key)
- return "?"
-
-
-def render_fact(fact: Fact, lang: str = "fr") -> str:
- """Rend un Fact en une phrase selon la langue.
-
- Retourne ``""`` si le template est absent pour ce type.
- """
- templates = _load_templates(lang)
- tpl = templates.get(fact.type.value)
- if not tpl:
- return ""
-
- try:
- return tpl.format_map(_SafeFormatMap(fact.payload))
- except (ValueError, KeyError) as e:
- logger.warning(
- "[narrative] rendu impossible pour %s : %s", fact.type.value, e,
- )
- return ""
-
-
-def render_synthesis(facts: Iterable[Fact], lang: str = "fr") -> list[str]:
- """Rend une liste de Fact en liste de phrases (ordre préservé)."""
- out: list[str] = []
- for fact in facts:
- phrase = render_fact(fact, lang)
- phrase = re.sub(r"\s+", " ", phrase).strip()
- if phrase:
- out.append(phrase)
- return out
-
-
-def extract_numbers(text: str) -> list[str]:
- """Extrait les nombres (décimaux ou entiers) présents dans une phrase.
-
- Utilisé par le test de traçabilité : chaque nombre remonté en synthèse
- doit être présent dans le JSON d'entrée.
- """
- return re.findall(r"\d+(?:[.,]\d+)?", text)
+import picarones.measurements.narrative.renderer as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/ner.py b/picarones/core/ner.py
index 7d3bbe8892ede723311146db19ae43a47d83c771..717e6a08df3cf1f571a2835ae991659a92e3d829 100644
--- a/picarones/core/ner.py
+++ b/picarones/core/ner.py
@@ -1,309 +1,19 @@
-"""Calcul des métriques de précision sur entités nommées (NER).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.ner`.
-Sprint 38 — A.II.1.a du plan d'évolution 2026 : couche de calcul pure.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.ner import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Pour un médiéviste, un archiviste ou un économiste-historien,
-l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui
-compte c'est de savoir si les **entités nommées** (personnes, lieux,
-dates, organisations) ont survécu à la transcription. Un CER de 5 %
-qui rate 80 % des noms propres est inutilisable pour l'indexation
-prosopographique.
-
-Stratégie de découpage en sprints
----------------------------------
-Comme pour la divergence taxonomique (Sprints 35-37), on découpe :
-
-- **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre
- deux listes d'entités, calcul de Precision/Recall/F1 par catégorie
- et global, détection des hallucinations d'entité. Aucune dépendance
- externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont
- fournies en entrée. Un test de l'enregistrement dans le registre
- typé Sprint 34 garantit l'intégration.
-- **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et
- câblage runner+narratif+HTML.
-
-Format des entités
-------------------
-Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un
-dictionnaire ``{"label": str, "start": int, "end": int, "text": str}``
-où ``start``/``end`` sont des offsets caractère.
-
-Convention d'alignement
------------------------
-Une entité hypothèse "matche" une entité de référence si :
-
-1. les **labels sont identiques** (case-insensitive),
-2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans
- caractère est ``≥ iou_threshold`` (défaut : 0,5).
-
-Une entité de référence non matchée → faux négatif (recall pénalisé).
-Une entité hypothèse non matchée → faux positif (précision pénalisée).
-Un faux positif est aussi compté comme **hallucination d'entité**, ce
-qui est utile pour les VLM/LLM qui inventent.
-
-Limites
--------
-- L'alignement bag-of-spans : une entité peut être matchée par au plus
- une entité de l'autre côté (sinon double-comptage).
-- Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique
- mesure conjointement OCR + NER. Documenter explicitement.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-from typing import Iterable
-
-from picarones.core.metric_registry import register_metric
-from picarones.core.modules import ArtifactType
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Modèle de données
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@dataclass(frozen=True)
-class Entity:
- """Entité nommée alignée sur un texte.
-
- Attributs
- ---------
- label:
- Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``).
- La comparaison se fait en *case-insensitive*.
- start, end:
- Offsets caractère (inclus, exclu) sur le texte de référence.
- text:
- Forme de surface — informative, **non utilisée pour
- l'alignement** (deux entités peuvent matcher même si leur
- forme de surface diffère, du moment que leurs spans
- chevauchent suffisamment).
- """
-
- label: str
- start: int
- end: int
- text: str = ""
-
- def __post_init__(self) -> None:
- if self.start > self.end:
- raise ValueError(
- f"Entity span invalide : start={self.start} > end={self.end}"
- )
-
- @property
- def length(self) -> int:
- return max(0, self.end - self.start)
-
-
-def _to_entity(obj: Entity | dict) -> Entity:
- """Coerce un dict (format EntitiesGT) en ``Entity``."""
- if isinstance(obj, Entity):
- return obj
- return Entity(
- label=str(obj["label"]),
- start=int(obj["start"]),
- end=int(obj["end"]),
- text=str(obj.get("text", "")),
- )
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Alignement par IoU
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _iou(a: Entity, b: Entity) -> float:
- """Intersection-over-Union sur les spans caractère."""
- inter_start = max(a.start, b.start)
- inter_end = min(a.end, b.end)
- inter = max(0, inter_end - inter_start)
- union = a.length + b.length - inter
- if union <= 0:
- return 0.0
- return inter / union
-
-
-def _align(
- references: list[Entity],
- hypotheses: list[Entity],
- iou_threshold: float,
-) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
- """Aligne deux listes d'entités par IoU décroissant (greedy).
-
- Returns
- -------
- matches:
- Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU
- décroissant — chaque entité n'apparaît qu'une fois.
- unmatched_refs:
- Indices des entités GT non matchées (faux négatifs).
- unmatched_hyps:
- Indices des entités hypothèse non matchées (faux positifs).
- """
- candidates: list[tuple[float, int, int]] = []
- for i, r in enumerate(references):
- for j, h in enumerate(hypotheses):
- if r.label.casefold() != h.label.casefold():
- continue
- score = _iou(r, h)
- if score >= iou_threshold:
- candidates.append((score, i, j))
-
- # Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires
- # pour garantir un tri stable et déterministe.
- candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
-
- matched_refs: set[int] = set()
- matched_hyps: set[int] = set()
- matches: list[tuple[int, int, float]] = []
- for score, i, j in candidates:
- if i in matched_refs or j in matched_hyps:
- continue
- matched_refs.add(i)
- matched_hyps.add(j)
- matches.append((i, j, score))
-
- unmatched_refs = set(range(len(references))) - matched_refs
- unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
- return matches, unmatched_refs, unmatched_hyps
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Calcul des métriques
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
- """Précision / rappel / F1 à partir des comptes."""
- precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
- recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
- f1 = (
- 2 * precision * recall / (precision + recall)
- if (precision + recall) > 0
- else 0.0
- )
- return {
- "precision": precision,
- "recall": recall,
- "f1": f1,
- "support": tp + fn,
- }
-
-
-def compute_ner_metrics(
- reference_entities: Iterable[Entity | dict],
- hypothesis_entities: Iterable[Entity | dict],
- iou_threshold: float = 0.5,
-) -> dict:
- """Calcule la précision/rappel/F1 sur entités nommées.
-
- Parameters
- ----------
- reference_entities:
- Liste d'entités GT (format ``Entity`` ou dict de
- ``EntitiesGT``).
- hypothesis_entities:
- Liste d'entités produites par le NER sur la sortie OCR.
- iou_threshold:
- Seuil de chevauchement caractère pour qu'un appariement
- soit valide (défaut : 0,5 — convention CoNLL/HIPE).
-
- Returns
- -------
- dict
- ``{
- "global": {"precision", "recall", "f1", "support"},
- "per_category": {label: {"precision", ...}},
- "true_positives": int,
- "false_positives": int,
- "false_negatives": int,
- "hallucinated_entities": list[dict], # entités OCR sans GT
- "missed_entities": list[dict], # entités GT non détectées
- "iou_threshold": float,
- }``
- """
- refs = [_to_entity(e) for e in reference_entities]
- hyps = [_to_entity(e) for e in hypothesis_entities]
-
- matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold)
-
- tp = len(matches)
- fn = len(unmatched_refs)
- fp = len(unmatched_hyps)
-
- # Comptes par catégorie
- cat_tp: dict[str, int] = {}
- cat_fn: dict[str, int] = {}
- cat_fp: dict[str, int] = {}
- for i, _j, _score in matches:
- cat = refs[i].label
- cat_tp[cat] = cat_tp.get(cat, 0) + 1
- for i in unmatched_refs:
- cat = refs[i].label
- cat_fn[cat] = cat_fn.get(cat, 0) + 1
- for j in unmatched_hyps:
- cat = hyps[j].label
- cat_fp[cat] = cat_fp.get(cat, 0) + 1
-
- all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
- per_category = {
- cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0))
- for cat in all_categories
- }
-
- return {
- "global": _prf(tp, fp, fn),
- "per_category": per_category,
- "true_positives": tp,
- "false_positives": fp,
- "false_negatives": fn,
- "hallucinated_entities": [
- {"label": hyps[j].label, "start": hyps[j].start,
- "end": hyps[j].end, "text": hyps[j].text}
- for j in sorted(unmatched_hyps)
- ],
- "missed_entities": [
- {"label": refs[i].label, "start": refs[i].start,
- "end": refs[i].end, "text": refs[i].text}
- for i in sorted(unmatched_refs)
- ],
- "iou_threshold": iou_threshold,
- }
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Enregistrement dans le registre typé (Sprint 34)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@register_metric(
- name="ner_f1",
- input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES),
- description=(
- "F1 global sur les entités nommées (alignement IoU ≥ 0,5, "
- "labels case-insensitive). Pour le détail par catégorie, "
- "utiliser compute_ner_metrics directement."
- ),
- higher_is_better=True,
- tags={"downstream", "ner", "structure"},
-)
-def ner_f1(
- reference_entities: Iterable[Entity | dict],
- hypothesis_entities: Iterable[Entity | dict],
-) -> float:
- """F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``."""
- return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"]
-
+from picarones.measurements.ner import * # noqa: F401, F403
-__all__ = [
- "Entity",
- "compute_ner_metrics",
- "ner_f1",
-]
+import picarones.measurements.ner as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/ner_backends.py b/picarones/core/ner_backends.py
index 4e944df927dec5919a3f89fc840d5e05b5af189e..5a65dfe1c5aa55670f21d31d268354d4c3ad4734 100644
--- a/picarones/core/ner_backends.py
+++ b/picarones/core/ner_backends.py
@@ -1,227 +1,19 @@
-"""Backends d'extraction d'entités nommées (Sprint 40).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.ner_backends`.
-Suite directe du Sprint 38 : la couche de calcul (`compute_ner_metrics`)
-prend deux listes d'entités, ce module fournit le moyen d'**obtenir** la
-liste d'entités d'un côté à partir d'un texte (généralement la sortie
-OCR du moteur).
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.ner_backends import ...``) de continuer
+à fonctionner sans modification.
-Architecture
-------------
-- ``EntityExtractor`` : Protocol Python qui décrit l'interface ; tout
- callable ``(text: str) -> list[dict]`` est un extracteur valide. Le
- format de sortie est compatible ``EntitiesGT`` (Sprint 32) et
- ``compute_ner_metrics`` (Sprint 38).
-- ``SpacyEntityExtractor`` : implémentation par défaut, lazy-import de
- spaCy. Si spaCy n'est pas installé OU si le modèle n'est pas
- téléchargé, retourne ``[]`` avec un ``logger.warning`` explicite
- (cf. règle CLAUDE.md : pas de ``except: pass``).
-- ``SPACY_PROFILES`` : dict de profils nommés vers noms de modèles
- spaCy (FR, EN, multilingue, HIPE pour les corpus historiques).
-- ``get_extractor(profile)`` : factory qui retourne l'extracteur
- correspondant au profil demandé.
-
-Découplage runner ↔ backend
----------------------------
-Le runner reçoit un ``EntityExtractor`` en paramètre — il n'importe
-**jamais** spaCy directement. Cela permet :
-
-1. de **tester** sans dépendance externe (le test injecte un callable
- qui simule l'extraction) ;
-2. de **brancher** des backends alternatifs (Stanza, HIPE custom,
- modèle fine-tuné maison) sans modifier le runner ;
-3. de **désactiver** la métrique en passant ``None`` — comportement
- par défaut, rétrocompat stricte.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Any, Protocol
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Interface
-# ──────────────────────────────────────────────────────────────────────────
-
-
-class EntityExtractor(Protocol):
- """Tout callable ``(text) -> list[dict]`` est un extracteur valide.
-
- Format de sortie attendu : liste de dicts
- ``{"label": str, "start": int, "end": int, "text": str}``
- compatibles avec ``compute_ner_metrics`` (Sprint 38) et
- ``EntitiesGT`` (Sprint 32).
- """
-
- def __call__(self, text: str) -> list[dict[str, Any]]: ...
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Profils spaCy nommés
-# ──────────────────────────────────────────────────────────────────────────
-
-
-SPACY_PROFILES: dict[str, str] = {
- "fr": "fr_core_news_sm",
- "fr_lg": "fr_core_news_lg",
- "en": "en_core_web_sm",
- "en_lg": "en_core_web_lg",
- "multilingual": "xx_ent_wiki_sm",
- # HIPE 2022 — modèle historique multilingue (Hugging Face). Pas
- # toujours disponible via ``spacy.load`` direct ; documenté pour
- # mémoire, l'utilisateur peut le wrapper dans un EntityExtractor
- # custom si besoin.
- "hipe": "fr_core_news_lg",
-}
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Backend spaCy
-# ──────────────────────────────────────────────────────────────────────────
-
-
-class SpacyEntityExtractor:
- """Extracteur d'entités basé sur spaCy.
-
- Lazy-import : ``spacy`` n'est importé qu'au premier appel. Le
- modèle est chargé une seule fois et mis en cache sur l'instance.
-
- Si spaCy n'est pas installé OU si le modèle demandé n'est pas
- téléchargé, l'extracteur tombe en mode dégradé (retourne ``[]``
- pour chaque appel) et émet un ``logger.warning`` au premier
- appel.
-
- Parameters
- ----------
- model_name:
- Nom du modèle spaCy à charger (ex. ``"fr_core_news_sm"``).
- label_mapping:
- Dict optionnel ``{spacy_label: target_label}`` pour
- normaliser les labels (ex. spaCy utilise ``"PERSON"``,
- on veut ``"PER"``). Si ``None``, garde les labels tels
- quels.
-
- Examples
- --------
- >>> extractor = SpacyEntityExtractor("fr_core_news_sm")
- >>> entities = extractor("Marie de Bourgogne, en 1477.")
- >>> # liste de dicts {label, start, end, text}, ou [] si spaCy absent
- """
-
- # Mapping par défaut spaCy → conventions HIPE/CoNLL courtes
- DEFAULT_LABEL_MAPPING: dict[str, str] = {
- "PERSON": "PER",
- "PER": "PER",
- "LOC": "LOC",
- "GPE": "LOC", # Geo-Political Entity → LOC
- "ORG": "ORG",
- "DATE": "DATE",
- "TIME": "DATE",
- "MISC": "MISC",
- }
-
- def __init__(
- self,
- model_name: str = "fr_core_news_sm",
- label_mapping: dict[str, str] | None = None,
- ) -> None:
- self.model_name = model_name
- self.label_mapping = (
- dict(label_mapping)
- if label_mapping is not None
- else dict(self.DEFAULT_LABEL_MAPPING)
- )
- self._nlp: Any | None = None
- self._loaded: bool = False
- self._available: bool = False
-
- def _load(self) -> None:
- """Charge spaCy + modèle au premier appel. Idempotent."""
- if self._loaded:
- return
- self._loaded = True
- try:
- import spacy # type: ignore[import-untyped]
- except ImportError as exc:
- logger.warning(
- "[ner_backends] spaCy non installé (%s) — extraction NER "
- "désactivée. Installer avec `pip install picarones[ner]`.",
- exc,
- )
- return
- try:
- self._nlp = spacy.load(self.model_name)
- self._available = True
- except OSError as exc:
- logger.warning(
- "[ner_backends] Modèle spaCy %r introuvable (%s) — extraction "
- "NER désactivée. Télécharger avec `python -m spacy download %s`.",
- self.model_name, exc, self.model_name,
- )
-
- @property
- def available(self) -> bool:
- """``True`` si spaCy + le modèle sont chargés et utilisables."""
- if not self._loaded:
- self._load()
- return self._available
-
- def __call__(self, text: str) -> list[dict[str, Any]]:
- if not text:
- return []
- if not self.available or self._nlp is None:
- return []
- doc = self._nlp(text)
- results: list[dict[str, Any]] = []
- for ent in doc.ents:
- label = self.label_mapping.get(ent.label_, ent.label_)
- results.append({
- "label": label,
- "start": int(ent.start_char),
- "end": int(ent.end_char),
- "text": ent.text,
- })
- return results
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Factory
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def get_extractor(profile: str = "fr") -> SpacyEntityExtractor:
- """Retourne un extracteur spaCy pour le profil demandé.
-
- Le profil peut être :
-
- - une clé de ``SPACY_PROFILES`` (ex. ``"fr"``, ``"en"``,
- ``"multilingual"``)
- - un nom de modèle spaCy direct (ex. ``"fr_core_news_lg"``)
-
- L'extracteur est instancié paresseusement (le modèle n'est chargé
- qu'au premier appel). Si le modèle n'est pas disponible,
- l'extracteur tombe en mode dégradé silencieux (retourne ``[]``).
- """
- model_name = SPACY_PROFILES.get(profile, profile)
- return SpacyEntityExtractor(model_name=model_name)
-
-
-def is_spacy_available() -> bool:
- """``True`` si la librairie ``spacy`` est importable, sans charger
- de modèle."""
- try:
- import spacy # noqa: F401
- except ImportError:
- return False
- return True
-
+from picarones.measurements.ner_backends import * # noqa: F401, F403
-__all__ = [
- "EntityExtractor",
- "SpacyEntityExtractor",
- "SPACY_PROFILES",
- "get_extractor",
- "is_spacy_available",
-]
+import picarones.measurements.ner_backends as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/normalization.py b/picarones/core/normalization.py
index 6c33b33d4752d0c00715e8dfd6b068b75c773498..a215882abe759deeb0ba7a665f22237d7277117e 100644
--- a/picarones/core/normalization.py
+++ b/picarones/core/normalization.py
@@ -1,420 +1,19 @@
-"""Profils de normalisation unicode pour le calcul du CER diplomatique.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.normalization`.
-La normalisation diplomatique permet de calculer un CER tenant compte des
-équivalences graphiques propres aux documents historiques : ſ=s, u=v, i=j, etc.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.normalization import ...``) de continuer
+à fonctionner sans modification.
-En appliquant la même table aux deux textes (GT et OCR), on mesure les erreurs
-"substantielles" (transcription erronée) en ignorant les variations graphiques
-codifiées connues.
-
-Trois niveaux de normalisation sont disponibles :
-
-1. NFC : normalisation Unicode canonique (décomposition+recomposition)
-2. caseless : NFC + pliage de casse (casefold)
-3. diplomatic: NFC + table de correspondances historiques configurables
-
-Les profils préconfigurés couvrent les cas d'usage patrimoniaux courants.
-Ils sont également chargeables depuis un fichier YAML.
-
-Exemple YAML
-------------
-name: medieval_custom
-caseless: false
-diplomatic:
- ſ: s
- u: v
- i: j
- y: i
- æ: ae
- œ: oe
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import unicodedata
-from dataclasses import dataclass, field
-from pathlib import Path
-
-
-# ---------------------------------------------------------------------------
-# Tables de correspondances diplomatiques préconfigurées
-# ---------------------------------------------------------------------------
-
-#: Français médiéval (XIIe–XVe siècle)
-DIPLOMATIC_FR_MEDIEVAL: dict[str, str] = {
- "ſ": "s", # s long → s
- "u": "v", # u/v interchangeables en position initiale
- "i": "j", # i/j interchangeables
- "y": "i", # y vocalique → i
- "æ": "ae", # ligature æ
- "œ": "oe", # ligature œ
- "ꝑ": "per", # abréviation per/par
- "ꝓ": "pro", # abréviation pro
- "\u0026": "et", # & → et
-}
-
-#: Français moderne / imprimés anciens (XVIe–XVIIIe siècle)
-DIPLOMATIC_FR_EARLY_MODERN: dict[str, str] = {
- "ſ": "s", # s long
- "æ": "ae",
- "œ": "oe",
- "\u0026": "et",
- "ỹ": "yn", # y tilde
-}
-
-#: Latin médiéval
-DIPLOMATIC_LATIN_MEDIEVAL: dict[str, str] = {
- "ſ": "s",
- "u": "v",
- "i": "j",
- "y": "i",
- "æ": "ae",
- "œ": "oe",
- "ꝑ": "per",
- "ꝓ": "pro",
- "ꝗ": "que", # q barré → que
- "\u0026": "et",
-}
-
-#: Profil minimal — uniquement NFC + s long
-DIPLOMATIC_MINIMAL: dict[str, str] = {
- "ſ": "s",
-}
-
-#: Anglais moderne / imprimés anciens (XVIe–XVIIIe siècle)
-#: Orthographe «early modern» : ſ=s, u/v, i/j, vv=w, þ=th, ð=th, ȝ=y
-DIPLOMATIC_EN_EARLY_MODERN: dict[str, str] = {
- "ſ": "s", # s long → s
- "u": "v", # u/v interchangeables (vpon → upon)
- "i": "j", # i/j interchangeables (ioy → joy)
- "vv": "w", # vv → w (vvhich → which)
- "þ": "th", # thorn → th
- "ð": "th", # eth → th
- "ȝ": "y", # yogh → y
- "æ": "ae", # ligature æ
- "œ": "oe", # ligature œ
- "\u0026": "and", # & → and
-}
-
-#: Anglais médiéval (XIIe–XVe siècle) — abréviations manuscrites incluses
-DIPLOMATIC_EN_MEDIEVAL: dict[str, str] = {
- "ſ": "s",
- "u": "v",
- "i": "j",
- "vv": "w",
- "þ": "th",
- "ð": "th",
- "ȝ": "y",
- "æ": "ae",
- "œ": "oe",
- "\u0026": "and",
- # Abréviations courantes dans les manuscrits anglais médiévaux
- "ꝑ": "per", # p barré → per/par
- "ꝓ": "pro", # p crocheté → pro
- "ꝗ": "que", # q barré → que
- "\ua75b": "r", # lettre r rotunda → r
-}
-
-#: Écriture secrétaire (XVIe–XVIIe siècle) — secretary hand
-#: Confusions visuelles propres à l'écriture cursive anglaise
-DIPLOMATIC_EN_SECRETARY: dict[str, str] = {
- "ſ": "s",
- "u": "v",
- "i": "j",
- "vv": "w",
- "þ": "th",
- "ð": "th",
- "ȝ": "y",
- "\u0026": "and",
- # Confusions visuelles typiques : e/c, n/u, m/w en secrétaire
- # Note : ne pas normaliser e/c automatiquement (trop agressif) ;
- # on se limite aux substituts graphiques historiquement documentés
-}
-
-
-# ---------------------------------------------------------------------------
-# Profil de normalisation
-# ---------------------------------------------------------------------------
-
-@dataclass
-class NormalizationProfile:
- """Décrit une stratégie de normalisation pour le calcul du CER diplomatique.
-
- Parameters
- ----------
- name:
- Identifiant lisible du profil (ex : ``"medieval_french"``).
- nfc:
- Applique la normalisation Unicode NFC (recommandé, activé par défaut).
- caseless:
- Pliage de casse (casefold) après NFC.
- diplomatic_table:
- Table de correspondances graphiques historiques appliquée caractère
- par caractère sur les deux textes avant calcul du CER.
- exclude_chars:
- Ensemble de caractères supprimés des deux textes (GT et OCR) avant
- tout calcul de métriques (CER, WER, MER, WIL et CER diplomatique).
- Utile pour ignorer la ponctuation ou les apostrophes.
- description:
- Description courte du profil (affichée dans le rapport HTML).
- """
-
- name: str
- nfc: bool = True
- caseless: bool = False
- diplomatic_table: dict[str, str] = field(default_factory=dict)
- exclude_chars: frozenset = field(default_factory=frozenset)
- description: str = ""
-
- def normalize(self, text: str) -> str:
- """Applique le profil de normalisation à un texte."""
- if self.exclude_chars:
- text = "".join(c for c in text if c not in self.exclude_chars)
- if self.nfc:
- text = unicodedata.normalize("NFC", text)
- if self.caseless:
- text = text.casefold()
- if self.diplomatic_table:
- text = _apply_diplomatic_table(text, self.diplomatic_table)
- return text
-
- def as_dict(self) -> dict:
- return {
- "name": self.name,
- "nfc": self.nfc,
- "caseless": self.caseless,
- "diplomatic_table": self.diplomatic_table,
- "exclude_chars": sorted(self.exclude_chars),
- "description": self.description,
- }
-
- @classmethod
- def from_yaml(cls, path: str | Path) -> "NormalizationProfile":
- """Charge un profil depuis un fichier YAML.
-
- Le fichier YAML doit contenir les clés ``name``, optionnellement
- ``caseless``, ``description``, ``diplomatic`` (dict str→str) et
- ``exclude_chars`` (liste ou chaîne de caractères à ignorer).
-
- Example
- -------
- .. code-block:: yaml
-
- name: medieval_custom
- caseless: false
- description: Français médiéval personnalisé
- exclude_chars: ".,;:!?"
- diplomatic:
- ſ: s
- u: v
- """
- try:
- import yaml
- except ImportError as exc:
- raise RuntimeError(
- "Le package 'pyyaml' est requis pour charger les profils YAML. "
- "Installez-le avec : pip install pyyaml"
- ) from exc
-
- data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
- return cls(
- name=data.get("name", Path(path).stem),
- nfc=bool(data.get("nfc", True)),
- caseless=bool(data.get("caseless", False)),
- diplomatic_table=data.get("diplomatic", {}),
- exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")),
- description=data.get("description", ""),
- )
-
- @classmethod
- def from_dict(cls, data: dict) -> "NormalizationProfile":
- """Charge un profil depuis un dictionnaire (ex : section YAML inline)."""
- return cls(
- name=data.get("name", "custom"),
- nfc=bool(data.get("nfc", True)),
- caseless=bool(data.get("caseless", False)),
- diplomatic_table=data.get("diplomatic", {}),
- exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")),
- description=data.get("description", ""),
- )
-
-
-# ---------------------------------------------------------------------------
-# Profils préconfigurés
-# ---------------------------------------------------------------------------
-
-NORMALIZATION_PROFILES: dict[str, NormalizationProfile] = {
- "nfc": NormalizationProfile(
- name="nfc",
- nfc=True,
- caseless=False,
- diplomatic_table={},
- description="Normalisation NFC uniquement",
- ),
- "caseless": NormalizationProfile(
- name="caseless",
- nfc=True,
- caseless=True,
- diplomatic_table={},
- description="NFC + insensible à la casse",
- ),
- "minimal": NormalizationProfile(
- name="minimal",
- nfc=True,
- caseless=False,
- diplomatic_table=DIPLOMATIC_MINIMAL,
- description="Minimal : NFC + s long seulement",
- ),
- "medieval_french": NormalizationProfile(
- name="medieval_french",
- nfc=True,
- caseless=False,
- diplomatic_table=DIPLOMATIC_FR_MEDIEVAL,
- description="Français médiéval (XIIe–XVe) : ſ=s, u=v, i=j, æ=ae, œ=oe",
- ),
- "early_modern_french": NormalizationProfile(
- name="early_modern_french",
- nfc=True,
- caseless=False,
- diplomatic_table=DIPLOMATIC_FR_EARLY_MODERN,
- description="Imprimés anciens (XVIe–XVIIIe) : ſ=s, æ=ae, œ=oe",
- ),
- "medieval_latin": NormalizationProfile(
- name="medieval_latin",
- nfc=True,
- caseless=False,
- diplomatic_table=DIPLOMATIC_LATIN_MEDIEVAL,
- description="Latin médiéval : ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro",
- ),
- "early_modern_english": NormalizationProfile(
- name="early_modern_english",
- nfc=True,
- caseless=False,
- diplomatic_table=DIPLOMATIC_EN_EARLY_MODERN,
- description="Early Modern English (XVIth–XVIIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y",
- ),
- "medieval_english": NormalizationProfile(
- name="medieval_english",
- nfc=True,
- caseless=False,
- diplomatic_table=DIPLOMATIC_EN_MEDIEVAL,
- description="Medieval English (XIIth–XVth c.): ſ=s, u=v, i=j, þ=th, ȝ=y, ꝑ=per, ꝓ=pro",
- ),
- "secretary_hand": NormalizationProfile(
- name="secretary_hand",
- nfc=True,
- caseless=False,
- diplomatic_table=DIPLOMATIC_EN_SECRETARY,
- description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y",
- ),
- # ── Profils d'exclusion de caractères ────────────────────────────────
- "sans_ponctuation": NormalizationProfile(
- name="sans_ponctuation",
- nfc=True,
- caseless=False,
- diplomatic_table={},
- exclude_chars=frozenset(". , ; : ! ? ' \u2019 \" - \u2013 \u2014 ( ) [ ]".split()),
- description="NFC + suppression de la ponctuation courante : . , ; : ! ? ' \" - – — ( ) [ ]",
- ),
- "sans_apostrophes": NormalizationProfile(
- name="sans_apostrophes",
- nfc=True,
- caseless=False,
- diplomatic_table={},
- exclude_chars=frozenset(["'", "\u2019"]), # apostrophe droite + apostrophe typographique
- description="NFC + suppression des apostrophes droite (') et typographique (\u2019)",
- ),
-}
-
-
-def get_builtin_profile(name: str) -> NormalizationProfile:
- """Retourne un profil préconfigurée par son identifiant.
-
- Identifiants disponibles
- ------------------------
- - ``"medieval_french"`` : français médiéval XIIe–XVe (ſ=s, u=v, i=j, æ=ae, œ=oe…)
- - ``"early_modern_french"`` : imprimés anciens XVIe–XVIIIe (ſ=s, œ=oe, æ=ae…)
- - ``"medieval_latin"`` : latin médiéval (ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro…)
- - ``"early_modern_english"`` : anglais imprimé XVIe–XVIIIe (ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y)
- - ``"medieval_english"`` : anglais manuscrit XIIe–XVe (+ abréviations ꝑ, ꝓ…)
- - ``"secretary_hand"`` : écriture secrétaire anglaise XVIe–XVIIe (cursive administrative)
- - ``"minimal"`` : uniquement NFC + s long
- - ``"nfc"`` : NFC seul (sans table diplomatique)
- - ``"caseless"`` : NFC + pliage de casse
-
- Raises
- ------
- KeyError
- Si le nom n'est pas reconnu.
- """
- if name not in NORMALIZATION_PROFILES:
- raise KeyError(
- f"Profil de normalisation inconnu : '{name}'. "
- f"Disponibles : {', '.join(NORMALIZATION_PROFILES)}"
- )
- return NORMALIZATION_PROFILES[name]
-
-
-# ---------------------------------------------------------------------------
-# Fonctions utilitaires
-# ---------------------------------------------------------------------------
-
-def _parse_exclude_chars(value: "str | list | None") -> frozenset:
- """Convertit une liste de caractères (str ou list) en frozenset.
-
- Accepte :
- - Une chaîne de caractères séparés par une virgule+espace (ex. ``"', -, –"``)
- ou simplement concaténés sans séparateur (ex. ``".,;:!?"``)
- - Une liste Python/YAML de chaînes (chacune un caractère)
- - None ou chaîne vide → frozenset vide
-
- Règle de désambiguïsation : si la chaîne contient la séquence ``", "``
- (virgule suivie d'un espace), on découpe par ``", "``. Sinon, chaque
- caractère Unicode est un item distinct.
- """
- if not value:
- return frozenset()
- if isinstance(value, (list, tuple)):
- return frozenset(str(c) for c in value if c)
- raw = str(value)
- # Désambiguïsation : séparer par ", " si présent (format lisible)
- if ", " in raw:
- return frozenset(c.strip() for c in raw.split(",") if c.strip())
- # Sinon, chaque caractère Unicode est un item distinct
- return frozenset(raw)
-
-
-def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str:
- """Applique une table de correspondances diplomatiques en un seul pass.
-
- Les clés multi-caractères (ex : ``"ae"`` → ``"æ"``) sont gérées en priorité
- sur les correspondances simples. Le remplacement est fait en un seul pass
- via regex pour éviter les remplacements en cascade (ex : ``"ſ"→"s"`` puis
- ``"s"→"z"`` donnerait ``"z"`` au lieu de ``"s"``).
- """
- if not table:
- return text
-
- import re
-
- # Séparer les clés simples (1 char) des clés multi-chars
- multi_keys = sorted(
- (k for k in table if len(k) > 1), key=len, reverse=True
- )
- simple_table = {k: v for k, v in table.items() if len(k) == 1}
-
- if multi_keys:
- # Single-pass : construire un pattern regex avec toutes les clés multi-chars
- # triées par longueur décroissante pour matcher les plus longues d'abord
- pattern = re.compile("|".join(re.escape(k) for k in multi_keys))
- text = pattern.sub(lambda m: table[m.group(0)], text)
-
- # Remplacements char par char (single-pass via itération)
- if simple_table:
- text = "".join(simple_table.get(c, c) for c in text)
-
- return text
-
+from picarones.measurements.normalization import * # noqa: F401, F403
-# Profil par défaut utilisé pour le CER diplomatique intégré
-DEFAULT_DIPLOMATIC_PROFILE: NormalizationProfile = get_builtin_profile("medieval_french")
+import picarones.measurements.normalization as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/numerical_sequences.py b/picarones/core/numerical_sequences.py
index 5698b4017fa693cec17a6dd1671bfed7d1cab38c..cae7cb71c16619d10e1064c3b6aa5aa4e5a760b1 100644
--- a/picarones/core/numerical_sequences.py
+++ b/picarones/core/numerical_sequences.py
@@ -1,422 +1,19 @@
-"""Précision sur séquences numériques — Sprint 85 (A.II.5b).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.numerical_sequences`.
-Sprint 85 — A.II.5b du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.numerical_sequences import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Pour un économiste-historien, un éditeur de chartes ou un
-archiviste, la **fidélité aux séquences numériques** est un
-proxy direct de la qualité éditoriale. Un OCR qui rate
-*« 1789 »* dans une charte révolutionnaire ou *« f. 12v »*
-dans une cote d'archives produit un corpus inutilisable pour la
-recherche fine, même si le CER global est respectable.
-
-Catégories couvertes
---------------------
-1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789``
- (le module détecte les **années** sur 4 chiffres dans la
- plage [1000-2099]).
-2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``.
- Réutilise ``picarones.core.roman_numerals`` (Sprint 60).
-3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``,
- ``p. 5``, ``pp. 12-15``, ``n° 42``.
-4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``,
- ``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime
- (``l.``, ``s.``, ``d.``).
-5. **Années régnales** : ``an III``, ``l'an V``, ``an de
- grâce 1450``, ``an de la République``.
-
-Méthode
--------
-Pour chaque catégorie, on extrait les occurrences (regex
-spécialisée) en GT et en hypothèse. On classe ensuite chaque
-GT en **3 statuts** :
-
-- ``strict_preserved`` : forme exacte présente dans
- l'hypothèse (sensible à la casse seulement pour la
- foliotation, sinon la convention est documentée par
- catégorie) ;
-- ``value_preserved`` : la **valeur** apparaît même si la
- forme diffère (ex. ``XIV`` GT et ``14`` hypothèse —
- considéré comme valeur préservée mais forme non) ;
-- ``lost`` : aucune trace exploitable.
-
-Sortie
-------
-``compute_numerical_sequence_metrics(reference, hypothesis)``
-retourne :
-
-```
-{
- "global_strict_score": float, # ∈ [0, 1]
- "global_value_score": float, # ∈ [0, 1]
- "n_total": int,
- "per_category": {
- "year": {"n_total": int, "strict": int, "value": int,
- "strict_score": float, "value_score": float,
- "lost_items": list[str]},
- "roman": {...},
- "foliation": {...},
- "currency": {...},
- "regnal": {...},
- },
-}
-```
-
-Limites
--------
-- Les regex sont **conservatrices** : on rate quelques
- formes rares plutôt que de produire des faux positifs (par
- exemple, ``mil cinq cens`` en français médiéval n'est pas
- détecté comme année — la couche calcul s'en tient aux
- formes les plus reconnaissables). Pour un corpus
- spécifique, l'utilisateur peut composer ses propres
- détecteurs et les passer via ``custom_detectors``.
-- ``value_preserved`` exige une équivalence de **valeur
- numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ;
- ``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la
- foliotation (recto/verso est une information distincte).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import re
-from typing import Optional
-
-from picarones.core.metric_registry import register_metric
-from picarones.core.modules import ArtifactType
-from picarones.core.roman_numerals import (
- detect_roman_numerals,
- roman_to_int,
-)
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Constantes / catégories
-# ──────────────────────────────────────────────────────────────────────────
-
-
-CATEGORIES = ("year", "roman", "foliation", "currency", "regnal")
-
-
-# Dates arabes — 4 chiffres dans la plage [1000-2099].
-# On exige une frontière de mot pour ne pas attraper
-# « 12345 » (volume) ou « 0001 » (numéro de page).
-_RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b")
-
-
-# Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42
-# La capture conserve la forme intégrale (avec ponctuation et
-# r/v) parce que recto/verso est une information distincte.
-_RE_FOLIATION = re.compile(
- r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n°
- r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15)
- r"\s*([rvRV])?", # suffixe optionnel r/v
- re.UNICODE,
-)
-
-
-# Montants : nombre suivi d'une unité monétaire.
-# On accepte espaces multiples mais pas de saut de ligne.
-_RE_CURRENCY = re.compile(
- r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal)
- r"(livres?|sols?|deniers?|écus?|florins?|francs?|"
- r"l\.|s\.|d\.|£|€|₣)" # unité
- r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole
- re.UNICODE | re.IGNORECASE,
-)
-
-
-# Années régnales : « an III », « an de grâce 1450 »,
-# « l'an V de la République ».
-# Capture le numéral (romain ou arabe).
-_RE_REGNAL = re.compile(
- r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?"
- r"([IVXLCDMivxlcdm]+|\d{1,4})\b",
- re.UNICODE,
-)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Détection par catégorie
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _detect_years(text: str) -> list[tuple[str, int]]:
- """Retourne [(forme, valeur)] pour chaque année 4 chiffres."""
- if not text:
- return []
- return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)]
-
-
-def _detect_romans_with_values(text: str) -> list[tuple[str, int]]:
- """Numéraux romains accompagnés de leur valeur entière.
- Délègue à ``roman_numerals.detect_roman_numerals`` (Sprint 60),
- qui retourne ``(start, form, value)``.
- """
- if not text:
- return []
- out: list[tuple[str, int]] = []
- for _start, form, value in detect_roman_numerals(text, min_length=2):
- if value is not None:
- out.append((form, value))
- return out
-
-
-def _detect_foliations(text: str) -> list[tuple[str, str]]:
- """Foliotation. Retourne [(forme_complète, clé_normalisée)] où la
- clé inclut le suffixe r/v normalisé (recto/verso).
- """
- if not text:
- return []
- out: list[tuple[str, str]] = []
- for m in _RE_FOLIATION.finditer(text):
- full = m.group(0).strip()
- nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15"
- suffix = (m.group(2) or "").lower()
- key = f"{nums}{suffix}"
- out.append((full, key))
- return out
-
-
-def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]:
- """Montants. Clé = (montant_normalisé, unité_canonique).
-
- L'unité canonique compresse les variantes (« livres » et
- « livre » → « livre » ; « £ » reste « £ »).
- """
- if not text:
- return []
- canon = {
- "livre": "livre", "livres": "livre", "l.": "livre",
- "sol": "sol", "sols": "sol", "s.": "sol",
- "denier": "denier", "deniers": "denier", "d.": "denier",
- "écu": "écu", "écus": "écu",
- "florin": "florin", "florins": "florin",
- "franc": "franc", "francs": "franc",
- "£": "£", "€": "€", "₣": "₣",
- }
- out: list[tuple[str, tuple[str, str]]] = []
- for m in _RE_CURRENCY.finditer(text):
- amount = m.group(1).replace(",", ".")
- unit_raw = m.group(2).lower()
- unit = canon.get(unit_raw, unit_raw)
- out.append((m.group(0), (amount, unit)))
- return out
-
-
-def _detect_regnal(text: str) -> list[tuple[str, int]]:
- """Années régnales. Retourne [(forme, valeur_int)] avec la
- valeur extraite (romain → int ou arabe → int).
- """
- if not text:
- return []
- out: list[tuple[str, int]] = []
- for m in _RE_REGNAL.finditer(text):
- numeral = m.group(1)
- value: Optional[int]
- if numeral.isdigit():
- value = int(numeral)
- else:
- value = roman_to_int(numeral)
- if value is not None:
- out.append((m.group(0), value))
- return out
-
-
-_DETECTORS = {
- "year": _detect_years,
- "roman": _detect_romans_with_values,
- "foliation": _detect_foliations,
- "currency": _detect_currencies,
- "regnal": _detect_regnal,
-}
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Calcul principal
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _classify_per_category(
- gt_items: list,
- hyp_items: list,
- *,
- form_extractor,
- value_extractor,
-) -> dict:
- """Pour chaque item GT, le classe en strict_preserved /
- value_preserved / lost.
-
- Multiplicité respectée : un item hypothèse ne peut servir
- qu'à un seul match (forme prioritaire sur valeur).
- """
- hyp_used = [False] * len(hyp_items)
- n_strict = 0
- n_value = 0
- lost: list[str] = []
- # Première passe : matchs stricts (forme exacte)
- matched: list[bool] = [False] * len(gt_items)
- for gi, gt_item in enumerate(gt_items):
- gt_form = form_extractor(gt_item)
- for hi, hyp_item in enumerate(hyp_items):
- if hyp_used[hi]:
- continue
- if form_extractor(hyp_item) == gt_form:
- hyp_used[hi] = True
- matched[gi] = True
- n_strict += 1
- break
- # Deuxième passe : matchs sur valeur (forme différente)
- for gi, gt_item in enumerate(gt_items):
- if matched[gi]:
- n_value += 1 # strict implique value
- continue
- gt_val = value_extractor(gt_item)
- for hi, hyp_item in enumerate(hyp_items):
- if hyp_used[hi]:
- continue
- if value_extractor(hyp_item) == gt_val:
- hyp_used[hi] = True
- matched[gi] = True
- n_value += 1
- break
- if not matched[gi]:
- lost.append(form_extractor(gt_item))
- n_total = len(gt_items)
- return {
- "n_total": n_total,
- "strict": n_strict,
- "value": n_value,
- "strict_score": n_strict / n_total if n_total else 0.0,
- "value_score": n_value / n_total if n_total else 0.0,
- "lost_items": lost,
- }
-
-
-def compute_numerical_sequence_metrics(
- reference: Optional[str],
- hypothesis: Optional[str],
-) -> dict:
- """Calcule la précision sur séquences numériques.
-
- Returns
- -------
- dict
- Voir docstring du module. Si ``reference`` est vide
- ou ne contient aucune séquence détectée, retourne
- ``{n_total: 0, ...}`` avec scores à 0 (pas None).
- """
- ref = reference or ""
- hyp = hypothesis or ""
-
- # Spécifications par catégorie : (gt_items, hyp_items,
- # extractor de forme, extractor de valeur).
- specs: dict[str, dict] = {}
- # year : (form="1789", value=1789)
- specs["year"] = {
- "gt": _detect_years(ref),
- "hyp": _detect_years(hyp),
- "form": lambda it: it[0],
- "value": lambda it: it[1],
- }
- # roman : (form="MDCLXVIII", value=1668)
- specs["roman"] = {
- "gt": _detect_romans_with_values(ref),
- "hyp": _detect_romans_with_values(hyp),
- "form": lambda it: it[0],
- "value": lambda it: it[1],
- }
- # foliation : (form="f. 12r", value="12r")
- specs["foliation"] = {
- "gt": _detect_foliations(ref),
- "hyp": _detect_foliations(hyp),
- "form": lambda it: it[0],
- "value": lambda it: it[1],
- }
- # currency : (form="12 livres", value=("12", "livre"))
- specs["currency"] = {
- "gt": _detect_currencies(ref),
- "hyp": _detect_currencies(hyp),
- "form": lambda it: it[0],
- "value": lambda it: it[1],
- }
- # regnal : (form="an III", value=3)
- specs["regnal"] = {
- "gt": _detect_regnal(ref),
- "hyp": _detect_regnal(hyp),
- "form": lambda it: it[0],
- "value": lambda it: it[1],
- }
-
- per_category: dict[str, dict] = {}
- total = 0
- total_strict = 0
- total_value = 0
- for cat, spec in specs.items():
- breakdown = _classify_per_category(
- spec["gt"], spec["hyp"],
- form_extractor=spec["form"],
- value_extractor=spec["value"],
- )
- per_category[cat] = breakdown
- total += breakdown["n_total"]
- total_strict += breakdown["strict"]
- total_value += breakdown["value"]
-
- return {
- "n_total": total,
- "global_strict_score": (
- total_strict / total if total else 0.0
- ),
- "global_value_score": (
- total_value / total if total else 0.0
- ),
- "per_category": per_category,
- }
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Enregistrement registre typé
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@register_metric(
- name="numerical_sequence_strict_score",
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
- description=(
- "Précision sur séquences numériques en mode strict (forme "
- "préservée). Couvre années arabes, numéraux romains, "
- "foliotation, montants Ancien Régime, années régnales."
- ),
-)
-def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float:
- return compute_numerical_sequence_metrics(
- reference, hypothesis,
- )["global_strict_score"]
-
-
-@register_metric(
- name="numerical_sequence_value_score",
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
- description=(
- "Précision sur séquences numériques en mode valeur "
- "(la valeur est préservée même si la forme diffère, "
- "ex. XIV → 14)."
- ),
-)
-def numerical_sequence_value_score(reference: str, hypothesis: str) -> float:
- return compute_numerical_sequence_metrics(
- reference, hypothesis,
- )["global_value_score"]
-
+from picarones.measurements.numerical_sequences import * # noqa: F401, F403
-__all__ = [
- "CATEGORIES",
- "compute_numerical_sequence_metrics",
- "numerical_sequence_strict_score",
- "numerical_sequence_value_score",
-]
+import picarones.measurements.numerical_sequences as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/numerical_sequences_runner.py b/picarones/core/numerical_sequences_runner.py
index c1d6d1f429e85c11efa6a2a5a6db632c0566c976..7ddedcda5b0940a30fe2457f8c7e064808a582d1 100644
--- a/picarones/core/numerical_sequences_runner.py
+++ b/picarones/core/numerical_sequences_runner.py
@@ -1,102 +1,19 @@
-"""Câblage runner des séquences numériques (Sprint 86).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.numerical_sequences_runner`.
-Sprint 86 — A.II.5b (vue HTML + câblage runner).
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.numerical_sequences_runner import ...``) de continuer
+à fonctionner sans modification.
-Le module ``picarones/core/numerical_sequences.py`` (Sprint 85)
-a livré la couche de calcul. Ce helper prépare la donnée
-adaptative pour le runner et agrège les compteurs par moteur.
-
-Adaptive masking
-----------------
-On ne stocke le résultat que si la GT contient au moins une
-séquence numérique détectée — sinon le module n'apparaît pas
-dans le rapport.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Iterable, Optional
-
-from picarones.core.numerical_sequences import (
- CATEGORIES,
- compute_numerical_sequence_metrics,
-)
-
-logger = logging.getLogger(__name__)
-
-
-def compute_numerical_sequence_metrics_adaptive(
- reference: Optional[str],
- hypothesis: Optional[str],
-) -> Optional[dict]:
- """Calcule les métriques séquences numériques avec masquage
- adaptatif : retourne ``None`` si la GT n'en contient
- aucune."""
- if not reference:
- return None
- result = compute_numerical_sequence_metrics(reference, hypothesis or "")
- if (result.get("n_total") or 0) == 0:
- return None
- return result
-
-
-def aggregate_numerical_sequence_metrics(
- per_doc: Iterable[Optional[dict]],
-) -> Optional[dict]:
- """Agrège par moteur : somme les compteurs par catégorie et
- recalcule les scores globaux et per-category.
-
- Format de sortie identique à ``compute_numerical_sequence_metrics``
- pour faciliter le rendu HTML symétrique.
- """
- docs = [d for d in per_doc if d]
- if not docs:
- return None
- total_n = 0
- total_strict = 0
- total_value = 0
- per_cat: dict[str, dict] = {}
- for cat in CATEGORIES:
- per_cat[cat] = {
- "n_total": 0,
- "strict": 0,
- "value": 0,
- "lost_items": [],
- }
- for d in docs:
- for cat in CATEGORIES:
- cat_data = (d.get("per_category") or {}).get(cat) or {}
- per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0)
- per_cat[cat]["strict"] += int(cat_data.get("strict") or 0)
- per_cat[cat]["value"] += int(cat_data.get("value") or 0)
- per_cat[cat]["lost_items"].extend(
- cat_data.get("lost_items") or [],
- )
- total_n += int(d.get("n_total") or 0)
- # Recalcul des scores
- for cat, slot in per_cat.items():
- n = slot["n_total"]
- slot["strict_score"] = slot["strict"] / n if n else 0.0
- slot["value_score"] = slot["value"] / n if n else 0.0
- # Cap des lost_items à 50 par catégorie
- slot["lost_items"] = slot["lost_items"][:50]
- total_strict += slot["strict"]
- total_value += slot["value"]
- return {
- "n_docs": len(docs),
- "n_total": total_n,
- "global_strict_score": (
- total_strict / total_n if total_n else 0.0
- ),
- "global_value_score": (
- total_value / total_n if total_n else 0.0
- ),
- "per_category": per_cat,
- }
-
+from picarones.measurements.numerical_sequences_runner import * # noqa: F401, F403
-__all__ = [
- "compute_numerical_sequence_metrics_adaptive",
- "aggregate_numerical_sequence_metrics",
-]
+import picarones.measurements.numerical_sequences_runner as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/pricing.py b/picarones/core/pricing.py
index 2ebf1bea27ba9b17d80ba2f6d3f2e1c84e7192d3..ad9e3a56f088d384f275ef647b6cba6fe7e3d751 100644
--- a/picarones/core/pricing.py
+++ b/picarones/core/pricing.py
@@ -1,309 +1,19 @@
-"""Modélisation des coûts — APIs cloud et temps d'inférence local.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.pricing`.
-Sert uniquement à la vue Pareto coût/qualité du rapport (Sprint 5).
-Les prix sont indicatifs et vieillissent vite : voir ``picarones/data/pricing.yaml``
-pour les hypothèses, dates et URLs de référence.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.pricing import ...``) de continuer
+à fonctionner sans modification.
-Conventions
------------
-- Unité monétaire : EUR (conversion indicative depuis USD quand applicable).
-- Coût exprimé par **1 000 pages** traitées.
-- Coût local = temps moyen d'inférence × taux horaire (paramétrable).
-- Empreinte carbone optionnelle : kWh × intensité g CO₂/kWh du réseau
- d'exécution (mix France bas carbone par défaut pour le local,
- moyenne cloud hyperscaler pour les APIs).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
+from picarones.measurements.pricing import * # noqa: F401, F403
-import logging
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Optional
-
-import yaml
-
-logger = logging.getLogger(__name__)
-
-_DEFAULT_PRICING_PATH = Path(__file__).parent.parent / "data" / "pricing.yaml"
-
-
-@dataclass(frozen=True)
-class PricingDefaults:
- """Valeurs par défaut du fichier de prix (section ``meta``)."""
-
- last_updated: Optional[str] = None
- currency: str = "EUR"
- hourly_rate_local_cpu_eur: float = 0.08
- hourly_rate_local_gpu_eur: float = 1.20
- grid_intensity_local: float = 58.0
- grid_intensity_cloud: float = 380.0
-
-
-@dataclass
-class EngineCost:
- """Coût estimé d'un moteur sur 1 000 pages, avec traçabilité des hypothèses.
-
- La représentation est immuable après construction : une fois que l'utilisateur
- a choisi un taux horaire local, toutes les instances partagent cette
- hypothèse par injection explicite dans ``build_costs_for_benchmark``.
- """
-
- engine_key: str
- """Nom ou modèle servant de clé dans la table (ex. ``"gpt-4o"``, ``"tesseract"``)."""
-
- type: str # "local" | "cloud_api" | "unknown"
-
- cost_per_1k_pages_eur: Optional[float] = None
- """Coût par 1 000 pages en euros. ``None`` si les données sont insuffisantes."""
-
- currency: str = "EUR"
-
- # Source / date
- pricing_source_url: Optional[str] = None
- pricing_date: Optional[str] = None
-
- # Pour les APIs cloud : prix brut
- api_price_per_1k_pages: Optional[float] = None
-
- # Pour le local : temps d'inférence et taux horaire utilisés
- local_mean_seconds_per_page: Optional[float] = None
- hourly_rate_eur: Optional[float] = None
-
- # Empreinte carbone (estimation — étiquetée "expérimentale" dans le rapport)
- kwh_per_1k_pages: Optional[float] = None
- grid_intensity_g_co2_per_kwh: Optional[float] = None
- co2_per_1k_pages_g: Optional[float] = None
-
- notes: Optional[str] = None
-
- assumptions: list[str] = field(default_factory=list)
- """Liste d'hypothèses textuelles à afficher sous le graphique."""
-
- def as_dict(self) -> dict:
- return {
- "engine_key": self.engine_key,
- "type": self.type,
- "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur,
- "currency": self.currency,
- "pricing_source_url": self.pricing_source_url,
- "pricing_date": self.pricing_date,
- "api_price_per_1k_pages": self.api_price_per_1k_pages,
- "local_mean_seconds_per_page": self.local_mean_seconds_per_page,
- "hourly_rate_eur": self.hourly_rate_eur,
- "kwh_per_1k_pages": self.kwh_per_1k_pages,
- "grid_intensity_g_co2_per_kwh": self.grid_intensity_g_co2_per_kwh,
- "co2_per_1k_pages_g": self.co2_per_1k_pages_g,
- "notes": self.notes,
- "assumptions": list(self.assumptions),
- }
-
-
-def load_pricing_database(path: Optional[Path] = None) -> tuple[PricingDefaults, dict]:
- """Charge la table de prix YAML.
-
- Retourne ``(defaults, engines_table)`` où ``engines_table`` est un dict
- ``{engine_key: raw_entry}``.
- """
- path = Path(path) if path else _DEFAULT_PRICING_PATH
- if not path.exists():
- logger.warning("[pricing] fichier %s introuvable", path)
- return PricingDefaults(), {}
- try:
- with path.open(encoding="utf-8") as fh:
- data = yaml.safe_load(fh) or {}
- except yaml.YAMLError as e:
- logger.warning("[pricing] échec parsing %s : %s", path, e)
- return PricingDefaults(), {}
-
- meta = data.get("meta", {}) or {}
- defaults = PricingDefaults(
- last_updated=meta.get("last_updated"),
- currency=meta.get("currency", "EUR"),
- hourly_rate_local_cpu_eur=float(meta.get("default_hourly_rate_local_cpu_eur", 0.08)),
- hourly_rate_local_gpu_eur=float(meta.get("default_hourly_rate_local_gpu_eur", 1.20)),
- grid_intensity_local=float(meta.get("default_grid_intensity_g_co2_per_kwh", 58.0)),
- grid_intensity_cloud=float(meta.get("cloud_grid_intensity_g_co2_per_kwh", 380.0)),
- )
- engines_table = data.get("engines", {}) or {}
- return defaults, engines_table
-
-
-def _match_key(engine_name: str, llm_model: Optional[str], table: dict) -> Optional[str]:
- """Cherche la meilleure clé pour ce moteur dans la table.
-
- Stratégie : d'abord le nom du modèle LLM (pour les pipelines), puis le
- nom OCR, puis un match partiel (substring) comme filet de sécurité.
- """
- candidates = [llm_model, engine_name]
- for c in candidates:
- if c and c in table:
- return c
- # Matching partiel — utile pour "tesseract → gpt-4o" ou "gpt-4o-vision"
- for c in candidates:
- if not c:
- continue
- for key in table:
- if key in c:
- return key
- return None
-
-
-def estimate_cost(
- engine_name: str,
- *,
- llm_model: Optional[str] = None,
- is_pipeline: bool = False,
- measured_seconds_per_page: Optional[float] = None,
- table: Optional[dict] = None,
- defaults: Optional[PricingDefaults] = None,
- hourly_rate_override_eur: Optional[float] = None,
-) -> EngineCost:
- """Calcule le ``EngineCost`` pour un moteur donné.
-
- Parameters
- ----------
- engine_name:
- Nom public du moteur (ex. ``"tesseract"``, ``"tesseract → gpt-4o"``).
- llm_model:
- Si pipeline OCR+LLM, le modèle LLM utilisé — prioritaire pour la
- lookup car c'est lui qui domine le coût.
- is_pipeline:
- Indique un pipeline OCR+LLM (change la sémantique de lookup).
- measured_seconds_per_page:
- Temps moyen observé sur le benchmark courant. Remplace la valeur
- indicative de la table si fournie (plus fiable).
- table, defaults:
- Overrides pour tests ou usage institutionnel.
- hourly_rate_override_eur:
- Taux horaire à utiliser pour le calcul local (sinon valeur table
- ou défaut).
- """
- if table is None or defaults is None:
- _defaults, _table = load_pricing_database()
- defaults = defaults or _defaults
- table = table or _table
-
- key = _match_key(engine_name, llm_model if is_pipeline else None, table)
- if key is None:
- return EngineCost(
- engine_key=engine_name,
- type="unknown",
- assumptions=["Aucune entrée dans la table de prix pour ce moteur."],
- )
-
- entry = table[key]
- etype = str(entry.get("type", "unknown"))
- notes = entry.get("notes")
- assumptions: list[str] = []
- currency = defaults.currency
-
- cost_eur: Optional[float] = None
- api_price: Optional[float] = None
- local_seconds = measured_seconds_per_page
- hourly_rate = None
-
- if etype == "cloud_api":
- api_price = entry.get("api_price_per_1k_pages")
- if api_price is not None:
- cost_eur = float(api_price)
- assumptions.append(
- f"Prix API indicatif : {cost_eur:.2f} €/1000 pages "
- f"(source : {entry.get('pricing_source_url', '—')}, {entry.get('pricing_date', 'date inconnue')})."
- )
- elif etype == "local":
- indicative_seconds = entry.get("local_mean_seconds_per_page")
- if local_seconds is None and indicative_seconds is not None:
- local_seconds = float(indicative_seconds)
- assumptions.append(
- f"Temps d'inférence indicatif : {local_seconds:.1f} s/page (non mesuré sur ce benchmark)."
- )
- elif local_seconds is not None:
- assumptions.append(
- f"Temps d'inférence mesuré : {local_seconds:.1f} s/page (moyenne sur le corpus)."
- )
-
- hourly_rate = (
- hourly_rate_override_eur
- if hourly_rate_override_eur is not None
- else entry.get("hourly_rate_override_eur")
- )
- if hourly_rate is None:
- # Heuristique : si l'entrée précise un override GPU, sinon CPU
- hourly_rate = (
- defaults.hourly_rate_local_gpu_eur
- if "gpu" in str(notes or "").lower()
- else defaults.hourly_rate_local_cpu_eur
- )
- hourly_rate = float(hourly_rate)
-
- if local_seconds is not None and hourly_rate is not None:
- cost_eur = (local_seconds / 3600.0) * hourly_rate * 1000.0
- assumptions.append(
- f"Taux horaire appliqué : {hourly_rate:.2f} €/h "
- f"(défaut {'GPU' if hourly_rate >= 0.5 else 'CPU'})."
- )
-
- # Empreinte carbone optionnelle
- kwh_1k = entry.get("kwh_per_1k_pages")
- grid = (
- entry.get("grid_intensity_g_co2_per_kwh")
- or (defaults.grid_intensity_cloud if etype == "cloud_api" else defaults.grid_intensity_local)
- )
- co2_g = None
- if kwh_1k is not None and grid is not None:
- co2_g = float(kwh_1k) * float(grid)
-
- return EngineCost(
- engine_key=key,
- type=etype,
- cost_per_1k_pages_eur=cost_eur,
- currency=currency,
- pricing_source_url=entry.get("pricing_source_url"),
- pricing_date=entry.get("pricing_date"),
- api_price_per_1k_pages=api_price,
- local_mean_seconds_per_page=local_seconds,
- hourly_rate_eur=hourly_rate,
- kwh_per_1k_pages=float(kwh_1k) if kwh_1k is not None else None,
- grid_intensity_g_co2_per_kwh=float(grid) if grid is not None else None,
- co2_per_1k_pages_g=co2_g,
- notes=notes,
- assumptions=assumptions,
- )
-
-
-def build_costs_for_benchmark(
- engines_summary: list[dict],
- durations_by_engine: dict[str, float],
- *,
- hourly_rate_local_eur: Optional[float] = None,
- pricing_path: Optional[Path] = None,
-) -> dict[str, dict]:
- """Calcule le coût de chaque moteur d'un benchmark.
-
- Returns
- -------
- dict ``{engine_name: EngineCost.as_dict()}``.
- """
- defaults, table = load_pricing_database(pricing_path)
- out: dict[str, dict] = {}
- for e in engines_summary:
- name = e.get("name")
- if not name:
- continue
- measured = durations_by_engine.get(name)
- llm_model = None
- pipeline_info = e.get("pipeline_info") or {}
- if pipeline_info:
- llm_model = pipeline_info.get("llm_model")
- cost = estimate_cost(
- engine_name=name,
- llm_model=llm_model,
- is_pipeline=bool(e.get("is_pipeline")),
- measured_seconds_per_page=measured,
- table=table,
- defaults=defaults,
- hourly_rate_override_eur=hourly_rate_local_eur,
- )
- out[name] = cost.as_dict()
- return out
+import picarones.measurements.pricing as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/rare_tokens.py b/picarones/core/rare_tokens.py
index 69f320e2c1b1922285c16f708f74240b51713709..4d57c79e5a5a3ddd931846103042365fb241a9f5 100644
--- a/picarones/core/rare_tokens.py
+++ b/picarones/core/rare_tokens.py
@@ -1,254 +1,19 @@
-"""Rare-token recall — Sprint 71 (A.I.1 chantier 2 du plan 2026).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.rare_tokens`.
-Pourquoi ce module
-------------------
-Le CER global d'un moteur peut sembler bon (ex. 5 %) tout en
-masquant des **erreurs systématiques sur les tokens rares** : noms
-propres, toponymes peu fréquents, mots techniques, formules latines
-récurrentes mais pas dominantes. Pour un usage prosopographique
-(indexation de noms, recherche généalogique), ce sont précisément
-ces tokens-là qui comptent.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.rare_tokens import ...``) de continuer
+à fonctionner sans modification.
-Ce module mesure le **rappel sur les tokens rares** d'un corpus —
-défaut : tokens dont la fréquence corpus-wide est ≤ 2 (hapax +
-dis legomena, terminologie de lexicométrie classique).
-
-Hypothèse à valider expérimentalement
--------------------------------------
-La conjecture du plan A.I.1 : *« cette métrique discrimine plus
-les moteurs que le CER global »*. Si confirmée sur un corpus
-patrimonial réel, elle gagne sa place dans le tableau de
-classement principal — décision laissée au chercheur après
-observation.
-
-Stratégie de découpage
-----------------------
-Cohérente avec NER (38), Flesch (52), philologie (55-60) : couche
-de calcul pure d'abord, sans intégration runner. La vue HTML
-« worst lines / rare tokens manqués » suit dans un sprint dédié.
-
-Pas d'enregistrement dans le registre typé Sprint 34
-----------------------------------------------------
-La métrique exige **trois entrées** (reference, hypothesis, set
-des tokens rares) et le set des rares est calculé corpus-wide
-(donc connu seulement après itération sur tout le corpus). La
-signature ne rentre pas dans ``(TEXT, TEXT)``. L'utilisateur
-appelle explicitement ``compute_rare_token_recall`` avec le set
-qu'il a calculé.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import re
-from collections import Counter
-from typing import Iterable, Optional
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Tokenisation Unicode-aware
-# ──────────────────────────────────────────────────────────────────────────
-
-# Token = séquence maximale de caractères de mot Unicode (\w en
-# Python 3 utilise déjà la table Unicode), incluant l'apostrophe
-# typographique '’' à l'intérieur (« l'an », « d’une ») et les
-# tirets internes (« peut-être »). La ponctuation isolée et les
-# espaces sont des séparateurs.
-
-_TOKEN_RE = re.compile(
- r"\w+(?:[’'\-]\w+)*",
- flags=re.UNICODE,
-)
-
-
-def tokenize(text: Optional[str]) -> list[str]:
- """Tokenisation Unicode-aware.
-
- Conserve les contractions (``l'an``, ``d’une``) et les mots
- composés (``peut-être``, ``c'est-à-dire``) comme un seul token.
- Casse préservée — l'utilisateur normalise lui-même via
- ``case_sensitive=False`` dans les fonctions aval s'il le veut.
- """
- if not text:
- return []
- return _TOKEN_RE.findall(text)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Distribution de fréquence corpus-wide
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def frequency_distribution(
- documents: Iterable[str],
- *,
- case_sensitive: bool = False,
-) -> Counter[str]:
- """Calcule ``{token: count}`` sur l'ensemble du corpus.
-
- Parameters
- ----------
- documents:
- Itérable de textes (typiquement les ``ground_truth`` des
- documents du corpus).
- case_sensitive:
- Si ``False`` (défaut), tous les tokens sont mis en
- minuscule avant comptage.
- """
- counter: Counter[str] = Counter()
- for doc in documents:
- tokens = tokenize(doc)
- if not case_sensitive:
- tokens = [t.lower() for t in tokens]
- counter.update(tokens)
- return counter
-
-
-def extract_rare_tokens(
- documents: Iterable[str],
- *,
- max_freq: int = 2,
- case_sensitive: bool = False,
-) -> frozenset[str]:
- """Retourne l'ensemble des tokens dont la fréquence
- corpus-wide est ``≤ max_freq``.
-
- Convention de lexicométrie : ``max_freq=1`` retourne uniquement
- les hapax legomena (1 occurrence) ; ``max_freq=2`` retourne
- hapax + dis legomena (≤ 2 occurrences) — défaut.
-
- Les tokens qui n'apparaissent **jamais** dans le corpus ne sont
- évidemment pas inclus (le ``Counter`` ne les liste pas).
- """
- if max_freq < 1:
- raise ValueError("max_freq doit être ≥ 1")
- counter = frequency_distribution(
- documents, case_sensitive=case_sensitive,
- )
- return frozenset(t for t, c in counter.items() if c <= max_freq)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Calcul du rappel par document
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def compute_rare_token_recall(
- reference: Optional[str],
- hypothesis: Optional[str],
- rare_tokens: Iterable[str],
- *,
- case_sensitive: bool = False,
-) -> dict:
- """Calcule le rappel sur les tokens rares présents dans la GT.
-
- Parameters
- ----------
- reference:
- Texte GT du document.
- hypothesis:
- Texte produit par l'OCR.
- rare_tokens:
- Itérable des tokens rares — typiquement le résultat de
- ``extract_rare_tokens`` sur le corpus complet.
- case_sensitive:
- Si ``False`` (défaut), la comparaison se fait sur les
- formes minuscules.
-
- Returns
- -------
- dict
- ``{
- "n_rare_tokens_in_reference": int,
- # nombre d'**occurrences** de tokens rares dans la GT
- # (multiplicité préservée — un token rare présent 2
- # fois compte 2)
- "n_rare_tokens_recalled": int,
- # nombre d'occurrences correctement présentes dans hyp
- # (alignement bag-of-tokens : min(count_ref, count_hyp))
- "recall": float,
- # ratio dans [0, 1], ou 0.0 si aucun rare en GT
- "missed_tokens": list[str],
- # liste des tokens rares **manqués** (avec multiplicité,
- # ex. "Dupont" présent 2 fois en GT et 1 fois en hyp →
- # missed_tokens contient ["Dupont"] une fois)
- }``
-
- Cas dégénérés
- -------------
- - GT vide ou aucun token rare présent → recall = 0.0, listes
- vides (convention : on ne récompense pas l'absence de
- tokens rares).
- - Hyp vide avec rares en GT → tous manqués, recall = 0.0.
- """
- ref = reference or ""
- hyp = hypothesis or ""
-
- if case_sensitive:
- rare_set = frozenset(rare_tokens)
- ref_tokens = tokenize(ref)
- hyp_tokens = tokenize(hyp)
- else:
- rare_set = frozenset(t.lower() for t in rare_tokens)
- ref_tokens = [t.lower() for t in tokenize(ref)]
- hyp_tokens = [t.lower() for t in tokenize(hyp)]
-
- # Multiplicité : on compte uniquement les rares présents dans la GT
- ref_rare_counts: Counter[str] = Counter(
- t for t in ref_tokens if t in rare_set
- )
- n_rare_in_ref = sum(ref_rare_counts.values())
- if n_rare_in_ref == 0:
- return {
- "n_rare_tokens_in_reference": 0,
- "n_rare_tokens_recalled": 0,
- "recall": 0.0,
- "missed_tokens": [],
- }
-
- # Bag-of-tokens dans hyp pour les tokens rares uniquement
- hyp_rare_counts: Counter[str] = Counter(
- t for t in hyp_tokens if t in rare_set
- )
- # Recall multiplicitaire : pour chaque token, min(ref_count, hyp_count)
- n_recalled = 0
- missed: list[str] = []
- for token, ref_count in ref_rare_counts.items():
- hyp_count = hyp_rare_counts.get(token, 0)
- recalled = min(ref_count, hyp_count)
- n_recalled += recalled
- missed_count = ref_count - recalled
- if missed_count > 0:
- missed.extend([token] * missed_count)
-
- return {
- "n_rare_tokens_in_reference": n_rare_in_ref,
- "n_rare_tokens_recalled": n_recalled,
- "recall": n_recalled / n_rare_in_ref,
- "missed_tokens": missed,
- }
-
-
-def rare_token_recall(
- reference: Optional[str],
- hypothesis: Optional[str],
- rare_tokens: Iterable[str],
- *,
- case_sensitive: bool = False,
-) -> float:
- """Raccourci : retourne uniquement le rappel ∈ [0, 1]."""
- return compute_rare_token_recall(
- reference, hypothesis, rare_tokens,
- case_sensitive=case_sensitive,
- )["recall"]
-
+from picarones.measurements.rare_tokens import * # noqa: F401, F403
-__all__ = [
- "tokenize",
- "frequency_distribution",
- "extract_rare_tokens",
- "compute_rare_token_recall",
- "rare_token_recall",
-]
+import picarones.measurements.rare_tokens as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/readability.py b/picarones/core/readability.py
index 941709c25b9f539366eb9c1a15a8625a97820d88..1f0b31c9639895ee78d773aa51ec6dd2ae8f5d17 100644
--- a/picarones/core/readability.py
+++ b/picarones/core/readability.py
@@ -1,252 +1,19 @@
-"""Métriques de lisibilité (Flesch) — Sprint 52.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.readability`.
-Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure
-de la métrique Flesch, indépendante de tout alignement OCR/GT.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.readability import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Les LLM produisent du texte plus « lisse » que les manuscrits
-historiques. Cette tendance à la modernisation est mesurable par la
-différence de score de lisibilité entre la GT et la sortie OCR/LLM —
-**indépendamment des classes taxonomiques** et **sans alignement
-caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne
-même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte
-moderne plausible mais déconnecté de la GT).
-
-Stratégie de découpage
-----------------------
-Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
-découpe :
-
-- **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
- ``flesch_delta``. Aucune dépendance externe ; les heuristiques de
- comptage de syllabes sont en pur Python, déterministes, testées.
-- **Sprints suivants** — câblage runner pour calculer
- ``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
-
-Formules
---------
-- **Anglais** (Flesch original 1948) :
- ``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)``
-- **Français** (Kandel-Moles 1958) :
- ``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)``
-
-Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire »,
-0 ↔ « très difficile ». Une **augmentation** du score quand on passe
-de la GT à l'OCR signale une simplification (typique des LLM
-modernisants). Une **chute** signale une dégradation OCR.
-
-Limites documentées
--------------------
-- Le comptage de syllabes est heuristique. En français, des règles
- comme « -ier non final = 2 syllabes » ne sont pas appliquées
- finement. Acceptable pour une métrique de **comparaison relative**
- (delta GT vs OCR), pas pour publier une absolue.
-- Sur des textes très courts (< 20 mots), la formule perd en
- fiabilité. Le seuil minimal est documenté.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import re
-from typing import Literal
-
-from picarones.core.metric_registry import register_metric
-from picarones.core.modules import ArtifactType
-
-logger = logging.getLogger(__name__)
-
-
-Language = Literal["fr", "en"]
-
-# Coefficients de la formule Flesch selon la langue.
-_FLESCH_COEFFS: dict[str, tuple[float, float, float]] = {
- "en": (206.835, 1.015, 84.6), # Flesch 1948
- "fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958
-}
-
-# Voyelles utilisées pour l'heuristique de comptage de syllabes.
-# On utilise un set qui inclut les diacritiques courantes en FR/EN.
-_VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ")
-
-# Regex de découpage en phrases : ponctuation finale + espace ou fin.
-# Tolère les multiples points (« ... ») et garde un découpage robuste.
-_SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)")
-
-# Regex de tokenisation simple (mots) : séquences de caractères "lettres".
-_WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Compteurs de base
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def count_words(text: str) -> int:
- """Nombre de mots (tokens alphanumériques) dans ``text``."""
- if not text:
- return 0
- return len(_WORD_RE.findall(text))
-
-
-def count_sentences(text: str) -> int:
- """Nombre de phrases dans ``text``.
-
- Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``).
- Renvoie au minimum 1 si ``text`` contient au moins un mot, pour
- éviter une division par zéro dans la formule de Flesch sur les
- textes sans ponctuation finale.
- """
- if not text:
- return 0
- parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
- n = len(parts)
- if n == 0 and count_words(text) > 0:
- return 1
- return n
-
-
-def count_syllables_word(word: str) -> int:
- """Heuristique de comptage de syllabes pour un mot isolé.
-
- Règle : on compte les **groupes de voyelles consécutives** (en
- incluant ``y`` et les diacritiques courantes). C'est une
- approximation grossière mais déterministe et testable.
-
- Cas limites :
- - mot vide → 0
- - mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``)
- - mot d'une seule voyelle isolée → 1
- """
- if not word:
- return 0
- word = word.lower()
- in_vowel_group = False
- count = 0
- for ch in word:
- if ch in _VOWELS:
- if not in_vowel_group:
- count += 1
- in_vowel_group = True
- else:
- in_vowel_group = False
- return count or 1
-
-
-def count_syllables(text: str) -> int:
- """Somme des syllabes de tous les mots de ``text``."""
- if not text:
- return 0
- return sum(count_syllables_word(w) for w in _WORD_RE.findall(text))
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Score Flesch
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def flesch_score(text: str, lang: Language = "fr") -> float:
- """Calcule le score de lisibilité Flesch pour ``text``.
-
- Parameters
- ----------
- text:
- Texte à évaluer. Peut contenir ponctuation, accents, etc.
- lang:
- ``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948).
-
- Returns
- -------
- float
- Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte
- vide ou sans mot exploitable.
-
- Notes
- -----
- Le score chute fortement avec :
- - longues phrases (mots/phrases élevé)
- - mots polysyllabiques (syllabes/mots élevé)
- Une montée du score lors du passage GT → OCR signale qu'un LLM a
- « lissé » la langue (phrases plus courtes, mots plus communs).
- """
- if lang not in _FLESCH_COEFFS:
- raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.")
-
- n_words = count_words(text)
- if n_words == 0:
- return 0.0
- n_sentences = max(1, count_sentences(text))
- n_syllables = count_syllables(text)
- if n_syllables == 0:
- return 0.0
-
- base, k_words, k_syll = _FLESCH_COEFFS[lang]
- raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words)
- return max(0.0, min(100.0, raw))
-
-
-def flesch_delta(
- reference: str,
- hypothesis: str,
- lang: Language = "fr",
-) -> float:
- """Différence ``flesch_score(hypothesis) - flesch_score(reference)``.
-
- Interprétation
- --------------
- - **Positif** : l'hypothèse OCR est plus lisible que la GT —
- signal d'**over-normalisation** (typique des LLM qui modernisent
- des textes anciens).
- - **Négatif** : l'OCR est moins lisible — signal de dégradation
- (caractères mal reconnus brisent la fluidité).
- - **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique.
-
- Borné dans ``[-100, +100]``.
- """
- return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Enregistrement dans le registre typé (Sprint 34)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@register_metric(
- name="flesch_delta_fr",
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
- description=(
- "Différence de score Flesch (Kandel-Moles, FR) entre la sortie "
- "OCR et la GT. Positif = OCR plus lisible (signal "
- "d'over-normalisation LLM). Aucun alignement requis."
- ),
- higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant
- tags={"text", "readability", "over_normalization"},
-)
-def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float:
- return flesch_delta(reference, hypothesis, lang="fr")
-
-
-@register_metric(
- name="flesch_delta_en",
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
- description=(
- "Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. "
- "Positive = OCR easier to read than GT (LLM smoothing signal). "
- "No alignment required."
- ),
- higher_is_better=False,
- tags={"text", "readability", "over_normalization"},
-)
-def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float:
- return flesch_delta(reference, hypothesis, lang="en")
-
+from picarones.measurements.readability import * # noqa: F401, F403
-__all__ = [
- "flesch_score",
- "flesch_delta",
- "count_words",
- "count_sentences",
- "count_syllables",
- "count_syllables_word",
-]
+import picarones.measurements.readability as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/readability_runner.py b/picarones/core/readability_runner.py
index 073b5f16fca2a59993e08453c191f915d8f75405..ed22538b086b1b02810856d5dd7aa253a22a739a 100644
--- a/picarones/core/readability_runner.py
+++ b/picarones/core/readability_runner.py
@@ -1,114 +1,19 @@
-"""Câblage runner du delta Flesch (Sprint 87 — A.II.2).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.readability_runner`.
-Sprint 87 — A.II.2 (vue HTML + câblage runner du delta Flesch
-livré par le Sprint 52).
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.readability_runner import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Le ``flesch_delta`` mesure la différence de lisibilité entre la
-GT et la sortie OCR. Un score positif signale une *over-
-normalisation* typique des LLM/VLM qui modernisent un texte
-ancien (le Flesch monte parce que les mots sont plus simples) ;
-un score négatif signale une dégradation OCR brutale.
-
-Cette métrique est calculée **automatiquement** par le runner
-sur chaque document, agrégée par moteur, et présentée dans le
-rapport.
-
-Adaptive masking
-----------------
-On ne calcule que si la GT contient ≥ 5 mots — en dessous, le
-Flesch est trop instable pour être informatif.
-
-Langue
-------
-Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour
-les corpus mixtes, l'utilisateur peut passer une langue
-explicite à l'orchestrateur.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import statistics
-from typing import Iterable, Optional
-
-from picarones.core.readability import (
- Language,
- count_words,
- flesch_delta,
- flesch_score,
-)
-
-logger = logging.getLogger(__name__)
-
-
-_MIN_WORDS_FOR_FLESCH = 5
-
-
-def compute_readability_metrics(
- reference: Optional[str],
- hypothesis: Optional[str],
- *,
- lang: Language = "fr",
-) -> Optional[dict]:
- """Calcule le delta Flesch d'un document avec adaptive masking.
-
- Retourne ``None`` si la GT contient moins de
- ``_MIN_WORDS_FOR_FLESCH`` mots.
- """
- ref = reference or ""
- n_ref_words = count_words(ref)
- if n_ref_words < _MIN_WORDS_FOR_FLESCH:
- return None
- hyp = hypothesis or ""
- flesch_ref = flesch_score(ref, lang=lang)
- flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None
- delta = (
- flesch_delta(ref, hyp, lang=lang) if hyp else None
- )
- return {
- "lang": lang,
- "flesch_reference": flesch_ref,
- "flesch_hypothesis": flesch_hyp,
- "flesch_delta": delta,
- "n_words_reference": n_ref_words,
- }
-
-
-def aggregate_readability_metrics(
- per_doc: Iterable[Optional[dict]],
-) -> Optional[dict]:
- """Agrège : moyenne/médiane des deltas + part de docs
- « over-normalisés » (delta > +5 points).
- """
- docs = [d for d in per_doc if d]
- if not docs:
- return None
- deltas = [
- float(d["flesch_delta"]) for d in docs
- if isinstance(d.get("flesch_delta"), (int, float))
- ]
- if not deltas:
- return None
- over_norm = sum(1 for d in deltas if d > 5.0)
- under_norm = sum(1 for d in deltas if d < -5.0)
- lang = docs[0].get("lang") or "fr"
- return {
- "lang": lang,
- "n_docs": len(docs),
- "n_docs_with_delta": len(deltas),
- "delta_mean": statistics.fmean(deltas),
- "delta_median": statistics.median(deltas),
- "delta_min": min(deltas),
- "delta_max": max(deltas),
- "n_over_normalized": over_norm,
- "n_under_normalized": under_norm,
- "over_normalized_rate": over_norm / len(deltas),
- }
-
+from picarones.measurements.readability_runner import * # noqa: F401, F403
-__all__ = [
- "compute_readability_metrics",
- "aggregate_readability_metrics",
-]
+import picarones.measurements.readability_runner as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/reading_order.py b/picarones/core/reading_order.py
index 655557dcffc56ed40554a1ea2369542f635859a1..65cc1152ef25eabec802f71e4eedb0ca3fa309f7 100644
--- a/picarones/core/reading_order.py
+++ b/picarones/core/reading_order.py
@@ -1,196 +1,19 @@
-"""Reading order F1 (ICDAR 2015, Antonacopoulos) — Sprint 53.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.reading_order`.
-Sprint 53 — A.II.2.1 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.reading_order import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Sur un manuscrit glosé, un journal multi-colonnes ou un registre
-paroissial complexe, le **classement des moteurs en CER** peut être
-trompeur : un moteur peut avoir un excellent CER caractère et un
-**ordre de lecture catastrophique**. Le résultat est inutilisable
-pour la recherche plein texte (Elastic, Solr) ou pour reconstituer
-une narration linéaire.
-
-La métrique standard est définie par Antonacopoulos et al. dans
-ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions
-ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède
-``b`` dans la GT :
-
-- **TP** si ``a`` précède aussi ``b`` dans l'hypothèse,
-- **FN** si la paire est manquante (régions absentes ou ordre
- inversé) côté hypothèse,
-- **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que
- la GT n'a pas cet ordre (régions hallucinées ou inversion).
-
-Le F1 est la moyenne harmonique des deux.
-
-Stratégie de découpage
-----------------------
-Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch
-(Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit
-deux listes ordonnées d'IDs de régions (typiquement extraites de
-ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML
-suivent dans des sprints dédiés.
-
-Compatible directement avec ``ReadingOrderGT`` du Sprint 32 :
-``ReadingOrderGT.region_order`` est exactement le format attendu.
-
-Convention sur les régions
---------------------------
-- Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.).
-- Les **doublons** sont ignorés au calcul des paires ordonnées
- (chaque ID compte une fois par séquence).
-- Une région présente dans la GT mais absente de l'hypothèse
- contribue aux paires FN.
-- Une région présente dans l'hypothèse mais absente de la GT
- contribue aux paires FP.
-- Si une séquence a < 2 régions distinctes, aucune paire n'est
- émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux
- séquences soient identiques.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from itertools import combinations
-from typing import Iterable
-
-from picarones.core.metric_registry import register_metric
-from picarones.core.modules import ArtifactType
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Helpers
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]:
- """Retourne l'ensemble des paires ``(a, b)`` telles que ``a``
- précède strictement ``b`` dans ``sequence``.
-
- Doublons : chaque ID est traité une seule fois (première occurrence
- dans la séquence). Cohérent avec ICDAR 2015 où les régions ont
- des IDs uniques.
- """
- seen: list[str] = []
- seen_set: set[str] = set()
- for r in sequence:
- if r not in seen_set:
- seen.append(r)
- seen_set.add(r)
- return set(combinations(seen, 2))
-
-
-def _normalize_input(value: Iterable[str] | None) -> list[str]:
- """Coerce une entrée en list[str], en filtrant les valeurs vides."""
- if value is None:
- return []
- return [str(v) for v in value if v is not None and str(v).strip()]
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Métrique principale
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def compute_reading_order_metrics(
- reference_order: Iterable[str] | None,
- hypothesis_order: Iterable[str] | None,
-) -> dict:
- """Calcule precision / recall / F1 sur l'ordre relatif des régions.
-
- Parameters
- ----------
- reference_order:
- Séquence ordonnée d'IDs de régions issue de la GT (typiquement
- ``ReadingOrderGT.region_order`` du Sprint 32).
- hypothesis_order:
- Séquence ordonnée d'IDs de régions produite par un moteur
- OCR/HTR ou un reconstructeur ALTO.
-
- Returns
- -------
- dict
- ``{"precision", "recall", "f1", "true_positives",
- "false_positives", "false_negatives", "n_ref_pairs",
- "n_hyp_pairs", "common_regions", "ref_only_regions",
- "hyp_only_regions"}``.
-
- Comportements aux bornes
- ------------------------
- - Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0.
- - Ordre strictement inversé → F1 = 0.0 (toutes les paires
- relatives sont fausses).
- - Une séquence vide vs une séquence non vide → F1 = 0.0.
- - Deux séquences vides → F1 = 0.0 et tous les compteurs à 0
- (convention : on ne récompense pas l'absence).
- """
- ref = _normalize_input(reference_order)
- hyp = _normalize_input(hypothesis_order)
-
- ref_pairs = _ordered_pairs(ref)
- hyp_pairs = _ordered_pairs(hyp)
-
- tp = len(ref_pairs & hyp_pairs)
- fn = len(ref_pairs - hyp_pairs)
- fp = len(hyp_pairs - ref_pairs)
-
- precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
- recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
- f1 = (
- 2 * precision * recall / (precision + recall)
- if (precision + recall) > 0
- else 0.0
- )
-
- ref_set = set(ref)
- hyp_set = set(hyp)
- return {
- "precision": precision,
- "recall": recall,
- "f1": f1,
- "true_positives": tp,
- "false_positives": fp,
- "false_negatives": fn,
- "n_ref_pairs": len(ref_pairs),
- "n_hyp_pairs": len(hyp_pairs),
- "common_regions": sorted(ref_set & hyp_set),
- "ref_only_regions": sorted(ref_set - hyp_set),
- "hyp_only_regions": sorted(hyp_set - ref_set),
- }
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Enregistrement dans le registre typé (Sprint 34)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@register_metric(
- name="reading_order_f1",
- input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
- description=(
- "F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, "
- "Antonacopoulos). Pour chaque paire (a,b) où a précède b dans "
- "la GT, vérifie que a précède aussi b dans l'hypothèse."
- ),
- higher_is_better=True,
- tags={"structure", "icdar", "alto", "page"},
-)
-def reading_order_f1(
- reference: Iterable[str] | None,
- hypothesis: Iterable[str] | None,
-) -> float:
- """Raccourci : retourne uniquement le F1 global.
-
- Pour les détails par paire (TP/FP/FN, régions communes, etc.),
- appeler ``compute_reading_order_metrics`` directement.
- """
- return compute_reading_order_metrics(reference, hypothesis)["f1"]
-
+from picarones.measurements.reading_order import * # noqa: F401, F403
-__all__ = [
- "compute_reading_order_metrics",
- "reading_order_f1",
-]
+import picarones.measurements.reading_order as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/reliability.py b/picarones/core/reliability.py
index 116bdc28d8312cc1a702d6d0caf156d2db78e0b9..ab0b5a9cc77ae691258e5efbc6ebdf43112c3352 100644
--- a/picarones/core/reliability.py
+++ b/picarones/core/reliability.py
@@ -1,360 +1,19 @@
-"""Métriques de fiabilité — Sprint 83 (A.II.4).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.reliability`.
-Sprint 83 — A.II.4 du plan d'évolution 2026 (Étape 4).
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.reliability import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Une publication scientifique qui rapporte un CER LLM sans
-stabilité est méthodologiquement faible. Et un benchmark qui
-ignore le plafond humain (« deux paléographes ne sont pas même
-d'accord ») crée des classements faussement optimistes. Ce
-module livre deux familles complémentaires :
-
-1. **Inter-annotator agreement (IAA)** — quand un document a
- plusieurs GT (deux paléographes, par ex.), Cohen κ et
- Krippendorff α mesurent l'accord au niveau caractère.
- Lecture : *« le CER de Pero (4,2 %) approche le plafond
- humain (κ = 0,89). »*
-
-2. **Stabilité multi-runs** — quand on relance la même
- pipeline LLM N fois sur les mêmes documents, on mesure :
- variance du CER, taux de tokens divergents entre runs,
- CER pairwise moyen.
-
-Périmètre Sprint 83
--------------------
-**Couche de calcul uniquement** — fonctions pures, pas
-d'intégration runner ni de vue HTML. L'extension du loader
-pour accepter ``doc_001.gt.A.txt`` / ``doc_001.gt.B.txt`` est
-documentée comme dépendance future ; en attendant le sprint
-dédié, on prend deux strings GT en entrée.
-
-Méthode
--------
-*IAA caractère par caractère.* On aligne les deux GT par
-``difflib.SequenceMatcher`` au niveau caractère et on construit
-une table de contingence ``(annotator_a_char, annotator_b_char)``
-sur les positions ``equal`` ou ``replace``. Cohen κ utilise
-cette table directement. Krippendorff α utilise la version
-matricielle (différence binaire pour le mode nominal).
-
-*Stabilité multi-runs.* ``compute_multirun_stability(runs)``
-prend une liste de N transcriptions du **même** document et
-renvoie variance/écart-type/coefficient de variation du CER si
-référence fournie ; sinon, taux pairwise de divergence
-(intersection-vs-union des tokens).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import statistics
-from typing import Optional, Sequence
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Helpers d'alignement caractère par caractère
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _aligned_char_pairs(
- text_a: str, text_b: str,
-) -> list[tuple[str, str]]:
- """Aligne ``text_a`` et ``text_b`` caractère par caractère.
-
- Retourne la liste des paires alignées sur les segments
- ``equal`` et ``replace`` de ``SequenceMatcher`` (les ``insert``
- et ``delete`` sont ignorés — pas d'alignement valide).
- """
- if not text_a and not text_b:
- return []
- import difflib
- matcher = difflib.SequenceMatcher(None, text_a, text_b, autojunk=False)
- pairs: list[tuple[str, str]] = []
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
- if tag == "equal":
- for k in range(i2 - i1):
- pairs.append((text_a[i1 + k], text_b[j1 + k]))
- elif tag == "replace":
- paired = min(i2 - i1, j2 - j1)
- for k in range(paired):
- pairs.append((text_a[i1 + k], text_b[j1 + k]))
- # insert/delete : pas d'alignement bilatéral exploitable
- return pairs
-
-
-__all__: list[str] = []
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# 1. Cohen's kappa (deux annotateurs, accord nominal)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def cohen_kappa(
- annotations_a: Sequence,
- annotations_b: Sequence,
-) -> Optional[float]:
- """Cohen's κ entre deux annotateurs sur des observations
- appariées.
-
- Définition :
-
- κ = (po - pe) / (1 - pe)
-
- où ``po`` est l'accord observé (proportion de paires égales)
- et ``pe`` l'accord attendu par hasard (somme sur les classes
- de p_a(c) × p_b(c)).
-
- Conventions :
- - retourne ``None`` si les deux séquences sont vides ou de
- tailles incompatibles ;
- - κ = 1.0 quand l'accord est parfait, 0.0 quand il égale le
- hasard, négatif si pire que le hasard ;
- - quand ``pe == 1`` (un seul label dans les deux séquences),
- retourne 1.0 si les séquences sont identiques, 0.0 sinon
- (κ est mathématiquement indéfini, on choisit une
- convention transparente documentée).
- """
- if len(annotations_a) != len(annotations_b):
- return None
- n = len(annotations_a)
- if n == 0:
- return None
- # Accord observé
- agree = sum(1 for a, b in zip(annotations_a, annotations_b) if a == b)
- p_o = agree / n
- # Accord attendu par hasard
- from collections import Counter
- count_a = Counter(annotations_a)
- count_b = Counter(annotations_b)
- classes = set(count_a) | set(count_b)
- p_e = sum(
- (count_a.get(c, 0) / n) * (count_b.get(c, 0) / n)
- for c in classes
- )
- if p_e >= 1.0 - 1e-12:
- # Indéfini ; convention : 1 si identité totale, 0 sinon
- return 1.0 if p_o >= 1.0 - 1e-12 else 0.0
- return (p_o - p_e) / (1.0 - p_e)
-
-
-__all__.append("cohen_kappa")
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# 2. Krippendorff's alpha (généralisation à N annotateurs)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def krippendorff_alpha(
- annotations_per_unit: Sequence[Sequence],
-) -> Optional[float]:
- """Krippendorff's α en mode nominal pour N annotateurs.
-
- Parameters
- ----------
- annotations_per_unit:
- Liste d'unités, chaque unité étant la liste des
- annotations produites par les différents annotateurs sur
- cette unité. ``None`` dans une cellule = annotation
- manquante (autorisée).
-
- Définition (Krippendorff 1980, équation pour métrique
- nominale) :
-
- α = 1 - D_o / D_e
-
- où ``D_o`` est le désaccord observé (paires en désaccord
- intra-unité, normalisées) et ``D_e`` le désaccord attendu
- par hasard. ``α = 1`` accord parfait, ``α = 0`` hasard,
- négatif si pire.
-
- Conventions :
- - unités avec moins de 2 annotations valides : ignorées
- (Krippendorff convention) ;
- - retourne ``None`` si moins d'une unité utilisable ou
- ``D_e == 0`` (un seul label dans tout le corpus).
- """
- from collections import Counter
- # Valeurs observées au niveau corpus
- value_counts: Counter = Counter()
- pair_disagree = 0.0
- pair_total = 0.0
- for unit in annotations_per_unit:
- valid = [v for v in unit if v is not None]
- m = len(valid)
- if m < 2:
- continue
- # paires intra-unité (sans repetition, ordonné)
- for i in range(m):
- for j in range(m):
- if i == j:
- continue
- pair_total += 1.0 / (m - 1)
- if valid[i] != valid[j]:
- pair_disagree += 1.0 / (m - 1)
- for v in valid:
- value_counts[v] += 1
- if pair_total == 0:
- return None
- n_total = sum(value_counts.values())
- if n_total < 2:
- return None
- # Désaccord attendu (sur paires aléatoires sans remise)
- expected_disagree = 0.0
- for v_a, c_a in value_counts.items():
- for v_b, c_b in value_counts.items():
- if v_a != v_b:
- expected_disagree += c_a * c_b
- expected_disagree /= n_total * (n_total - 1)
- if expected_disagree <= 1e-12:
- return None
- d_o = pair_disagree / pair_total
- return 1.0 - (d_o / expected_disagree)
-
-
-__all__.append("krippendorff_alpha")
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# 3. Helpers IAA caractère
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def compute_iaa(
- transcription_a: str,
- transcription_b: str,
-) -> Optional[dict]:
- """Calcule κ et α au niveau caractère entre deux
- transcriptions du même document.
-
- Aligne via ``_aligned_char_pairs`` puis :
- - κ : sur la liste des paires alignées ;
- - α : sur les unités à 2 annotations (équivalent à κ sur ce
- cas, mais le cadre généralise à N annotateurs).
-
- Retourne ``None`` si pas d'alignement possible (transcriptions
- vides ou totalement disjointes).
- """
- pairs = _aligned_char_pairs(transcription_a, transcription_b)
- if not pairs:
- return None
- kappa = cohen_kappa([a for a, _ in pairs], [b for _, b in pairs])
- alpha = krippendorff_alpha([[a, b] for a, b in pairs])
- return {
- "n_aligned_chars": len(pairs),
- "cohen_kappa": kappa,
- "krippendorff_alpha": alpha,
- "agreement_rate": (
- sum(1 for a, b in pairs if a == b) / len(pairs)
- ),
- }
-
-
-__all__.append("compute_iaa")
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# 4. Stabilité multi-runs (variance CER, divergence pairwise)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _split_words(text: str) -> list[str]:
- return text.split() if text else []
-
-
-def compute_multirun_stability(
- runs: Sequence[str],
- *,
- reference: Optional[str] = None,
-) -> Optional[dict]:
- """Mesure la stabilité de N runs successifs d'une même
- pipeline (typiquement LLM/VLM non déterministe) sur un
- document.
-
- Parameters
- ----------
- runs:
- Liste des transcriptions produites à chaque run (≥ 2).
- reference:
- Transcription de référence (GT). Si fournie, on calcule
- ``cer_per_run``, leur variance et leur coefficient de
- variation.
-
- Returns
- -------
- dict | None
- ``{
- "n_runs": int,
- "pairwise_disagreement_mean": float, # divergence moyenne
- "pairwise_disagreement_max": float,
- "identical_run_rate": float, # paires identiques / total
- "cer_per_run": Optional[list[float]],
- "cer_mean": Optional[float],
- "cer_stdev": Optional[float],
- "cer_cv": Optional[float], # cv = stdev / mean
- "n_distinct_outputs": int,
- }``
- ou ``None`` si moins de 2 runs.
- """
- if len(runs) < 2:
- return None
- runs_list = list(runs)
- # Divergence pairwise (token-level Jaccard distance)
- n = len(runs_list)
- n_pairs = 0
- sum_disagree = 0.0
- max_disagree = 0.0
- n_identical = 0
- for i in range(n):
- for j in range(i + 1, n):
- n_pairs += 1
- tokens_i = set(_split_words(runs_list[i]))
- tokens_j = set(_split_words(runs_list[j]))
- union = tokens_i | tokens_j
- if not union:
- disagree = 0.0
- else:
- disagree = 1.0 - len(tokens_i & tokens_j) / len(union)
- sum_disagree += disagree
- if disagree > max_disagree:
- max_disagree = disagree
- if runs_list[i] == runs_list[j]:
- n_identical += 1
- pairwise_mean = sum_disagree / n_pairs if n_pairs else 0.0
- identical_rate = n_identical / n_pairs if n_pairs else 0.0
- distinct = len(set(runs_list))
-
- cer_per_run: Optional[list[float]] = None
- cer_mean: Optional[float] = None
- cer_stdev: Optional[float] = None
- cer_cv: Optional[float] = None
- if reference is not None:
- from picarones.core.metrics import _cer_from_strings
- cer_per_run = [_cer_from_strings(reference, r) for r in runs_list]
- cer_per_run = [v for v in cer_per_run if v is not None]
- if cer_per_run:
- cer_mean = statistics.fmean(cer_per_run)
- if len(cer_per_run) >= 2:
- cer_stdev = statistics.stdev(cer_per_run)
- cer_cv = (
- cer_stdev / cer_mean if cer_mean and cer_mean > 0
- else None
- )
- return {
- "n_runs": n,
- "pairwise_disagreement_mean": pairwise_mean,
- "pairwise_disagreement_max": max_disagree,
- "identical_run_rate": identical_rate,
- "n_distinct_outputs": distinct,
- "cer_per_run": cer_per_run,
- "cer_mean": cer_mean,
- "cer_stdev": cer_stdev,
- "cer_cv": cer_cv,
- }
-
+from picarones.measurements.reliability import * # noqa: F401, F403
-__all__.append("compute_multirun_stability")
+import picarones.measurements.reliability as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/robustness.py b/picarones/core/robustness.py
index 27f407234709718dab9752114a00250c0fc2db60..44f9008f5b71b887a603199e9721914431b06d87 100644
--- a/picarones/core/robustness.py
+++ b/picarones/core/robustness.py
@@ -1,731 +1,19 @@
-"""Analyse de robustesse des moteurs OCR face aux dégradations d'image.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.robustness`.
-Fonctionnement
---------------
-1. Génération de versions dégradées des images du corpus à différents niveaux :
- - Bruit gaussien (sigma croissant)
- - Flou gaussien (kernel size croissant)
- - Rotation (angle croissant)
- - Réduction de résolution (facteur de downscaling)
- - Binarisation (seuillage Otsu ou fixe)
-2. Exécution du moteur OCR sur chaque version dégradée
-3. Calcul du CER pour chaque niveau de dégradation
-4. Génération de courbes de robustesse (CER en fonction du niveau)
-5. Identification du seuil critique (niveau à partir duquel CER > seuil)
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.robustness import ...``) de continuer
+à fonctionner sans modification.
-Usage
------
->>> from picarones.core.robustness import RobustnessAnalyzer
->>> analyzer = RobustnessAnalyzer(engine, degradation_types=["noise", "blur"])
->>> report = analyzer.analyze(corpus)
->>> print(report.critical_thresholds)
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
+from picarones.measurements.robustness import * # noqa: F401, F403
-import logging
-import math
-import os
-import tempfile
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import TYPE_CHECKING, Optional
-
-if TYPE_CHECKING:
- from picarones.core.corpus import Corpus, Document
- from picarones.engines.base import BaseOCREngine
-
-logger = logging.getLogger(__name__)
-
-
-# ---------------------------------------------------------------------------
-# Paramètres de dégradation
-# ---------------------------------------------------------------------------
-
-# Niveaux de dégradation pour chaque type
-DEGRADATION_LEVELS: dict[str, list] = {
- "noise": [0, 5, 15, 30, 50, 80], # sigma du bruit gaussien
- "blur": [0, 1, 2, 3, 5, 8], # rayon du flou gaussien (pixels)
- "rotation": [0, 1, 2, 5, 10, 20], # angle de rotation (degrés)
- "resolution": [1.0, 0.75, 0.5, 0.33, 0.25, 0.1], # facteur de résolution
- "binarization": [0, 64, 96, 128, 160, 192], # seuil de binarisation (0 = Otsu)
-}
-
-DEGRADATION_LABELS: dict[str, list[str]] = {
- "noise": ["original", "σ=5", "σ=15", "σ=30", "σ=50", "σ=80"],
- "blur": ["original", "r=1", "r=2", "r=3", "r=5", "r=8"],
- "rotation": ["0°", "1°", "2°", "5°", "10°", "20°"],
- "resolution": ["100%", "75%", "50%", "33%", "25%", "10%"],
- "binarization": ["original", "seuil=64", "seuil=96", "seuil=128", "seuil=160", "seuil=192"],
-}
-
-ALL_DEGRADATION_TYPES = list(DEGRADATION_LEVELS.keys())
-
-
-# ---------------------------------------------------------------------------
-# Dégradation d'image (pure Python + stdlib, optionnellement Pillow/NumPy)
-# ---------------------------------------------------------------------------
-
-def _apply_gaussian_noise(pixels: list[list[list[int]]], sigma: float, rng_seed: int = 0) -> list[list[list[int]]]:
- """Applique du bruit gaussien (pure Python)."""
- import random
- rng = random.Random(rng_seed)
- h = len(pixels)
- w = len(pixels[0]) if h > 0 else 0
- result = []
- for y in range(h):
- row = []
- for x in range(w):
- pixel = []
- for c in pixels[y][x]:
- noise = rng.gauss(0, sigma)
- val = int(c + noise)
- pixel.append(max(0, min(255, val)))
- row.append(pixel)
- result.append(row)
- return result
-
-
-def _apply_box_blur(pixels: list[list[list[int]]], radius: int) -> list[list[list[int]]]:
- """Applique un flou de boîte (approximation du flou gaussien, pure Python)."""
- if radius <= 0:
- return pixels
- h = len(pixels)
- w = len(pixels[0]) if h > 0 else 0
- channels = len(pixels[0][0]) if h > 0 and w > 0 else 3
-
- def blur_pass(data: list[list[list[int]]]) -> list[list[list[int]]]:
- out = []
- for y in range(h):
- row = []
- for x in range(w):
- totals = [0] * channels
- count = 0
- for dy in range(-radius, radius + 1):
- for dx in range(-radius, radius + 1):
- ny, nx = y + dy, x + dx
- if 0 <= ny < h and 0 <= nx < w:
- for c in range(channels):
- totals[c] += data[ny][nx][c]
- count += 1
- row.append([t // count for t in totals])
- out.append(row)
- return out
-
- return blur_pass(pixels)
-
-
-def _apply_rotation_simple(pixels: list[list[list[int]]], angle_deg: float) -> list[list[list[int]]]:
- """Rotation avec interpolation au plus proche voisin (pure Python).
-
- Pour des angles faibles, l'effet est réaliste.
- """
- if angle_deg == 0:
- return pixels
- h = len(pixels)
- w = len(pixels[0]) if h > 0 else 0
- channels = len(pixels[0][0]) if h > 0 and w > 0 else 3
-
- angle_rad = math.radians(angle_deg)
- cos_a = math.cos(angle_rad)
- sin_a = math.sin(angle_rad)
- cx, cy = w / 2, h / 2
-
- result = [[[245, 240, 232][:channels] for _ in range(w)] for _ in range(h)]
- for y in range(h):
- for x in range(w):
- # Coordonnées source
- sx = cos_a * (x - cx) + sin_a * (y - cy) + cx
- sy = -sin_a * (x - cx) + cos_a * (y - cy) + cy
- ix, iy = int(round(sx)), int(round(sy))
- if 0 <= ix < w and 0 <= iy < h:
- result[y][x] = list(pixels[iy][ix])
- return result
-
-
-def _apply_resolution_reduction(
- pixels: list[list[list[int]]], factor: float
-) -> list[list[list[int]]]:
- """Réduit la résolution puis remonte à la taille originale (pixelisation)."""
- if factor >= 1.0:
- return pixels
- h = len(pixels)
- w = len(pixels[0]) if h > 0 else 0
- new_h = max(1, int(h * factor))
- new_w = max(1, int(w * factor))
-
- # Downscale
- small = []
- for y in range(new_h):
- row = []
- src_y = int(y / factor)
- for x in range(new_w):
- src_x = int(x / factor)
- row.append(list(pixels[min(src_y, h - 1)][min(src_x, w - 1)]))
- small.append(row)
-
- # Upscale (nearest-neighbor)
- result = []
- for y in range(h):
- row = []
- src_y = min(int(y * factor), new_h - 1)
- for x in range(w):
- src_x = min(int(x * factor), new_w - 1)
- row.append(list(small[src_y][src_x]))
- result.append(row)
- return result
-
-
-def _apply_binarization(
- pixels: list[list[list[int]]], threshold: int
-) -> list[list[list[int]]]:
- """Binarise l'image (seuillage fixe sur luminosité)."""
- h = len(pixels)
- w = len(pixels[0]) if h > 0 else 0
- result = []
-
- # Calculer le seuil Otsu si threshold == 0
- if threshold == 0:
- histogram = [0] * 256
- total = h * w
- for y in range(h):
- for x in range(w):
- p = pixels[y][x]
- lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0]
- histogram[lum] += 1
- # Otsu simplifié
- best_thresh = 128
- best_var = -1.0
- total_sum = sum(i * histogram[i] for i in range(256))
- w0, w1, sum0 = 0, total, 0.0
- for t in range(256):
- w0 += histogram[t]
- if w0 == 0:
- continue
- w1 = total - w0
- if w1 == 0:
- break
- sum0 += t * histogram[t]
- mean0 = sum0 / w0
- mean1 = (total_sum - sum0) / w1
- var = w0 * w1 * (mean0 - mean1) ** 2
- if var > best_var:
- best_var = var
- best_thresh = t
- threshold = best_thresh
-
- for y in range(h):
- row = []
- for x in range(w):
- p = pixels[y][x]
- lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0]
- val = 255 if lum >= threshold else 0
- row.append([val] * len(p))
- result.append(row)
- return result
-
-
-def degrade_image_bytes(
- png_bytes: bytes,
- degradation_type: str,
- level: float,
-) -> bytes:
- """Dégrade une image PNG et retourne les bytes PNG modifiés.
-
- Utilise Pillow si disponible, sinon utilise l'implémentation pure Python.
-
- Parameters
- ----------
- png_bytes:
- Bytes de l'image PNG source.
- degradation_type:
- Type de dégradation (``"noise"``, ``"blur"``, ``"rotation"``,
- ``"resolution"``, ``"binarization"``).
- level:
- Niveau de dégradation (valeur numérique selon le type).
-
- Returns
- -------
- bytes
- Bytes de l'image PNG dégradée.
- """
- try:
- return _degrade_pillow(png_bytes, degradation_type, level)
- except ImportError:
- return _degrade_pure_python(png_bytes, degradation_type, level)
-
-
-def _degrade_pillow(png_bytes: bytes, degradation_type: str, level: float) -> bytes:
- """Dégradation avec Pillow (meilleure qualité)."""
- import io
- from PIL import Image, ImageFilter
-
- img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
-
- if degradation_type == "noise":
- if level > 0:
- import random
- # RGB : 3 octets par pixel, tobytes() reste stable Pillow 10 → 14+
- raw = img.tobytes()
- rng = random.Random(0)
- noisy = []
- for i in range(0, len(raw), 3):
- r, g, b = raw[i], raw[i + 1], raw[i + 2]
- noisy.append((
- max(0, min(255, int(r + rng.gauss(0, level)))),
- max(0, min(255, int(g + rng.gauss(0, level)))),
- max(0, min(255, int(b + rng.gauss(0, level)))),
- ))
- img.putdata(noisy)
-
- elif degradation_type == "blur":
- if level > 0:
- img = img.filter(ImageFilter.GaussianBlur(radius=level))
-
- elif degradation_type == "rotation":
- if level != 0:
- img = img.rotate(-level, expand=False, fillcolor=(245, 240, 232))
-
- elif degradation_type == "resolution":
- if level < 1.0:
- w, h = img.size
- new_w, new_h = max(1, int(w * level)), max(1, int(h * level))
- img = img.resize((new_w, new_h), Image.NEAREST)
- img = img.resize((w, h), Image.NEAREST)
-
- elif degradation_type == "binarization":
- img = img.convert("L") # niveaux de gris
- if level == 0:
- # Seuillage Otsu : calcul du seuil optimal
- histogram = img.histogram()
- total = img.size[0] * img.size[1]
- best_thresh, best_var = 128, -1.0
- total_sum = sum(i * histogram[i] for i in range(256))
- w0, sum0 = 0, 0.0
- for t in range(256):
- w0 += histogram[t]
- if w0 == 0:
- continue
- w1 = total - w0
- if w1 == 0:
- break
- sum0 += t * histogram[t]
- var = w0 * w1 * (sum0 / w0 - (total_sum - sum0) / w1) ** 2
- if var > best_var:
- best_var = var
- best_thresh = t
- threshold = best_thresh
- else:
- threshold = int(level)
- img = img.point(lambda p: 255 if p >= threshold else 0, "1").convert("RGB")
-
- buf = io.BytesIO()
- img.save(buf, format="PNG")
- return buf.getvalue()
-
-
-def _degrade_pure_python(png_bytes: bytes, degradation_type: str, level: float) -> bytes:
- """Dégradation en pur Python (sans Pillow).
-
- Décode le PNG, applique la transformation, ré-encode en PNG.
- Note : n'implémente pas le décodage PNG complet — utilise des stubs.
- """
- # Pour l'implémentation pure Python, on applique des transformations
- # minimales sur les bytes bruts en créant une image de test synthétique.
- # En pratique, Pillow est presque toujours disponible dans l'environnement Picarones.
- logger.warning(
- "Pillow non disponible : dégradation '%s' appliquée en mode dégradé (stub)",
- degradation_type,
- )
- # Retourner l'image originale légèrement modifiée (simulation)
- return png_bytes
-
-
-# ---------------------------------------------------------------------------
-# Structures de résultats
-# ---------------------------------------------------------------------------
-
-@dataclass
-class DegradationCurve:
- """Courbe CER vs niveau de dégradation pour un moteur et un type de dégradation."""
- engine_name: str
- degradation_type: str
- levels: list[float]
- labels: list[str]
- cer_values: list[Optional[float]]
- """CER moyen (0-1) à chaque niveau. None si calcul impossible."""
- critical_threshold_level: Optional[float] = None
- """Niveau à partir duquel CER > cer_threshold."""
- cer_threshold: float = 0.20
- """Seuil de CER utilisé pour déterminer le niveau critique."""
-
- def as_dict(self) -> dict:
- return {
- "engine_name": self.engine_name,
- "degradation_type": self.degradation_type,
- "levels": self.levels,
- "labels": self.labels,
- "cer_values": self.cer_values,
- "critical_threshold_level": self.critical_threshold_level,
- "cer_threshold": self.cer_threshold,
- }
-
-
-@dataclass
-class RobustnessReport:
- """Rapport complet d'analyse de robustesse pour un ou plusieurs moteurs."""
- engine_names: list[str]
- corpus_name: str
- degradation_types: list[str]
- curves: list[DegradationCurve]
- summary: dict = field(default_factory=dict)
- """Résumé : moteur le plus robuste par type de dégradation, seuils critiques…"""
-
- def get_curves_for_engine(self, engine_name: str) -> list[DegradationCurve]:
- return [c for c in self.curves if c.engine_name == engine_name]
-
- def get_curves_for_type(self, degradation_type: str) -> list[DegradationCurve]:
- return [c for c in self.curves if c.degradation_type == degradation_type]
-
- def as_dict(self) -> dict:
- return {
- "engine_names": self.engine_names,
- "corpus_name": self.corpus_name,
- "degradation_types": self.degradation_types,
- "curves": [c.as_dict() for c in self.curves],
- "summary": self.summary,
- }
-
-
-# ---------------------------------------------------------------------------
-# Analyseur de robustesse
-# ---------------------------------------------------------------------------
-
-class RobustnessAnalyzer:
- """Lance une analyse de robustesse sur un corpus.
-
- Parameters
- ----------
- engines:
- Un ou plusieurs moteurs OCR (``BaseOCREngine``).
- degradation_types:
- Liste des types de dégradation à tester.
- Par défaut : tous (``"noise"``, ``"blur"``, ``"rotation"``,
- ``"resolution"``, ``"binarization"``).
- cer_threshold:
- Seuil de CER pour définir le niveau critique (défaut : 0.20 = 20%).
- custom_levels:
- Niveaux personnalisés par type (remplace les valeurs par défaut).
-
- Examples
- --------
- >>> from picarones.engines.tesseract import TesseractEngine
- >>> from picarones.core.robustness import RobustnessAnalyzer
- >>> engine = TesseractEngine(config={"lang": "fra"})
- >>> analyzer = RobustnessAnalyzer([engine], degradation_types=["noise", "blur"])
- >>> report = analyzer.analyze(corpus)
- """
-
- def __init__(
- self,
- engines: "list[BaseOCREngine]",
- degradation_types: Optional[list[str]] = None,
- cer_threshold: float = 0.20,
- custom_levels: Optional[dict[str, list]] = None,
- ) -> None:
- if not isinstance(engines, list):
- engines = [engines]
- self.engines = engines
- self.degradation_types = degradation_types or ALL_DEGRADATION_TYPES
- self.cer_threshold = cer_threshold
- self.levels = dict(DEGRADATION_LEVELS)
- if custom_levels:
- self.levels.update(custom_levels)
-
- def analyze(
- self,
- corpus: "Corpus",
- show_progress: bool = True,
- max_docs: int = 10,
- ) -> RobustnessReport:
- """Lance l'analyse de robustesse sur le corpus.
-
- Parameters
- ----------
- corpus:
- Corpus Picarones avec images et GT.
- show_progress:
- Affiche la progression.
- max_docs:
- Nombre maximum de documents à traiter (pour la rapidité).
-
- Returns
- -------
- RobustnessReport
- """
- from picarones.core.metrics import compute_metrics
-
- docs = corpus.documents[:max_docs]
- curves: list[DegradationCurve] = []
-
- for engine in self.engines:
- for deg_type in self.degradation_types:
- levels = self.levels[deg_type]
- labels = DEGRADATION_LABELS.get(deg_type, [str(lv) for lv in levels])
-
- cer_per_level: list[Optional[float]] = []
-
- if show_progress:
- try:
- from tqdm import tqdm
- level_iter = tqdm(
- list(enumerate(levels)),
- desc=f"{engine.name} / {deg_type}",
- )
- except ImportError:
- level_iter = enumerate(levels)
- else:
- level_iter = enumerate(levels)
-
- for lvl_idx, level in level_iter:
- doc_cers: list[float] = []
-
- for doc in docs:
- gt = doc.ground_truth.strip()
- if not gt:
- continue
-
- # Obtenir l'image (fichier ou data URI)
- degraded_bytes = self._get_degraded_image(
- doc, deg_type, level
- )
- if degraded_bytes is None:
- continue
-
- # Sauvegarder temporairement et OCR
- with tempfile.NamedTemporaryFile(
- suffix=".png", delete=False
- ) as tmp:
- tmp.write(degraded_bytes)
- tmp_path = tmp.name
-
- try:
- ocr_result = engine.run(tmp_path)
- hypothesis = ocr_result.text
- metrics = compute_metrics(gt, hypothesis)
- doc_cers.append(metrics.cer)
- except Exception as exc:
- logger.debug(
- "Erreur OCR %s niveau %s=%s: %s",
- engine.name, deg_type, level, exc
- )
- finally:
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
-
- if doc_cers:
- cer_per_level.append(sum(doc_cers) / len(doc_cers))
- else:
- cer_per_level.append(None)
-
- # Calculer le niveau critique
- critical = self._find_critical_level(
- levels, cer_per_level, self.cer_threshold
- )
-
- curves.append(DegradationCurve(
- engine_name=engine.name,
- degradation_type=deg_type,
- levels=levels,
- labels=labels[:len(levels)],
- cer_values=cer_per_level,
- critical_threshold_level=critical,
- cer_threshold=self.cer_threshold,
- ))
-
- summary = self._build_summary(curves)
-
- return RobustnessReport(
- engine_names=[e.name for e in self.engines],
- corpus_name=corpus.name,
- degradation_types=self.degradation_types,
- curves=curves,
- summary=summary,
- )
-
- def _get_degraded_image(
- self,
- doc: "Document",
- degradation_type: str,
- level: float,
- ) -> Optional[bytes]:
- """Retourne les bytes PNG de l'image dégradée."""
- # Charger l'image originale
- original_bytes = self._load_image(doc)
- if original_bytes is None:
- return None
-
- # Niveau 0 = image originale (sauf binarisation à 0 = Otsu)
- if (degradation_type == "noise" and level == 0) or \
- (degradation_type == "blur" and level == 0) or \
- (degradation_type == "rotation" and level == 0) or \
- (degradation_type == "resolution" and level >= 1.0):
- return original_bytes
-
- return degrade_image_bytes(original_bytes, degradation_type, level)
-
- def _load_image(self, doc: "Document") -> Optional[bytes]:
- """Charge les bytes PNG de l'image d'un document."""
- img_path = doc.image_path
-
- # Data URI (base64)
- if img_path.startswith("data:image/"):
- import base64
- try:
- _, b64 = img_path.split(",", 1)
- return base64.b64decode(b64)
- except Exception as exc:
- logger.debug("Impossible de décoder data URI: %s", exc)
- return None
-
- # Fichier local
- path = Path(img_path)
- if path.exists():
- return path.read_bytes()
-
- logger.debug("Image introuvable : %s", img_path)
- return None
-
- @staticmethod
- def _find_critical_level(
- levels: list[float],
- cer_values: list[Optional[float]],
- threshold: float,
- ) -> Optional[float]:
- """Trouve le niveau à partir duquel CER dépasse le seuil."""
- for level, cer in zip(levels, cer_values):
- if cer is not None and cer > threshold:
- return level
- return None
-
- @staticmethod
- def _build_summary(curves: list[DegradationCurve]) -> dict:
- """Construit le résumé de l'analyse."""
- summary: dict = {}
-
- # Par type de dégradation : moteur le plus robuste
- by_type: dict[str, dict[str, list]] = {}
- for curve in curves:
- dt = curve.degradation_type
- if dt not in by_type:
- by_type[dt] = {}
- valid_cers = [c for c in curve.cer_values if c is not None]
- if valid_cers:
- by_type[dt][curve.engine_name] = valid_cers
-
- for dt, engine_cers in by_type.items():
- if not engine_cers:
- continue
- # Robustesse = CER moyen sur tous les niveaux (plus bas = plus robuste)
- best_engine = min(engine_cers, key=lambda e: sum(engine_cers[e]) / len(engine_cers[e]))
- summary[f"most_robust_{dt}"] = best_engine
-
- # Seuils critiques par moteur
- for curve in curves:
- key = f"critical_{curve.engine_name}_{curve.degradation_type}"
- summary[key] = curve.critical_threshold_level
-
- return summary
-
-
-# ---------------------------------------------------------------------------
-# Données de démonstration de robustesse
-# ---------------------------------------------------------------------------
-
-def generate_demo_robustness_report(
- engine_names: Optional[list[str]] = None,
- seed: int = 42,
-) -> RobustnessReport:
- """Génère un rapport de robustesse fictif mais réaliste pour la démo.
-
- Parameters
- ----------
- engine_names:
- Noms des moteurs à simuler (défaut : tesseract, pero_ocr).
- seed:
- Graine aléatoire.
-
- Returns
- -------
- RobustnessReport
- """
- import random
- rng = random.Random(seed)
-
- if engine_names is None:
- engine_names = ["tesseract", "pero_ocr"]
-
- # CER de base par moteur
- base_cer = {
- "tesseract": 0.12,
- "pero_ocr": 0.07,
- "ancien_moteur": 0.25,
- }
-
- # Sensibilité par type de dégradation (facteur multiplicatif par niveau)
- sensitivity = {
- "tesseract": {
- "noise": 0.04, "blur": 0.05, "rotation": 0.06,
- "resolution": 0.12, "binarization": 0.03,
- },
- "pero_ocr": {
- "noise": 0.02, "blur": 0.03, "rotation": 0.04,
- "resolution": 0.08, "binarization": 0.02,
- },
- "ancien_moteur": {
- "noise": 0.06, "blur": 0.08, "rotation": 0.10,
- "resolution": 0.15, "binarization": 0.05,
- },
- }
-
- deg_types = ALL_DEGRADATION_TYPES
- curves: list[DegradationCurve] = []
-
- for engine_name in engine_names:
- cer_base = base_cer.get(engine_name, 0.15)
- sens = sensitivity.get(engine_name, {dt: 0.05 for dt in deg_types})
-
- for deg_type in deg_types:
- levels = DEGRADATION_LEVELS[deg_type]
- labels = DEGRADATION_LABELS[deg_type]
- s = sens.get(deg_type, 0.05)
-
- cer_values = []
- for i, level in enumerate(levels):
- noise = rng.gauss(0, 0.005)
- cer = min(1.0, cer_base + s * i + noise)
- cer_values.append(round(max(0.0, cer), 4))
-
- critical = RobustnessAnalyzer._find_critical_level(levels, cer_values, 0.20)
-
- curves.append(DegradationCurve(
- engine_name=engine_name,
- degradation_type=deg_type,
- levels=list(levels),
- labels=labels[:len(levels)],
- cer_values=cer_values,
- critical_threshold_level=critical,
- cer_threshold=0.20,
- ))
-
- summary = RobustnessAnalyzer._build_summary(curves)
-
- return RobustnessReport(
- engine_names=engine_names,
- corpus_name="Corpus de démonstration — Chroniques médiévales",
- degradation_types=deg_types,
- curves=curves,
- summary=summary,
- )
+import picarones.measurements.robustness as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/robustness_projection.py b/picarones/core/robustness_projection.py
index dc6c66a0a62c62e6a70839288e08c85a415a7c0c..9e82ebdd4c41c5e3034e710173f551e9fd65146c 100644
--- a/picarones/core/robustness_projection.py
+++ b/picarones/core/robustness_projection.py
@@ -1,287 +1,19 @@
-"""Projection de robustesse synthétique sur le corpus réel —
-Sprint 81 (A.I.8).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.robustness_projection`.
-Sprint 81 — A.I.8 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.robustness_projection import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Le module ``picarones/core/robustness.py`` (Sprint 8) génère des
-courbes CER vs niveau de dégradation **synthétique** (bruit, flou,
-rotation, résolution). ``picarones/core/image_quality.py`` mesure
-le bruit/flou/contraste **réels** des images du corpus. Ce
-sprint **projette** les caractéristiques réelles sur les courbes
-synthétiques pour estimer le **déficit attendu de CER** sur le
-corpus dans son état actuel.
-
-Lecture concrète
-----------------
-*« 30 % de vos documents ont un bruit équivalent à σ=15 où
-Tesseract perd 8 points de CER — soit un déficit attendu global
-de 2,4 points (30 % × 8 points). »*
-
-Méthode
--------
-1. Pour chaque document, on extrait la valeur de qualité réelle
- (``noise_level``, ``blur_score``, ``contrast_score``…) depuis
- ``ImageQualityResult``.
-2. Pour chaque type de dégradation, on interpole linéairement la
- ``DegradationCurve`` synthétique : CER attendu à ce niveau.
-3. On agrège : CER moyen attendu, % docs au-dessus du seuil
- critique de la courbe, déficit projeté = CER_attendu -
- CER_baseline (niveau nul).
-
-Sortie
-------
-``project_robustness_on_corpus(curves, image_qualities)`` retourne
-``{engine_name: {degradation_type: {expected_cer_mean,
-deficit_vs_baseline, n_docs_above_critical, n_docs}}}``.
-
-Limites
--------
-- Mapping ``image_quality → degradation level`` : on suppose que
- ``noise_level`` (ImageQualityResult) correspond à σ
- (DegradationCurve), et idem pour ``blur_score`` ↔ rayon de
- flou. Si un corpus expose ces valeurs avec une échelle
- différente, le mapping est documenté et l'utilisateur peut
- passer ``quality_to_level`` custom.
-- Interpolation **linéaire** entre les points de la courbe. Au-
- delà des bornes, on **clip** au point extrême (pas
- d'extrapolation hasardeuse).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-import statistics
-from typing import Callable, Iterable, Optional
-
-logger = logging.getLogger(__name__)
-
-
-# Mapping par défaut entre attributs ImageQualityResult et types
-# de dégradation synthétique. L'utilisateur peut passer un dict
-# custom pour modifier ce mapping.
-_DEFAULT_QUALITY_FIELD: dict[str, str] = {
- "noise": "noise_level", # σ
- "blur": "blur_score", # Variance laplacienne (inverse)
- "contrast": "contrast_score",
- "rotation": "rotation_angle",
- "resolution": "resolution_score", # peut être absent
-}
-
-
-def _interpolate_cer(
- levels: list[float],
- cer_values: list[Optional[float]],
- target_level: float,
-) -> Optional[float]:
- """Interpolation linéaire : retourne CER attendu à
- ``target_level``.
-
- - Si ``target_level`` est en-dessous du minimum de levels,
- retourne le CER au minimum (clip).
- - Si au-dessus du maximum, retourne le CER au maximum.
- - Sinon, interpolation linéaire entre les deux points
- encadrants.
- - Retourne ``None`` si aucun ``cer_value`` valide.
- """
- if not levels:
- return None
- # Filtrer les paires (level, cer) où cer est None
- pairs = [
- (lvl, cer) for lvl, cer in zip(levels, cer_values)
- if cer is not None
- ]
- if not pairs:
- return None
- pairs.sort(key=lambda p: p[0])
- # Clip
- if target_level <= pairs[0][0]:
- return pairs[0][1]
- if target_level >= pairs[-1][0]:
- return pairs[-1][1]
- # Interpolation
- for i in range(len(pairs) - 1):
- lo_lvl, lo_cer = pairs[i]
- hi_lvl, hi_cer = pairs[i + 1]
- if lo_lvl <= target_level <= hi_lvl:
- if hi_lvl == lo_lvl:
- return lo_cer
- ratio = (target_level - lo_lvl) / (hi_lvl - lo_lvl)
- return lo_cer + (hi_cer - lo_cer) * ratio
- return None # ne devrait pas arriver
-
-
-def _extract_quality_value(
- quality: dict, degradation_type: str,
- custom_mapping: Optional[dict[str, str]] = None,
-) -> Optional[float]:
- """Extrait la valeur de qualité pertinente pour un type de
- dégradation depuis un ``ImageQualityResult.as_dict()``."""
- mapping = custom_mapping or _DEFAULT_QUALITY_FIELD
- field = mapping.get(degradation_type)
- if field is None:
- return None
- value = quality.get(field)
- if value is None:
- return None
- try:
- return float(value)
- except (TypeError, ValueError):
- return None
-
-
-def project_robustness_on_corpus(
- curves: Iterable,
- image_qualities: list[dict],
- *,
- quality_to_level: Optional[Callable[[dict, str], Optional[float]]] = None,
- critical_threshold: Optional[float] = None,
-) -> dict:
- """Projette les courbes de robustesse sur les qualités réelles.
-
- Parameters
- ----------
- curves:
- Itérable de ``DegradationCurve`` (ou dicts compatibles
- avec ``engine_name``, ``degradation_type``, ``levels``,
- ``cer_values``, ``critical_threshold_level``).
- image_qualities:
- Liste de dicts ``ImageQualityResult.as_dict()`` (un par
- document). Si vide, retourne une projection vide.
- quality_to_level:
- Fonction custom ``(quality_dict, degradation_type) →
- Optional[float]`` pour adapter le mapping qualité→niveau.
- Par défaut, utilise ``_DEFAULT_QUALITY_FIELD``.
- critical_threshold:
- Override pour le seuil critique de CER (défaut : utilise
- ``DegradationCurve.cer_threshold``).
-
- Returns
- -------
- dict
- ``{
- engine_name: {
- degradation_type: {
- "n_docs": int,
- "n_docs_with_data": int, # qualité disponible
- "expected_cer_mean": float, # moyenne CER attendu
- "expected_cer_median": float,
- "baseline_cer": float, # CER à niveau min
- "deficit_vs_baseline": float,
- "n_docs_above_critical": int,
- "critical_threshold_level": float | None,
- "critical_threshold_cer": float,
- },
- },
- }``
- """
- extractor = quality_to_level or (
- lambda q, dt: _extract_quality_value(q, dt)
- )
- out: dict[str, dict] = {}
-
- for curve in curves:
- # Accepter dict ou DegradationCurve
- if hasattr(curve, "as_dict"):
- data = curve.as_dict()
- else:
- data = curve
- engine = data.get("engine_name")
- deg_type = data.get("degradation_type")
- levels = data.get("levels") or []
- cer_values = data.get("cer_values") or []
- crit_lvl = data.get("critical_threshold_level")
- crit_cer = (
- critical_threshold
- if critical_threshold is not None
- else data.get("cer_threshold", 0.20)
- )
- if not engine or not deg_type:
- continue
-
- per_doc_cer: list[float] = []
- n_docs_with_data = 0
- n_above_critical = 0
- for quality in image_qualities:
- level = extractor(quality, deg_type)
- if level is None:
- continue
- n_docs_with_data += 1
- cer = _interpolate_cer(levels, cer_values, level)
- if cer is None:
- continue
- per_doc_cer.append(cer)
- if cer > crit_cer:
- n_above_critical += 1
-
- if not per_doc_cer:
- continue
-
- # Baseline = CER au niveau minimum (sans dégradation)
- baseline = _interpolate_cer(
- levels, cer_values,
- min(levels) if levels else 0.0,
- )
- expected_mean = statistics.fmean(per_doc_cer)
- expected_median = statistics.median(per_doc_cer)
- deficit = (
- expected_mean - baseline
- if baseline is not None else None
- )
-
- out.setdefault(engine, {})[deg_type] = {
- "n_docs": len(image_qualities),
- "n_docs_with_data": n_docs_with_data,
- "expected_cer_mean": expected_mean,
- "expected_cer_median": expected_median,
- "baseline_cer": baseline,
- "deficit_vs_baseline": deficit,
- "n_docs_above_critical": n_above_critical,
- "critical_threshold_level": crit_lvl,
- "critical_threshold_cer": crit_cer,
- }
- return out
-
-
-def aggregate_projection_per_engine(projection: dict) -> dict:
- """Pour chaque moteur, agrège le déficit projeté en sommant
- sur tous les types de dégradation.
-
- Lecture : *« déficit total attendu pour Tesseract = 5,2 points
- de CER si on considère les 4 dégradations indépendamment »*.
-
- Note : la sommation **suppose l'indépendance** des
- dégradations, ce qui n'est pas strictement vrai mais reste
- une approximation utile pour le diagnostic.
- """
- out: dict[str, dict] = {}
- for engine, per_type in projection.items():
- total_deficit = 0.0
- n_types_with_data = 0
- max_deficit_type: Optional[tuple[str, float]] = None
- for deg_type, stats in per_type.items():
- deficit = stats.get("deficit_vs_baseline")
- if deficit is None:
- continue
- total_deficit += deficit
- n_types_with_data += 1
- if max_deficit_type is None or deficit > max_deficit_type[1]:
- max_deficit_type = (deg_type, deficit)
- out[engine] = {
- "total_expected_deficit": total_deficit,
- "n_degradation_types": n_types_with_data,
- "worst_degradation_type": (
- max_deficit_type[0] if max_deficit_type else None
- ),
- "worst_degradation_deficit": (
- max_deficit_type[1] if max_deficit_type else None
- ),
- }
- return out
-
+from picarones.measurements.robustness_projection import * # noqa: F401, F403
-__all__ = [
- "project_robustness_on_corpus",
- "aggregate_projection_per_engine",
-]
+import picarones.measurements.robustness_projection as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/searchability.py b/picarones/core/searchability.py
index efe9209835a81a051db26fe478fe16db24a88e64..5d165c99bb493d5f5380de7db877ba6b9ac48380 100644
--- a/picarones/core/searchability.py
+++ b/picarones/core/searchability.py
@@ -1,225 +1,19 @@
-"""Recherchabilité fuzzy — Sprint 84 (A.II.5).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.searchability`.
-Sprint 84 — A.II.5 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.searchability import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Le CER mesure les erreurs caractère par caractère. Mais pour
-un usage *recherche plein-texte* (ce que font Elastic, Solr en
-mode fuzzy, ou la recherche full-text de Gallica), la question
-réelle est :
-
- *« Combien de mots de ma GT sont retrouvables dans la
- sortie OCR, à orthographe approchée près ? »*
-
-Un CER de 8 % peut donner 95 % de findability si les erreurs
-sont concentrées sur des caractères non-significatifs ou sur
-quelques mots aberrants ; à l'inverse, 4 % de CER mais
-distribué sur tous les noms propres rend le corpus inutilisable
-pour l'indexation prosopographique.
-
-Méthode
--------
-Pour chaque token GT, on regarde s'il existe au moins un token
-hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut
-2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5
-caractères). Le **rappel** est la proportion de tokens GT
-ainsi retrouvés.
-
-Multiplicité
-------------
-Si la GT contient *« le »* deux fois et l'hypothèse une fois,
-seul un token GT est compté comme retrouvé (alignement
-multi-set, comme ``rare_token_recall`` Sprint 71).
-
-Sortie
-------
-``compute_searchability(reference, hypothesis)`` retourne
-``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
-
-Limites documentées
--------------------
-- Tokenisation par split sur whitespace (cohérent avec le reste
- du codebase). Pas de stemming ni de lemmatisation.
-- Levenshtein non pondéré — substitution = insertion = suppression
- = 1. Pour un poids différent (par ex. faute classique
- diacritique = 0,5), passer une fonction custom.
-- Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la
- similarité sémantique, voir des modules futurs (BERTScore).
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Optional
-
-from picarones.core.metric_registry import register_metric
-from picarones.core.modules import ArtifactType
-
-logger = logging.getLogger(__name__)
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Tokenisation et distance d'édition
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def _split_words(text: Optional[str]) -> list[str]:
- """Tokenisation par whitespace — cohérent avec
- ``lexical_modernization.py``, ``rare_tokens.py``, etc."""
- if not text:
- return []
- return text.split()
-
-
-def levenshtein_distance(a: str, b: str) -> int:
- """Distance de Levenshtein (substitution=insertion=suppression=1).
-
- Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)).
- """
- if a == b:
- return 0
- if len(a) < len(b):
- a, b = b, a
- # |a| ≥ |b|
- if not b:
- return len(a)
- previous = list(range(len(b) + 1))
- for i, ca in enumerate(a, start=1):
- current = [i] + [0] * len(b)
- for j, cb in enumerate(b, start=1):
- cost = 0 if ca == cb else 1
- current[j] = min(
- current[j - 1] + 1, # insertion
- previous[j] + 1, # suppression
- previous[j - 1] + cost, # substitution
- )
- previous = current
- return previous[-1]
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Calcul principal
-# ──────────────────────────────────────────────────────────────────────────
-
-
-def compute_searchability(
- reference: Optional[str],
- hypothesis: Optional[str],
- *,
- max_distance: int = 2,
- case_sensitive: bool = False,
-) -> dict:
- """Recherchabilité fuzzy de ``reference`` dans ``hypothesis``.
-
- Parameters
- ----------
- reference, hypothesis:
- Transcriptions GT et OCR.
- max_distance:
- Seuil de distance de Levenshtein (≤ pour considérer un
- token comme retrouvé). Défaut 2 — convention
- ``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères.
- case_sensitive:
- Si False (défaut), casse insensible côté match — la
- sortie ``missed_tokens`` reste avec la casse GT
- originale.
-
- Returns
- -------
- dict
- ``{
- "n_gt_tokens": int,
- "n_searchable": int,
- "recall": float | None, # None si n_gt_tokens == 0
- "missed_tokens": list[str],
- "max_distance": int,
- }``
- """
- if max_distance < 0:
- raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
- gt_tokens = _split_words(reference)
- hyp_tokens = _split_words(hypothesis)
- n_gt = len(gt_tokens)
- if n_gt == 0:
- return {
- "n_gt_tokens": 0,
- "n_searchable": 0,
- "recall": None,
- "missed_tokens": [],
- "max_distance": max_distance,
- }
- # Multi-set : un token hypothèse ne peut servir qu'une fois.
- # Tri par longueur croissante pour matcher d'abord les
- # tokens GT les plus courts (où ε-fautes sont plus rares).
- if case_sensitive:
- gt_for_match = list(gt_tokens)
- hyp_for_match = list(hyp_tokens)
- else:
- gt_for_match = [t.lower() for t in gt_tokens]
- hyp_for_match = [t.lower() for t in hyp_tokens]
-
- hyp_used = [False] * len(hyp_for_match)
- n_searchable = 0
- missed: list[str] = []
- for gi, gt_match in enumerate(gt_for_match):
- # Court-circuit si match exact disponible
- best_idx = -1
- best_dist = max_distance + 1
- for hi, used in enumerate(hyp_used):
- if used:
- continue
- hyp_match = hyp_for_match[hi]
- # Court-circuit longueur (Levenshtein ≥ |Δlen|)
- if abs(len(hyp_match) - len(gt_match)) > max_distance:
- continue
- d = levenshtein_distance(gt_match, hyp_match)
- if d < best_dist:
- best_dist = d
- best_idx = hi
- if d == 0:
- break # match exact, inutile de chercher mieux
- if best_idx >= 0 and best_dist <= max_distance:
- hyp_used[best_idx] = True
- n_searchable += 1
- else:
- missed.append(gt_tokens[gi])
- recall = n_searchable / n_gt
- return {
- "n_gt_tokens": n_gt,
- "n_searchable": n_searchable,
- "recall": recall,
- "missed_tokens": missed,
- "max_distance": max_distance,
- }
-
-
-# ──────────────────────────────────────────────────────────────────────────
-# Enregistrement registre typé (Sprint 34)
-# ──────────────────────────────────────────────────────────────────────────
-
-
-@register_metric(
- name="searchability_recall",
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
- description=(
- "Recherchabilité fuzzy : proportion de tokens GT retrouvés "
- "dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de "
- "la qualité pour la recherche plein-texte (Elastic, Solr)."
- ),
-)
-def searchability_recall_metric(reference: str, hypothesis: str) -> float:
- """Variante scalaire pour le registre typé : retourne le
- rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
- cohérente avec rare_token_recall Sprint 71).
- """
- result = compute_searchability(reference, hypothesis)
- recall = result.get("recall")
- return 0.0 if recall is None else recall
-
+from picarones.measurements.searchability import * # noqa: F401, F403
-__all__ = [
- "levenshtein_distance",
- "compute_searchability",
- "searchability_recall_metric",
-]
+import picarones.measurements.searchability as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/searchability_runner.py b/picarones/core/searchability_runner.py
index cf822338cd0fb86c4a58301f136568a77080a346..75474d5310a4355dc7020e7befb82a91ee035f59 100644
--- a/picarones/core/searchability_runner.py
+++ b/picarones/core/searchability_runner.py
@@ -1,81 +1,19 @@
-"""Câblage runner de la recherchabilité (Sprint 86).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.searchability_runner`.
-Sprint 86 — A.II.5a (vue HTML + câblage runner).
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.searchability_runner import ...``) de continuer
+à fonctionner sans modification.
-Le module ``picarones/core/searchability.py`` (Sprint 84) a livré
-la couche de calcul. Ce helper prépare la donnée pour le runner
-historique et l'agrégation par moteur.
-
-Adaptive masking
-----------------
-Comme pour les modules philologiques (Sprint 61), on ne calcule
-le rappel que si la GT contient au moins un token — pas de
-calcul vide qui produirait du bruit dans le rapport.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Iterable, Optional
-
-from picarones.core.searchability import (
- _split_words,
- compute_searchability,
-)
-
-logger = logging.getLogger(__name__)
-
-
-def compute_searchability_metrics(
- reference: Optional[str],
- hypothesis: Optional[str],
- *,
- max_distance: int = 2,
-) -> Optional[dict]:
- """Recherchabilité d'un document (adaptive).
-
- Retourne ``None`` si la GT est vide ou ne contient aucun
- token — ce qui déclenche l'adaptive masking côté HTML.
- """
- if not reference or not _split_words(reference):
- return None
- return compute_searchability(
- reference, hypothesis or "", max_distance=max_distance,
- )
-
-
-def aggregate_searchability_metrics(
- per_doc: Iterable[Optional[dict]],
-) -> Optional[dict]:
- """Agrège les métriques par-doc en un score corpus-wide.
-
- Convention : on somme les ``n_gt_tokens`` et ``n_searchable``
- et on recalcule un rappel **micro** (cohérent avec ECE/MCE
- Sprint 39 et NER Sprint 38).
- """
- docs = [d for d in per_doc if d]
- if not docs:
- return None
- n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
- n_search = sum(int(d.get("n_searchable") or 0) for d in docs)
- if n_gt == 0:
- return None
- # On garde l'union des missed_tokens (capped pour ne pas
- # exploser le JSON sur de gros corpus)
- missed: list[str] = []
- for d in docs:
- missed.extend(d.get("missed_tokens") or [])
- return {
- "n_docs": len(docs),
- "n_gt_tokens": n_gt,
- "n_searchable": n_search,
- "recall": n_search / n_gt,
- "missed_tokens_sample": missed[:50],
- "max_distance": docs[0].get("max_distance", 2),
- }
-
+from picarones.measurements.searchability_runner import * # noqa: F401, F403
-__all__ = [
- "compute_searchability_metrics",
- "aggregate_searchability_metrics",
-]
+import picarones.measurements.searchability_runner as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/specialization.py b/picarones/core/specialization.py
index a3f251c56c578701544d9b57aea1f9d21554f033..491e79921d6ddc534fe743b4948f27bec5e9e114 100644
--- a/picarones/core/specialization.py
+++ b/picarones/core/specialization.py
@@ -1,187 +1,19 @@
-"""Score de spécialisation inter-moteurs — Sprint 89 (A.II.8b).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.specialization`.
-Sprint 89 — A.II.8b du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.specialization import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-La matrice de divergence taxonomique (Sprint 35
-``inter_engine.taxonomy_divergence_matrix``) répond à *« à quel
-point ces moteurs se trompent-ils différemment ? »*. Ce
-sprint la transforme en un **score de spécialisation** lisible
-et complète la lecture par :
-
-- une **classification** discrète (similar / distinct /
- highly_specialized) que le chercheur peut consommer sans
- avoir à interpréter une distance ;
-- un **top-N des paires** les plus spécialisées, qui répond
- directement à la question *« quels moteurs sont les meilleurs
- candidats pour un voting ensemble ? »*.
-
-Ce module **ne recommande pas** de pipeline d'ensemble — il
-fournit l'observation factuelle et laisse le chercheur arbitrer.
-
-Convention de score
--------------------
-On utilise la **Jensen-Shannon divergence** déjà calculée par
-``inter_engine.jensen_shannon_divergence`` : elle est
-symétrique, bornée dans [0, 1], et son interprétation est
-intuitive :
-
-- ≈ 0 → profils taxonomiques identiques
-- 1 → distributions totalement disjointes
-
-Dépendances
------------
-S'appuie strictement sur ``picarones.core.inter_engine`` (Sprint
-35) — pas de double calcul, pas de logique nouvelle de
-divergence.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Optional
-
-from picarones.core.inter_engine import jensen_shannon_divergence
-
-logger = logging.getLogger(__name__)
-
-
-# Seuils par convention éditoriale. La roadmap ne fixe rien :
-# ces seuils sont des **guides de lecture**, pas des verdicts.
-# Le chercheur peut les surcharger via ``classify_specialization``.
-DEFAULT_THRESHOLDS = (
- ("similar", 0.10),
- ("distinct", 0.30),
- ("highly_specialized", 1.01), # tout score ≥ 0.30
-)
-
-
-def compute_specialization_score(
- taxonomy_a: dict[str, float],
- taxonomy_b: dict[str, float],
-) -> float:
- """Score de spécialisation entre deux moteurs ∈ [0, 1].
-
- 0 = mêmes erreurs, 1 = erreurs totalement disjointes.
- Délègue à ``jensen_shannon_divergence`` (Sprint 35).
- """
- return jensen_shannon_divergence(taxonomy_a, taxonomy_b)
-
-
-def classify_specialization(
- score: float,
- thresholds: Optional[tuple[tuple[str, float], ...]] = None,
-) -> str:
- """Classe le score en catégorie discrète.
-
- Convention :
- - score < 0.10 → ``similar``
- - 0.10 ≤ score < 0.30 → ``distinct``
- - score ≥ 0.30 → ``highly_specialized``
-
- L'utilisateur peut passer ses propres ``thresholds`` (liste
- triée par valeur croissante de tuples ``(label, max_score)``).
- """
- rules = thresholds or DEFAULT_THRESHOLDS
- for label, max_score in rules:
- if score < max_score:
- return label
- # Garde-fou : si aucun seuil ne match, dernière catégorie
- return rules[-1][0]
-
-
-def compute_specialization_matrix(
- taxonomies: dict[str, dict[str, float]],
-) -> Optional[dict]:
- """Matrice de spécialisation symétrique entre tous les moteurs.
-
- Parameters
- ----------
- taxonomies:
- Map ``{engine_name: {error_class: count_or_proportion}}``.
-
- Returns
- -------
- dict | None
- ``{
- "engines": list[str],
- "matrix": list[list[float]], # carrée, symétrique
- "n_pairs": int, # paires distinctes
- "max_score": float,
- "max_pair": (str, str) | None,
- }`` ; ``None`` si moins de 2 moteurs.
- """
- if not taxonomies or len(taxonomies) < 2:
- return None
- engines = sorted(taxonomies.keys())
- n = len(engines)
- matrix = [[0.0] * n for _ in range(n)]
- n_pairs = 0
- max_score = 0.0
- max_pair: Optional[tuple[str, str]] = None
- for i in range(n):
- for j in range(i + 1, n):
- score = compute_specialization_score(
- taxonomies[engines[i]], taxonomies[engines[j]],
- )
- matrix[i][j] = score
- matrix[j][i] = score
- n_pairs += 1
- if score > max_score:
- max_score = score
- max_pair = (engines[i], engines[j])
- return {
- "engines": engines,
- "matrix": matrix,
- "n_pairs": n_pairs,
- "max_score": max_score,
- "max_pair": max_pair,
- }
-
-
-def top_specialized_pairs(
- matrix_data: Optional[dict],
- n: int = 5,
- *,
- min_score: float = 0.0,
-) -> list[dict]:
- """Top-N paires de moteurs triées par score décroissant.
-
- Returns
- -------
- list[dict]
- Une liste de ``{
- "engine_a": str, "engine_b": str,
- "score": float, "category": str,
- }`` triée par score décroissant. Liste vide si
- ``matrix_data`` est ``None`` ou que toutes les paires
- sont sous ``min_score``.
- """
- if not matrix_data:
- return []
- engines = matrix_data["engines"]
- matrix = matrix_data["matrix"]
- pairs: list[dict] = []
- for i, engine_a in enumerate(engines):
- for j in range(i + 1, len(engines)):
- score = matrix[i][j]
- if score < min_score:
- continue
- pairs.append({
- "engine_a": engine_a,
- "engine_b": engines[j],
- "score": score,
- "category": classify_specialization(score),
- })
- pairs.sort(key=lambda p: -p["score"])
- return pairs[:n]
-
+from picarones.measurements.specialization import * # noqa: F401, F403
-__all__ = [
- "DEFAULT_THRESHOLDS",
- "compute_specialization_score",
- "classify_specialization",
- "compute_specialization_matrix",
- "top_specialized_pairs",
-]
+import picarones.measurements.specialization as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/statistics.py b/picarones/core/statistics.py
index e5a6a8d6ee204d2e06fc87ff67458a68d950785c..ea93762bb6e11b00726a627cb7b29db399186f45 100644
--- a/picarones/core/statistics.py
+++ b/picarones/core/statistics.py
@@ -1,1127 +1,19 @@
-"""Tests statistiques et clustering d'erreurs pour Picarones.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.statistics`.
-Fonctions fournies
-------------------
-- wilcoxon_test(a, b) : Wilcoxon signé-rangé (2 moteurs appariés)
-- bootstrap_ci(values, ...) : intervalle de confiance à 95 % par bootstrap
-- compute_pairwise_stats(...) : matrice de Wilcoxon entre toutes les paires
-- friedman_test(engine_cer_map) : Friedman (k moteurs, n documents) [Sprint 17]
-- nemenyi_posthoc(engine_cer_map) : post-hoc Nemenyi avec critical distance [Sprint 17]
-- build_critical_difference_svg(...) : rendu SVG du CDD (Demšar 2006) [Sprint 17]
-- compute_pareto_front(points, ...) : frontière de Pareto multi-objectifs [Sprint 19]
-- cluster_errors(...) : regroupement des patterns d'erreurs
-- compute_correlation_matrix(...) : matrice de corrélation des métriques
-- compute_reliability_curve(...) : courbe CER vs. % docs les plus faciles
-- compute_venn_data(...) : diagramme de Venn 2/3 moteurs
-"""
-
-from __future__ import annotations
-
-import math
-import random
-import re
-from collections import defaultdict
-from dataclasses import dataclass
-from typing import Optional
-
-# Import optionnel de scipy — utilisé pour le test de Wilcoxon si disponible
-# (méthode exacte pour n ≤ 25, approximation normale pour n > 25).
-# En son absence, l'implémentation native (approximation normale pour n ≥ 10)
-# est utilisée automatiquement.
-try:
- from scipy.stats import wilcoxon as _scipy_wilcoxon # type: ignore[import-untyped]
- _SCIPY_AVAILABLE = True
-except ImportError:
- _SCIPY_AVAILABLE = False
-
-
-# ---------------------------------------------------------------------------
-# Bootstrap CI
-# ---------------------------------------------------------------------------
-
-def bootstrap_ci(
- values: list[float],
- n_iter: int = 1000,
- ci: float = 0.95,
- seed: int = 42,
-) -> tuple[float, float]:
- """Intervalle de confiance par bootstrap.
-
- Parameters
- ----------
- values : liste des valeurs (ex. CER par document)
- n_iter : nombre d'itérations bootstrap (défaut 1000)
- ci : niveau de confiance (défaut 0.95 → 95 %)
- seed : graine RNG pour reproductibilité
-
- Returns
- -------
- (lower, upper) — les bornes de l'IC à ``ci`` %
- """
- if not values:
- return (0.0, 0.0)
- rng = random.Random(seed)
- n = len(values)
- means = []
- for _ in range(n_iter):
- sample = [values[rng.randint(0, n - 1)] for _ in range(n)]
- means.append(sum(sample) / n)
- means.sort()
- alpha = (1.0 - ci) / 2.0
- lo_idx = max(0, int(alpha * n_iter))
- hi_idx = min(n_iter - 1, int((1.0 - alpha) * n_iter))
- return (means[lo_idx], means[hi_idx])
-
-
-# ---------------------------------------------------------------------------
-# Test de Wilcoxon signé-rangé (implémentation pure Python)
-# ---------------------------------------------------------------------------
-
-def wilcoxon_test(
- a: list[float],
- b: list[float],
- zero_method: str = "wilcox",
-) -> dict:
- """Test de Wilcoxon signé-rangé entre deux séries de CER appariées.
-
- Retourne un dict avec :
- - statistic : W = min(W⁺, W⁻)
- - p_value : p-value bilatérale
- - significant : bool (p < 0.05)
- - interpretation : phrase lisible
- - n_pairs : nombre de paires utilisées (après retrait des zéros)
- - W_plus : somme des rangs des différences positives
- - W_minus : somme des rangs des différences négatives
-
- Hypothèses et limites
- ---------------------
- * Les observations sont appariées (même corpus, deux moteurs différents).
- * Le test est non-paramétrique : aucune hypothèse de normalité des CER.
- * ``zero_method="wilcox"`` (défaut) : les paires sans différence (aᵢ = bᵢ)
- sont simplement exclues. Les autres méthodes (``"pratt"``, ``"zsplit"``)
- nécessitent scipy.
- * **Approximation normale** (implémentation native, n ≥ 10) :
- L'approximation est raisonnable pour n ≥ 10 et converge vers la
- distribution exacte. Pour n < 10, une table critique simplifiée est
- utilisée (p ∈ {0.04, 0.20}) — résultat **conservateur**.
- * **scipy** (si installé) : ``scipy.stats.wilcoxon`` est utilisé à la place
- de l'approximation native. scipy utilise la méthode exacte pour n ≤ 25
- et l'approximation normale pour n > 25, ce qui est plus précis.
- * **Validité** : le test suppose la symétrie de la distribution des
- différences. Avec de très petits n (< 5), les résultats sont peu fiables
- quelle que soit la méthode.
-
- Parameters
- ----------
- a, b : séries de CER (même longueur, même ordre de documents)
- zero_method : gestion des paires nulles (défaut : ``"wilcox"``)
- """
- if len(a) != len(b):
- raise ValueError("Les deux listes doivent avoir la même longueur")
-
- diffs = [x - y for x, y in zip(a, b)]
-
- # Retirer les zéros (méthode "wilcox")
- if zero_method == "wilcox":
- diffs = [d for d in diffs if d != 0.0]
-
- n = len(diffs)
- if n == 0:
- return {
- "statistic": 0.0,
- "p_value": 1.0,
- "significant": False,
- "interpretation": "Aucune différence entre les deux concurrents.",
- "n_pairs": 0,
- }
-
- # Rangs des valeurs absolues
- abs_diffs = [abs(d) for d in diffs]
- indexed = sorted(enumerate(abs_diffs), key=lambda x: x[1])
-
- # Gestion des ex-aequo : rang moyen
- ranks = [0.0] * n
- i = 0
- while i < n:
- j = i
- while j < n and abs_diffs[indexed[j][0]] == abs_diffs[indexed[i][0]]:
- j += 1
- avg_rank = (i + j + 1) / 2.0 # rang moyen (1-based)
- for k in range(i, j):
- ranks[indexed[k][0]] = avg_rank
- i = j
-
- W_plus = sum(ranks[k] for k in range(n) if diffs[k] > 0)
- W_minus = sum(ranks[k] for k in range(n) if diffs[k] < 0)
- W = min(W_plus, W_minus)
-
- # Calcul de la p-value : scipy si disponible, sinon approximation native
- if _SCIPY_AVAILABLE:
- try:
- scipy_res = _scipy_wilcoxon(diffs, zero_method=zero_method)
- p_value = float(scipy_res.pvalue)
- except Exception:
- # Repli sur l'implémentation native en cas d'erreur scipy
- p_value = _native_p_value(n, W)
- else:
- p_value = _native_p_value(n, W)
-
- significant = p_value < 0.05
-
- if significant:
- better = "premier" if W_plus < W_minus else "second"
- interpretation = (
- f"Différence statistiquement significative (p = {p_value:.4f} < 0.05). "
- f"Le {better} concurrent obtient de meilleurs scores."
- )
- else:
- interpretation = (
- f"Différence non significative (p = {p_value:.4f} ≥ 0.05). "
- "On ne peut pas conclure que l'un surpasse l'autre."
- )
-
- return {
- "statistic": round(W, 4),
- "p_value": round(p_value, 6),
- "significant": significant,
- "interpretation": interpretation,
- "n_pairs": n,
- "W_plus": round(W_plus, 4),
- "W_minus": round(W_minus, 4),
- }
-
-
-def _normal_sf(z: float) -> float:
- """Survival function de la loi normale standard (1 - CDF)."""
- # Approximation Abramowitz & Stegun 26.2.17
- t = 1.0 / (1.0 + 0.2316419 * abs(z))
- poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937
- + t * (-1.821255978 + t * 1.330274429))))
- phi_z = math.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi)
- p = phi_z * poly
- return p if z >= 0 else 1.0 - p
-
-
-# Table des valeurs critiques de W pour α=0.05 bilatéral (test exact, source : tables de Wilcoxon)
-_W_CRITICAL = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 2, 8: 3, 9: 5}
-
-
-def _wilcoxon_exact_p(n: int, w: float) -> float:
- """P-value approximée pour petits n (< 10) via table critique simplifiée.
-
- Note : résultat **conservateur** — seules deux valeurs sont retournées :
- 0.04 (significatif à 5 %) ou 0.20 (non significatif).
- Préférer scipy pour des p-values exactes.
- """
- critical = _W_CRITICAL.get(n, 0)
- if w <= critical:
- return 0.04 # significatif à 5 %
- return 0.20 # non significatif (approximation conservative)
-
-
-def _native_p_value(n: int, W: float) -> float:
- """Calcule la p-value via l'approximation normale (n ≥ 10) ou la table exacte (n < 10)."""
- if n >= 10:
- mu = n * (n + 1) / 4.0
- sigma2 = n * (n + 1) * (2 * n + 1) / 24.0
- if sigma2 <= 0:
- return 1.0
- z = abs((W + 0.5) - mu) / math.sqrt(sigma2) # correction de continuité
- return 2.0 * _normal_sf(z) # test bilatéral
- return _wilcoxon_exact_p(n, W)
-
-
-# ---------------------------------------------------------------------------
-# Matrice des tests pairwise
-# ---------------------------------------------------------------------------
-
-def compute_pairwise_stats(
- engine_cer_map: dict[str, list[float]],
-) -> list[dict]:
- """Calcule les tests de Wilcoxon entre toutes les paires de concurrents.
-
- Parameters
- ----------
- engine_cer_map : dict {engine_name → [cer_doc1, cer_doc2, ...]}
-
- Returns
- -------
- Liste de dicts, un par paire :
- - engine_a, engine_b, statistic, p_value, significant, interpretation
- """
- names = list(engine_cer_map.keys())
- results = []
- for i in range(len(names)):
- for j in range(i + 1, len(names)):
- a_name, b_name = names[i], names[j]
- a_vals = engine_cer_map[a_name]
- b_vals = engine_cer_map[b_name]
- # Aligner les longueurs
- min_len = min(len(a_vals), len(b_vals))
- if min_len < 2:
- continue
- res = wilcoxon_test(a_vals[:min_len], b_vals[:min_len])
- results.append({
- "engine_a": a_name,
- "engine_b": b_name,
- **res,
- })
- return results
-
-
-# ---------------------------------------------------------------------------
-# Test de Friedman + post-hoc Nemenyi (Sprint 17)
-# ---------------------------------------------------------------------------
-#
-# Référence : Demšar, J. (2006), "Statistical Comparisons of Classifiers over
-# Multiple Data Sets", Journal of Machine Learning Research 7:1-30. Standard
-# de facto pour comparer plusieurs systèmes sur plusieurs datasets — ici :
-# plusieurs moteurs OCR sur plusieurs documents. Le CDD (critical difference
-# diagram) issu de Nemenyi est le rendu canonique.
-
-# Valeurs critiques de la distribution du Studentized Range divisées par √2,
-# pour df = ∞ (approximation usuelle pour Nemenyi). Source : tables de Tukey.
-# Clé : nombre de traitements k ; valeur : q_α pour α ∈ {0.05, 0.01}.
-_NEMENYI_Q_TABLE = {
- # k q_0.05 q_0.01
- 2: (1.960, 2.576),
- 3: (2.343, 2.913),
- 4: (2.569, 3.113),
- 5: (2.728, 3.255),
- 6: (2.850, 3.364),
- 7: (2.949, 3.452),
- 8: (3.031, 3.526),
- 9: (3.102, 3.590),
- 10: (3.164, 3.646),
- 11: (3.219, 3.696),
- 12: (3.268, 3.741),
- 13: (3.313, 3.781),
- 14: (3.354, 3.818),
- 15: (3.391, 3.853),
- 16: (3.426, 3.886),
- 17: (3.458, 3.916),
- 18: (3.489, 3.944),
- 19: (3.517, 3.970),
- 20: (3.544, 3.995),
- 25: (3.658, 4.095),
- 30: (3.739, 4.167),
- 40: (3.858, 4.272),
- 50: (3.945, 4.349),
-}
-
-
-def _chi_square_sf(x: float, df: int) -> float:
- """Survival function de la loi chi², 1 - CDF(x).
-
- Utilise scipy si disponible (méthode exacte), sinon Wilson-Hilferty
- (approximation normale précise dès df ≥ 3).
- """
- if x <= 0 or df <= 0:
- return 1.0
- try:
- from scipy.stats import chi2 as _chi2 # type: ignore[import-untyped]
- return float(_chi2.sf(x, df))
- except ImportError:
- pass
- # Wilson-Hilferty : transforme chi² en approximation normale
- z = (((x / df) ** (1.0 / 3.0)) - (1.0 - 2.0 / (9.0 * df))) / math.sqrt(2.0 / (9.0 * df))
- return _normal_sf(z)
-
-
-def _rank_row(values: list[float]) -> list[float]:
- """Rangs d'une ligne — petit = rang 1. Ex-aequo : rangs moyens."""
- n = len(values)
- indexed = sorted(range(n), key=lambda i: values[i])
- ranks = [0.0] * n
- i = 0
- while i < n:
- j = i
- while j < n and values[indexed[j]] == values[indexed[i]]:
- j += 1
- avg_rank = (i + j + 1) / 2.0 # 1-based
- for k in range(i, j):
- ranks[indexed[k]] = avg_rank
- i = j
- return ranks
-
-
-def _aligned_cer_matrix(
- engine_cer_map: dict[str, list[float]],
-) -> tuple[list[str], list[list[float]]]:
- """Construit la matrice (k moteurs × n documents) alignée sur la longueur
- minimale. Retourne ``(noms, matrice_colonne_par_moteur)``.
-
- Friedman exige des blocs (documents) complets : si les moteurs n'ont pas
- tous été exécutés sur les mêmes documents, on tronque à la longueur
- minimale, documentée dans le résultat via ``n_blocks``.
- """
- names = list(engine_cer_map.keys())
- if not names:
- return [], []
- min_len = min(len(v) for v in engine_cer_map.values())
- if min_len == 0:
- return names, []
- matrix = [engine_cer_map[n][:min_len] for n in names]
- return names, matrix
-
-
-def friedman_test(engine_cer_map: dict[str, list[float]]) -> dict:
- """Test de Friedman — k moteurs sur n documents appariés.
-
- Test non-paramétrique équivalent à l'ANOVA à mesures répétées pour des
- données ordinales. Hypothèse nulle : tous les moteurs ont la même
- performance moyenne. Rejet → au moins un moteur diffère des autres.
-
- Parameters
- ----------
- engine_cer_map:
- Dict ``{engine_name → [cer_doc1, cer_doc2, ...]}``. Tous les moteurs
- doivent avoir été évalués sur les mêmes documents (dans le même ordre).
-
- Returns
- -------
- dict avec :
- - ``statistic`` : Q corrigé pour les ex-aequo
- - ``p_value`` : p-value (scipy si dispo, sinon Wilson-Hilferty)
- - ``significant`` : bool, p < 0.05
- - ``df`` : degrés de liberté = k - 1
- - ``n_blocks`` : nombre de documents (blocs) utilisés
- - ``n_engines`` : nombre de moteurs (k)
- - ``mean_ranks`` : dict ``{engine: rang_moyen}``
- - ``interpretation``: phrase lisible
- - ``error`` : message si le test n'est pas applicable
- """
- names, matrix = _aligned_cer_matrix(engine_cer_map)
- k = len(names)
- n = len(matrix[0]) if matrix else 0
-
- if k < 2:
- return {
- "statistic": 0.0, "p_value": 1.0, "significant": False,
- "df": 0, "n_blocks": n, "n_engines": k,
- "mean_ranks": {names[0]: 1.0} if k == 1 else {},
- "interpretation": "Test de Friedman non applicable : il faut au moins 2 moteurs.",
- "error": "not_enough_engines",
- }
- if n < 2:
- return {
- "statistic": 0.0, "p_value": 1.0, "significant": False,
- "df": k - 1, "n_blocks": n, "n_engines": k,
- "mean_ranks": {name: 1.0 for name in names},
- "interpretation": "Test de Friedman non applicable : il faut au moins 2 documents communs.",
- "error": "not_enough_blocks",
- }
-
- # Rangs par bloc (document) : pour chaque doc, ranger les k moteurs
- ranks_by_engine: list[list[float]] = [[] for _ in range(k)]
- for j in range(n):
- row = [matrix[i][j] for i in range(k)]
- row_ranks = _rank_row(row)
- for i in range(k):
- ranks_by_engine[i].append(row_ranks[i])
-
- rank_sums = [sum(r) for r in ranks_by_engine]
- mean_ranks = {names[i]: rank_sums[i] / n for i in range(k)}
-
- # Statistique Q non-corrigée (sans ex-aequo)
- # Q = 12 / (n·k·(k+1)) · Σ R_j² − 3·n·(k+1)
- Q = (12.0 / (n * k * (k + 1))) * sum(rs ** 2 for rs in rank_sums) - 3.0 * n * (k + 1)
-
- # Correction pour les ex-aequo (ties factor) — ajuste si des rangs sont
- # partagés dans certains blocs. Formule : Q_corr = Q / (1 - T/(n·(k³−k)))
- # où T = Σ (tⱼ³ − tⱼ) sur tous les groupes d'ex-aequo.
- tie_correction = 0.0
- for j in range(n):
- row = [matrix[i][j] for i in range(k)]
- sorted_row = sorted(row)
- i = 0
- while i < len(sorted_row):
- count = 1
- while i + count < len(sorted_row) and sorted_row[i + count] == sorted_row[i]:
- count += 1
- if count > 1:
- tie_correction += count ** 3 - count
- i += count
- denom = 1.0 - tie_correction / (n * (k ** 3 - k)) if k >= 2 else 1.0
- if denom > 0:
- Q = Q / denom
-
- df = k - 1
- p_value = _chi_square_sf(Q, df)
- significant = p_value < 0.05
-
- if significant:
- interpretation = (
- f"Test de Friedman significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). "
- f"Au moins un moteur diffère des autres — utiliser le post-hoc Nemenyi "
- f"pour identifier les paires distinguables."
- )
- else:
- interpretation = (
- f"Test de Friedman non significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). "
- f"Aucune différence globale détectée entre les moteurs sur ce corpus."
- )
-
- return {
- "statistic": round(Q, 4),
- "p_value": round(p_value, 6),
- "significant": significant,
- "df": df,
- "n_blocks": n,
- "n_engines": k,
- "mean_ranks": {k_: round(v, 4) for k_, v in mean_ranks.items()},
- "interpretation": interpretation,
- }
-
-
-def _nemenyi_critical_value(k: int, alpha: float = 0.05) -> Optional[float]:
- """Valeur critique q_α pour k traitements, df = ∞.
-
- Retourne ``None`` si k est hors table (< 2 ou > 50).
- """
- if k < 2:
- return None
- if k in _NEMENYI_Q_TABLE:
- q05, q01 = _NEMENYI_Q_TABLE[k]
- return q05 if alpha == 0.05 else q01 if alpha == 0.01 else q05
- # Au-delà de la table : borne supérieure (conservateur)
- max_k = max(_NEMENYI_Q_TABLE.keys())
- if k > max_k:
- q05, q01 = _NEMENYI_Q_TABLE[max_k]
- return q05 if alpha == 0.05 else q01
- # Entre deux clés : interpolation linéaire
- keys = sorted(_NEMENYI_Q_TABLE.keys())
- for i in range(len(keys) - 1):
- if keys[i] < k < keys[i + 1]:
- lo, hi = keys[i], keys[i + 1]
- q_lo = _NEMENYI_Q_TABLE[lo][0 if alpha == 0.05 else 1]
- q_hi = _NEMENYI_Q_TABLE[hi][0 if alpha == 0.05 else 1]
- frac = (k - lo) / (hi - lo)
- return q_lo + frac * (q_hi - q_lo)
- return None
-
-
-def nemenyi_posthoc(
- engine_cer_map: dict[str, list[float]],
- alpha: float = 0.05,
-) -> dict:
- """Post-hoc de Nemenyi — identifie les paires de moteurs statistiquement
- indiscernables après un test de Friedman.
-
- Calcule la *critical distance* CD = q_α · √(k·(k+1) / (6·n)). Deux moteurs
- dont les rangs moyens diffèrent de moins que CD ne sont **pas**
- statistiquement distinguables au seuil α.
-
- Returns
- -------
- dict avec :
- - ``alpha`` : seuil utilisé
- - ``critical_distance`` : CD calculée
- - ``q_alpha`` : valeur critique q_α issue de la table
- - ``n_blocks``, ``n_engines``
- - ``mean_ranks`` : rangs moyens par moteur (dict)
- - ``engines_sorted`` : liste des moteurs triés par rang croissant
- - ``significant_matrix`` : matrice bool (list[list[bool]]),
- ``True`` = paire significativement différente
- - ``tied_groups`` : liste de listes de moteurs indiscernables
- (groupes maximaux d'ex-aequo pratiques)
- - ``error`` : présent si le test n'est pas applicable
- """
- names, matrix = _aligned_cer_matrix(engine_cer_map)
- k = len(names)
- n = len(matrix[0]) if matrix else 0
-
- if k < 2 or n < 2:
- return {
- "alpha": alpha,
- "critical_distance": 0.0,
- "q_alpha": 0.0,
- "n_blocks": n,
- "n_engines": k,
- "mean_ranks": {name: 1.0 for name in names},
- "engines_sorted": list(names),
- "significant_matrix": [[False] * k for _ in range(k)],
- "tied_groups": [list(names)] if names else [],
- "error": "not_enough_data",
- }
-
- # Friedman fournit les rangs moyens — on les recalcule ici pour rester
- # autonome (sans forcer l'utilisateur à chaîner les deux appels).
- ranks_by_engine: list[list[float]] = [[] for _ in range(k)]
- for j in range(n):
- row = [matrix[i][j] for i in range(k)]
- row_ranks = _rank_row(row)
- for i in range(k):
- ranks_by_engine[i].append(row_ranks[i])
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.statistics import ...``) de continuer
+à fonctionner sans modification.
- mean_ranks_list = [sum(r) / n for r in ranks_by_engine]
- mean_ranks = {names[i]: round(mean_ranks_list[i], 4) for i in range(k)}
-
- q_alpha = _nemenyi_critical_value(k, alpha) or 0.0
- critical_distance = q_alpha * math.sqrt(k * (k + 1) / (6.0 * n))
-
- # Matrice de significativité : paire (i,j) significative si |R_i - R_j| > CD
- significant_matrix = [
- [
- (i != j) and (abs(mean_ranks_list[i] - mean_ranks_list[j]) > critical_distance)
- for j in range(k)
- ]
- for i in range(k)
- ]
-
- # Groupes d'ex-aequo pratiques : fenêtre glissante sur les rangs triés.
- # Deux moteurs sont dans le même groupe si leur écart ≤ CD.
- order = sorted(range(k), key=lambda i: mean_ranks_list[i])
- sorted_names = [names[i] for i in order]
- sorted_ranks = [mean_ranks_list[i] for i in order]
-
- tied_groups: list[list[str]] = []
- i = 0
- while i < len(sorted_names):
- # étendre le groupe tant que le moteur suivant est à ≤ CD du premier du groupe
- j = i
- while j + 1 < len(sorted_names) and (sorted_ranks[j + 1] - sorted_ranks[i]) <= critical_distance:
- j += 1
- tied_groups.append(sorted_names[i:j + 1])
- i = j + 1 if j > i else i + 1
-
- return {
- "alpha": alpha,
- "critical_distance": round(critical_distance, 4),
- "q_alpha": round(q_alpha, 4),
- "n_blocks": n,
- "n_engines": k,
- "mean_ranks": mean_ranks,
- "engines_sorted": sorted_names,
- "significant_matrix": significant_matrix,
- "tied_groups": tied_groups,
- }
-
-
-# ---------------------------------------------------------------------------
-# Critical Difference Diagram — rendu SVG (Sprint 17)
-# ---------------------------------------------------------------------------
-
-def build_critical_difference_svg(
- nemenyi_result: dict,
- width: int = 780,
- row_height: int = 22,
-) -> str:
- """Génère le SVG du Critical Difference Diagram (Demšar 2006).
-
- Le diagramme montre :
- * un axe horizontal des rangs moyens (1 à k),
- * chaque moteur positionné sur l'axe à son rang moyen,
- * des barres horizontales épaisses reliant les moteurs statistiquement
- indiscernables (distance ≤ CD),
- * la longueur de CD affichée au-dessus de l'axe en référence.
-
- Parameters
- ----------
- nemenyi_result:
- Résultat de ``nemenyi_posthoc``.
- width:
- Largeur totale du SVG en pixels.
- row_height:
- Hauteur de chaque ligne d'étiquette moteur (auto-adaptatif).
-
- Returns
- -------
- Chaîne contenant le SVG (balise racine ````).
- """
- k = nemenyi_result.get("n_engines", 0)
- if k < 2 or nemenyi_result.get("error"):
- return (
- ''
- )
-
- engines_sorted: list[str] = list(nemenyi_result.get("engines_sorted", []))
- mean_ranks: dict[str, float] = dict(nemenyi_result.get("mean_ranks", {}))
- tied_groups: list[list[str]] = list(nemenyi_result.get("tied_groups", []))
- cd: float = float(nemenyi_result.get("critical_distance", 0.0))
-
- # Dimensions
- left_pad, right_pad = 40, 40
- top_pad = 50 # espace pour l'affichage CD
- axis_y = top_pad + 10
- bars_start_y = axis_y + 20 # première barre d'ex-aequo sous l'axe
- # Empiler une ligne par groupe + une ligne par étiquette
- label_rows = k # chaque moteur a sa propre ligne de label
- bars_count = len(tied_groups)
- total_h = bars_start_y + bars_count * 10 + label_rows * row_height + 20
-
- axis_x0, axis_x1 = left_pad, width - right_pad
- axis_width = axis_x1 - axis_x0
-
- def x_for_rank(r: float) -> float:
- # Rang 1 à gauche, rang k à droite
- if k <= 1:
- return axis_x0
- return axis_x0 + (r - 1.0) / (k - 1.0) * axis_width
-
- parts: list[str] = []
- parts.append(
- f'')
- return "".join(parts)
-
-
-def _svg_escape(text: str) -> str:
- """Échappe un texte pour inclusion sûre dans un nœud SVG/XML."""
- return (text.replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- .replace('"', """)
- .replace("'", "'"))
-
-
-# ---------------------------------------------------------------------------
-# Frontière de Pareto (Sprint 19)
-# ---------------------------------------------------------------------------
-
-def compute_pareto_front(
- points: list[dict],
- objectives: tuple[str, ...] = ("cer", "cost"),
- name_key: str = "engine",
- minimize: Optional[tuple[bool, ...]] = None,
-) -> list[str]:
- """Calcule la frontière de Pareto sur ``len(objectives)`` dimensions.
-
- Un point ``p`` est Pareto-dominant si aucun autre point n'a, pour TOUS
- les objectifs, une valeur au moins aussi bonne ET au moins une valeur
- strictement meilleure.
-
- Parameters
- ----------
- points:
- Liste de dicts. Chaque dict doit contenir ``name_key`` et toutes les
- clés de ``objectives``. Les points dont une valeur d'objectif est
- ``None`` sont ignorés (pas de comparaison possible).
- objectives:
- Clés des objectifs à minimiser/maximiser.
- name_key:
- Clé identifiant le point (par défaut ``"engine"``).
- minimize:
- Pour chaque objectif, ``True`` = minimiser (ex. CER, coût),
- ``False`` = maximiser (ex. ancrage). Doit avoir la même longueur
- que ``objectives``.
-
- Returns
- -------
- Liste des ``name`` des points sur le front Pareto, ordre stable depuis
- ``points``.
- """
- if minimize is None:
- minimize = tuple(True for _ in objectives)
- if len(minimize) != len(objectives):
- raise ValueError("`minimize` doit avoir la même longueur que `objectives`")
-
- valid = []
- for p in points:
- try:
- vals = tuple(float(p[k]) for k in objectives)
- except (KeyError, TypeError, ValueError):
- continue
- valid.append((p[name_key], vals))
-
- front: list[str] = []
- for name_a, vals_a in valid:
- dominated = False
- for name_b, vals_b in valid:
- if name_a == name_b:
- continue
- # B domine A si B est ≥ aussi bon partout ET strictement meilleur quelque part
- better_or_equal_everywhere = True
- strictly_better_somewhere = False
- for va, vb, mini in zip(vals_a, vals_b, minimize):
- if mini:
- if vb > va:
- better_or_equal_everywhere = False
- break
- if vb < va:
- strictly_better_somewhere = True
- else: # maximiser
- if vb < va:
- better_or_equal_everywhere = False
- break
- if vb > va:
- strictly_better_somewhere = True
- if better_or_equal_everywhere and strictly_better_somewhere:
- dominated = True
- break
- if not dominated:
- front.append(name_a)
- return front
-
-
-# ---------------------------------------------------------------------------
-# Clustering des patterns d'erreurs
-# ---------------------------------------------------------------------------
-
-# Patterns d'erreurs fréquentes (OCR + HTR documents patrimoniaux)
-_ERROR_PATTERNS = [
- # (pattern_re, label)
- (r"\brn\b.*\bm\b|\bm\b.*\brn\b|rn→m|m→rn", "confusion rn/m"),
- (r"[lI]→1|1→[lI]|l→1|1→l|I→1|1→I", "confusion l/1/I"),
- (r"u→n|n→u|v→u|u→v", "confusion u/n/v"),
- (r"[oO]→0|0→[oO]", "confusion O/0"),
- (r"ſ→[fs]|[fs]→ſ", "confusion ſ/f/s"),
- (r"é→e|è→e|ê→e|e→[éèê]", "erreur diacritique é/e"),
- (r"œ→oe|oe→œ|æ→ae|ae→æ", "ligature œ/æ"),
- (r"[fF]i→fi|fi→[fF]i", "ligature fi"),
- (r"[fF]l→fl|fl→[fF]l", "ligature fl"),
- (r"\s+→''|''→\s+", "segmentation espace"),
-]
-
-def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]:
- """Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution."""
- from picarones.report.diff_utils import compute_word_diff
- ops = compute_word_diff(gt, hyp)
- pairs = []
- for op in ops:
- if op["op"] == "replace":
- pairs.append((op["old"], op["new"]))
- elif op["op"] == "delete":
- pairs.append((op["text"], ""))
- elif op["op"] == "insert":
- pairs.append(("", op["text"]))
- return pairs
-
-
-@dataclass
-class ErrorCluster:
- """Un cluster d'erreurs similaires."""
- cluster_id: int
- label: str
- """Description humaine du pattern (ex. 'confusion rn/m')."""
- count: int
- examples: list[dict]
- """Liste de {engine, gt_fragment, ocr_fragment}."""
-
- def as_dict(self) -> dict:
- return {
- "cluster_id": self.cluster_id,
- "label": self.label,
- "count": self.count,
- "examples": self.examples[:5], # 5 exemples max
- }
-
-
-def cluster_errors(
- error_data: list[dict],
- max_clusters: int = 8,
-) -> list[ErrorCluster]:
- """Regroupe les erreurs en clusters avec labels lisibles.
-
- Parameters
- ----------
- error_data : liste de dicts {engine, gt, hypothesis}
- max_clusters : nombre max de clusters à retourner
-
- Returns
- -------
- Liste de ErrorCluster triée par count décroissant.
- """
- # Collecter tous les patterns d'erreur avec contexte
- # Clé : catégorie d'erreur → liste d'exemples
- bucket: dict[str, list[dict]] = defaultdict(list)
- other_pairs: list[dict] = []
-
- for item in error_data:
- engine = item.get("engine", "")
- gt = item.get("gt", "")
- hyp = item.get("hypothesis", "")
- pairs = _extract_error_pairs(gt, hyp)
-
- for old, new in pairs:
- if not old and not new:
- continue
- matched = False
- # Essayer de matcher un pattern connu
- probe = f"{old}→{new}"
- for _pat, label in _ERROR_PATTERNS:
- try:
- if re.search(_pat, probe, re.IGNORECASE):
- bucket[label].append({
- "engine": engine,
- "gt_fragment": old,
- "ocr_fragment": new,
- })
- matched = True
- break
- except re.error:
- pass
-
- if not matched:
- # Regrouper les substitutions restantes par paire de caractères
- if len(old) <= 3 and len(new) <= 3:
- key = f"{old}→{new}" if (old and new) else (f"—→{new}" if new else f"{old}→—")
- bucket[key].append({
- "engine": engine,
- "gt_fragment": old,
- "ocr_fragment": new,
- })
- else:
- other_pairs.append({
- "engine": engine,
- "gt_fragment": old,
- "ocr_fragment": new,
- })
-
- # Construire les clusters triés par fréquence
- clusters: list[ErrorCluster] = []
- cluster_id = 1
- sorted_buckets = sorted(bucket.items(), key=lambda x: -len(x[1]))
-
- for label, examples in sorted_buckets[:max_clusters - 1]:
- clusters.append(ErrorCluster(
- cluster_id=cluster_id,
- label=label,
- count=len(examples),
- examples=examples,
- ))
- cluster_id += 1
-
- # Cluster "autres"
- if other_pairs:
- clusters.append(ErrorCluster(
- cluster_id=cluster_id,
- label="autres substitutions",
- count=len(other_pairs),
- examples=other_pairs,
- ))
-
- # Trier par count décroissant et limiter
- clusters.sort(key=lambda c: -c.count)
- return clusters[:max_clusters]
-
-
-# ---------------------------------------------------------------------------
-# Matrice de corrélation entre métriques
-# ---------------------------------------------------------------------------
-
-def _pearson(x: list[float], y: list[float]) -> float:
- """Coefficient de corrélation de Pearson."""
- n = len(x)
- if n < 2:
- return 0.0
- mx = sum(x) / n
- my = sum(y) / n
- num = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y))
- den = math.sqrt(
- sum((xi - mx) ** 2 for xi in x) * sum((yi - my) ** 2 for yi in y)
- )
- return num / den if den > 0 else 0.0
-
-
-def compute_correlation_matrix(
- metrics_per_doc: list[dict],
- metric_keys: Optional[list[str]] = None,
-) -> dict:
- """Calcule la matrice de corrélation entre toutes les métriques numériques.
-
- Parameters
- ----------
- metrics_per_doc : liste de dicts, un par document, contenant les métriques
- metric_keys : clés à inclure (None → toutes les clés numériques)
-
- Returns
- -------
- {
- "labels": [...],
- "matrix": [[r_ij, ...], ...] // coefficients de Pearson
- }
- """
- if not metrics_per_doc:
- return {"labels": [], "matrix": []}
-
- if metric_keys is None:
- # Déduire les clés numériques
- sample = metrics_per_doc[0]
- metric_keys = [k for k, v in sample.items() if isinstance(v, (int, float))]
-
- # Construire les vecteurs
- vectors: dict[str, list[float]] = {k: [] for k in metric_keys}
- for doc in metrics_per_doc:
- for k in metric_keys:
- v = doc.get(k)
- vectors[k].append(float(v) if v is not None else 0.0)
-
- # Calculer la matrice
- labels = metric_keys
- n = len(labels)
- matrix = []
- for i in range(n):
- row = []
- for j in range(n):
- r = _pearson(vectors[labels[i]], vectors[labels[j]])
- row.append(round(r, 4))
- matrix.append(row)
-
- return {"labels": labels, "matrix": matrix}
-
-
-# ---------------------------------------------------------------------------
-# Courbe de fiabilité (reliability curve)
-# ---------------------------------------------------------------------------
-
-def compute_reliability_curve(
- cer_values: list[float],
- steps: int = 20,
-) -> list[dict]:
- """Pour les X% documents les plus faciles, quel est le CER moyen ?
-
- Returns
- -------
- Liste de {pct_docs: float, mean_cer: float}
- """
- if not cer_values:
- return []
- sorted_cer = sorted(cer_values)
- n = len(sorted_cer)
- points = []
- for step in range(1, steps + 1):
- pct = step / steps
- cutoff = max(1, int(pct * n))
- subset = sorted_cer[:cutoff]
- mean_cer = sum(subset) / len(subset)
- points.append({"pct_docs": round(pct * 100, 1), "mean_cer": round(mean_cer, 6)})
- return points
-
-
-# ---------------------------------------------------------------------------
-# Données pour le diagramme de Venn (erreurs communes / exclusives)
-# ---------------------------------------------------------------------------
-
-def compute_venn_data(
- engine_error_sets: dict[str, set[str]],
-) -> dict:
- """Calcule les cardinalités pour un diagramme de Venn entre 2 ou 3 concurrents.
-
- Parameters
- ----------
- engine_error_sets : {engine_name → set of doc_id:error_token_pair strings}
-
- Returns
- -------
- Pour 2 concurrents :
- {only_a, only_b, both, label_a, label_b}
- Pour 3 concurrents :
- {only_a, only_b, only_c, ab, ac, bc, abc, label_a, label_b, label_c}
- """
- names = list(engine_error_sets.keys())[:3] # max 3 pour Venn lisible
- if len(names) < 2:
- return {}
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
+"""
- sets = {n: engine_error_sets[n] for n in names}
+from picarones.measurements.statistics import * # noqa: F401, F403
- if len(names) == 2:
- a, b = names
- sa, sb = sets[a], sets[b]
- return {
- "type": "venn2",
- "label_a": a,
- "label_b": b,
- "only_a": len(sa - sb),
- "only_b": len(sb - sa),
- "both": len(sa & sb),
- }
- else:
- a, b, c = names
- sa, sb, sc = sets[a], sets[b], sets[c]
- return {
- "type": "venn3",
- "label_a": a,
- "label_b": b,
- "label_c": c,
- "only_a": len(sa - sb - sc),
- "only_b": len(sb - sa - sc),
- "only_c": len(sc - sa - sb),
- "ab": len((sa & sb) - sc),
- "ac": len((sa & sc) - sb),
- "bc": len((sb & sc) - sa),
- "abc": len(sa & sb & sc),
- }
+import picarones.measurements.statistics as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/structure.py b/picarones/core/structure.py
index 2724f4fc76f0a86b0b7c6e3df6fb287dcf56a45b..54a6e738a4c50b8c5b7ba0eff11343eda9e458bb 100644
--- a/picarones/core/structure.py
+++ b/picarones/core/structure.py
@@ -1,229 +1,19 @@
-"""Analyse structurelle des résultats OCR.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.structure`.
-Mesures
--------
-- **Taux de fusion de lignes** : l'OCR produit moins de lignes que le GT
- (plusieurs lignes GT fusionnées en une seule).
-- **Taux de fragmentation** : l'OCR produit plus de lignes que le GT
- (une ligne GT découpée en plusieurs).
-- **Score d'ordre de lecture** : corrélation entre l'ordre des mots GT et OCR,
- approximé par la longueur de la sous-séquence commune la plus longue (LCS).
-- **Taux de conservation des paragraphes** : respect des sauts de paragraphe.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.structure import ...``) de continuer
+à fonctionner sans modification.
-Ces métriques sont calculées indépendamment du contenu textuel — elles mesurent
-la fidélité de la mise en page, pas la qualité des caractères.
-
-Note : sans bounding boxes disponibles, l'analyse se base uniquement sur les
-sauts de ligne présents dans les textes GT et OCR.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import difflib
-from dataclasses import dataclass
-
-
-@dataclass
-class StructureResult:
- """Résultat de l'analyse structurelle pour un document."""
-
- gt_line_count: int = 0
- """Nombre de lignes dans le GT."""
- ocr_line_count: int = 0
- """Nombre de lignes dans l'OCR."""
-
- line_fusion_count: int = 0
- """Nombre de fusions de lignes (GT lignes absorbées)."""
- line_fragmentation_count: int = 0
- """Nombre de fragmentations (GT lignes splittées)."""
-
- reading_order_score: float = 1.0
- """Score d'ordre de lecture [0, 1]. 1 = ordre parfait."""
-
- paragraph_conservation_score: float = 1.0
- """Score de conservation des paragraphes [0, 1]."""
-
- @property
- def line_fusion_rate(self) -> float:
- """Taux de fusion = fusions / lignes GT."""
- return self.line_fusion_count / self.gt_line_count if self.gt_line_count > 0 else 0.0
-
- @property
- def line_fragmentation_rate(self) -> float:
- """Taux de fragmentation = fragmentations / lignes GT."""
- return self.line_fragmentation_count / self.gt_line_count if self.gt_line_count > 0 else 0.0
-
- @property
- def line_accuracy(self) -> float:
- """Exactitude du nombre de lignes : 1 - |delta| / max(gt, ocr)."""
- if self.gt_line_count == 0 and self.ocr_line_count == 0:
- return 1.0
- max_lines = max(self.gt_line_count, self.ocr_line_count)
- delta = abs(self.gt_line_count - self.ocr_line_count)
- return max(0.0, 1.0 - delta / max_lines)
-
- def as_dict(self) -> dict:
- return {
- "gt_line_count": self.gt_line_count,
- "ocr_line_count": self.ocr_line_count,
- "line_fusion_count": self.line_fusion_count,
- "line_fragmentation_count": self.line_fragmentation_count,
- "line_fusion_rate": round(self.line_fusion_rate, 4),
- "line_fragmentation_rate": round(self.line_fragmentation_rate, 4),
- "line_accuracy": round(self.line_accuracy, 4),
- "reading_order_score": round(self.reading_order_score, 4),
- "paragraph_conservation_score": round(self.paragraph_conservation_score, 4),
- }
-
- @classmethod
- def from_dict(cls, data: dict) -> "StructureResult":
- return cls(
- gt_line_count=data.get("gt_line_count", 0),
- ocr_line_count=data.get("ocr_line_count", 0),
- line_fusion_count=data.get("line_fusion_count", 0),
- line_fragmentation_count=data.get("line_fragmentation_count", 0),
- reading_order_score=data.get("reading_order_score", 1.0),
- paragraph_conservation_score=data.get("paragraph_conservation_score", 1.0),
- )
-
-
-def analyze_structure(ground_truth: str, hypothesis: str) -> StructureResult:
- """Analyse la structure d'un document OCR comparée au GT.
-
- Parameters
- ----------
- ground_truth:
- Texte de référence (vérité terrain), avec sauts de ligne.
- hypothesis:
- Texte produit par l'OCR, avec sauts de ligne.
-
- Returns
- -------
- StructureResult
- """
- gt_lines = [ln for ln in ground_truth.splitlines() if ln.strip()]
- ocr_lines = [ln for ln in hypothesis.splitlines() if ln.strip()]
-
- n_gt = len(gt_lines)
- n_ocr = len(ocr_lines)
-
- # Fusions et fragmentations
- fusion_count, frag_count = _count_line_changes(gt_lines, ocr_lines)
-
- # Score d'ordre de lecture via LCS sur les mots
- reading_order = _reading_order_score(ground_truth, hypothesis)
-
- # Score de conservation des paragraphes (sauts de ligne vides = paragraphes)
- para_score = _paragraph_conservation_score(ground_truth, hypothesis)
-
- return StructureResult(
- gt_line_count=n_gt,
- ocr_line_count=n_ocr,
- line_fusion_count=fusion_count,
- line_fragmentation_count=frag_count,
- reading_order_score=reading_order,
- paragraph_conservation_score=para_score,
- )
-
-
-def _count_line_changes(gt_lines: list[str], ocr_lines: list[str]) -> tuple[int, int]:
- """Compte les fusions et fragmentations de lignes via SequenceMatcher."""
- if not gt_lines or not ocr_lines:
- return 0, 0
-
- fusion_count = 0
- frag_count = 0
-
- # Aligner les lignes par contenu
- matcher = difflib.SequenceMatcher(
- None,
- [ln.strip()[:30] for ln in gt_lines], # fingerprint court pour la comparaison
- [ln.strip()[:30] for ln in ocr_lines],
- autojunk=False,
- )
-
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
- if tag == "replace":
- gt_len = i2 - i1
- ocr_len = j2 - j1
- if ocr_len < gt_len:
- # Moins de lignes OCR → fusions
- fusion_count += gt_len - ocr_len
- elif ocr_len > gt_len:
- # Plus de lignes OCR → fragmentations
- frag_count += ocr_len - gt_len
- elif tag == "delete":
- # Lignes GT supprimées dans l'OCR → lacunes (pas fusion/frag)
- pass
- elif tag == "insert":
- # Lignes insérées par l'OCR
- frag_count += j2 - j1
-
- return fusion_count, frag_count
-
-
-def _reading_order_score(ground_truth: str, hypothesis: str) -> float:
- """Score d'ordre de lecture [0, 1] basé sur la LCS des mots.
-
- On calcule la longueur de la sous-séquence commune la plus longue (LCS)
- entre les listes de mots GT et OCR. Un score de 1 signifie que tous les
- mots communs apparaissent dans le même ordre.
- """
- gt_words = ground_truth.split()
- hyp_words = hypothesis.split()
-
- if not gt_words or not hyp_words:
- return 1.0
-
- # Utiliser SequenceMatcher pour approximer la LCS
- matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False)
- # Ratio est 2 * nb_correspondances / (len_gt + len_ocr)
- # C'est un proxy raisonnable de l'ordre de lecture
- ratio = matcher.ratio()
- return round(ratio, 4)
-
-
-def _paragraph_conservation_score(ground_truth: str, hypothesis: str) -> float:
- """Score de conservation des paragraphes [0, 1].
-
- Compte les sauts de paragraphe (lignes vides) dans le GT et mesure
- le taux de conservation dans l'OCR.
- """
- # Un saut de paragraphe = deux sauts de ligne consécutifs
- gt_paras = [p for p in ground_truth.split("\n\n") if p.strip()]
- ocr_paras = [p for p in hypothesis.split("\n\n") if p.strip()]
-
- n_gt_paras = len(gt_paras)
- if n_gt_paras <= 1:
- return 1.0 # pas de paragraphe distinct → score parfait
-
- n_ocr_paras = len(ocr_paras)
- delta = abs(n_gt_paras - n_ocr_paras)
- score = max(0.0, 1.0 - delta / n_gt_paras)
- return round(score, 4)
-
-
-def aggregate_structure(results: list[StructureResult]) -> dict:
- """Agrège les résultats structurels sur un corpus."""
- if not results:
- return {}
-
- import statistics
-
- def _mean(values: list[float]) -> float:
- return round(statistics.mean(values), 4) if values else 0.0
-
- fusion_rates = [r.line_fusion_rate for r in results]
- frag_rates = [r.line_fragmentation_rate for r in results]
- reading_scores = [r.reading_order_score for r in results]
- para_scores = [r.paragraph_conservation_score for r in results]
- line_accuracies = [r.line_accuracy for r in results]
+from picarones.measurements.structure import * # noqa: F401, F403
- return {
- "mean_line_fusion_rate": _mean(fusion_rates),
- "mean_line_fragmentation_rate": _mean(frag_rates),
- "mean_reading_order_score": _mean(reading_scores),
- "mean_paragraph_conservation": _mean(para_scores),
- "mean_line_accuracy": _mean(line_accuracies),
- "document_count": len(results),
- }
+import picarones.measurements.structure as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/taxonomy.py b/picarones/core/taxonomy.py
index a8d36076528d81c781e1ccbf4dd18c9341032237..735c53e2738c63bebd577c6d1e8ccd6b0df98b4a 100644
--- a/picarones/core/taxonomy.py
+++ b/picarones/core/taxonomy.py
@@ -1,350 +1,19 @@
-"""Taxonomie des erreurs OCR — classification automatique (classes 1 à 9).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.taxonomy`.
-Chaque erreur identifiée par l'alignement GT↔OCR est catégorisée selon
-la taxonomie Picarones :
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.taxonomy import ...``) de continuer
+à fonctionner sans modification.
-| Classe | Nom | Description |
-|--------|-------------------|----------------------------------------------------|
-| 1 | visual_confusion | Confusion morphologique (rn/m, l/1, O/0, u/n…) |
-| 2 | diacritic_error | Diacritique absent, incorrect ou ajouté |
-| 3 | case_error | Erreur de casse uniquement (A/a) |
-| 4 | ligature_error | Ligature non résolue ou mal résolue |
-| 5 | abbreviation_error| Abréviation médiévale non développée |
-| 6 | hapax | Mot introuvable dans tout lexique |
-| 7 | segmentation_error| Fusion ou fragmentation de tokens (mots/lignes) |
-| 8 | oov_character | Caractère hors-vocabulaire du moteur |
-| 9 | lacuna | Texte présent dans le GT absent de l'OCR |
-| 10 | over_normalization| Sur-normalisation LLM (voir pipelines/) |
-
-Note : la classe 10 est calculée par picarones/pipelines/over_normalization.py.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import difflib
-import unicodedata
-from dataclasses import dataclass, field
-
-
-# ---------------------------------------------------------------------------
-# Tables de référence pour la classification
-# ---------------------------------------------------------------------------
-
-#: Confusions visuelles bien connues en OCR (caractères morphologiquement proches)
-VISUAL_CONFUSIONS: dict[frozenset, str] = {}
-_VISUAL_PAIRS: list[tuple[str, str]] = [
- # Minuscules
- ("r", "n"), ("rn", "m"), ("l", "1"), ("l", "i"), ("l", "|"),
- ("O", "0"), ("O", "o"), ("u", "n"), ("n", "u"), ("v", "u"),
- ("c", "e"), ("e", "c"), ("a", "o"), ("o", "a"),
- ("f", "ſ"), ("ſ", "f"), ("f", "t"),
- ("h", "li"), ("h", "lı"),
- ("m", "rn"), ("m", "in"),
- ("d", "cl"), ("d", "a"),
- ("q", "g"), ("p", "q"),
- # Majuscules ↔ minuscules homographes (classe 1, pas classe 3)
- ("I", "l"), ("I", "1"),
- # Chiffres
- ("1", "I"), ("1", "l"), ("0", "O"),
- # Ponctuation
- (".", ","), (",", "."),
-]
-for _a, _b in _VISUAL_PAIRS:
- VISUAL_CONFUSIONS[frozenset({_a, _b})] = f"{_a}/{_b}"
-
-#: Couples de ligatures pour la détection des erreurs de ligatures
-from picarones.core.char_scores import LIGATURE_TABLE, DIACRITIC_MAP # noqa: E402
-
-# Caractères hors-ASCII présumés hors-vocabulaire (alphabet non latin de base)
-_LATIN_BASIC = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
- " \t\n.,;:!?-_'\"«»()[]{}/@#%&*+=/\\|<>~^")
-
-
-# ---------------------------------------------------------------------------
-# Résultat structuré
-# ---------------------------------------------------------------------------
-
-@dataclass
-class TaxonomyResult:
- """Résultat de la classification taxonomique des erreurs pour un document."""
-
- counts: dict[str, int] = field(default_factory=dict)
- """Nombre d'erreurs par classe. Clés : 'visual_confusion', 'diacritic_error'…"""
-
- examples: dict[str, list[dict]] = field(default_factory=dict)
- """Exemples d'erreurs par classe (max 5 par classe).
- Format : [{'gt': 'chaîne', 'ocr': 'chaîne', 'position': int}]
- """
-
- total_errors: int = 0
- """Nombre total d'erreurs classifiées."""
-
- @property
- def class_distribution(self) -> dict[str, float]:
- """Distribution relative (0–1) par classe."""
- if not self.total_errors:
- return {}
- return {
- cls: round(cnt / self.total_errors, 4)
- for cls, cnt in self.counts.items()
- }
-
- def as_dict(self) -> dict:
- return {
- "counts": self.counts,
- "total_errors": self.total_errors,
- "class_distribution": self.class_distribution,
- "examples": {
- cls: exs[:3] for cls, exs in self.examples.items()
- },
- }
-
- @classmethod
- def from_dict(cls, data: dict) -> "TaxonomyResult":
- return cls(
- counts=data.get("counts", {}),
- examples=data.get("examples", {}),
- total_errors=data.get("total_errors", 0),
- )
-
-
-# Noms des classes en ordre
-ERROR_CLASSES = [
- "visual_confusion",
- "diacritic_error",
- "case_error",
- "ligature_error",
- "abbreviation_error",
- "hapax",
- "segmentation_error",
- "oov_character",
- "lacuna",
-]
-
-
-# ---------------------------------------------------------------------------
-# Classification principale
-# ---------------------------------------------------------------------------
-
-def classify_errors(
- ground_truth: str,
- hypothesis: str,
- max_examples: int = 5,
-) -> TaxonomyResult:
- """Classifie automatiquement les erreurs OCR dans une paire GT/OCR.
-
- L'alignement utilise difflib.SequenceMatcher au niveau mot pour détecter
- les erreurs de segmentation, puis au niveau caractère pour les autres classes.
-
- Parameters
- ----------
- ground_truth:
- Texte de référence (vérité terrain).
- hypothesis:
- Texte produit par l'OCR.
- max_examples:
- Nombre maximal d'exemples conservés par classe.
-
- Returns
- -------
- TaxonomyResult
- """
- counts: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES}
- examples: dict[str, list[dict]] = {cls: [] for cls in ERROR_CLASSES}
- total = 0
-
- if not ground_truth and not hypothesis:
- return TaxonomyResult(counts=counts, examples=examples, total_errors=0)
-
- # -----------------------------------------------------------------------
- # Niveau mot : détecter segmentation (classe 7) et lacunes (classe 9)
- # -----------------------------------------------------------------------
- gt_words = ground_truth.split()
- hyp_words = hypothesis.split()
-
- word_matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False)
- for tag, i1, i2, j1, j2 in word_matcher.get_opcodes():
- if tag == "delete":
- # Mots GT absents de l'OCR → lacune (classe 9)
- for w in gt_words[i1:i2]:
- counts["lacuna"] += 1
- total += 1
- if len(examples["lacuna"]) < max_examples:
- examples["lacuna"].append({"gt": w, "ocr": "", "position": i1})
-
- elif tag == "insert":
- # Mots ajoutés par l'OCR → généralement classe 8 (hors-vocab)
- for w in hyp_words[j1:j2]:
- if _is_oov_word(w):
- counts["oov_character"] += 1
- total += 1
-
- elif tag == "replace":
- gt_seg = gt_words[i1:i2]
- hyp_seg = hyp_words[j1:j2]
- # Segmentation : fusion de mots (moins de mots OCR) ou fragmentation
- if len(hyp_seg) != len(gt_seg):
- n_seg = abs(len(gt_seg) - len(hyp_seg))
- counts["segmentation_error"] += n_seg
- total += n_seg
- if len(examples["segmentation_error"]) < max_examples:
- examples["segmentation_error"].append({
- "gt": " ".join(gt_seg),
- "ocr": " ".join(hyp_seg),
- "position": i1,
- })
- else:
- # Paires mot-à-mot
- for gt_w, hyp_w in zip(gt_seg, hyp_seg):
- if gt_w != hyp_w:
- _classify_word_error(
- gt_w, hyp_w, counts, examples, max_examples
- )
- total += 1
-
- return TaxonomyResult(
- counts=counts,
- examples=examples,
- total_errors=total,
- )
-
-
-def _classify_word_error(
- gt_word: str,
- hyp_word: str,
- counts: dict[str, int],
- examples: dict[str, list[dict]],
- max_examples: int,
-) -> None:
- """Classifie l'erreur entre deux mots non-identiques."""
- # Classe 3 : erreur de casse seule
- if gt_word.casefold() == hyp_word.casefold() and gt_word != hyp_word:
- counts["case_error"] += 1
- if len(examples["case_error"]) < max_examples:
- examples["case_error"].append({"gt": gt_word, "ocr": hyp_word})
- return
-
- # Classe 4 : erreur de ligature
- gt_norm = unicodedata.normalize("NFC", gt_word)
- hyp_norm = unicodedata.normalize("NFC", hyp_word)
- if _is_ligature_error(gt_norm, hyp_norm):
- counts["ligature_error"] += 1
- if len(examples["ligature_error"]) < max_examples:
- examples["ligature_error"].append({"gt": gt_word, "ocr": hyp_word})
- return
-
- # Classe 5 : erreur d'abréviation (présence de ꝑ, ꝓ, ꝗ dans le GT)
- if _is_abbreviation_error(gt_norm, hyp_norm):
- counts["abbreviation_error"] += 1
- if len(examples["abbreviation_error"]) < max_examples:
- examples["abbreviation_error"].append({"gt": gt_word, "ocr": hyp_word})
- return
-
- # Classe 2 : erreur diacritique
- if _is_diacritic_error(gt_norm, hyp_norm):
- counts["diacritic_error"] += 1
- if len(examples["diacritic_error"]) < max_examples:
- examples["diacritic_error"].append({"gt": gt_word, "ocr": hyp_word})
- return
-
- # Classe 1 : confusion visuelle (comparaison char par char)
- if _is_visual_confusion(gt_norm, hyp_norm):
- counts["visual_confusion"] += 1
- if len(examples["visual_confusion"]) < max_examples:
- examples["visual_confusion"].append({"gt": gt_word, "ocr": hyp_word})
- return
-
- # Classe 8 : caractère hors-vocabulaire
- if _is_oov_word(hyp_word):
- counts["oov_character"] += 1
- if len(examples["oov_character"]) < max_examples:
- examples["oov_character"].append({"gt": gt_word, "ocr": hyp_word})
- return
-
- # Classe 6 : hapax (erreur résiduelle non classifiable)
- counts["hapax"] += 1
- if len(examples["hapax"]) < max_examples:
- examples["hapax"].append({"gt": gt_word, "ocr": hyp_word})
-
-
-def _is_ligature_error(gt: str, hyp: str) -> bool:
- """Vrai si la différence implique une ligature Unicode."""
- # GT contient une ligature que l'OCR a décomposée, ou vice versa
- for lig, seqs in LIGATURE_TABLE.items():
- if lig in gt:
- for seq in seqs:
- if seq in hyp and lig not in hyp:
- return True
- for seq in seqs:
- if seq in gt and lig in hyp:
- return True
- return False
-
-
-def _is_abbreviation_error(gt: str, hyp: str) -> bool:
- """Vrai si le GT contient un caractère d'abréviation médiévale."""
- abbreviation_chars = "\uA751\uA753\uA757" # ꝑ ꝓ ꝗ
- return any(c in gt for c in abbreviation_chars)
-
-
-def _is_diacritic_error(gt: str, hyp: str) -> bool:
- """Vrai si la différence est principalement due à des diacritiques."""
- # Comparer les formes sans diacritiques
- def strip_diacritics(text: str) -> str:
- nfd = unicodedata.normalize("NFD", text)
- return "".join(c for c in nfd if unicodedata.category(c) != "Mn")
-
- gt_stripped = strip_diacritics(gt)
- hyp_stripped = strip_diacritics(hyp)
- # Si les mots sont identiques sans diacritiques → erreur diacritique
- if gt_stripped.casefold() == hyp_stripped.casefold() and gt != hyp:
- return True
- # Si le GT contient des diacritiques que l'OCR a perdus et que les textes
- # sans diacritiques sont identiques (même longueur requise)
- gt_has_diac = any(c in DIACRITIC_MAP for c in gt)
- return gt_has_diac and len(gt) == len(hyp) and gt_stripped.casefold() == hyp_stripped.casefold()
-
-
-def _is_visual_confusion(gt: str, hyp: str) -> bool:
- """Vrai si la différence implique des confusions visuelles connues."""
- if abs(len(gt) - len(hyp)) > 2:
- return False
- # Vérifier les paires de confusions connues
- for pair in VISUAL_CONFUSIONS:
- chars = list(pair)
- if len(chars) == 2:
- a, b = chars
- if a in gt and b in hyp and a not in hyp:
- return True
- if b in gt and a in hyp and b not in hyp:
- return True
- return False
-
-
-def _is_oov_word(word: str) -> bool:
- """Vrai si le mot contient des caractères hors de l'alphabet latin de base."""
- return any(c not in _LATIN_BASIC and not c.isalpha() for c in word)
-
-
-# ---------------------------------------------------------------------------
-# Agrégation
-# ---------------------------------------------------------------------------
-
-def aggregate_taxonomy(results: list[TaxonomyResult]) -> dict:
- """Agrège les résultats taxonomiques sur un corpus."""
- combined: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES}
- total = 0
- for r in results:
- for cls, cnt in r.counts.items():
- combined[cls] = combined.get(cls, 0) + cnt
- total += r.total_errors
+from picarones.measurements.taxonomy import * # noqa: F401, F403
- distribution = {
- cls: round(cnt / total, 4) if total > 0 else 0.0
- for cls, cnt in combined.items()
- }
- return {
- "counts": combined,
- "total_errors": total,
- "class_distribution": distribution,
- }
+import picarones.measurements.taxonomy as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/taxonomy_comparison.py b/picarones/core/taxonomy_comparison.py
index eb99d5ef20d8af1985c2dd42b777499c3d1b58f3..f8ece378cc73903201da37cd7ee9da9d8ab615b8 100644
--- a/picarones/core/taxonomy_comparison.py
+++ b/picarones/core/taxonomy_comparison.py
@@ -1,161 +1,19 @@
-"""Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.taxonomy_comparison`.
-Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4).
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.taxonomy_comparison import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale
-qu'un moteur a un profil taxonomique éloigné de ses concurrents,
-mais le rapport n'expose pas cette différence visuellement. Ce
-sprint répond à *« deux moteurs ont le même CER global, mais lequel
-fait des erreurs plus récupérables ? »*.
-
-Lecture concrète
-----------------
-- Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables
- par un post-processing trivial (récupérables).
-- Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) →
- irrécupérables sans relire l'image.
-
-À CER égal, A est massivement préférable pour un workflow
-d'édition critique. Cette vue rend la différence visible.
-
-Catégorisation des classes
---------------------------
-On annote chaque classe d'erreur d'un degré de **récupérabilité**
-(critère éditorial pragmatique, pas verdict imposé) :
-
-- ``recoverable`` : récupérable par post-processing trivial
- (case_error, ligature_error, abbreviation_error)
-- ``difficult`` : récupérable au prix d'un effort
- (diacritic_error, visual_confusion, hapax)
-- ``irrecoverable`` : impossible à corriger sans l'image
- (lacuna, oov_character, segmentation_error)
-
-L'utilisateur consulte ces catégories comme un guide, pas un
-verdict — c'est lui qui juge selon ses besoins éditoriaux.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-# Classification éditoriale. Documentée dans la docstring.
-RECOVERABILITY: dict[str, str] = {
- "case_error": "recoverable",
- "ligature_error": "recoverable",
- "abbreviation_error": "recoverable",
- "diacritic_error": "difficult",
- "visual_confusion": "difficult",
- "hapax": "difficult",
- "lacuna": "irrecoverable",
- "oov_character": "irrecoverable",
- "segmentation_error": "irrecoverable",
-}
-
-
-def _normalize_counts(counts: dict[str, int]) -> dict[str, float]:
- """Convertit un dict de comptes en proportions [0, 1]."""
- total = sum(counts.values())
- if total <= 0:
- return {k: 0.0 for k in counts}
- return {k: v / total for k, v in counts.items()}
-
-
-def compare_taxonomies(
- engine_a_name: str,
- engine_a_counts: dict[str, int],
- engine_b_name: str,
- engine_b_counts: dict[str, int],
-) -> Optional[dict]:
- """Compare deux profils taxonomiques.
-
- Parameters
- ----------
- engine_a_name, engine_b_name:
- Noms d'identification des moteurs (utilisés dans le rendu).
- engine_a_counts, engine_b_counts:
- Maps ``{class_name: count}`` produites par
- ``aggregate_taxonomy``.
-
- Returns
- -------
- Optional[dict]
- ``{
- "engine_a": str, "engine_b": str,
- "total_a": int, "total_b": int,
- "classes": list[str], # classes apparaissant chez A ou B
- "proportions_a": dict[str, float],
- "proportions_b": dict[str, float],
- "deltas": dict[str, float], # prop_b - prop_a (signé)
- "recoverability": dict[str, str], # mapping class → niveau
- "totals_by_recoverability": {
- "recoverable": {"a": float, "b": float},
- "difficult": {"a": float, "b": float},
- "irrecoverable": {"a": float, "b": float},
- },
- }``
- Ou ``None`` si les deux moteurs ont 0 erreur chacun.
- """
- if engine_a_name == engine_b_name:
- # On accepte des comparaisons même si les noms sont
- # identiques (cas tests), mais on émet un warning.
- logger.warning(
- "[taxonomy_comparison] engine_a et engine_b ont le même nom : %s",
- engine_a_name,
- )
-
- total_a = sum(engine_a_counts.values()) if engine_a_counts else 0
- total_b = sum(engine_b_counts.values()) if engine_b_counts else 0
- if total_a == 0 and total_b == 0:
- return None
-
- classes = sorted(set(engine_a_counts) | set(engine_b_counts))
- if not classes:
- return None
-
- prop_a = _normalize_counts(
- {c: engine_a_counts.get(c, 0) for c in classes},
- )
- prop_b = _normalize_counts(
- {c: engine_b_counts.get(c, 0) for c in classes},
- )
- deltas = {c: prop_b[c] - prop_a[c] for c in classes}
-
- # Agrégat par récupérabilité (utile pour la lecture rapide)
- totals_recov: dict[str, dict[str, float]] = {
- "recoverable": {"a": 0.0, "b": 0.0},
- "difficult": {"a": 0.0, "b": 0.0},
- "irrecoverable": {"a": 0.0, "b": 0.0},
- }
- for cls in classes:
- level = RECOVERABILITY.get(cls, "difficult")
- if level not in totals_recov:
- level = "difficult"
- totals_recov[level]["a"] += prop_a[cls]
- totals_recov[level]["b"] += prop_b[cls]
-
- return {
- "engine_a": engine_a_name,
- "engine_b": engine_b_name,
- "total_a": total_a,
- "total_b": total_b,
- "classes": classes,
- "proportions_a": prop_a,
- "proportions_b": prop_b,
- "deltas": deltas,
- "recoverability": {
- cls: RECOVERABILITY.get(cls, "difficult") for cls in classes
- },
- "totals_by_recoverability": totals_recov,
- }
-
+from picarones.measurements.taxonomy_comparison import * # noqa: F401, F403
-__all__ = [
- "RECOVERABILITY",
- "compare_taxonomies",
-]
+import picarones.measurements.taxonomy_comparison as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/throughput.py b/picarones/core/throughput.py
index 47d0ed674492f221013aa8a53c3632db14cbe6b5..2564b62b0b192ff0a7dd38c15898843fdcb272f8 100644
--- a/picarones/core/throughput.py
+++ b/picarones/core/throughput.py
@@ -1,165 +1,19 @@
-"""Throughput effectif (Sprint 91 — A.II.6).
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.throughput`.
-Sprint 91 — A.II.6 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.throughput import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Le throughput brut (pages/heure d'OCR pur) ment quand un moteur
-est rapide mais imprécis : la correction humaine *post hoc*
-absorbe le gain. La **vraie** vitesse opérationnelle inclut
-le temps de correction. Cette métrique discrimine fortement
-entre un cloud rapide à 30 % de timeouts/erreurs et un local
-lent à 100 % de fiabilité.
-
-Formule
--------
-.. code::
-
- pages_par_heure_utilisable =
- pages_traitées / (durée_totale + temps_correction_humaine)
-
-Le temps de correction est estimé linéairement :
-``temps_par_erreur × nombre_d_erreurs``. Le défaut
-``time_per_error_seconds=5.0`` correspond aux études HTR-United
-(saisie manuelle d'une correction de mot par un opérateur
-formé : ≈ 5 s par erreur). L'utilisateur peut le surcharger
-pour son institution.
-
-Sortie
-------
-``compute_effective_throughput(n_pages, duration_seconds,
-n_errors, time_per_error_seconds=5.0)`` retourne ``{n_pages,
-duration_seconds, n_errors, time_per_error_seconds,
-correction_time_seconds, total_seconds, pages_per_hour_raw,
-pages_per_hour_effective, drag_ratio}``.
-
-``aggregate_effective_throughput(per_engine_data)`` agrège par
-moteur sur l'ensemble du corpus.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from typing import Iterable, Optional
-
-logger = logging.getLogger(__name__)
-
-
-_DEFAULT_TIME_PER_ERROR_SECONDS = 5.0
-
-
-def compute_effective_throughput(
- n_pages: int,
- duration_seconds: float,
- n_errors: int,
- *,
- time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS,
-) -> Optional[dict]:
- """Throughput effectif (pages/heure utilisables).
-
- Parameters
- ----------
- n_pages:
- Nombre de pages traitées.
- duration_seconds:
- Durée totale de l'OCR (somme des durées par doc).
- n_errors:
- Nombre d'erreurs (au niveau mot, typiquement
- ``WER × n_words_total``).
- time_per_error_seconds:
- Temps moyen de correction humaine par erreur. Défaut
- 5 s (HTR-United). Doit être ≥ 0.
-
- Returns
- -------
- dict | None
- ``None`` si ``n_pages == 0`` ou ``total_seconds == 0``
- (pas de division par zéro).
- """
- if n_pages <= 0:
- return None
- if duration_seconds < 0 or n_errors < 0 or time_per_error_seconds < 0:
- raise ValueError(
- "duration_seconds, n_errors et time_per_error_seconds "
- "doivent être ≥ 0",
- )
- correction_seconds = float(n_errors) * float(time_per_error_seconds)
- total_seconds = float(duration_seconds) + correction_seconds
- if total_seconds <= 0:
- # Aucun temps écoulé : impossible de définir un throughput
- return None
- pages_per_hour_raw = (
- n_pages / duration_seconds * 3600.0
- if duration_seconds > 0 else None
- )
- pages_per_hour_effective = n_pages / total_seconds * 3600.0
- drag_ratio = (
- correction_seconds / total_seconds if total_seconds > 0 else 0.0
- )
- return {
- "n_pages": int(n_pages),
- "duration_seconds": float(duration_seconds),
- "n_errors": int(n_errors),
- "time_per_error_seconds": float(time_per_error_seconds),
- "correction_time_seconds": correction_seconds,
- "total_seconds": total_seconds,
- "pages_per_hour_raw": pages_per_hour_raw,
- "pages_per_hour_effective": pages_per_hour_effective,
- "drag_ratio": drag_ratio,
- }
-
-
-def aggregate_effective_throughput(
- per_engine: Iterable[dict],
- *,
- time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS,
-) -> Optional[dict]:
- """Agrège le throughput effectif par moteur.
-
- Parameters
- ----------
- per_engine:
- Itérable de dicts ``{engine_name, n_pages,
- duration_seconds, n_errors}``.
-
- Returns
- -------
- dict | None
- ``{
- "engines": [
- {"engine_name", ..., compute_effective_throughput
- fields},
- ...
- ],
- "time_per_error_seconds": float,
- }`` ou ``None`` si aucun moteur exploitable.
- """
- rows: list[dict] = []
- for entry in per_engine:
- if not isinstance(entry, dict):
- continue
- name = entry.get("engine_name") or entry.get("engine")
- if not name:
- continue
- result = compute_effective_throughput(
- int(entry.get("n_pages") or 0),
- float(entry.get("duration_seconds") or 0.0),
- int(entry.get("n_errors") or 0),
- time_per_error_seconds=time_per_error_seconds,
- )
- if result is None:
- continue
- result["engine_name"] = str(name)
- rows.append(result)
- if not rows:
- return None
- return {
- "engines": rows,
- "time_per_error_seconds": float(time_per_error_seconds),
- }
-
+from picarones.measurements.throughput import * # noqa: F401, F403
-__all__ = [
- "compute_effective_throughput",
- "aggregate_effective_throughput",
-]
+import picarones.measurements.throughput as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/core/worst_lines.py b/picarones/core/worst_lines.py
index dfece53263f29f83db9cb6dbaaf749d719b04857..ebf267317a1207c015d9247712ea09f7eb05ff44 100644
--- a/picarones/core/worst_lines.py
+++ b/picarones/core/worst_lines.py
@@ -1,199 +1,19 @@
-"""Extraction transversale des « Worst lines » du corpus — Sprint 72.
+"""Alias rétrocompat — module déplacé dans :mod:`picarones.measurements.worst_lines`.
-Sprint 72 — A.I.1 chantier 1 du plan d'évolution 2026.
+Phase E du chantier de refonte en 3 cercles. Cette mesure (Cercle 2)
+n'est plus dans ``picarones.core/`` ; elle vit dans
+``picarones.measurements/``. L'alias ici permet aux imports
+historiques (``from picarones.core.worst_lines import ...``) de continuer
+à fonctionner sans modification.
-Pourquoi ce module
-------------------
-Le percentile p95 du CER ligne (calculé par ``line_metrics.py``,
-Sprint 10) est un nombre abstrait : *« 5 % de mes lignes ont un
-CER > 0,42 »*. Le chercheur veut **voir** ces lignes : leur
-texte, leur diff, leur document parent, pour comprendre ce qui
-casse.
-
-Ce module fournit la requête transversale qui collecte, depuis un
-``BenchmarkResult``, les **N lignes les plus mal transcrites de
-tout le corpus**, classées par CER ligne. Filtrable par moteur
-et par strate.
-
-Limite documentée
------------------
-``DocumentResult.line_metrics`` ne stocke que les CER par ligne,
-**pas le texte des lignes**. Pour récupérer les textes GT/hyp
-on resplitte ``ground_truth`` et ``hypothesis`` du
-``DocumentResult`` à l'index de la ligne. Cette logique
-**suppose un BenchmarkResult non-compacté** — après ``compact()``
-les textes sont tronqués à 200 caractères et les lignes au-delà
-de cette troncature ne sont plus accessibles. En pratique on
-extrait les worst lines **avant** la sérialisation/compactage.
+Voir :doc:`docs/architecture-cercles.md` pour la cartographie des
+3 cercles. Le ``core/`` strict ne contient plus que les abstractions
+du domaine et l'orchestration (Cercle 1).
"""
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-from typing import Optional
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class WorstLineEntry:
- """Une ligne du corpus identifiée comme mal transcrite.
-
- Champs
- ------
- rank:
- Position dans le classement (1-based, 1 = pire CER).
- cer:
- CER de la ligne ∈ [0, 1].
- engine_name:
- Nom du moteur ayant produit cette hypothèse.
- doc_id:
- Identifiant du document parent.
- line_index:
- Index 0-based de la ligne dans le document GT.
- gt_line:
- Texte de la ligne dans la GT.
- hyp_line:
- Texte correspondant dans l'hypothèse (peut être ``""``
- si l'OCR a sauté la ligne).
- script_type:
- Strate du document si disponible (``script_type``
- capturé par le runner pour la stratification A.III).
- """
-
- rank: int
- cer: float
- engine_name: str
- doc_id: str
- line_index: int
- gt_line: str
- hyp_line: str
- script_type: Optional[str] = None
-
-
-def _split_lines(text: Optional[str]) -> list[str]:
- """Splitte un texte en lignes (cohérent avec ``line_metrics``).
-
- Supporte les fins de ligne ``\\n``, ``\\r\\n``, ``\\r``. Les
- lignes vides sont préservées. Retourne une liste vide si le
- texte est None ou vide.
- """
- if not text:
- return []
- # ``splitlines`` gère \r\n et \r correctement
- return text.splitlines()
-
-
-def _line_at(text: Optional[str], index: int) -> str:
- """Retourne la ligne à l'index demandé, ou ``""`` si l'index
- est hors borne (cas où l'OCR a moins de lignes que la GT)."""
- lines = _split_lines(text)
- if 0 <= index < len(lines):
- return lines[index]
- return ""
-
-
-def extract_worst_lines(
- benchmark,
- *,
- top_n: int = 20,
- engine_filter: Optional[str] = None,
- script_type_filter: Optional[str] = None,
-) -> list[WorstLineEntry]:
- """Extrait les ``top_n`` lignes les plus mal transcrites du
- corpus, transversalement à tous les moteurs et documents.
-
- Parameters
- ----------
- benchmark:
- ``BenchmarkResult`` non-compacté (cf. limite ci-dessus).
- L'objet doit exposer ``engine_reports`` (liste de
- ``EngineReport``) et optionnellement ``doc_strata``
- (map ``{doc_id: script_type}``, Sprint 45).
- top_n:
- Nombre de lignes à retourner. Défaut : 20.
- engine_filter:
- Si fourni, n'inclut que les lignes produites par ce moteur
- (match exact sur ``engine_name``).
- script_type_filter:
- Si fourni, n'inclut que les lignes des documents de cette
- strate (nécessite ``benchmark.doc_strata``).
-
- Returns
- -------
- list[WorstLineEntry]
- Liste triée par CER décroissant (pire en premier),
- rang 1-based attribué après tri. Vide si aucune ligne
- exploitable.
- """
- if top_n <= 0:
- return []
-
- doc_strata = getattr(benchmark, "doc_strata", None) or {}
- candidates: list[tuple[float, str, str, int, str, str, Optional[str]]] = []
-
- for engine_report in getattr(benchmark, "engine_reports", []):
- engine_name = engine_report.engine_name
- if engine_filter is not None and engine_name != engine_filter:
- continue
- for dr in engine_report.document_results:
- line_metrics = getattr(dr, "line_metrics", None)
- if not line_metrics:
- continue
- cer_per_line = line_metrics.get("cer_per_line") if isinstance(
- line_metrics, dict,
- ) else getattr(line_metrics, "cer_per_line", None)
- if not cer_per_line:
- continue
- doc_id = dr.doc_id
- doc_strata_value = doc_strata.get(doc_id)
- if (
- script_type_filter is not None
- and doc_strata_value != script_type_filter
- ):
- continue
- for idx, cer in enumerate(cer_per_line):
- if cer <= 0.0:
- continue
- gt_line = _line_at(dr.ground_truth, idx)
- hyp_line = _line_at(dr.hypothesis, idx)
- if not gt_line and not hyp_line:
- continue
- candidates.append((
- float(cer), engine_name, doc_id, idx,
- gt_line, hyp_line, doc_strata_value,
- ))
-
- if not candidates:
- return []
-
- # Tri par CER décroissant ; en cas d'égalité, ordre stable
- # (engine, doc_id, line_index) pour reproductibilité.
- candidates.sort(
- key=lambda c: (-c[0], c[1], c[2], c[3]),
- )
- selected = candidates[:top_n]
-
- return [
- WorstLineEntry(
- rank=i + 1,
- cer=cer,
- engine_name=engine,
- doc_id=doc_id,
- line_index=line_index,
- gt_line=gt_line,
- hyp_line=hyp_line,
- script_type=script_type,
- )
- for i, (
- cer, engine, doc_id, line_index,
- gt_line, hyp_line, script_type,
- ) in enumerate(selected)
- ]
-
+from picarones.measurements.worst_lines import * # noqa: F401, F403
-__all__ = [
- "WorstLineEntry",
- "extract_worst_lines",
-]
+import picarones.measurements.worst_lines as _module
+__all__ = getattr(_module, "__all__", [
+ nm for nm in dir(_module) if not nm.startswith("_")
+])
diff --git a/picarones/measurements/__init__.py b/picarones/measurements/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d5ae5f22b556d6c3680939ed9a69f42eeb65b202
--- /dev/null
+++ b/picarones/measurements/__init__.py
@@ -0,0 +1,87 @@
+"""Métriques officielles Picarones — Cercle 2.
+
+Phase E du chantier de refonte en 3 cercles. Ce package contient
+l'ensemble des **mesures et analyses au-delà du noyau** : tout ce qui
+calcule, agrège ou interprète des métriques sur un corpus, mais qui
+n'est pas une abstraction du domaine (Cercle 1, ``core/``) ni un
+plugin niche (Cercle 3, ``extras/``).
+
+Sous-modules
+------------
+Métriques scalaires et structurelles :
+
+- :mod:`confusion` matrice de confusion Unicode
+- :mod:`char_scores` scores ligatures/diacritiques
+- :mod:`taxonomy` taxonomie 9 classes d'erreurs
+- :mod:`taxonomy_comparison` comparaison taxonomique miroir
+- :mod:`structure` analyse structurelle (lignes/blocs)
+- :mod:`line_metrics` distribution CER par ligne (Gini, percentiles)
+- :mod:`hallucination` détection hallucinations VLM
+- :mod:`reading_order` F1 ordre de lecture (ICDAR 2015)
+- :mod:`layout` F1 layout par type de région
+- :mod:`error_absorption` correction vs introduction par jonction
+- :mod:`searchability` recherchabilité fuzzy (Levenshtein)
+- :mod:`numerical_sequences` préservation dates/cotes/numéraux
+- :mod:`numerical_sequences_runner`
+- :mod:`rare_tokens` rappel sur tokens rares
+- :mod:`readability` Δ Flesch (sur-normalisation)
+- :mod:`readability_runner`
+- :mod:`searchability_runner`
+- :mod:`specialization` spécialisation inter-moteurs
+- :mod:`worst_lines` lignes pires globales
+- :mod:`inter_engine` divergence taxonomique + oracle gap
+- :mod:`incremental_comparison` ANOVA-like par slot
+- :mod:`baseline_comparison` comparaison à l'historique
+- :mod:`longitudinal` régression linéaire + change-point
+
+Fiabilité et calibration :
+
+- :mod:`calibration` ECE, MCE, reliability bins
+- :mod:`reliability` IAA Cohen κ + multirun stability
+- :mod:`robustness` courbes CER vs dégradation
+- :mod:`robustness_projection` projection sur corpus réel
+
+NER :
+
+- :mod:`ner`, :mod:`ner_backends`
+
+Économie et opération :
+
+- :mod:`pricing` table tarifaire
+- :mod:`throughput` pages/h effectif
+- :mod:`cost_projection` projection à volume cible
+- :mod:`marginal_cost` coût par erreur évitée
+
+Contexte corpus :
+
+- :mod:`history` historique SQLite
+- :mod:`difficulty` score difficulté intrinsèque
+- :mod:`image_quality` contraste, bruit, flou…
+- :mod:`normalization` profils Unicode
+
+Statistiques :
+
+- :mod:`statistics` Wilcoxon, Friedman, Nemenyi, Pareto, CDD
+
+Aide à la décision :
+
+- :mod:`levers` leviers d'amélioration factuels
+- :mod:`equivalence_profile` curseur fin équivalences diplomatiques
+
+Hooks et registres :
+
+- :mod:`builtin_hooks` 12 hooks doc + 12 agrégateurs natifs
+
+Moteur narratif :
+
+- :mod:`narrative` (sous-package) : facts, registry, arbiter, renderer, 18 détecteurs
+
+Rétrocompatibilité absolue
+--------------------------
+Tous les modules historiquement dans ``picarones.core.X`` restent
+accessibles via des fichiers-shims qui les redirigent vers le nouvel
+emplacement. Aucun import existant ne casse.
+
+Voir :doc:`docs/architecture-cercles.md` et la phase E du plan de
+refonte.
+"""
diff --git a/picarones/measurements/baseline_comparison.py b/picarones/measurements/baseline_comparison.py
new file mode 100644
index 0000000000000000000000000000000000000000..22f021aaceb4952d7f96271e325b6864b50a9258
--- /dev/null
+++ b/picarones/measurements/baseline_comparison.py
@@ -0,0 +1,229 @@
+"""Comparaison à la baseline historique — Sprint 73 (A.I.3).
+
+Sprint 73 — chantier 2 d'A.I.3 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+L'historique SQLite (``picarones/core/history.py``, Sprint 8)
+existe mais aucun détecteur narratif ne le lit. Ce module fournit
+la couche de calcul qui répond à *« comment ce moteur se
+comporte-t-il sur ce corpus, **par rapport à ses runs précédents
+de mon institution** ? »*.
+
+Sortie typique
+--------------
+Un dict par moteur :
+
+.. code-block:: python
+
+ {
+ "engine_name": "tesseract",
+ "cer_current": 0.052,
+ "cer_historical_mean": 0.041,
+ "cer_historical_median": 0.040,
+ "n_runs": 12,
+ "absolute_delta": 0.011,
+ "relative_delta": 0.268, # +26,8 % vs moyenne
+ "off_baseline": True,
+ }
+
+Le détecteur narratif ``engine_off_baseline`` (Sprint 73)
+consomme cette structure pour émettre des Facts.
+
+Garde-fous
+----------
+- ``min_runs`` (défaut 5) : si l'historique pour le moteur×corpus
+ contient moins de runs, on retourne ``None`` plutôt que de
+ comparer à un échantillon trop petit.
+- ``corpus_name`` est utilisé pour ne comparer qu'aux runs **du
+ même corpus** (sinon on compare des pommes et des oranges :
+ registres paroissiaux vs imprimés modernes).
+- Le run courant lui-même n'est pas inclus dans la baseline (on
+ passe le ``current_run_id`` à exclure).
+"""
+
+from __future__ import annotations
+
+import logging
+import statistics
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+def compute_engine_baseline(
+ history,
+ engine_name: str,
+ corpus_name: str,
+ current_cer: float,
+ *,
+ current_run_id: Optional[str] = None,
+ min_runs: int = 5,
+ relative_delta_threshold: float = 0.20,
+) -> Optional[dict]:
+ """Compare le CER courant d'un moteur à sa moyenne historique
+ sur le **même corpus**.
+
+ Parameters
+ ----------
+ history:
+ Instance de ``BenchmarkHistory`` (ou compatible : doit
+ exposer une méthode ``query(engine, corpus, limit)``
+ retournant une liste d'``HistoryEntry`` avec attribut
+ ``cer_mean`` et ``run_id``).
+ engine_name:
+ Nom du moteur dont on calcule la baseline.
+ corpus_name:
+ Nom du corpus — limite la comparaison aux runs antérieurs
+ sur ce même corpus.
+ current_cer:
+ CER moyen observé dans le run courant.
+ current_run_id:
+ Si fourni, le run portant cet identifiant est exclu de la
+ baseline (utile quand le run courant est déjà enregistré
+ dans l'historique avant d'appeler ce calcul).
+ min_runs:
+ Nombre minimum de runs historiques pour que la
+ comparaison soit considérée fiable. Sous ce seuil, on
+ retourne ``None``.
+ relative_delta_threshold:
+ Seuil au-delà duquel ``off_baseline`` vaut ``True``
+ (défaut : 0,20 = 20 % d'écart relatif).
+
+ Returns
+ -------
+ Optional[dict]
+ ``None`` si :
+ - moins de ``min_runs`` runs historiques disponibles
+ - ``current_cer`` est ``None`` ou négatif
+ - tous les CER historiques sont ``None``
+
+ Sinon, dict avec les champs documentés dans le module.
+ """
+ if current_cer is None or current_cer < 0:
+ return None
+ try:
+ entries = history.query(
+ engine=engine_name, corpus=corpus_name, limit=1000,
+ )
+ except Exception as exc: # pragma: no cover — défense
+ logger.warning(
+ "[baseline_comparison] query history a levé : %s", exc,
+ )
+ return None
+
+ historical_cers: list[float] = []
+ for entry in entries:
+ if current_run_id is not None and entry.run_id == current_run_id:
+ continue
+ cer = entry.cer_mean
+ if cer is None or cer < 0:
+ continue
+ historical_cers.append(float(cer))
+
+ if len(historical_cers) < min_runs:
+ return None
+
+ mean = statistics.fmean(historical_cers)
+ median = statistics.median(historical_cers)
+ absolute_delta = current_cer - mean
+ if mean > 0:
+ relative_delta = absolute_delta / mean
+ elif current_cer == 0:
+ relative_delta = 0.0
+ else:
+ # Baseline à 0 mais CER courant > 0 : écart infini —
+ # convention : on signale comme off_baseline avec
+ # relative_delta = None.
+ relative_delta = None
+
+ off_baseline = (
+ relative_delta is not None
+ and abs(relative_delta) > relative_delta_threshold
+ )
+
+ return {
+ "engine_name": engine_name,
+ "corpus_name": corpus_name,
+ "cer_current": float(current_cer),
+ "cer_historical_mean": mean,
+ "cer_historical_median": median,
+ "n_runs": len(historical_cers),
+ "absolute_delta": absolute_delta,
+ "relative_delta": relative_delta,
+ "off_baseline": off_baseline,
+ }
+
+
+def compute_corpus_difficulty_percentile(
+ history,
+ current_difficulty: float,
+ *,
+ min_runs: int = 5,
+) -> Optional[dict]:
+ """Place la difficulté du corpus courant dans la distribution
+ des difficultés historiques.
+
+ Lit les difficultés stockées dans ``HistoryEntry.metadata``
+ sous la clé ``difficulty`` (convention de
+ ``picarones/core/difficulty.py``).
+
+ Returns
+ -------
+ Optional[dict]
+ ``{
+ "current_difficulty": float,
+ "percentile": float, # 0..100
+ "n_runs": int,
+ "median_historical": float,
+ "harder_than_usual": bool, # percentile > 75
+ "easier_than_usual": bool, # percentile < 25
+ }``
+ ou ``None`` si moins de ``min_runs`` runs historiques ont
+ une difficulté enregistrée.
+ """
+ if current_difficulty is None:
+ return None
+ try:
+ entries = history.query(limit=1000)
+ except Exception as exc: # pragma: no cover
+ logger.warning(
+ "[baseline_comparison] query history a levé : %s", exc,
+ )
+ return None
+
+ historical_difficulties: list[float] = []
+ for entry in entries:
+ diff = entry.metadata.get("difficulty") if entry.metadata else None
+ if diff is None:
+ continue
+ try:
+ historical_difficulties.append(float(diff))
+ except (TypeError, ValueError):
+ continue
+
+ if len(historical_difficulties) < min_runs:
+ return None
+
+ sorted_diff = sorted(historical_difficulties)
+ n = len(sorted_diff)
+ # Percentile = % de corpus historiques de difficulté ≤
+ # current_difficulty. Convention courante (P_i = i/n × 100).
+ n_below = sum(1 for d in sorted_diff if d <= current_difficulty)
+ percentile = (n_below / n) * 100.0
+ median = statistics.median(sorted_diff)
+
+ return {
+ "current_difficulty": float(current_difficulty),
+ "percentile": percentile,
+ "n_runs": n,
+ "median_historical": median,
+ "harder_than_usual": percentile > 75.0,
+ "easier_than_usual": percentile < 25.0,
+ }
+
+
+__all__ = [
+ "compute_engine_baseline",
+ "compute_corpus_difficulty_percentile",
+]
diff --git a/picarones/measurements/builtin_hooks.py b/picarones/measurements/builtin_hooks.py
new file mode 100644
index 0000000000000000000000000000000000000000..ebb3481d460e89241bf2b02dc77930c7dc1f7743
--- /dev/null
+++ b/picarones/measurements/builtin_hooks.py
@@ -0,0 +1,582 @@
+"""Enregistrement des hooks de métriques natifs de Picarones.
+
+Chantier 2 du plan d'évolution post-Sprint 97.
+
+Ce module **migre** les 12 hooks document-level et 12 agrégateurs
+corpus-level qui étaient codés en dur dans
+``picarones.core.runner._compute_document_result`` et autour de la
+boucle d'agrégation (lignes 794-827 du runner pré-chantier-2).
+
+Approche additive — rétrocompat stricte
+---------------------------------------
+Tous les hooks sont enregistrés sur les profils ``standard``,
+``philological``, ``diagnostics`` et ``full`` (i.e. activés par
+défaut quand le runner est appelé sans paramètre ``profile``). Le
+profil ``minimal`` n'active aucun hook (pour bench massif où seul
+CER/WER comptent). Les profils ``economics`` et ``pipeline`` sont
+réservés pour des hooks futurs.
+
+L'import de ce module **suffit** à peupler les registres :
+:mod:`picarones.core.metric_hooks` se contente d'exposer les
+décorateurs ; le runner ne dépend que d'une seule fonction —
+``select_document_hooks(profile)`` — pour découvrir les hooks actifs.
+
+Liste complète des hooks (Sprint d'origine)
+-------------------------------------------
+**Document-level** (12) :
+
+- ``confusion`` (Sprint 5) — ``confusion_matrix``
+- ``char_scores`` (Sprint 5) — ``char_scores``
+- ``taxonomy`` (Sprint 5) — ``taxonomy``
+- ``structure`` (Sprint 5) — ``structure``
+- ``image_quality`` (Sprint 5) — ``image_quality``
+- ``line_metrics`` (Sprint 10) — ``line_metrics``
+- ``hallucination`` (Sprint 10) — ``hallucination_metrics``
+- ``calibration`` (Sprint 42) — ``calibration_metrics``
+- ``philological`` (Sprint 61) — ``philological_metrics``
+- ``searchability`` (Sprint 86) — ``searchability_metrics``
+- ``numerical_sequences`` (Sprint 86) — ``numerical_sequence_metrics``
+- ``readability`` (Sprint 87) — ``readability_metrics``
+
+**Corpus-level** (12) : un agrégateur par hook documentaire,
+remplissant le champ ``aggregated_*`` correspondant du
+``EngineReport``.
+
+Le hook ``ner`` (Sprint 40) reste hors de ce mécanisme : il dépend
+d'un ``EntityExtractor`` injecté à la main par l'utilisateur, ce
+qui n'entre pas dans la sémantique des profils.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections import Counter
+from typing import Any, Optional
+
+from picarones.core.metric_hooks import (
+ PROFILE_DIAGNOSTICS,
+ PROFILE_FULL,
+ PROFILE_PHILOLOGICAL,
+ PROFILE_STANDARD,
+ register_corpus_aggregator,
+ register_document_metric,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# Profils dans lesquels les 12 hooks "standard" s'activent. Égalent
+# par construction le comportement runner pré-chantier-2 ; le profil
+# ``minimal`` est volontairement absent.
+_STANDARD_PROFILES = (
+ PROFILE_STANDARD,
+ PROFILE_PHILOLOGICAL,
+ PROFILE_DIAGNOSTICS,
+ PROFILE_FULL,
+)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Helper de calibration (déplacé depuis runner.py — chantier 2)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def calibration_from_engine_result(
+ ground_truth: str,
+ token_confidences: list,
+) -> Optional[dict]:
+ """Aligne les ``token_confidences`` du moteur sur la GT (bag-of-words)
+ pour produire les listes parallèles ``confidences`` / ``is_correct``,
+ puis appelle ``compute_calibration_metrics`` (Sprint 39).
+
+ Convention d'alignement (proxy bag-of-words avec multiplicité, comme
+ ``oracle_token_recall`` du Sprint 35) : un token de l'hypothèse est
+ "correct" si la GT contient encore une occurrence de ce token.
+
+ Les confidences ``> 1.0`` sont supposées en pourcentage et
+ normalisées à ``[0, 1]``. Les confidences négatives (Tesseract met
+ -1 pour les non-mots) sont ignorées.
+ """
+ from picarones.core.calibration import compute_calibration_metrics
+
+ if not token_confidences:
+ return None
+
+ gt_counter = Counter((ground_truth or "").split())
+ confidences: list[float] = []
+ is_correct: list[int] = []
+
+ for tc in token_confidences:
+ if not isinstance(tc, dict):
+ continue
+ token = str(tc.get("token", ""))
+ if not token:
+ continue
+ try:
+ conf = float(tc.get("confidence"))
+ except (TypeError, ValueError):
+ continue
+ if conf < 0:
+ continue
+ if conf > 1.0:
+ conf = conf / 100.0
+ if not 0.0 <= conf <= 1.0:
+ continue
+ if gt_counter[token] > 0:
+ is_correct.append(1)
+ gt_counter[token] -= 1
+ else:
+ is_correct.append(0)
+ confidences.append(conf)
+
+ if not confidences:
+ return None
+ return compute_calibration_metrics(confidences, is_correct)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Document-level hooks (12)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@register_document_metric(
+ name="confusion",
+ attribute="confusion_matrix",
+ profiles=_STANDARD_PROFILES,
+ requires_success=True,
+)
+def _confusion_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.confusion import build_confusion_matrix
+ return build_confusion_matrix(ground_truth, hypothesis).as_dict()
+
+
+@register_document_metric(
+ name="char_scores",
+ attribute="char_scores",
+ profiles=_STANDARD_PROFILES,
+ requires_success=True,
+)
+def _char_scores_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.char_scores import (
+ compute_diacritic_score,
+ compute_ligature_score,
+ )
+ lig = compute_ligature_score(ground_truth, hypothesis)
+ diac = compute_diacritic_score(ground_truth, hypothesis)
+ return {"ligature": lig.as_dict(), "diacritic": diac.as_dict()}
+
+
+@register_document_metric(
+ name="taxonomy",
+ attribute="taxonomy",
+ profiles=_STANDARD_PROFILES,
+ requires_success=True,
+)
+def _taxonomy_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.taxonomy import classify_errors
+ return classify_errors(ground_truth, hypothesis).as_dict()
+
+
+@register_document_metric(
+ name="structure",
+ attribute="structure",
+ profiles=_STANDARD_PROFILES,
+ requires_success=True,
+)
+def _structure_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.structure import analyze_structure
+ return analyze_structure(ground_truth, hypothesis).as_dict()
+
+
+@register_document_metric(
+ name="line_metrics",
+ attribute="line_metrics",
+ profiles=_STANDARD_PROFILES,
+ requires_success=True,
+)
+def _line_metrics_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.line_metrics import compute_line_metrics
+ return compute_line_metrics(ground_truth, hypothesis).as_dict()
+
+
+@register_document_metric(
+ name="hallucination",
+ attribute="hallucination_metrics",
+ profiles=_STANDARD_PROFILES,
+ requires_success=True,
+)
+def _hallucination_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.hallucination import compute_hallucination_metrics
+ return compute_hallucination_metrics(ground_truth, hypothesis).as_dict()
+
+
+@register_document_metric(
+ name="calibration",
+ attribute="calibration_metrics",
+ profiles=_STANDARD_PROFILES,
+ requires_token_confidences=True,
+)
+def _calibration_hook(*, ground_truth, ocr_result, **_):
+ return calibration_from_engine_result(
+ ground_truth, ocr_result.token_confidences,
+ )
+
+
+@register_document_metric(
+ name="image_quality",
+ attribute="image_quality",
+ profiles=_STANDARD_PROFILES,
+ # Pas de requires_success : on analyse l'image quel que soit le
+ # résultat OCR (pour comparer un échec OCR à la qualité image).
+)
+def _image_quality_hook(*, image_path, **_):
+ from picarones.core.image_quality import analyze_image_quality
+ iq = analyze_image_quality(image_path)
+ if iq.error is not None:
+ return None
+ return iq.as_dict()
+
+
+@register_document_metric(
+ name="philological",
+ attribute="philological_metrics",
+ profiles=_STANDARD_PROFILES,
+ # Pas de requires_success : le runner pré-chantier-2 calculait
+ # même sur échec OCR (avec hyp=""). Les modules philologiques
+ # retournent ``None`` quand la GT n'a pas de signal exploitable
+ # — comportement adaptive intact.
+)
+def _philological_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.philological_runner import compute_philological_metrics
+ return compute_philological_metrics(ground_truth, hypothesis)
+
+
+@register_document_metric(
+ name="searchability",
+ attribute="searchability_metrics",
+ profiles=_STANDARD_PROFILES,
+)
+def _searchability_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.searchability_runner import compute_searchability_metrics
+ return compute_searchability_metrics(ground_truth, hypothesis)
+
+
+@register_document_metric(
+ name="numerical_sequences",
+ attribute="numerical_sequence_metrics",
+ profiles=_STANDARD_PROFILES,
+)
+def _numerical_sequences_hook(*, ground_truth, hypothesis, **_):
+ from picarones.core.numerical_sequences_runner import (
+ compute_numerical_sequence_metrics_adaptive,
+ )
+ return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis)
+
+
+@register_document_metric(
+ name="readability",
+ attribute="readability_metrics",
+ profiles=_STANDARD_PROFILES,
+)
+def _readability_hook(*, ground_truth, hypothesis, corpus_lang, **_):
+ from picarones.core.readability_runner import compute_readability_metrics
+ return compute_readability_metrics(ground_truth, hypothesis, lang=corpus_lang)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Corpus-level aggregators (12)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@register_corpus_aggregator(
+ name="confusion",
+ attribute="aggregated_confusion",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_confusion(doc_results: list) -> Optional[dict]:
+ from picarones.core.confusion import (
+ ConfusionMatrix, aggregate_confusion_matrices,
+ )
+ matrices = [
+ ConfusionMatrix(**dr.confusion_matrix)
+ for dr in doc_results
+ if dr.confusion_matrix is not None
+ ]
+ if not matrices:
+ return None
+ return aggregate_confusion_matrices(matrices).as_compact_dict(min_count=2)
+
+
+@register_corpus_aggregator(
+ name="char_scores",
+ attribute="aggregated_char_scores",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_char_scores(doc_results: list) -> Optional[dict]:
+ from picarones.core.char_scores import (
+ DiacriticScore,
+ LigatureScore,
+ aggregate_diacritic_scores,
+ aggregate_ligature_scores,
+ )
+ lig_scores = [
+ LigatureScore(**dr.char_scores["ligature"])
+ for dr in doc_results
+ if dr.char_scores is not None
+ ]
+ diac_scores = [
+ DiacriticScore(**dr.char_scores["diacritic"])
+ for dr in doc_results
+ if dr.char_scores is not None
+ ]
+ if not lig_scores:
+ return None
+ return {
+ "ligature": aggregate_ligature_scores(lig_scores),
+ "diacritic": aggregate_diacritic_scores(diac_scores),
+ }
+
+
+@register_corpus_aggregator(
+ name="taxonomy",
+ attribute="aggregated_taxonomy",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_taxonomy(doc_results: list) -> Optional[dict]:
+ from picarones.core.taxonomy import TaxonomyResult, aggregate_taxonomy
+ results = [
+ TaxonomyResult.from_dict(dr.taxonomy)
+ for dr in doc_results
+ if dr.taxonomy is not None
+ ]
+ if not results:
+ return None
+ return aggregate_taxonomy(results)
+
+
+@register_corpus_aggregator(
+ name="structure",
+ attribute="aggregated_structure",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_structure(doc_results: list) -> Optional[dict]:
+ from picarones.core.structure import StructureResult, aggregate_structure
+ results = [
+ StructureResult.from_dict(dr.structure)
+ for dr in doc_results
+ if dr.structure is not None
+ ]
+ if not results:
+ return None
+ return aggregate_structure(results)
+
+
+@register_corpus_aggregator(
+ name="image_quality",
+ attribute="aggregated_image_quality",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_image_quality(doc_results: list) -> Optional[dict]:
+ from picarones.core.image_quality import (
+ ImageQualityResult, aggregate_image_quality,
+ )
+ results = [
+ ImageQualityResult.from_dict(dr.image_quality)
+ for dr in doc_results
+ if dr.image_quality is not None
+ ]
+ if not results:
+ return None
+ return aggregate_image_quality(results)
+
+
+@register_corpus_aggregator(
+ name="line_metrics",
+ attribute="aggregated_line_metrics",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_line_metrics(doc_results: list) -> Optional[dict]:
+ from picarones.core.line_metrics import (
+ LineMetrics, aggregate_line_metrics,
+ )
+ results = [
+ LineMetrics.from_dict(dr.line_metrics)
+ for dr in doc_results
+ if dr.line_metrics is not None
+ ]
+ if not results:
+ return None
+ return aggregate_line_metrics(results)
+
+
+@register_corpus_aggregator(
+ name="hallucination",
+ attribute="aggregated_hallucination",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_hallucination(doc_results: list) -> Optional[dict]:
+ from picarones.core.hallucination import (
+ HallucinationMetrics, aggregate_hallucination_metrics,
+ )
+ results = [
+ HallucinationMetrics.from_dict(dr.hallucination_metrics)
+ for dr in doc_results
+ if dr.hallucination_metrics is not None
+ ]
+ if not results:
+ return None
+ return aggregate_hallucination_metrics(results)
+
+
+@register_corpus_aggregator(
+ name="calibration",
+ attribute="aggregated_calibration",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_calibration(doc_results: list) -> Optional[dict]:
+ """Agrège la calibration micro sur tous les docs.
+
+ Recalcule ECE/MCE à partir de la **somme des bins** de chaque
+ document : pour chaque bin, on additionne ``count``, on agrège la
+ confiance moyenne pondérée par count, et on agrège l'accuracy
+ pondérée par count. L'ECE micro est ensuite la moyenne pondérée
+ par bin de ``|conf - acc|``.
+
+ Comportement déplacé verbatim depuis ``runner._aggregate_calibration``
+ (chantier 2 — rétrocompat octet par octet du sérialisé).
+ """
+ relevant = [
+ dr for dr in doc_results
+ if dr.calibration_metrics is not None
+ and (dr.calibration_metrics.get("bins") or [])
+ ]
+ if not relevant:
+ return None
+
+ n_bins = relevant[0].calibration_metrics.get("n_bins", 10)
+ sum_conf: list[float] = [0.0] * n_bins
+ sum_acc: list[float] = [0.0] * n_bins
+ counts: list[int] = [0] * n_bins
+ bin_lows: list[float] = [
+ b["bin_low"] for b in relevant[0].calibration_metrics["bins"]
+ ]
+ bin_highs: list[float] = [
+ b["bin_high"] for b in relevant[0].calibration_metrics["bins"]
+ ]
+
+ for dr in relevant:
+ m = dr.calibration_metrics
+ if m.get("n_bins") != n_bins:
+ logger.warning(
+ "[aggregate_calibration] %s : n_bins=%s ≠ %s — ignoré",
+ dr.doc_id, m.get("n_bins"), n_bins,
+ )
+ continue
+ for k, b in enumerate(m["bins"]):
+ n = int(b.get("count") or 0)
+ if n == 0:
+ continue
+ counts[k] += n
+ sum_conf[k] += float(b.get("avg_confidence") or 0.0) * n
+ sum_acc[k] += float(b.get("accuracy") or 0.0) * n
+
+ total = sum(counts)
+ if total == 0:
+ return None
+
+ bins: list[dict] = []
+ ece = 0.0
+ mce = 0.0
+ for k in range(n_bins):
+ n = counts[k]
+ if n == 0:
+ bins.append({
+ "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
+ "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
+ "avg_confidence": None,
+ "accuracy": None,
+ "count": 0,
+ "gap": None,
+ })
+ continue
+ avg_conf = sum_conf[k] / n
+ accuracy = sum_acc[k] / n
+ gap = abs(avg_conf - accuracy)
+ bins.append({
+ "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
+ "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
+ "avg_confidence": avg_conf,
+ "accuracy": accuracy,
+ "count": n,
+ "gap": gap,
+ })
+ ece += (n / total) * gap
+ if gap > mce:
+ mce = gap
+
+ overall_acc = sum(sum_acc) / total
+ overall_conf = sum(sum_conf) / total
+
+ return {
+ "ece": ece,
+ "mce": mce,
+ "n_bins": n_bins,
+ "n_predictions": total,
+ "overall_accuracy": overall_acc,
+ "overall_confidence": overall_conf,
+ "bins": bins,
+ "doc_count": len(relevant),
+ }
+
+
+@register_corpus_aggregator(
+ name="philological",
+ attribute="aggregated_philological",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_philological(doc_results: list) -> Optional[dict]:
+ from picarones.core.philological_runner import aggregate_philological_metrics
+ return aggregate_philological_metrics(
+ [dr.philological_metrics for dr in doc_results],
+ )
+
+
+@register_corpus_aggregator(
+ name="searchability",
+ attribute="aggregated_searchability",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_searchability(doc_results: list) -> Optional[dict]:
+ from picarones.core.searchability_runner import aggregate_searchability_metrics
+ return aggregate_searchability_metrics(
+ [dr.searchability_metrics for dr in doc_results],
+ )
+
+
+@register_corpus_aggregator(
+ name="numerical_sequences",
+ attribute="aggregated_numerical_sequences",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]:
+ from picarones.core.numerical_sequences_runner import (
+ aggregate_numerical_sequence_metrics,
+ )
+ return aggregate_numerical_sequence_metrics(
+ [dr.numerical_sequence_metrics for dr in doc_results],
+ )
+
+
+@register_corpus_aggregator(
+ name="readability",
+ attribute="aggregated_readability",
+ profiles=_STANDARD_PROFILES,
+)
+def _aggregate_readability(doc_results: list) -> Optional[dict]:
+ from picarones.core.readability_runner import aggregate_readability_metrics
+ return aggregate_readability_metrics(
+ [dr.readability_metrics for dr in doc_results],
+ )
+
+
+__all__ = ["calibration_from_engine_result"]
diff --git a/picarones/measurements/calibration.py b/picarones/measurements/calibration.py
new file mode 100644
index 0000000000000000000000000000000000000000..35819b20332e0b915b4cb13a5b9c55555f50c392
--- /dev/null
+++ b/picarones/measurements/calibration.py
@@ -0,0 +1,323 @@
+"""Calibration des moteurs : ECE, MCE, reliability diagram.
+
+Sprint 39 — A.II.1.b du plan d'évolution 2026 : couche de calcul pure.
+
+Pourquoi ce module
+------------------
+Tous les moteurs OCR cibles fournissent une confidence par token ou par
+ligne (Tesseract via le ``tsv``, Pero OCR via le ``PageLayout``,
+Mistral OCR via ``confidence``, Google Vision via ``Word.confidence``).
+La question naturelle pour un workflow patrimonial est : *« quand le
+moteur dit qu'il est sûr, est-il vraiment sûr ? »*. Pour une équipe
+qui doit vérifier humainement un corpus de 50 000 pages, la différence
+entre vérifier 100 % vs 15 % du volume est l'effet de la calibration.
+
+Ce module fournit les trois mesures classiques :
+
+- **Expected Calibration Error (ECE)** — moyenne pondérée par bin de
+ l'écart absolu entre confiance moyenne et précision moyenne.
+ ``ECE = 0`` ↔ moteur parfaitement calibré ; ``ECE`` élevé ↔ écart
+ systématique entre confiance affichée et fiabilité réelle.
+- **Maximum Calibration Error (MCE)** — max de cet écart sur les bins.
+ Utile pour repérer le pire mensonge du moteur (ex. il dit toujours
+ 95 % de confiance et il a tort une fois sur deux).
+- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf,
+ accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en
+ Chart.js côté navigateur dans un sprint suivant.
+
+Stratégie de découpage
+----------------------
+Comme pour le NER (Sprint 38) et la divergence (Sprints 35-37),
+on découpe :
+
+- **Sprint 39** (ici) — couche de calcul pure : entrée = deux listes
+ parallèles ``confidences`` (∈ [0, 1]) et ``is_correct`` (bool/0-1).
+ Aucune dépendance externe.
+- **Sprint à venir** — exposition de ``token_confidences`` sur
+ ``EngineResult``, alignement caractère/token avec la GT pour produire
+ ``is_correct``, intégration dans le runner et vue HTML reliability.
+
+Ce qui est explicitement hors scope
+-----------------------------------
+Ce sprint ne touche **aucun adaptateur OCR**. Aucune confiance n'est
+extraite ; on calcule uniquement à partir de séquences de prédictions
+fournies en entrée. C'est ce qui permet de tester rigoureusement les
+invariants mathématiques (ECE = 0 ↔ calibré, ECE = |bias| pour bias
+constant, etc.) sans dépendre d'un backend.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Iterable
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Modèle de données
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class CalibrationBin:
+ """Un bin du reliability diagram.
+
+ Attributs
+ ---------
+ bin_low, bin_high:
+ Bornes du bin sur l'axe de confiance (``[bin_low, bin_high)`` —
+ sauf le dernier bin qui inclut ``1.0``).
+ avg_confidence:
+ Moyenne des confidences des prédictions tombées dans le bin.
+ ``None`` si le bin est vide.
+ accuracy:
+ Fraction de prédictions correctes dans le bin (``∈ [0, 1]``).
+ ``None`` si le bin est vide.
+ count:
+ Nombre de prédictions dans le bin.
+ """
+
+ bin_low: float
+ bin_high: float
+ avg_confidence: float | None
+ accuracy: float | None
+ count: int
+
+ @property
+ def gap(self) -> float | None:
+ """Écart absolu ``|confidence - accuracy|`` ou ``None`` si vide."""
+ if self.avg_confidence is None or self.accuracy is None:
+ return None
+ return abs(self.avg_confidence - self.accuracy)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Validation
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _validate_inputs(
+ confidences: list[float],
+ is_correct: list[bool | int],
+) -> None:
+ if len(confidences) != len(is_correct):
+ raise ValueError(
+ f"Longueurs incompatibles : confidences={len(confidences)} "
+ f"vs is_correct={len(is_correct)}"
+ )
+ for i, c in enumerate(confidences):
+ if not (0.0 <= float(c) <= 1.0):
+ raise ValueError(
+ f"Confiance hors [0, 1] à l'index {i} : {c!r}"
+ )
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Reliability diagram (binning)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def reliability_diagram(
+ confidences: Iterable[float],
+ is_correct: Iterable[bool | int],
+ n_bins: int = 10,
+) -> list[CalibrationBin]:
+ """Découpe les prédictions en ``n_bins`` bins équidistants par confiance
+ et calcule pour chacun la confiance moyenne, la précision et le compte.
+
+ Parameters
+ ----------
+ confidences:
+ Confidences des prédictions, ``∈ [0, 1]``.
+ is_correct:
+ Indicateur booléen (1 = prédiction correcte, 0 = incorrecte).
+ n_bins:
+ Nombre de bins (défaut : 10). Bornes : ``[k/n_bins, (k+1)/n_bins)``
+ sauf le dernier bin qui inclut ``1.0``.
+
+ Returns
+ -------
+ list[CalibrationBin]
+ Liste de ``n_bins`` bins, dans l'ordre croissant des confidences.
+ """
+ if n_bins < 1:
+ raise ValueError(f"n_bins doit être ≥ 1 — reçu {n_bins}")
+
+ confs = [float(c) for c in confidences]
+ correct = [int(bool(x)) for x in is_correct]
+ _validate_inputs(confs, correct)
+
+ bin_width = 1.0 / n_bins
+ sums: list[float] = [0.0] * n_bins
+ correct_counts: list[int] = [0] * n_bins
+ counts: list[int] = [0] * n_bins
+
+ for c, ok in zip(confs, correct):
+ # Calcul du bin index par multiplication ``c * n_bins`` plutôt que
+ # division ``c / bin_width`` pour éviter les pièges de
+ # représentation flottante (ex. ``0.6 / 0.1 = 5.999…`` en IEEE 754
+ # qui placerait 0.6 dans le bin [0.5, 0.6) au lieu de [0.6, 0.7)).
+ if c >= 1.0:
+ idx = n_bins - 1
+ else:
+ idx = int(c * n_bins)
+ # Garde-fou en cas d'arrondi flottant
+ if idx >= n_bins:
+ idx = n_bins - 1
+ elif idx < 0:
+ idx = 0
+ sums[idx] += c
+ correct_counts[idx] += ok
+ counts[idx] += 1
+
+ bins: list[CalibrationBin] = []
+ for k in range(n_bins):
+ low = k * bin_width
+ high = (k + 1) * bin_width
+ n = counts[k]
+ if n == 0:
+ bins.append(CalibrationBin(low, high, None, None, 0))
+ else:
+ bins.append(CalibrationBin(
+ bin_low=low,
+ bin_high=high,
+ avg_confidence=sums[k] / n,
+ accuracy=correct_counts[k] / n,
+ count=n,
+ ))
+ return bins
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# ECE et MCE
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def expected_calibration_error(
+ confidences: Iterable[float],
+ is_correct: Iterable[bool | int],
+ n_bins: int = 10,
+) -> float:
+ """Expected Calibration Error : moyenne pondérée par bin de l'écart
+ absolu confiance ↔ précision.
+
+ ``ECE = sum_k (n_k / N) * |avg_conf_k - accuracy_k|``
+
+ où la somme porte sur les bins non vides.
+
+ Returns
+ -------
+ float
+ ``∈ [0, 1]``. ``0`` ↔ calibration parfaite.
+ """
+ bins = reliability_diagram(confidences, is_correct, n_bins=n_bins)
+ total = sum(b.count for b in bins)
+ if total == 0:
+ return 0.0
+ ece = 0.0
+ for b in bins:
+ if b.count == 0 or b.gap is None:
+ continue
+ ece += (b.count / total) * b.gap
+ return ece
+
+
+def maximum_calibration_error(
+ confidences: Iterable[float],
+ is_correct: Iterable[bool | int],
+ n_bins: int = 10,
+) -> float:
+ """Maximum Calibration Error : pire écart confiance ↔ précision sur
+ tous les bins non vides.
+
+ Utile pour repérer un mensonge ponctuel du moteur (ex. il dit 95 %
+ de confiance et il a tort une fois sur deux dans ce bin).
+
+ Returns
+ -------
+ float
+ ``∈ [0, 1]``. ``0`` ↔ calibration parfaite.
+ """
+ bins = reliability_diagram(confidences, is_correct, n_bins=n_bins)
+ gaps = [b.gap for b in bins if b.gap is not None]
+ return max(gaps) if gaps else 0.0
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Vue agrégée
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def compute_calibration_metrics(
+ confidences: Iterable[float],
+ is_correct: Iterable[bool | int],
+ n_bins: int = 10,
+) -> dict:
+ """Calcule l'ensemble des métriques de calibration en un appel.
+
+ Returns
+ -------
+ dict
+ ``{
+ "ece": float,
+ "mce": float,
+ "n_bins": int,
+ "n_predictions": int,
+ "overall_accuracy": float,
+ "overall_confidence": float,
+ "bins": [
+ {"bin_low", "bin_high", "avg_confidence",
+ "accuracy", "count", "gap"},
+ ...
+ ],
+ }``
+ """
+ confs = list(confidences)
+ correct = list(is_correct)
+ bins = reliability_diagram(confs, correct, n_bins=n_bins)
+ total = sum(b.count for b in bins)
+ overall_acc = (
+ sum(int(bool(x)) for x in correct) / total if total > 0 else 0.0
+ )
+ overall_conf = (
+ sum(float(c) for c in confs) / total if total > 0 else 0.0
+ )
+
+ ece = 0.0
+ if total > 0:
+ for b in bins:
+ if b.gap is None:
+ continue
+ ece += (b.count / total) * b.gap
+ mce = max((b.gap for b in bins if b.gap is not None), default=0.0)
+
+ return {
+ "ece": ece,
+ "mce": mce,
+ "n_bins": n_bins,
+ "n_predictions": total,
+ "overall_accuracy": overall_acc,
+ "overall_confidence": overall_conf,
+ "bins": [
+ {
+ "bin_low": b.bin_low,
+ "bin_high": b.bin_high,
+ "avg_confidence": b.avg_confidence,
+ "accuracy": b.accuracy,
+ "count": b.count,
+ "gap": b.gap,
+ }
+ for b in bins
+ ],
+ }
+
+
+__all__ = [
+ "CalibrationBin",
+ "reliability_diagram",
+ "expected_calibration_error",
+ "maximum_calibration_error",
+ "compute_calibration_metrics",
+]
diff --git a/picarones/measurements/char_scores.py b/picarones/measurements/char_scores.py
new file mode 100644
index 0000000000000000000000000000000000000000..e390462b14d027212f05f765d6691b8fe8542aa3
--- /dev/null
+++ b/picarones/measurements/char_scores.py
@@ -0,0 +1,370 @@
+"""Scores de reconnaissance des ligatures et des diacritiques.
+
+Ces métriques sont spécifiques aux documents patrimoniaux (manuscrits, imprimés
+anciens) où ligatures et diacritiques jouent un rôle paléographique essentiel.
+
+Ligatures
+---------
+Caractères encodés comme une séquence unique dans Unicode mais représentant
+deux ou plusieurs glyphes fusionnés : fi (fi), fl (fl), œ, æ, etc.
+
+Pour chaque ligature présente dans le GT, on vérifie si l'OCR a produit
+soit le caractère Unicode équivalent, soit la séquence décomposée équivalente.
+
+Diacritiques
+-----------
+Accents, cédilles, trémas et autres signes diacritiques. Pour chaque caractère
+accentué dans le GT, on vérifie si l'OCR a conservé le diacritique ou l'a
+remplacé par la lettre de base.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+import unicodedata
+
+
+# ---------------------------------------------------------------------------
+# Tables de ligatures (char ligature → séquences équivalentes acceptées)
+# ---------------------------------------------------------------------------
+
+#: Table principale des ligatures et leurs équivalents acceptés.
+#: Clé = caractère ligature Unicode ; valeur = liste de séquences équivalentes.
+LIGATURE_TABLE: dict[str, list[str]] = {
+ # Ligatures typographiques latines (Unicode Letterlike Symbols / Alphabetic Presentation Forms)
+ "\uFB00": ["ff"], # ff ff
+ "\uFB01": ["fi"], # fi fi
+ "\uFB02": ["fl"], # fl fl
+ "\uFB03": ["ffi"], # ffi ffi
+ "\uFB04": ["ffl"], # ffl ffl
+ "\uFB05": ["st", "\u017Ft"], # ſt st / ſt
+ "\uFB06": ["st"], # st st (variante)
+ # Ligatures latines patrimoniales (Unicode Latin Extended Additional)
+ "\u0153": ["oe"], # œ oe
+ "\u00E6": ["ae"], # æ ae
+ "\u0152": ["OE"], # Œ OE
+ "\u00C6": ["AE"], # Æ AE
+ # Abréviations latines / médiévales
+ "\uA751": ["per", "p\u0332"], # ꝑ per / p̲
+ "\uA753": ["pro"], # ꝓ pro
+ "\uA757": ["que"], # ꝗ que
+ # Ligatures germaniques
+ "\u00DF": ["ss"], # ß ss
+ "\u1E9E": ["SS"], # ẞ SS
+}
+
+# Ensemble de toutes les ligatures pour recherche rapide
+_ALL_LIGATURES: frozenset[str] = frozenset(LIGATURE_TABLE)
+
+# Mapping inverse : séquence → ligature
+_SEQ_TO_LIGATURE: dict[str, str] = {}
+for _lig, _seqs in LIGATURE_TABLE.items():
+ for _seq in _seqs:
+ _SEQ_TO_LIGATURE[_seq] = _lig
+
+
+# ---------------------------------------------------------------------------
+# Table des caractères diacritiques
+# ---------------------------------------------------------------------------
+
+def _build_diacritic_map() -> dict[str, str]:
+ """Construit automatiquement la table diacritique depuis l'Unicode."""
+ table: dict[str, str] = {}
+ for codepoint in range(0x00C0, 0x0250): # Latin Étendu A + B
+ ch = chr(codepoint)
+ nfd = unicodedata.normalize("NFD", ch)
+ if len(nfd) > 1: # le caractère est décomposable
+ base = nfd[0] # lettre de base
+ if base.isalpha() and base != ch:
+ table[ch] = base
+ # Compléments manuels
+ table.update({
+ "\u0107": "c", # ć
+ "\u0119": "e", # ę
+ "\u0142": "l", # ł
+ "\u0144": "n", # ń
+ "\u015B": "s", # ś
+ "\u017A": "z", # ź
+ "\u017C": "z", # ż
+ })
+ return table
+
+
+DIACRITIC_MAP: dict[str, str] = _build_diacritic_map()
+_ALL_DIACRITICS: frozenset[str] = frozenset(DIACRITIC_MAP)
+
+# Ligatures qui NE sont PAS des diacritiques (pour éviter les doublons)
+_LIGATURE_SET: frozenset[str] = frozenset(LIGATURE_TABLE)
+
+
+# ---------------------------------------------------------------------------
+# Résultats structurés
+# ---------------------------------------------------------------------------
+
+@dataclass
+class LigatureScore:
+ """Score de reconnaissance des ligatures pour une paire (GT, OCR)."""
+
+ total_in_gt: int = 0
+ """Nombre de ligatures présentes dans le GT."""
+ correctly_recognized: int = 0
+ """Nombre de ligatures correctement transcrites (unicode ou équivalent)."""
+ score: float = 0.0
+ """Taux de reconnaissance = correctly_recognized / total_in_gt. 1.0 si total=0."""
+ per_ligature: dict[str, dict] = field(default_factory=dict)
+ """Détail par ligature : {'fi': {'gt_count': 5, 'ocr_correct': 3, 'score': 0.6}}"""
+
+ def as_dict(self) -> dict:
+ return {
+ "total_in_gt": self.total_in_gt,
+ "correctly_recognized": self.correctly_recognized,
+ "score": round(self.score, 4),
+ "per_ligature": {
+ k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()}
+ for k, v in self.per_ligature.items()
+ },
+ }
+
+
+@dataclass
+class DiacriticScore:
+ """Score de conservation des diacritiques pour une paire (GT, OCR)."""
+
+ total_in_gt: int = 0
+ """Nombre de caractères accentués dans le GT."""
+ correctly_recognized: int = 0
+ """Nombre de diacritiques correctement conservés."""
+ score: float = 0.0
+ """Taux de conservation = correctly_recognized / total_in_gt. 1.0 si total=0."""
+ per_diacritic: dict[str, dict] = field(default_factory=dict)
+ """Détail par caractère diacritique."""
+
+ def as_dict(self) -> dict:
+ return {
+ "total_in_gt": self.total_in_gt,
+ "correctly_recognized": self.correctly_recognized,
+ "score": round(self.score, 4),
+ "per_diacritic": {
+ k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()}
+ for k, v in self.per_diacritic.items()
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Calcul des scores
+# ---------------------------------------------------------------------------
+
+def compute_ligature_score(ground_truth: str, hypothesis: str) -> LigatureScore:
+ """Calcule le score de reconnaissance des ligatures.
+
+ Pour chaque ligature dans le GT, on vérifie si l'OCR a produit :
+ - Exactement le même caractère ligature Unicode (ex. fi → fi)
+ - Ou la séquence de lettres équivalente (ex. fi → fi)
+
+ Les deux sont considérés comme corrects — ce qui correspond à la pratique
+ éditoriale patrimoniaux (certains éditeurs développent les ligatures).
+
+ Parameters
+ ----------
+ ground_truth:
+ Texte de référence.
+ hypothesis:
+ Texte produit par l'OCR.
+
+ Returns
+ -------
+ LigatureScore
+ """
+ if not ground_truth:
+ return LigatureScore(score=1.0)
+
+ # Construire un index de position dans l'hypothèse pour recherche rapide
+ hyp_norm = unicodedata.normalize("NFC", hypothesis)
+ gt_norm = unicodedata.normalize("NFC", ground_truth)
+
+ per_lig: dict[str, dict] = {}
+ total = 0
+ correct = 0
+
+ # Trouver toutes les ligatures dans le GT
+ i = 0
+ while i < len(gt_norm):
+ ch = gt_norm[i]
+ if ch in _ALL_LIGATURES:
+ total += 1
+ equivalents = [ch] + LIGATURE_TABLE[ch] # unicode direct ou séquences équivalentes
+
+ # Vérifier si la position correspondante dans l'OCR contient l'équivalent
+ is_correct = _check_char_at_context(gt_norm, hyp_norm, i, ch, equivalents)
+ if is_correct:
+ correct += 1
+
+ if ch not in per_lig:
+ per_lig[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0}
+ per_lig[ch]["gt_count"] += 1
+ if is_correct:
+ per_lig[ch]["ocr_correct"] += 1
+ i += 1
+
+ # Calculer les scores individuels
+ for lig_data in per_lig.values():
+ lig_data["score"] = (
+ lig_data["ocr_correct"] / lig_data["gt_count"]
+ if lig_data["gt_count"] > 0
+ else 1.0
+ )
+
+ score = correct / total if total > 0 else 1.0
+ return LigatureScore(
+ total_in_gt=total,
+ correctly_recognized=correct,
+ score=score,
+ per_ligature=per_lig,
+ )
+
+
+def compute_diacritic_score(ground_truth: str, hypothesis: str) -> DiacriticScore:
+ """Calcule le score de conservation des diacritiques.
+
+ Pour chaque caractère accentué dans le GT, on vérifie si l'OCR a produit
+ le même caractère (conservation) ou a substitué la lettre de base (perte).
+ On accepte aussi les formes NFD équivalentes.
+
+ Parameters
+ ----------
+ ground_truth:
+ Texte de référence.
+ hypothesis:
+ Texte produit par l'OCR.
+
+ Returns
+ -------
+ DiacriticScore
+ """
+ if not ground_truth:
+ return DiacriticScore(score=1.0)
+
+ gt_norm = unicodedata.normalize("NFC", ground_truth)
+ hyp_norm = unicodedata.normalize("NFC", hypothesis)
+
+ per_diac: dict[str, dict] = {}
+ total = 0
+ correct = 0
+
+ # Utiliser difflib pour l'alignement
+ import difflib
+ matcher = difflib.SequenceMatcher(None, gt_norm, hyp_norm, autojunk=False)
+ gt_to_hyp: dict[int, Optional[int]] = {}
+
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == "equal":
+ for k in range(i2 - i1):
+ gt_to_hyp[i1 + k] = j1 + k
+ elif tag == "replace" and (i2 - i1) == (j2 - j1):
+ for k in range(i2 - i1):
+ gt_to_hyp[i1 + k] = j1 + k
+ else:
+ # delete ou replace de longueurs différentes
+ for k in range(i1, i2):
+ gt_to_hyp[k] = None
+
+ for i, ch in enumerate(gt_norm):
+ if ch in _ALL_DIACRITICS and ch not in _LIGATURE_SET:
+ total += 1
+ hyp_pos = gt_to_hyp.get(i)
+ is_correct = False
+ if hyp_pos is not None and hyp_pos < len(hyp_norm):
+ hyp_ch = hyp_norm[hyp_pos]
+ is_correct = (hyp_ch == ch)
+ if is_correct:
+ correct += 1
+
+ if ch not in per_diac:
+ per_diac[ch] = {"gt_count": 0, "ocr_correct": 0, "score": 0.0}
+ per_diac[ch]["gt_count"] += 1
+ if is_correct:
+ per_diac[ch]["ocr_correct"] += 1
+
+ for diac_data in per_diac.values():
+ diac_data["score"] = (
+ diac_data["ocr_correct"] / diac_data["gt_count"]
+ if diac_data["gt_count"] > 0
+ else 1.0
+ )
+
+ score = correct / total if total > 0 else 1.0
+ return DiacriticScore(
+ total_in_gt=total,
+ correctly_recognized=correct,
+ score=score,
+ per_diacritic=per_diac,
+ )
+
+
+def _check_char_at_context(
+ gt: str,
+ hyp: str,
+ gt_pos: int,
+ gt_char: str,
+ equivalents: list[str],
+) -> bool:
+ """Vérifie si la position correspondante dans l'hypothèse contient un équivalent.
+
+ Cherche dans une fenêtre de ±5 caractères autour de la position estimée
+ pour tolérer les décalages d'alignement OCR.
+ """
+ # Position estimée dans l'hypothèse (ratio proportionnel)
+ if len(gt) == 0:
+ return False
+ est_pos = int(gt_pos * len(hyp) / len(gt)) if len(gt) > 0 else 0
+ window = 5
+ start = max(0, est_pos - window)
+ end = min(len(hyp), est_pos + window + len(gt_char))
+ context = hyp[start:end]
+ for equiv in equivalents:
+ if equiv in context:
+ return True
+ return False
+
+
+def aggregate_ligature_scores(scores: list[LigatureScore]) -> dict:
+ """Agrège les scores de ligatures sur un corpus."""
+ total_gt = sum(s.total_in_gt for s in scores)
+ total_correct = sum(s.correctly_recognized for s in scores)
+ score = total_correct / total_gt if total_gt > 0 else 1.0
+
+ # Agrégation par ligature
+ per_lig: dict[str, dict] = {}
+ for s in scores:
+ for lig, data in s.per_ligature.items():
+ if lig not in per_lig:
+ per_lig[lig] = {"gt_count": 0, "ocr_correct": 0}
+ per_lig[lig]["gt_count"] += data["gt_count"]
+ per_lig[lig]["ocr_correct"] += data["ocr_correct"]
+ for lig_data in per_lig.values():
+ lig_data["score"] = (
+ lig_data["ocr_correct"] / lig_data["gt_count"]
+ if lig_data["gt_count"] > 0 else 1.0
+ )
+
+ return {
+ "score": round(score, 4),
+ "total_in_gt": total_gt,
+ "correctly_recognized": total_correct,
+ "per_ligature": per_lig,
+ }
+
+
+def aggregate_diacritic_scores(scores: list[DiacriticScore]) -> dict:
+ """Agrège les scores diacritiques sur un corpus."""
+ total_gt = sum(s.total_in_gt for s in scores)
+ total_correct = sum(s.correctly_recognized for s in scores)
+ score = total_correct / total_gt if total_gt > 0 else 1.0
+ return {
+ "score": round(score, 4),
+ "total_in_gt": total_gt,
+ "correctly_recognized": total_correct,
+ }
diff --git a/picarones/measurements/confusion.py b/picarones/measurements/confusion.py
new file mode 100644
index 0000000000000000000000000000000000000000..a90d9ebb9b3eb6a5585e4f172a0a6bbf4be79689
--- /dev/null
+++ b/picarones/measurements/confusion.py
@@ -0,0 +1,268 @@
+"""Matrice de confusion unicode pour l'analyse fine des erreurs OCR.
+
+Pour chaque moteur, on calcule quels caractères du GT sont transcrits par
+quels caractères OCR (substitutions). Cette "empreinte d'erreur" est
+caractéristique de chaque moteur ou pipeline.
+
+Méthode
+-------
+L'alignement caractère par caractère utilise les opérations d'édition
+de la distance de Levenshtein (via difflib.SequenceMatcher), ce qui permet
+d'identifier les substitutions, insertions et suppressions.
+
+La matrice est stockée comme un dict de dict :
+ ``{gt_char: {ocr_char: count}}``
+
+La valeur spéciale ``"∅"`` (U+2205) représente un caractère vide :
+- ``{"a": {"∅": 3}}`` → 'a' supprimé 3 fois dans l'OCR
+- ``{"∅": {"x": 2}}`` → 'x' inséré 2 fois dans l'OCR (absent du GT)
+"""
+
+from __future__ import annotations
+
+import difflib
+from collections import defaultdict
+from dataclasses import dataclass, field
+
+# Symbole représentant un caractère absent (insertion / suppression)
+EMPTY_CHAR = "∅"
+
+# Caractères non pertinents à ignorer dans la matrice (espaces, sauts de ligne)
+_WHITESPACE = set(" \t\n\r")
+
+
+@dataclass
+class ConfusionMatrix:
+ """Matrice de confusion unicode pour une paire (GT, OCR)."""
+
+ matrix: dict[str, dict[str, int]] = field(default_factory=dict)
+ """Clé externe = char GT ; clé interne = char OCR ; valeur = count."""
+
+ total_substitutions: int = 0
+ total_insertions: int = 0
+ total_deletions: int = 0
+
+ @property
+ def total_errors(self) -> int:
+ return self.total_substitutions + self.total_insertions + self.total_deletions
+
+ def top_confusions(self, n: int = 20) -> list[dict]:
+ """Retourne les n confusions les plus fréquentes (substitutions uniquement)."""
+ pairs: list[tuple[str, str, int]] = []
+ for gt_char, ocr_counts in self.matrix.items():
+ if gt_char == EMPTY_CHAR:
+ continue # insertions
+ for ocr_char, count in ocr_counts.items():
+ if ocr_char == EMPTY_CHAR:
+ continue # suppressions
+ if gt_char != ocr_char:
+ pairs.append((gt_char, ocr_char, count))
+ pairs.sort(key=lambda x: -x[2])
+ return [
+ {"gt": gt, "ocr": ocr, "count": cnt}
+ for gt, ocr, cnt in pairs[:n]
+ ]
+
+ def as_compact_dict(self, min_count: int = 1) -> dict:
+ """Sérialise la matrice en éliminant les entrées rares."""
+ compact: dict[str, dict[str, int]] = {}
+ for gt_char, ocr_counts in self.matrix.items():
+ filtered = {
+ oc: cnt for oc, cnt in ocr_counts.items()
+ if cnt >= min_count
+ }
+ if filtered:
+ compact[gt_char] = filtered
+ return {
+ "matrix": compact,
+ "total_substitutions": self.total_substitutions,
+ "total_insertions": self.total_insertions,
+ "total_deletions": self.total_deletions,
+ }
+
+ def as_dict(self) -> dict:
+ return self.as_compact_dict(min_count=1)
+
+
+def build_confusion_matrix(
+ ground_truth: str,
+ hypothesis: str,
+ ignore_whitespace: bool = True,
+ ignore_correct: bool = True,
+) -> ConfusionMatrix:
+ """Construit la matrice de confusion unicode pour une paire GT/OCR.
+
+ Parameters
+ ----------
+ ground_truth:
+ Texte de référence (vérité terrain).
+ hypothesis:
+ Texte produit par l'OCR.
+ ignore_whitespace:
+ Si True, ignore les espaces, tabulations et sauts de ligne.
+ ignore_correct:
+ Si True, n'enregistre pas les paires identiques (gt_char == ocr_char).
+ Par défaut True pour réduire la taille de la matrice.
+
+ Returns
+ -------
+ ConfusionMatrix
+ """
+ matrix: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
+ n_subs = n_ins = n_dels = 0
+
+ if not ground_truth and not hypothesis:
+ return ConfusionMatrix(dict(matrix), 0, 0, 0)
+
+ # SequenceMatcher sur listes de chars pour un alignement précis
+ matcher = difflib.SequenceMatcher(None, ground_truth, hypothesis, autojunk=False)
+
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == "equal":
+ if not ignore_correct:
+ for ch in ground_truth[i1:i2]:
+ if ignore_whitespace and ch in _WHITESPACE:
+ continue
+ matrix[ch][ch] += 1
+ elif tag == "replace":
+ # Aligner char par char les séquences de longueurs différentes
+ gt_seg = ground_truth[i1:i2]
+ oc_seg = hypothesis[j1:j2]
+ _align_segments(gt_seg, oc_seg, matrix, ignore_whitespace)
+ # Substitutions = longueur commune, surplus = insertions ou suppressions
+ n_subs += min(len(gt_seg), len(oc_seg))
+ surplus = abs(len(gt_seg) - len(oc_seg))
+ if len(gt_seg) > len(oc_seg):
+ n_dels += surplus
+ else:
+ n_ins += surplus
+ elif tag == "delete":
+ for ch in ground_truth[i1:i2]:
+ if ignore_whitespace and ch in _WHITESPACE:
+ continue
+ matrix[ch][EMPTY_CHAR] += 1
+ n_dels += 1
+ elif tag == "insert":
+ for ch in hypothesis[j1:j2]:
+ if ignore_whitespace and ch in _WHITESPACE:
+ continue
+ matrix[EMPTY_CHAR][ch] += 1
+ n_ins += 1
+
+ # Convertir defaultdict en dict normal
+ result_matrix: dict[str, dict[str, int]] = {
+ k: dict(v) for k, v in matrix.items()
+ }
+
+ return ConfusionMatrix(
+ matrix=result_matrix,
+ total_substitutions=n_subs,
+ total_insertions=n_ins,
+ total_deletions=n_dels,
+ )
+
+
+def _align_segments(
+ gt_seg: str,
+ oc_seg: str,
+ matrix: dict,
+ ignore_whitespace: bool,
+) -> None:
+ """Aligne deux segments de longueurs potentiellement différentes."""
+ if not gt_seg:
+ for ch in oc_seg:
+ if ignore_whitespace and ch in _WHITESPACE:
+ continue
+ matrix[EMPTY_CHAR][ch] += 1
+ return
+ if not oc_seg:
+ for ch in gt_seg:
+ if ignore_whitespace and ch in _WHITESPACE:
+ continue
+ matrix[ch][EMPTY_CHAR] += 1
+ return
+
+ if len(gt_seg) == len(oc_seg):
+ # Substitutions 1-pour-1
+ for g, o in zip(gt_seg, oc_seg):
+ if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE):
+ continue
+ matrix[g][o] += 1
+ else:
+ # Longueurs différentes : utiliser SequenceMatcher récursif sur segments courts
+ sub = difflib.SequenceMatcher(None, gt_seg, oc_seg, autojunk=False)
+ for tag2, i1, i2, j1, j2 in sub.get_opcodes():
+ if tag2 == "equal":
+ pass
+ elif tag2 == "replace":
+ # Régression simple : aligner par troncature
+ for g, o in zip(gt_seg[i1:i2], oc_seg[j1:j2]):
+ if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE):
+ continue
+ matrix[g][o] += 1
+ elif tag2 == "delete":
+ for g in gt_seg[i1:i2]:
+ if ignore_whitespace and g in _WHITESPACE:
+ continue
+ matrix[g][EMPTY_CHAR] += 1
+ elif tag2 == "insert":
+ for o in oc_seg[j1:j2]:
+ if ignore_whitespace and o in _WHITESPACE:
+ continue
+ matrix[EMPTY_CHAR][o] += 1
+
+
+def aggregate_confusion_matrices(matrices: list[ConfusionMatrix]) -> ConfusionMatrix:
+ """Agrège plusieurs matrices de confusion en une seule.
+
+ Utile pour obtenir la matrice agrégée sur l'ensemble du corpus.
+ """
+ combined: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
+ total_subs = total_ins = total_dels = 0
+
+ for cm in matrices:
+ for gt_char, ocr_counts in cm.matrix.items():
+ for ocr_char, count in ocr_counts.items():
+ combined[gt_char][ocr_char] += count
+ total_subs += cm.total_substitutions
+ total_ins += cm.total_insertions
+ total_dels += cm.total_deletions
+
+ return ConfusionMatrix(
+ matrix={k: dict(v) for k, v in combined.items()},
+ total_substitutions=total_subs,
+ total_insertions=total_ins,
+ total_deletions=total_dels,
+ )
+
+
+def top_confused_chars(
+ matrix: ConfusionMatrix,
+ n: int = 15,
+ exclude_empty: bool = True,
+) -> list[dict]:
+ """Retourne les caractères GT les plus souvent confondus.
+
+ Retourne une liste triée par nombre total d'erreurs décroissant :
+ ``[{"char": "ſ", "total_errors": 47, "top_substitutes": [...]}, ...]``
+ """
+ char_stats: dict[str, dict] = {}
+ for gt_char, ocr_counts in matrix.matrix.items():
+ if exclude_empty and gt_char == EMPTY_CHAR:
+ continue
+ error_count = sum(
+ cnt for oc, cnt in ocr_counts.items()
+ if (oc != gt_char) and (not exclude_empty or oc != EMPTY_CHAR)
+ )
+ if error_count > 0:
+ top_subs = sorted(
+ [{"ocr": oc, "count": cnt} for oc, cnt in ocr_counts.items() if oc != gt_char],
+ key=lambda x: -x["count"],
+ )[:5]
+ char_stats[gt_char] = {
+ "char": gt_char,
+ "total_errors": error_count,
+ "top_substitutes": top_subs,
+ }
+
+ return sorted(char_stats.values(), key=lambda x: -x["total_errors"])[:n]
diff --git a/picarones/measurements/cost_projection.py b/picarones/measurements/cost_projection.py
new file mode 100644
index 0000000000000000000000000000000000000000..f9eab7a6d47731e7b6b0722cb86ad9b1aae0729b
--- /dev/null
+++ b/picarones/measurements/cost_projection.py
@@ -0,0 +1,169 @@
+"""Projection de coût en volume cible — Sprint 79 (A.I.6).
+
+Sprint 79 — A.I.6 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par
+unité (1 000 pages). Pour décider business-side, il faut projeter
+ce coût sur le **volume cible** que l'utilisateur prévoit de
+traiter — payer 50 € de plus sur 50 pages est trivial, sur
+5 millions ça change tout.
+
+Sortie typique
+--------------
+*« Pour vos 80 000 pages BMS — Tesseract = 3 €, Pero = 0 € (local
+amorti), Mistral OCR = 280 €, GPT-4o post-correction = 600 €. »*
+
+Aucun seuil arbitraire imposé : le module fournit les chiffres,
+le chercheur arbitre selon son budget.
+
+Dépendance
+----------
+S'appuie sur ``picarones.core.pricing`` (Sprint 20) qui expose
+``EngineCost.cost_per_1k_pages_eur`` et
+``co2_per_1k_pages_g``.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Optional
+
+from picarones.core.pricing import EngineCost
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class ProjectedCost:
+ """Coût total projeté d'un moteur pour un volume cible."""
+ engine_key: str
+ target_pages: int
+ cost_total_eur: Optional[float]
+ co2_total_g: Optional[float]
+ cost_per_1k_pages_eur: Optional[float]
+ co2_per_1k_pages_g: Optional[float]
+ type: str # "local" / "cloud_api" / "unknown"
+
+ def as_dict(self) -> dict:
+ return {
+ "engine_key": self.engine_key,
+ "target_pages": self.target_pages,
+ "cost_total_eur": self.cost_total_eur,
+ "co2_total_g": self.co2_total_g,
+ "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur,
+ "co2_per_1k_pages_g": self.co2_per_1k_pages_g,
+ "type": self.type,
+ }
+
+
+def project_cost_total(
+ engine_cost: EngineCost, target_pages: int,
+) -> Optional[float]:
+ """Coût total projeté en euros pour ``target_pages`` pages.
+
+ Retourne ``None`` si ``cost_per_1k_pages_eur`` est ``None``
+ (données insuffisantes) ou si ``target_pages`` est négatif.
+ """
+ if target_pages < 0:
+ return None
+ if engine_cost.cost_per_1k_pages_eur is None:
+ return None
+ return engine_cost.cost_per_1k_pages_eur * target_pages / 1000.0
+
+
+def project_co2_total(
+ engine_cost: EngineCost, target_pages: int,
+) -> Optional[float]:
+ """Empreinte CO₂ totale en grammes pour ``target_pages`` pages."""
+ if target_pages < 0:
+ return None
+ if engine_cost.co2_per_1k_pages_g is None:
+ return None
+ return engine_cost.co2_per_1k_pages_g * target_pages / 1000.0
+
+
+def project_engine(
+ engine_cost: EngineCost, target_pages: int,
+) -> ProjectedCost:
+ """Retourne le ``ProjectedCost`` complet pour un moteur."""
+ return ProjectedCost(
+ engine_key=engine_cost.engine_key,
+ target_pages=int(target_pages),
+ cost_total_eur=project_cost_total(engine_cost, target_pages),
+ co2_total_g=project_co2_total(engine_cost, target_pages),
+ cost_per_1k_pages_eur=engine_cost.cost_per_1k_pages_eur,
+ co2_per_1k_pages_g=engine_cost.co2_per_1k_pages_g,
+ type=engine_cost.type,
+ )
+
+
+def project_all_engines(
+ engine_costs: dict[str, EngineCost],
+ target_pages: int,
+) -> dict[str, ProjectedCost]:
+ """Projette les coûts de plusieurs moteurs sur le volume cible.
+
+ Retourne un dict ``{engine_name: ProjectedCost}`` avec entrée
+ pour chaque moteur, y compris ceux sans données de coût (où
+ ``cost_total_eur`` sera ``None``).
+ """
+ if target_pages < 0:
+ raise ValueError("target_pages doit être ≥ 0")
+ return {
+ name: project_engine(cost, target_pages)
+ for name, cost in engine_costs.items()
+ }
+
+
+def cost_gap_table(
+ projections: dict[str, ProjectedCost],
+ baseline_engine: str,
+) -> dict[str, dict[str, Optional[float]]]:
+ """Pour chaque moteur, écart de coût total vs baseline.
+
+ Retourne ``{engine: {"total": float, "delta_abs": float,
+ "delta_rel": float}}`` où :
+
+ - ``delta_abs`` = ``cost - cost_baseline`` (None si l'un des
+ deux est None)
+ - ``delta_rel`` = ``delta_abs / cost_baseline`` (None si
+ baseline = 0 ou None)
+
+ Lève ``KeyError`` si la baseline est inconnue.
+ """
+ if baseline_engine not in projections:
+ raise KeyError(
+ f"baseline {baseline_engine!r} absente des projections",
+ )
+ baseline_total = projections[baseline_engine].cost_total_eur
+ out: dict[str, dict[str, Optional[float]]] = {}
+ for name, proj in projections.items():
+ total = proj.cost_total_eur
+ if total is None or baseline_total is None:
+ delta_abs: Optional[float] = None
+ delta_rel: Optional[float] = None
+ else:
+ delta_abs = total - baseline_total
+ if baseline_total != 0:
+ delta_rel = delta_abs / baseline_total
+ else:
+ delta_rel = None
+ out[name] = {
+ "total": total,
+ "delta_abs": delta_abs,
+ "delta_rel": delta_rel,
+ }
+ return out
+
+
+__all__ = [
+ "ProjectedCost",
+ "project_cost_total",
+ "project_co2_total",
+ "project_engine",
+ "project_all_engines",
+ "cost_gap_table",
+]
diff --git a/picarones/measurements/difficulty.py b/picarones/measurements/difficulty.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f037a48d4f67d06e7162473b901fc261681373a
--- /dev/null
+++ b/picarones/measurements/difficulty.py
@@ -0,0 +1,202 @@
+"""Score de difficulté intrinsèque par document.
+
+Le score est indépendant des moteurs OCR : il mesure la difficulté
+*objective* d'un document, indépendamment de la qualité des transcriptions.
+
+Formule
+-------
+ difficulty = w_variance * variance_norm
+ + w_quality * (1 - image_quality_score)
+ + w_density * special_char_density
+
+où :
+ - variance_norm : variance inter-moteurs du CER, normalisée [0, 1]
+ - image_quality : score de qualité image [0, 1] (netteté, contraste…)
+ - special_chars : densité de caractères spéciaux dans la GT [0, 1]
+
+Les poids sont configurables (défaut : 0.4 / 0.35 / 0.25).
+
+Score final : [0, 1] — 0 = document facile, 1 = très difficile.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Optional
+
+
+# Poids par défaut
+_W_VARIANCE = 0.40
+_W_QUALITY = 0.35
+_W_DENSITY = 0.25
+
+# Caractères spéciaux patrimoniaux (ligatures, abréviations, diacritiques rares)
+_SPECIAL_CHARS_RE = re.compile(
+ r"[ſœæꝑꝓ&]" # ligatures / abréviations médiévales
+ r"|[ḁ-ỿ]" # Latin Étendu Additionnel (diacritiques rares)
+ r"|[\u0300-\u036f]" # Diacritiques combinants
+ r"|[\ufb00-\ufb06]" # Formes de présentation latines (fi, fl…)
+ r"|[IVXLCDM]{3,}" # Chiffres romains (3+ caractères)
+)
+
+
+@dataclass
+class DifficultyScore:
+ """Score de difficulté intrinsèque d'un document."""
+ doc_id: str
+ score: float
+ """Score global [0, 1] — plus élevé = plus difficile."""
+ variance_component: float
+ """Composante variance inter-moteurs [0, 1]."""
+ quality_component: float
+ """Composante qualité image inversée [0, 1]."""
+ density_component: float
+ """Composante densité caractères spéciaux [0, 1]."""
+ cer_variance: float
+ """Variance brute du CER entre moteurs."""
+ image_quality_score: float
+ """Score de qualité image (si disponible, sinon 0.5)."""
+ special_char_ratio: float
+ """Ratio caractères spéciaux / longueur GT."""
+
+ def as_dict(self) -> dict:
+ return {
+ "doc_id": self.doc_id,
+ "score": round(self.score, 4),
+ "variance_component": round(self.variance_component, 4),
+ "quality_component": round(self.quality_component, 4),
+ "density_component": round(self.density_component, 4),
+ "cer_variance": round(self.cer_variance, 6),
+ "image_quality_score": round(self.image_quality_score, 4),
+ "special_char_ratio": round(self.special_char_ratio, 4),
+ }
+
+
+def _special_char_density(text: str) -> float:
+ """Ratio de caractères spéciaux patrimoniaux dans le texte."""
+ if not text:
+ return 0.0
+ matches = len(_SPECIAL_CHARS_RE.findall(text))
+ return min(1.0, matches / len(text))
+
+
+def _variance(values: list[float]) -> float:
+ """Variance d'une liste de valeurs."""
+ if len(values) < 2:
+ return 0.0
+ mu = sum(values) / len(values)
+ return sum((v - mu) ** 2 for v in values) / len(values)
+
+
+def compute_difficulty_score(
+ doc_id: str,
+ ground_truth: str,
+ cer_per_engine: list[float],
+ image_quality_score: Optional[float] = None,
+ weights: tuple[float, float, float] = (_W_VARIANCE, _W_QUALITY, _W_DENSITY),
+) -> DifficultyScore:
+ """Calcule le score de difficulté intrinsèque pour un document.
+
+ Parameters
+ ----------
+ doc_id : identifiant du document
+ ground_truth : texte de référence
+ cer_per_engine : liste des CER (un par moteur concurrent)
+ image_quality_score: score de qualité image [0, 1] (None → 0.5 neutre)
+ weights : (w_variance, w_quality, w_density)
+
+ Returns
+ -------
+ DifficultyScore
+ """
+ w_var, w_qual, w_den = weights
+
+ # 1. Variance inter-moteurs (normalisée sur [0, 1] — variance max ≈ 0.25)
+ cer_var = _variance(cer_per_engine)
+ variance_norm = min(1.0, cer_var / 0.25)
+
+ # 2. Qualité image inversée
+ iq = image_quality_score if image_quality_score is not None else 0.5
+ iq = max(0.0, min(1.0, iq))
+ quality_component = 1.0 - iq
+
+ # 3. Densité de caractères spéciaux
+ density = _special_char_density(ground_truth)
+ # Amplifier légèrement (la densité brute est souvent faible)
+ density_component = min(1.0, density * 3.0)
+
+ # Score combiné
+ score = (
+ w_var * variance_norm
+ + w_qual * quality_component
+ + w_den * density_component
+ )
+ score = max(0.0, min(1.0, score))
+
+ return DifficultyScore(
+ doc_id=doc_id,
+ score=score,
+ variance_component=variance_norm,
+ quality_component=quality_component,
+ density_component=density_component,
+ cer_variance=cer_var,
+ image_quality_score=iq,
+ special_char_ratio=density,
+ )
+
+
+def compute_all_difficulties(
+ doc_ids: list[str],
+ ground_truths: dict[str, str],
+ cer_map: dict[str, dict[str, float]],
+ image_quality_map: Optional[dict[str, float]] = None,
+) -> dict[str, DifficultyScore]:
+ """Calcule les scores de difficulté pour tous les documents d'un corpus.
+
+ Parameters
+ ----------
+ doc_ids : liste des identifiants de documents
+ ground_truths : {doc_id → gt_text}
+ cer_map : {doc_id → {engine_name → cer}}
+ image_quality_map : {doc_id → quality_score} (facultatif)
+
+ Returns
+ -------
+ {doc_id → DifficultyScore}
+ """
+ result = {}
+ for doc_id in doc_ids:
+ gt = ground_truths.get(doc_id, "")
+ engine_cers = list(cer_map.get(doc_id, {}).values())
+ iq = (image_quality_map or {}).get(doc_id)
+ result[doc_id] = compute_difficulty_score(
+ doc_id=doc_id,
+ ground_truth=gt,
+ cer_per_engine=engine_cers,
+ image_quality_score=iq,
+ )
+ return result
+
+
+def difficulty_label(score: float) -> str:
+ """Retourne un label lisible pour un score de difficulté."""
+ if score < 0.25:
+ return "Facile"
+ if score < 0.50:
+ return "Modéré"
+ if score < 0.75:
+ return "Difficile"
+ return "Très difficile"
+
+
+def difficulty_color(score: float) -> str:
+ """Retourne une couleur CSS pour un score de difficulté."""
+ from picarones.core.colors import COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_RED
+ if score < 0.25:
+ return COLOR_GREEN
+ if score < 0.50:
+ return COLOR_YELLOW
+ if score < 0.75:
+ return COLOR_ORANGE
+ return COLOR_RED
diff --git a/picarones/measurements/equivalence_profile.py b/picarones/measurements/equivalence_profile.py
new file mode 100644
index 0000000000000000000000000000000000000000..e3c9adf1cb24c3e73567733ac16c883ba8396e81
--- /dev/null
+++ b/picarones/measurements/equivalence_profile.py
@@ -0,0 +1,199 @@
+"""Équivalences diplomatiques granulaires — Sprint 78 (A.I.5).
+
+Sprint 78 — A.I.5 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Aujourd'hui les profils de ``picarones/core/normalization.py``
+(``medieval_french``, ``early_modern_french``, etc.) appliquent un
+**bloc entier** de transformations. Mais un éditeur peut vouloir
+nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par
+exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont
+distinctes mais où le s long doit être normalisé.
+
+Ce module **éclate** chaque profil en règles d'équivalence
+**nommées et indépendantes** que l'utilisateur peut activer ou
+désactiver une par une. La couche de calcul retourne le CER
+recalculé avec un sous-ensemble personnalisé.
+
+Format
+------
+Chaque règle a :
+
+- ``name`` : identifiant stable utilisé dans les URLs et l'UX
+ (ex. ``"longs_s"``, ``"u_eq_v"``)
+- ``source`` : caractère ou séquence à remplacer
+- ``target`` : caractère ou séquence cible
+- ``description`` : phrase courte FR destinée à l'utilisateur
+- ``profile_tag`` : nom du profil dont elle est issue (utile pour
+ grouper dans l'UX)
+
+Stratégie de découpage
+----------------------
+Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau
+avancé (cases à cocher + recalcul JS client + URL state) suivra
+dans un sprint dédié — la couche calcul livrée ici est une
+fondation suffisante pour qu'un développeur frontend câble la vue.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Iterable, Optional
+
+from picarones.core.normalization import (
+ DIPLOMATIC_EN_EARLY_MODERN,
+ DIPLOMATIC_FR_EARLY_MODERN,
+ DIPLOMATIC_LATIN_MEDIEVAL,
+ DIPLOMATIC_MINIMAL,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class EquivalenceRule:
+ """Une équivalence diplomatique nommée et indépendante."""
+ name: str
+ source: str
+ target: str
+ description: str
+ profile_tag: str
+
+
+# Catalogue : on dérive des profils existants en attribuant un nom
+# stable à chaque transformation. Les doublons (ex. ``ſ → s``
+# présent dans plusieurs profils) sont fusionnés sous un nom unique
+# (le premier rencontré).
+def _build_catalog() -> dict[str, EquivalenceRule]:
+ catalog: dict[str, EquivalenceRule] = {}
+
+ # Noms canoniques pour les transformations courantes
+ canonical_names: dict[tuple[str, str], tuple[str, str]] = {
+ ("ſ", "s"): ("longs_s", "s long ſ → s"),
+ ("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"),
+ ("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"),
+ ("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"),
+ ("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"),
+ ("æ", "ae"): ("ae_ligature", "æ → ae"),
+ ("œ", "oe"): ("oe_ligature", "œ → oe"),
+ ("þ", "th"): ("thorn_th", "þ (thorn) → th"),
+ ("ð", "th"): ("eth_th", "ð (eth) → th"),
+ ("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"),
+ ("&", "et"): ("ampersand_et", "& → et (esperluette)"),
+ ("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"),
+ ("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"),
+ ("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"),
+ ("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"),
+ }
+
+ sources = [
+ ("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL),
+ ("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN),
+ ("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN),
+ ("minimal", DIPLOMATIC_MINIMAL),
+ ]
+
+ for profile_tag, profile_dict in sources:
+ for source, target in profile_dict.items():
+ key = (source, target)
+ if key in canonical_names:
+ name, desc = canonical_names[key]
+ else:
+ # Fallback : générer un nom à partir des codepoints
+ name = f"{source}_to_{target}".replace(" ", "_")
+ desc = f"{source} → {target}"
+ if name in catalog:
+ # On garde le profile_tag du premier rencontré, mais
+ # on note que la règle est partagée.
+ continue
+ catalog[name] = EquivalenceRule(
+ name=name,
+ source=source,
+ target=target,
+ description=desc,
+ profile_tag=profile_tag,
+ )
+ return catalog
+
+
+BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog()
+
+
+def list_equivalences_by_profile(
+ profile_name: Optional[str] = None,
+) -> list[EquivalenceRule]:
+ """Liste les règles d'équivalence disponibles.
+
+ Si ``profile_name`` est fourni, ne retourne que les règles dont
+ ``profile_tag == profile_name`` (ou les règles dérivées de
+ plusieurs profils dont au moins un est ``profile_name``).
+ """
+ if profile_name is None:
+ return list(BUILTIN_EQUIVALENCES.values())
+ return [
+ rule for rule in BUILTIN_EQUIVALENCES.values()
+ if rule.profile_tag == profile_name
+ ]
+
+
+def apply_selected_equivalences(
+ text: Optional[str],
+ selected_names: Iterable[str],
+) -> str:
+ """Applique uniquement les règles dont le nom est dans
+ ``selected_names``.
+
+ L'ordre d'application est l'ordre du catalogue interne — les
+ transformations sont appliquées séquentiellement sur le texte.
+ Les règles inconnues sont silencieusement ignorées (avec
+ warning).
+ """
+ if not text:
+ return text or ""
+ selected_set = set(selected_names)
+ if not selected_set:
+ return text
+ out = text
+ for name, rule in BUILTIN_EQUIVALENCES.items():
+ if name not in selected_set:
+ continue
+ out = out.replace(rule.source, rule.target)
+ # Détection des règles inconnues (pour logger explicite)
+ unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys())
+ if unknown:
+ logger.warning(
+ "[equivalence_profile] règles inconnues ignorées : %s",
+ sorted(unknown),
+ )
+ return out
+
+
+def compute_cer_with_equivalences(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+ selected_names: Iterable[str],
+) -> float:
+ """Calcule le CER après application des équivalences sélectionnées
+ sur les **deux** côtés (GT et hypothèse).
+
+ Utilise ``picarones.core.metrics.compute_metrics`` et extrait
+ le champ ``cer`` du résultat.
+ """
+ from picarones.core.metrics import compute_metrics
+
+ selected_list = list(selected_names)
+ ref = apply_selected_equivalences(reference or "", selected_list)
+ hyp = apply_selected_equivalences(hypothesis or "", selected_list)
+ result = compute_metrics(ref, hyp)
+ return result.cer
+
+
+__all__ = [
+ "EquivalenceRule",
+ "BUILTIN_EQUIVALENCES",
+ "list_equivalences_by_profile",
+ "apply_selected_equivalences",
+ "compute_cer_with_equivalences",
+]
diff --git a/picarones/measurements/error_absorption.py b/picarones/measurements/error_absorption.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce1021d64b625397fd5c3dca1d15475d6d83477b
--- /dev/null
+++ b/picarones/measurements/error_absorption.py
@@ -0,0 +1,276 @@
+"""Métrique d'absorption d'erreur — Sprint 94 (B.3).
+
+Sprint 94 — B.3 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Quand un module de post-correction LLM aplatit les différences
+entre OCR amont, ce n'est pas qu'il « améliore » tous les
+moteurs — c'est qu'il introduit ses propres biais qui dominent
+ceux de l'OCR. Mesurer la dégradation par étape ne suffit
+pas : il faut **séparer** les deux flux.
+
+À chaque jonction où un module transforme un artefact, on
+mesure :
+
+- **Taux de correction** : parmi les erreurs présentes en
+ entrée du module, combien sont corrigées en sortie ?
+- **Taux d'introduction** : parmi les erreurs présentes en
+ sortie, combien sont **nouvelles** (absentes en entrée) ?
+
+C'est la généralisation du score de sur-normalisation
+(chantier A.I.7) à toute jonction. La formule s'applique
+uniformément à OCR→LLM, OCR→reconstructor, VLM→ALTO_mapper —
+toute jonction qui transforme un artefact en un autre du même
+type.
+
+Méthode (token-level)
+---------------------
+On split en tokens whitespace ``reference``, ``before``,
+``after``. On compare en **multiset** (un token GT consommé
+au plus une fois) :
+
+- ``errors_before`` = tokens GT non retrouvés dans ``before``
+- ``errors_after`` = tokens GT non retrouvés dans ``after``
+- ``corrected`` = ``errors_before \\ errors_after``
+ (présents avant, absents après → corrigés)
+- ``introduced`` = ``errors_after \\ errors_before``
+ (absents avant, présents après → introduits)
+
+Garde-fou : le module ne classe pas les erreurs (visuelles,
+abréviations, etc.) — c'est une métrique d'**absorption de
+volume**, pas de qualité éditoriale. L'intersection sémantique
+avec ``taxonomy`` (Sprint 5) est documentée dans le glossaire.
+
+Sortie
+------
+``compute_error_absorption(reference, before, after)`` retourne :
+
+.. code-block:: text
+
+ {
+ "n_gt_tokens": int,
+ "n_errors_before": int,
+ "n_errors_after": int,
+ "n_corrected": int,
+ "n_introduced": int,
+ "n_kept_wrong": int,
+ "correction_rate": float | None, # n_corrected / n_errors_before
+ "introduction_rate": float | None, # n_introduced / n_errors_after
+ "net_improvement": int, # n_corrected - n_introduced
+ "corrected_tokens": list[str],
+ "introduced_tokens": list[str],
+ }
+
+``aggregate_error_absorption(per_doc_results)`` somme les
+compteurs corpus-wide et recalcule les taux *micro*.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections import Counter
+from typing import Iterable, Optional
+
+logger = logging.getLogger(__name__)
+
+
+def _split_words(text: Optional[str]) -> list[str]:
+ if not text:
+ return []
+ return text.split()
+
+
+def _missing_tokens(
+ reference: list[str], hypothesis: list[str],
+) -> Counter:
+ """Tokens GT manquants en hypothèse au sens multiset.
+
+ Un token GT compte plusieurs fois s'il apparaît plusieurs
+ fois ; chaque occurrence en hypothèse en absorbe au plus
+ une. Retourne un Counter ``{token: nb_occurrences_manquees}``.
+ """
+ ref_count = Counter(reference)
+ hyp_count = Counter(hypothesis)
+ missing: Counter = Counter()
+ for token, n_ref in ref_count.items():
+ n_hyp = hyp_count.get(token, 0)
+ if n_hyp < n_ref:
+ missing[token] = n_ref - n_hyp
+ return missing
+
+
+def compute_error_absorption(
+ reference: Optional[str],
+ before: Optional[str],
+ after: Optional[str],
+ *,
+ case_sensitive: bool = False,
+) -> Optional[dict]:
+ """Mesure l'absorption d'erreur entre ``before`` et ``after``.
+
+ Parameters
+ ----------
+ reference:
+ GT (vérité terrain).
+ before:
+ Sortie de l'étape précédente (typiquement OCR amont).
+ after:
+ Sortie de l'étape courante (typiquement post-correction LLM).
+ case_sensitive:
+ Si False (défaut), match case-insensitive — la sortie
+ ``corrected_tokens``/``introduced_tokens`` reste en casse
+ GT originale.
+
+ Returns
+ -------
+ dict | None
+ ``None`` si la GT est vide ou ne contient aucun token.
+ """
+ ref_tokens = _split_words(reference)
+ if not ref_tokens:
+ return None
+ before_tokens = _split_words(before)
+ after_tokens = _split_words(after)
+
+ if case_sensitive:
+ ref_match = list(ref_tokens)
+ before_match = list(before_tokens)
+ after_match = list(after_tokens)
+ else:
+ ref_match = [t.lower() for t in ref_tokens]
+ before_match = [t.lower() for t in before_tokens]
+ after_match = [t.lower() for t in after_tokens]
+
+ # Map case-insensitive token → liste de casses GT originales
+ ref_orig_by_match: dict[str, list[str]] = {}
+ for orig, m in zip(ref_tokens, ref_match):
+ ref_orig_by_match.setdefault(m, []).append(orig)
+
+ missing_before = _missing_tokens(ref_match, before_match)
+ missing_after = _missing_tokens(ref_match, after_match)
+
+ n_errors_before = sum(missing_before.values())
+ n_errors_after = sum(missing_after.values())
+
+ # Calcul corrigé / introduit en multiset
+ corrected_counter: Counter = Counter()
+ introduced_counter: Counter = Counter()
+ kept_wrong_counter: Counter = Counter()
+ all_tokens = set(missing_before) | set(missing_after)
+ for tok in all_tokens:
+ nb = missing_before.get(tok, 0)
+ na = missing_after.get(tok, 0)
+ if nb > na:
+ corrected_counter[tok] = nb - na
+ kept_wrong_counter[tok] = na
+ elif na > nb:
+ introduced_counter[tok] = na - nb
+ kept_wrong_counter[tok] = nb
+ else:
+ kept_wrong_counter[tok] = nb
+
+ n_corrected = sum(corrected_counter.values())
+ n_introduced = sum(introduced_counter.values())
+ n_kept_wrong = sum(kept_wrong_counter.values())
+
+ correction_rate = (
+ n_corrected / n_errors_before
+ if n_errors_before > 0 else None
+ )
+ introduction_rate = (
+ n_introduced / n_errors_after
+ if n_errors_after > 0 else None
+ )
+
+ def _expand(counter: Counter) -> list[str]:
+ out: list[str] = []
+ for tok, count in counter.items():
+ origs = ref_orig_by_match.get(tok, [tok])
+ # Ne renvoie que la casse représentative GT
+ display = origs[0] if origs else tok
+ out.extend([display] * count)
+ return out
+
+ return {
+ "n_gt_tokens": len(ref_tokens),
+ "n_errors_before": n_errors_before,
+ "n_errors_after": n_errors_after,
+ "n_corrected": n_corrected,
+ "n_introduced": n_introduced,
+ "n_kept_wrong": n_kept_wrong,
+ "correction_rate": correction_rate,
+ "introduction_rate": introduction_rate,
+ "net_improvement": n_corrected - n_introduced,
+ "corrected_tokens": _expand(corrected_counter),
+ "introduced_tokens": _expand(introduced_counter),
+ }
+
+
+def aggregate_error_absorption(
+ per_doc: Iterable[Optional[dict]],
+ *,
+ sample_tokens: int = 50,
+) -> Optional[dict]:
+ """Agrège les compteurs corpus-wide et recalcule les taux
+ *micro*.
+
+ Parameters
+ ----------
+ per_doc:
+ Itérable de sorties de ``compute_error_absorption`` (ou
+ ``None`` pour les docs sans GT).
+ sample_tokens:
+ Nombre maximal de tokens corrigés/introduits gardés dans
+ l'échantillon (cap pour ne pas exploser le JSON).
+
+ Returns
+ -------
+ dict | None
+ ``None`` si aucune entry valide.
+ """
+ docs = [d for d in per_doc if d]
+ if not docs:
+ return None
+ n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
+ n_errors_before = sum(int(d.get("n_errors_before") or 0) for d in docs)
+ n_errors_after = sum(int(d.get("n_errors_after") or 0) for d in docs)
+ n_corrected = sum(int(d.get("n_corrected") or 0) for d in docs)
+ n_introduced = sum(int(d.get("n_introduced") or 0) for d in docs)
+ n_kept_wrong = sum(int(d.get("n_kept_wrong") or 0) for d in docs)
+ correction_rate = (
+ n_corrected / n_errors_before if n_errors_before > 0 else None
+ )
+ introduction_rate = (
+ n_introduced / n_errors_after if n_errors_after > 0 else None
+ )
+ corrected_sample: list[str] = []
+ introduced_sample: list[str] = []
+ for d in docs:
+ corrected_sample.extend(d.get("corrected_tokens") or [])
+ introduced_sample.extend(d.get("introduced_tokens") or [])
+ if (
+ len(corrected_sample) >= sample_tokens
+ and len(introduced_sample) >= sample_tokens
+ ):
+ break
+ return {
+ "n_docs": len(docs),
+ "n_gt_tokens": n_gt,
+ "n_errors_before": n_errors_before,
+ "n_errors_after": n_errors_after,
+ "n_corrected": n_corrected,
+ "n_introduced": n_introduced,
+ "n_kept_wrong": n_kept_wrong,
+ "correction_rate": correction_rate,
+ "introduction_rate": introduction_rate,
+ "net_improvement": n_corrected - n_introduced,
+ "corrected_tokens_sample": corrected_sample[:sample_tokens],
+ "introduced_tokens_sample": introduced_sample[:sample_tokens],
+ }
+
+
+__all__ = [
+ "compute_error_absorption",
+ "aggregate_error_absorption",
+]
diff --git a/picarones/measurements/hallucination.py b/picarones/measurements/hallucination.py
new file mode 100644
index 0000000000000000000000000000000000000000..07eda573ca8d1b4e659600482d3af3e87f245c21
--- /dev/null
+++ b/picarones/measurements/hallucination.py
@@ -0,0 +1,331 @@
+"""Détection des hallucinations VLM/LLM — Sprint 10.
+
+Métriques calculées
+-------------------
+- Taux d'insertion net : mots/caractères ajoutés absents du GT, distinct du WIL existant
+- Ratio de longueur : len(hyp) / len(gt) — ratio > 1.2 → hallucination potentielle
+- Score d'ancrage : proportion des n-grammes (trigrammes) de la sortie présents dans le GT
+- Blocs hallucinés : segments continus de la sortie sans correspondance GT au-delà d'un seuil
+- Badge hallucination : True si ancrage faible ou ratio de longueur anormal
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+
+# ---------------------------------------------------------------------------
+# Helpers texte
+# ---------------------------------------------------------------------------
+
+def _tokenize(text: str) -> list[str]:
+ """Découpe en mots (minuscules, sans ponctuation)."""
+ return re.findall(r"[^\s]+", text.lower())
+
+
+def _ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]:
+ """Génère les n-grammes d'une liste de tokens."""
+ if len(tokens) < n:
+ return [tuple(tokens)] if tokens else []
+ return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]
+
+
+# ---------------------------------------------------------------------------
+# Blocs hallucinés (segments continus sans ancrage)
+# ---------------------------------------------------------------------------
+
+@dataclass
+class HallucinatedBlock:
+ """Segment continu de la sortie sans correspondance dans le GT."""
+ start_token: int
+ end_token: int
+ text: str
+ length: int # nombre de tokens
+
+ def as_dict(self) -> dict:
+ return {
+ "start_token": self.start_token,
+ "end_token": self.end_token,
+ "text": self.text,
+ "length": self.length,
+ }
+
+
+def _detect_hallucinated_blocks(
+ hyp_tokens: list[str],
+ gt_token_set: set[str],
+ tolerance: int = 3,
+ min_block_length: int = 4,
+) -> list[HallucinatedBlock]:
+ """Détecte les blocs de tokens hypothèse sans correspondance dans le GT.
+
+ Un bloc est un segment contigu de tokens hypothèse dont aucun n'est présent
+ dans le vocabulaire GT. Une tolérance de ``tolerance`` tokens connus interrompus
+ est acceptée avant de clore un bloc.
+
+ Parameters
+ ----------
+ hyp_tokens:
+ Tokens de la sortie OCR/VLM.
+ gt_token_set:
+ Ensemble des tokens du GT (pour recherche O(1)).
+ tolerance:
+ Nombre de tokens connus consécutifs interrompant un bloc avant de le clore.
+ min_block_length:
+ Longueur minimale (tokens) pour qu'un bloc soit signalé.
+
+ Returns
+ -------
+ list[HallucinatedBlock]
+ """
+ blocks: list[HallucinatedBlock] = []
+ if not hyp_tokens:
+ return blocks
+
+ in_block = False
+ block_start = 0
+ consecutive_known = 0
+
+ for i, tok in enumerate(hyp_tokens):
+ is_unknown = tok not in gt_token_set
+ if is_unknown:
+ if not in_block:
+ in_block = True
+ block_start = i
+ consecutive_known = 0
+ else:
+ consecutive_known = 0
+ else:
+ if in_block:
+ consecutive_known += 1
+ if consecutive_known >= tolerance:
+ # Clore le bloc
+ end = i - consecutive_known
+ length = end - block_start + 1
+ if length >= min_block_length:
+ text = " ".join(hyp_tokens[block_start:end + 1])
+ blocks.append(HallucinatedBlock(
+ start_token=block_start,
+ end_token=end,
+ text=text,
+ length=length,
+ ))
+ in_block = False
+ consecutive_known = 0
+
+ # Bloc non terminé
+ if in_block:
+ end = len(hyp_tokens) - 1
+ length = end - block_start + 1
+ if length >= min_block_length:
+ text = " ".join(hyp_tokens[block_start:end + 1])
+ blocks.append(HallucinatedBlock(
+ start_token=block_start,
+ end_token=end,
+ text=text,
+ length=length,
+ ))
+
+ return blocks
+
+
+# ---------------------------------------------------------------------------
+# Résultat structuré
+# ---------------------------------------------------------------------------
+
+@dataclass
+class HallucinationMetrics:
+ """Métriques de détection des hallucinations pour une paire (GT, hypothèse)."""
+
+ net_insertion_rate: float
+ """Taux d'insertion nette : tokens hypothèse absents du GT / total tokens hypothèse."""
+
+ length_ratio: float
+ """Ratio de longueur : len(hyp) / len(gt) en caractères. > 1.2 = signal d'hallucination."""
+
+ anchor_score: float
+ """Score d'ancrage : proportion des trigrammes hypothèse présents dans les trigrammes GT.
+ Score élevé → l'hypothèse s'ancre bien dans le GT. Score faible → hallucinations probables."""
+
+ hallucinated_blocks: list[HallucinatedBlock]
+ """Segments continus de la sortie sans correspondance GT (au-dessus du seuil de tolérance)."""
+
+ is_hallucinating: bool
+ """True si anchor_score < anchor_threshold OU length_ratio > length_ratio_threshold."""
+
+ # Détails supplémentaires
+ gt_word_count: int = 0
+ hyp_word_count: int = 0
+ net_inserted_words: int = 0
+ anchor_threshold_used: float = 0.5
+ length_ratio_threshold_used: float = 1.2
+ ngram_size_used: int = 3
+
+ def as_dict(self) -> dict:
+ return {
+ "net_insertion_rate": round(self.net_insertion_rate, 6),
+ "length_ratio": round(self.length_ratio, 6),
+ "anchor_score": round(self.anchor_score, 6),
+ "hallucinated_blocks": [b.as_dict() for b in self.hallucinated_blocks],
+ "is_hallucinating": self.is_hallucinating,
+ "gt_word_count": self.gt_word_count,
+ "hyp_word_count": self.hyp_word_count,
+ "net_inserted_words": self.net_inserted_words,
+ "anchor_threshold_used": self.anchor_threshold_used,
+ "length_ratio_threshold_used": self.length_ratio_threshold_used,
+ "ngram_size_used": self.ngram_size_used,
+ }
+
+ @classmethod
+ def from_dict(cls, d: dict) -> "HallucinationMetrics":
+ blocks = [
+ HallucinatedBlock(**b) for b in d.get("hallucinated_blocks", [])
+ ]
+ return cls(
+ net_insertion_rate=d.get("net_insertion_rate", 0.0),
+ length_ratio=d.get("length_ratio", 1.0),
+ anchor_score=d.get("anchor_score", 1.0),
+ hallucinated_blocks=blocks,
+ is_hallucinating=d.get("is_hallucinating", False),
+ gt_word_count=d.get("gt_word_count", 0),
+ hyp_word_count=d.get("hyp_word_count", 0),
+ net_inserted_words=d.get("net_inserted_words", 0),
+ anchor_threshold_used=d.get("anchor_threshold_used", 0.5),
+ length_ratio_threshold_used=d.get("length_ratio_threshold_used", 1.2),
+ ngram_size_used=d.get("ngram_size_used", 3),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Calcul principal
+# ---------------------------------------------------------------------------
+
+def compute_hallucination_metrics(
+ reference: str,
+ hypothesis: str,
+ n: int = 3,
+ length_ratio_threshold: float = 1.2,
+ anchor_threshold: float = 0.5,
+ block_tolerance: int = 3,
+ min_block_length: int = 4,
+) -> HallucinationMetrics:
+ """Calcule les métriques de détection des hallucinations VLM/LLM.
+
+ Parameters
+ ----------
+ reference:
+ Texte de vérité terrain (GT).
+ hypothesis:
+ Texte produit par le modèle.
+ n:
+ Taille des n-grammes pour le score d'ancrage (défaut : trigrammes).
+ length_ratio_threshold:
+ Seuil de ratio de longueur au-dessus duquel on signale une hallucination potentielle.
+ anchor_threshold:
+ Seuil de score d'ancrage en dessous duquel on signale une hallucination potentielle.
+ block_tolerance:
+ Nombre de tokens connus consécutifs acceptés dans un bloc halluciné.
+ min_block_length:
+ Longueur minimale (tokens) pour signaler un bloc halluciné.
+
+ Returns
+ -------
+ HallucinationMetrics
+ """
+ gt_tokens = _tokenize(reference)
+ hyp_tokens = _tokenize(hypothesis)
+
+ gt_len_chars = len(reference.strip())
+ hyp_len_chars = len(hypothesis.strip())
+
+ # ── Ratio de longueur ────────────────────────────────────────────────
+ if gt_len_chars == 0:
+ length_ratio = 1.0 if hyp_len_chars == 0 else float("inf")
+ else:
+ length_ratio = hyp_len_chars / gt_len_chars
+
+ # ── Taux d'insertion nette ───────────────────────────────────────────
+ gt_token_set = set(gt_tokens)
+ hyp_token_count = len(hyp_tokens)
+
+ if hyp_token_count == 0:
+ net_insertion_rate = 0.0
+ net_inserted_words = 0
+ else:
+ net_inserted = [t for t in hyp_tokens if t not in gt_token_set]
+ net_inserted_words = len(net_inserted)
+ net_insertion_rate = net_inserted_words / hyp_token_count
+
+ # ── Score d'ancrage (n-grammes) ──────────────────────────────────────
+ gt_ngrams = set(_ngrams(gt_tokens, n))
+ hyp_ngrams = _ngrams(hyp_tokens, n)
+
+ if not hyp_ngrams:
+ # Pas de n-grammes dans l'hypothèse → ancrage parfait (hypothèse vide ou trop courte)
+ anchor_score = 1.0 if not gt_ngrams else 0.0
+ elif not gt_ngrams:
+ anchor_score = 0.0
+ else:
+ anchored = sum(1 for ng in hyp_ngrams if ng in gt_ngrams)
+ anchor_score = anchored / len(hyp_ngrams)
+
+ # ── Blocs hallucinés ─────────────────────────────────────────────────
+ blocks = _detect_hallucinated_blocks(
+ hyp_tokens=hyp_tokens,
+ gt_token_set=gt_token_set,
+ tolerance=block_tolerance,
+ min_block_length=min_block_length,
+ )
+
+ # ── Badge hallucination ──────────────────────────────────────────────
+ is_hallucinating = (
+ anchor_score < anchor_threshold
+ or length_ratio > length_ratio_threshold
+ )
+
+ return HallucinationMetrics(
+ net_insertion_rate=net_insertion_rate,
+ length_ratio=min(length_ratio, 9.99), # plafonner pour la sérialisation
+ anchor_score=anchor_score,
+ hallucinated_blocks=blocks,
+ is_hallucinating=is_hallucinating,
+ gt_word_count=len(gt_tokens),
+ hyp_word_count=hyp_token_count,
+ net_inserted_words=net_inserted_words,
+ anchor_threshold_used=anchor_threshold,
+ length_ratio_threshold_used=length_ratio_threshold,
+ ngram_size_used=n,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Agrégation sur un corpus
+# ---------------------------------------------------------------------------
+
+def aggregate_hallucination_metrics(results: list[HallucinationMetrics]) -> dict:
+ """Agrège les métriques d'hallucination sur un corpus.
+
+ Returns
+ -------
+ dict
+ Statistiques agrégées : anchor_score moyen, taux de documents hallucinés…
+ """
+ if not results:
+ return {}
+
+ n = len(results)
+ anchor_values = [r.anchor_score for r in results]
+ ratio_values = [r.length_ratio for r in results]
+ insertion_values = [r.net_insertion_rate for r in results]
+ hallucinating_count = sum(1 for r in results if r.is_hallucinating)
+
+ return {
+ "anchor_score_mean": round(sum(anchor_values) / n, 6),
+ "anchor_score_min": round(min(anchor_values), 6),
+ "length_ratio_mean": round(sum(ratio_values) / n, 6),
+ "net_insertion_rate_mean": round(sum(insertion_values) / n, 6),
+ "hallucinating_doc_count": hallucinating_count,
+ "hallucinating_doc_rate": round(hallucinating_count / n, 6),
+ "document_count": n,
+ }
diff --git a/picarones/measurements/history.py b/picarones/measurements/history.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a360851ae767a99635a18eadae3fa0dcd9fb411
--- /dev/null
+++ b/picarones/measurements/history.py
@@ -0,0 +1,615 @@
+"""Suivi longitudinal des benchmarks — base SQLite optionnelle.
+
+Fonctionnement
+--------------
+- Chaque run de benchmark est enregistré dans une table SQLite avec horodatage,
+ corpus, moteurs, métriques agrégées.
+- L'historique permet de tracer des courbes d'évolution du CER dans le temps.
+- La détection de régression compare le dernier run à une baseline configurable.
+
+Structure de la base
+--------------------
+Table ``runs`` :
+ run_id TEXT PRIMARY KEY — UUID ou hash du run
+ timestamp TEXT — ISO 8601
+ corpus_name TEXT
+ engine_name TEXT
+ cer_mean REAL
+ wer_mean REAL
+ doc_count INTEGER
+ metadata TEXT — JSON
+
+Usage
+-----
+>>> from picarones.core.history import BenchmarkHistory
+>>> history = BenchmarkHistory("~/.picarones/history.db")
+>>> history.record(benchmark_result)
+>>> df = history.query(engine="tesseract", corpus="chroniques")
+>>> regression = history.detect_regression(engine="tesseract", threshold=0.02)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from picarones.core.results import BenchmarkResult
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Structures de données
+# ---------------------------------------------------------------------------
+
+@dataclass
+class HistoryEntry:
+ """Un enregistrement dans l'historique des benchmarks."""
+ run_id: str
+ timestamp: str
+ corpus_name: str
+ engine_name: str
+ cer_mean: Optional[float]
+ wer_mean: Optional[float]
+ doc_count: int
+ metadata: dict = field(default_factory=dict)
+
+ @property
+ def cer_percent(self) -> Optional[float]:
+ return self.cer_mean * 100 if self.cer_mean is not None else None
+
+ def as_dict(self) -> dict:
+ return {
+ "run_id": self.run_id,
+ "timestamp": self.timestamp,
+ "corpus_name": self.corpus_name,
+ "engine_name": self.engine_name,
+ "cer_mean": self.cer_mean,
+ "wer_mean": self.wer_mean,
+ "doc_count": self.doc_count,
+ "metadata": self.metadata,
+ }
+
+
+@dataclass
+class RegressionResult:
+ """Résultat d'une détection de régression."""
+ engine_name: str
+ corpus_name: str
+ baseline_run_id: str
+ baseline_timestamp: str
+ baseline_cer: Optional[float]
+ current_run_id: str
+ current_timestamp: str
+ current_cer: Optional[float]
+ delta_cer: Optional[float]
+ """Delta CER (current - baseline). Positif = régression."""
+ is_regression: bool
+ threshold: float
+
+ def as_dict(self) -> dict:
+ return {
+ "engine_name": self.engine_name,
+ "corpus_name": self.corpus_name,
+ "baseline_run_id": self.baseline_run_id,
+ "baseline_timestamp": self.baseline_timestamp,
+ "baseline_cer": self.baseline_cer,
+ "current_run_id": self.current_run_id,
+ "current_timestamp": self.current_timestamp,
+ "current_cer": self.current_cer,
+ "delta_cer": self.delta_cer,
+ "is_regression": self.is_regression,
+ "threshold": self.threshold,
+ }
+
+
+# ---------------------------------------------------------------------------
+# BenchmarkHistory
+# ---------------------------------------------------------------------------
+
+class BenchmarkHistory:
+ """Gestionnaire de l'historique des benchmarks dans SQLite.
+
+ Parameters
+ ----------
+ db_path:
+ Chemin vers le fichier SQLite. Utiliser ``":memory:"`` pour les tests.
+
+ Examples
+ --------
+ >>> history = BenchmarkHistory("~/.picarones/history.db")
+ >>> history.record(benchmark)
+ >>> entries = history.query(engine="tesseract")
+ >>> for e in entries:
+ ... print(e.timestamp, f"CER={e.cer_percent:.2f}%")
+ """
+
+ _CREATE_TABLE = """
+ CREATE TABLE IF NOT EXISTS runs (
+ run_id TEXT PRIMARY KEY,
+ timestamp TEXT NOT NULL,
+ corpus_name TEXT NOT NULL,
+ engine_name TEXT NOT NULL,
+ cer_mean REAL,
+ wer_mean REAL,
+ doc_count INTEGER,
+ metadata TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_engine ON runs (engine_name);
+ CREATE INDEX IF NOT EXISTS idx_corpus ON runs (corpus_name);
+ CREATE INDEX IF NOT EXISTS idx_timestamp ON runs (timestamp);
+ """
+
+ def __init__(self, db_path: str = "~/.picarones/history.db") -> None:
+ if db_path != ":memory:":
+ path = Path(db_path).expanduser()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ self.db_path = str(path)
+ else:
+ self.db_path = ":memory:"
+ self._conn: Optional[sqlite3.Connection] = None
+ self._init_db()
+
+ def _connect(self) -> sqlite3.Connection:
+ if self._conn is None:
+ self._conn = sqlite3.connect(self.db_path)
+ self._conn.row_factory = sqlite3.Row
+ return self._conn
+
+ def _init_db(self) -> None:
+ conn = self._connect()
+ conn.executescript(self._CREATE_TABLE)
+ conn.commit()
+
+ def close(self) -> None:
+ """Ferme la connexion SQLite."""
+ if self._conn:
+ self._conn.close()
+ self._conn = None
+
+ # ------------------------------------------------------------------
+ # Enregistrement
+ # ------------------------------------------------------------------
+
+ def record(
+ self,
+ benchmark_result: "BenchmarkResult",
+ run_id: Optional[str] = None,
+ extra_metadata: Optional[dict] = None,
+ ) -> str:
+ """Enregistre les résultats d'un benchmark dans l'historique.
+
+ Parameters
+ ----------
+ benchmark_result:
+ Résultats à enregistrer (``BenchmarkResult``).
+ run_id:
+ Identifiant du run (auto-généré si None).
+ extra_metadata:
+ Métadonnées supplémentaires à stocker.
+
+ Returns
+ -------
+ str
+ L'identifiant du run enregistré.
+ """
+ if run_id is None:
+ run_id = str(uuid.uuid4())
+
+ timestamp = datetime.now(timezone.utc).isoformat()
+ conn = self._connect()
+
+ for report in benchmark_result.engine_reports:
+ ranking = benchmark_result.ranking()
+ engine_entry = next(
+ (r for r in ranking if r["engine"] == report.engine_name),
+ None,
+ )
+ cer_mean = engine_entry["mean_cer"] if engine_entry else None
+ wer_mean = engine_entry["mean_wer"] if engine_entry else None
+
+ meta = {
+ "engine_version": report.engine_version,
+ "engine_config": report.engine_config,
+ "picarones_version": benchmark_result.metadata.get("picarones_version", ""),
+ **(extra_metadata or {}),
+ }
+
+ conn.execute(
+ """
+ INSERT OR REPLACE INTO runs
+ (run_id, timestamp, corpus_name, engine_name,
+ cer_mean, wer_mean, doc_count, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ f"{run_id}_{report.engine_name}",
+ timestamp,
+ benchmark_result.corpus_name,
+ report.engine_name,
+ cer_mean,
+ wer_mean,
+ benchmark_result.document_count,
+ json.dumps(meta, ensure_ascii=False),
+ ),
+ )
+
+ conn.commit()
+ logger.info("Benchmark enregistré dans l'historique : run_id=%s", run_id)
+ return run_id
+
+ def record_single(
+ self,
+ run_id: str,
+ corpus_name: str,
+ engine_name: str,
+ cer_mean: Optional[float],
+ wer_mean: Optional[float],
+ doc_count: int,
+ timestamp: Optional[str] = None,
+ metadata: Optional[dict] = None,
+ ) -> str:
+ """Enregistre manuellement une entrée dans l'historique.
+
+ Utile pour les tests, les imports de données externes, ou pour
+ enregistrer des résultats calculés en dehors de Picarones.
+
+ Returns
+ -------
+ str
+ run_id enregistré.
+ """
+ if timestamp is None:
+ timestamp = datetime.now(timezone.utc).isoformat()
+
+ conn = self._connect()
+ conn.execute(
+ """
+ INSERT OR REPLACE INTO runs
+ (run_id, timestamp, corpus_name, engine_name,
+ cer_mean, wer_mean, doc_count, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ run_id,
+ timestamp,
+ corpus_name,
+ engine_name,
+ cer_mean,
+ wer_mean,
+ doc_count,
+ json.dumps(metadata or {}, ensure_ascii=False),
+ ),
+ )
+ conn.commit()
+ return run_id
+
+ # ------------------------------------------------------------------
+ # Requêtes
+ # ------------------------------------------------------------------
+
+ def query(
+ self,
+ engine: Optional[str] = None,
+ corpus: Optional[str] = None,
+ since: Optional[str] = None,
+ limit: int = 100,
+ ) -> list[HistoryEntry]:
+ """Retourne l'historique des runs, avec filtres optionnels.
+
+ Parameters
+ ----------
+ engine:
+ Filtre sur le nom du moteur.
+ corpus:
+ Filtre sur le nom du corpus.
+ since:
+ Date ISO 8601 minimale (``"2025-01-01"``).
+ limit:
+ Nombre maximum d'entrées retournées.
+
+ Returns
+ -------
+ list[HistoryEntry]
+ Entrées triées par timestamp croissant.
+ """
+ clauses: list[str] = []
+ params: list = []
+
+ if engine:
+ clauses.append("engine_name = ?")
+ params.append(engine)
+ if corpus:
+ clauses.append("corpus_name = ?")
+ params.append(corpus)
+ if since:
+ clauses.append("timestamp >= ?")
+ params.append(since)
+
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(limit)
+
+ conn = self._connect()
+ rows = conn.execute(
+ f"SELECT * FROM runs {where} ORDER BY timestamp ASC LIMIT ?",
+ params,
+ ).fetchall()
+
+ return [
+ HistoryEntry(
+ run_id=row["run_id"],
+ timestamp=row["timestamp"],
+ corpus_name=row["corpus_name"],
+ engine_name=row["engine_name"],
+ cer_mean=row["cer_mean"],
+ wer_mean=row["wer_mean"],
+ doc_count=row["doc_count"],
+ metadata=json.loads(row["metadata"] or "{}"),
+ )
+ for row in rows
+ ]
+
+ def list_engines(self) -> list[str]:
+ """Retourne la liste des moteurs présents dans l'historique."""
+ conn = self._connect()
+ rows = conn.execute(
+ "SELECT DISTINCT engine_name FROM runs ORDER BY engine_name"
+ ).fetchall()
+ return [row[0] for row in rows]
+
+ def list_corpora(self) -> list[str]:
+ """Retourne la liste des corpus présents dans l'historique."""
+ conn = self._connect()
+ rows = conn.execute(
+ "SELECT DISTINCT corpus_name FROM runs ORDER BY corpus_name"
+ ).fetchall()
+ return [row[0] for row in rows]
+
+ def count(self) -> int:
+ """Nombre total d'entrées dans l'historique."""
+ conn = self._connect()
+ return conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
+
+ # ------------------------------------------------------------------
+ # Courbes d'évolution
+ # ------------------------------------------------------------------
+
+ def get_cer_curve(
+ self,
+ engine: str,
+ corpus: Optional[str] = None,
+ ) -> list[dict]:
+ """Retourne les données pour tracer la courbe d'évolution du CER.
+
+ Parameters
+ ----------
+ engine:
+ Nom du moteur.
+ corpus:
+ Corpus spécifique (None = tous les corpus pour ce moteur).
+
+ Returns
+ -------
+ list[dict]
+ Chaque dict contient ``{"timestamp": str, "cer": float, "run_id": str}``.
+ """
+ entries = self.query(engine=engine, corpus=corpus, limit=1000)
+ return [
+ {
+ "timestamp": e.timestamp,
+ "cer": e.cer_mean,
+ "cer_percent": e.cer_percent,
+ "run_id": e.run_id,
+ "corpus_name": e.corpus_name,
+ }
+ for e in entries
+ if e.cer_mean is not None
+ ]
+
+ # ------------------------------------------------------------------
+ # Détection de régression
+ # ------------------------------------------------------------------
+
+ def detect_regression(
+ self,
+ engine: str,
+ corpus: Optional[str] = None,
+ threshold: float = 0.01,
+ baseline_run_id: Optional[str] = None,
+ ) -> Optional[RegressionResult]:
+ """Détecte une régression du CER entre deux runs.
+
+ Compare le run le plus récent à une baseline (le run précédent ou
+ un run spécifique).
+
+ Parameters
+ ----------
+ engine:
+ Nom du moteur à surveiller.
+ corpus:
+ Corpus spécifique (None = tous).
+ threshold:
+ Seuil de régression en points absolus de CER (ex : 0.01 = 1%).
+ Si delta_cer > threshold → régression détectée.
+ baseline_run_id:
+ run_id de référence. Si None, utilise l'avant-dernier run.
+
+ Returns
+ -------
+ RegressionResult | None
+ None si moins de 2 runs disponibles.
+ """
+ entries = self.query(engine=engine, corpus=corpus, limit=1000)
+ if len(entries) < 2:
+ logger.info("Pas assez de runs pour détecter une régression (moteur=%s)", engine)
+ return None
+
+ current = entries[-1]
+
+ if baseline_run_id:
+ baseline_list = [e for e in entries[:-1] if e.run_id == baseline_run_id]
+ baseline = baseline_list[0] if baseline_list else entries[-2]
+ else:
+ baseline = entries[-2]
+
+ delta = None
+ is_regression = False
+ if current.cer_mean is not None and baseline.cer_mean is not None:
+ delta = current.cer_mean - baseline.cer_mean
+ is_regression = delta > threshold
+
+ return RegressionResult(
+ engine_name=engine,
+ corpus_name=corpus or "tous",
+ baseline_run_id=baseline.run_id,
+ baseline_timestamp=baseline.timestamp,
+ baseline_cer=baseline.cer_mean,
+ current_run_id=current.run_id,
+ current_timestamp=current.timestamp,
+ current_cer=current.cer_mean,
+ delta_cer=delta,
+ is_regression=is_regression,
+ threshold=threshold,
+ )
+
+ def detect_all_regressions(
+ self,
+ threshold: float = 0.01,
+ ) -> list[RegressionResult]:
+ """Détecte les régressions pour tous les moteurs et corpus connus.
+
+ Parameters
+ ----------
+ threshold:
+ Seuil de régression.
+
+ Returns
+ -------
+ list[RegressionResult]
+ Uniquement les moteurs où une régression est détectée.
+ """
+ results: list[RegressionResult] = []
+ engines = self.list_engines()
+ corpora = self.list_corpora()
+
+ for engine in engines:
+ for corpus in corpora:
+ result = self.detect_regression(engine, corpus, threshold)
+ if result and result.is_regression:
+ results.append(result)
+
+ return results
+
+ # ------------------------------------------------------------------
+ # Export
+ # ------------------------------------------------------------------
+
+ def export_json(self, output_path: str) -> Path:
+ """Exporte l'historique complet en JSON.
+
+ Parameters
+ ----------
+ output_path:
+ Chemin du fichier JSON de sortie.
+
+ Returns
+ -------
+ Path
+ Chemin vers le fichier créé.
+ """
+ entries = self.query(limit=100_000)
+ path = Path(output_path)
+ data = {
+ "picarones_history": True,
+ "exported_at": datetime.now(timezone.utc).isoformat(),
+ "total_runs": len(entries),
+ "engines": self.list_engines(),
+ "corpora": self.list_corpora(),
+ "runs": [e.as_dict() for e in entries],
+ }
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+ return path
+
+ def __repr__(self) -> str:
+ return f"BenchmarkHistory(db='{self.db_path}', runs={self.count()})"
+
+
+# ---------------------------------------------------------------------------
+# Données de démonstration longitudinale
+# ---------------------------------------------------------------------------
+
+def generate_demo_history(
+ db: BenchmarkHistory,
+ n_runs: int = 8,
+ seed: int = 42,
+) -> None:
+ """Insère des données fictives de suivi longitudinal pour la démo.
+
+ Simule l'amélioration progressive d'un modèle tesseract sur 8 runs,
+ avec une légère régression au run 5.
+
+ Parameters
+ ----------
+ db:
+ Base d'historique à remplir.
+ n_runs:
+ Nombre de runs à générer.
+ seed:
+ Graine aléatoire.
+ """
+ import random
+ rng = random.Random(seed)
+
+ engines = ["tesseract", "pero_ocr", "ancien_moteur"]
+ corpus = "Chroniques médiévales"
+
+ # Trajectoires de CER simulées (amélioration progressive + bruit)
+ base_cers = {
+ "tesseract": 0.15,
+ "pero_ocr": 0.09,
+ "ancien_moteur": 0.28,
+ }
+ improvements = {
+ "tesseract": -0.008, # améliore de ~0.8% par run
+ "pero_ocr": -0.005, # améliore de ~0.5% par run
+ "ancien_moteur": -0.003,
+ }
+
+ from datetime import timedelta
+ base_date = datetime(2024, 9, 1, tzinfo=timezone.utc)
+
+ for run_idx in range(n_runs):
+ run_date = base_date + timedelta(weeks=run_idx * 2)
+ run_id = f"demo_run_{run_idx + 1:02d}"
+
+ for engine in engines:
+ cer = base_cers[engine] + improvements[engine] * run_idx
+ # Ajouter du bruit + régression au run 5
+ noise = rng.gauss(0, 0.005)
+ if run_idx == 4 and engine == "tesseract":
+ noise += 0.02 # régression simulée
+ cer = max(0.01, min(0.5, cer + noise))
+
+ wer = cer * 1.8 + rng.gauss(0, 0.01)
+ wer = max(0.01, min(0.9, wer))
+
+ db.record_single(
+ run_id=f"{run_id}_{engine}",
+ corpus_name=corpus,
+ engine_name=engine,
+ cer_mean=round(cer, 4),
+ wer_mean=round(wer, 4),
+ doc_count=12,
+ timestamp=run_date.isoformat(),
+ metadata={
+ "note": f"Run de démonstration #{run_idx + 1}",
+ "engine_version": f"5.{run_idx}.0" if engine == "tesseract" else "0.7.2",
+ },
+ )
diff --git a/picarones/measurements/image_quality.py b/picarones/measurements/image_quality.py
new file mode 100644
index 0000000000000000000000000000000000000000..929bf67f7a4c0a60d2f7029ebdba72a6d665e1fb
--- /dev/null
+++ b/picarones/measurements/image_quality.py
@@ -0,0 +1,391 @@
+"""Analyse automatique de la qualité des images de documents numérisés.
+
+Métriques
+---------
+- **Score de netteté** : variance du laplacien (plus élevé = plus net)
+- **Niveau de bruit** : écart-type des résidus haute-fréquence
+- **Angle de rotation résiduel** : estimé par projection horizontale
+- **Score de contraste** : ratio Michelson entre zones sombres (encre) et claires (fond)
+- **Score de qualité global** : combinaison normalisée des métriques ci-dessus
+
+Ces calculs sont réalisés en pur Python + bibliothèques stdlib ou Pillow.
+NumPy est utilisé si disponible (calculs plus rapides), mais les méthodes
+de fallback n'en dépendent pas.
+
+Note
+----
+Pour les images placeholder (fixtures), des valeurs fictives cohérentes
+sont générées via `generate_mock_quality_scores()`.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import statistics
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ImageQualityResult:
+ """Métriques de qualité d'une image de document."""
+
+ sharpness_score: float = 0.0
+ """Score de netteté [0, 1]. Basé sur la variance du laplacien normalisée."""
+
+ noise_level: float = 0.0
+ """Niveau de bruit [0, 1]. 0 = pas de bruit, 1 = très bruité."""
+
+ rotation_degrees: float = 0.0
+ """Angle de rotation résiduel estimé en degrés (positif = sens horaire)."""
+
+ contrast_score: float = 0.0
+ """Score de contraste [0, 1]. Ratio Michelson encre/fond."""
+
+ quality_score: float = 0.0
+ """Score de qualité global [0, 1]. Combinaison pondérée des autres métriques."""
+
+ analysis_method: str = "none"
+ """Méthode d'analyse utilisée : 'pillow', 'numpy', 'mock'."""
+
+ error: Optional[str] = None
+ """Erreur si l'analyse a échoué."""
+
+ @property
+ def is_good_quality(self) -> bool:
+ """Vrai si le score de qualité global est ≥ 0.7."""
+ return self.quality_score >= 0.7
+
+ @property
+ def quality_tier(self) -> str:
+ """Catégorie de qualité : 'good', 'medium', 'poor'."""
+ if self.quality_score >= 0.7:
+ return "good"
+ elif self.quality_score >= 0.4:
+ return "medium"
+ return "poor"
+
+ def as_dict(self) -> dict:
+ d = {
+ "sharpness_score": round(self.sharpness_score, 4),
+ "noise_level": round(self.noise_level, 4),
+ "rotation_degrees": round(self.rotation_degrees, 2),
+ "contrast_score": round(self.contrast_score, 4),
+ "quality_score": round(self.quality_score, 4),
+ "quality_tier": self.quality_tier,
+ "analysis_method": self.analysis_method,
+ }
+ if self.error:
+ d["error"] = self.error
+ return d
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "ImageQualityResult":
+ return cls(
+ sharpness_score=data.get("sharpness_score", 0.0),
+ noise_level=data.get("noise_level", 0.0),
+ rotation_degrees=data.get("rotation_degrees", 0.0),
+ contrast_score=data.get("contrast_score", 0.0),
+ quality_score=data.get("quality_score", 0.0),
+ analysis_method=data.get("analysis_method", "none"),
+ error=data.get("error"),
+ )
+
+
+def analyze_image_quality(image_path: str | Path) -> ImageQualityResult:
+ """Analyse la qualité d'une image de document numérisé.
+
+ Essaie successivement :
+ 1. Pillow + NumPy (méthode complète)
+ 2. Pillow seul (méthode simplifiée)
+ 3. Fallback : retourne un résultat vide avec erreur
+
+ Parameters
+ ----------
+ image_path:
+ Chemin vers l'image (JPG, PNG, TIFF…).
+
+ Returns
+ -------
+ ImageQualityResult
+ """
+ path = Path(image_path)
+ if not path.exists():
+ return ImageQualityResult(
+ error=f"Fichier image introuvable : {image_path}",
+ analysis_method="none",
+ )
+
+ # Essai avec Pillow + NumPy
+ try:
+ import numpy as np
+ from PIL import Image
+ return _analyze_with_numpy(path, np, Image)
+ except ImportError:
+ pass
+
+ # Essai avec Pillow seul
+ try:
+ from PIL import Image
+ return _analyze_with_pillow(path, Image)
+ except ImportError:
+ pass
+
+ return ImageQualityResult(
+ error="Pillow non disponible (pip install Pillow)",
+ analysis_method="none",
+ quality_score=0.5, # valeur neutre
+ )
+
+
+def _analyze_with_numpy(path: Path, np, Image) -> ImageQualityResult:
+ """Analyse complète avec NumPy."""
+ img = Image.open(path).convert("L") # niveaux de gris
+ arr = np.array(img, dtype=np.float32)
+
+ # 1. Netteté : variance du laplacien
+ laplacian = _laplacian_variance_numpy(arr, np)
+ # Normalisation empirique : variance > 500 = très net, < 50 = flou
+ sharpness = min(1.0, laplacian / 500.0)
+
+ # 2. Bruit : écart-type des résidus (différence image - image lissée)
+ noise = _noise_level_numpy(arr, np)
+
+ # 3. Rotation : angle d'inclinaison estimé
+ rotation = _estimate_rotation_numpy(arr, np)
+
+ # 4. Contraste : ratio Michelson
+ contrast = _contrast_score_numpy(arr, np)
+
+ # 5. Score global pondéré
+ quality = _global_quality_score(sharpness, noise, abs(rotation), contrast)
+
+ return ImageQualityResult(
+ sharpness_score=float(sharpness),
+ noise_level=float(noise),
+ rotation_degrees=float(rotation),
+ contrast_score=float(contrast),
+ quality_score=float(quality),
+ analysis_method="numpy",
+ )
+
+
+def _analyze_with_pillow(path: Path, Image) -> ImageQualityResult:
+ """Analyse simplifiée avec Pillow seul (sans NumPy)."""
+ img = Image.open(path).convert("L")
+ pixels = list(img.tobytes()) # mode "L" = 1 byte/pixel
+ w, h = img.size
+
+ if not pixels:
+ return ImageQualityResult(quality_score=0.5, analysis_method="pillow")
+
+ # Contraste : étendue des valeurs
+ min_val = min(pixels)
+ max_val = max(pixels)
+ if max_val + min_val > 0:
+ contrast = (max_val - min_val) / (max_val + min_val)
+ else:
+ contrast = 0.0
+
+ # Netteté approximée : variance globale des pixels
+ try:
+ variance = statistics.variance(pixels)
+ except statistics.StatisticsError:
+ variance = 0.0
+ sharpness = min(1.0, math.sqrt(variance) / 128.0)
+
+ # Bruit : approximation grossière
+ noise = min(1.0, statistics.stdev(pixels[:min(1000, len(pixels))]) / 64.0) if len(pixels) > 1 else 0.0
+
+ quality = _global_quality_score(sharpness, noise, 0.0, contrast)
+
+ return ImageQualityResult(
+ sharpness_score=sharpness,
+ noise_level=noise,
+ rotation_degrees=0.0, # non calculé sans NumPy
+ contrast_score=contrast,
+ quality_score=quality,
+ analysis_method="pillow",
+ )
+
+
+def _laplacian_variance_numpy(arr, np) -> float:
+ """Calcule la variance du laplacien (mesure de netteté)."""
+ # Convolution laplacien 3x3 via slicing (bordures ignorées)
+ h, w = arr.shape
+ if h < 3 or w < 3:
+ return float(np.var(arr))
+
+ # Utiliser une convolution rapide avec slicing
+ center = arr[1:-1, 1:-1]
+ top = arr[:-2, 1:-1]
+ bottom = arr[2:, 1:-1]
+ left = arr[1:-1, :-2]
+ right = arr[1:-1, 2:]
+ lap = top + bottom + left + right - 4 * center
+
+ return float(np.var(lap))
+
+
+def _noise_level_numpy(arr, np) -> float:
+ """Estime le niveau de bruit par la MAD (Median Absolute Deviation) des gradients."""
+ h, w = arr.shape
+ if h < 2 or w < 2:
+ return 0.0
+ # Différences horizontales et verticales
+ diff_h = np.abs(arr[:, 1:] - arr[:, :-1])
+ diff_v = np.abs(arr[1:, :] - arr[:-1, :])
+ noise_std = float(np.median(np.concatenate([diff_h.ravel(), diff_v.ravel()])))
+ # Normaliser : 0 = pas de bruit, 1 = très bruité (seuil à ~30)
+ return min(1.0, noise_std / 30.0)
+
+
+def _estimate_rotation_numpy(arr, np) -> float:
+ """Estime l'angle de rotation par projection horizontale simplifiée.
+
+ Retourne l'angle estimé en degrés [-45, 45].
+ """
+ # Méthode simplifiée : analyse de la variance des projections à différents angles
+ # Limiter à quelques angles pour la performance
+ h, w = arr.shape
+ if h < 20 or w < 20:
+ return 0.0
+
+ # Sous-échantillonnage pour la performance
+ step = max(1, h // 100)
+ sample = arr[::step, :]
+
+ best_angle = 0.0
+ best_var = -1.0
+
+ for angle_deg in range(-5, 6): # ±5 degrés, pas de 1°
+ angle_rad = math.radians(angle_deg)
+ # Projection horizontale après rotation approximative
+ # (approximation linéaire rapide)
+ offsets = np.round(
+ np.arange(sample.shape[0]) * math.tan(angle_rad)
+ ).astype(int)
+ offsets = np.clip(offsets, 0, w - 1)
+
+ # Variance des sommes de lignes décalées
+ try:
+ row_sums = np.array([
+ float(np.sum(sample[i, max(0, offsets[i]):min(w, offsets[i]+w)]))
+ for i in range(sample.shape[0])
+ ])
+ var = float(np.var(row_sums))
+ if var > best_var:
+ best_var = var
+ best_angle = float(angle_deg)
+ except Exception as e:
+ logger.warning(
+ "[image_quality] projection à %d° indisponible : %s",
+ angle_deg, e,
+ )
+
+ return best_angle
+
+
+def _contrast_score_numpy(arr, np) -> float:
+ """Score de contraste Michelson [0, 1]."""
+ p5 = float(np.percentile(arr, 5)) # fond clair
+ p95 = float(np.percentile(arr, 95)) # encre sombre
+ if p5 + p95 == 0:
+ return 0.0
+ # Michelson : (Imax - Imin) / (Imax + Imin)
+ return float((p95 - p5) / (p95 + p5))
+
+
+def _global_quality_score(
+ sharpness: float,
+ noise: float,
+ rotation_abs: float,
+ contrast: float,
+) -> float:
+ """Calcule le score de qualité global pondéré."""
+ # Poids : netteté (40%), contraste (30%), bruit (20%), rotation (10%)
+ score = (
+ 0.40 * sharpness
+ + 0.30 * contrast
+ + 0.20 * (1.0 - noise) # moins de bruit = mieux
+ + 0.10 * max(0.0, 1.0 - rotation_abs / 10.0) # ±10° max
+ )
+ return round(min(1.0, max(0.0, score)), 4)
+
+
+# ---------------------------------------------------------------------------
+# Données fictives pour les fixtures de démo
+# ---------------------------------------------------------------------------
+
+def generate_mock_quality_scores(
+ doc_id: str,
+ seed: Optional[int] = None,
+) -> ImageQualityResult:
+ """Génère des métriques de qualité fictives mais cohérentes pour un document.
+
+ Utilisé par les fixtures de démo pour simuler une diversité réaliste
+ de qualités d'image (bonne, moyenne, dégradée).
+
+ Parameters
+ ----------
+ doc_id:
+ Identifiant du document (utilisé pour la reproductibilité).
+ seed:
+ Graine aléatoire optionnelle.
+ """
+ import random
+ rng = random.Random(seed or hash(doc_id) % 2**32)
+
+ # Générer une qualité cohérente : certains docs sont plus difficiles
+ base_quality = 0.3 + rng.random() * 0.6 # 0.3 à 0.9
+
+ sharpness = max(0.1, min(1.0, base_quality + rng.gauss(0, 0.1)))
+ noise = max(0.0, min(1.0, (1.0 - base_quality) * 0.8 + rng.gauss(0, 0.05)))
+ rotation = rng.gauss(0, 1.5) # ±1.5° typique
+ contrast = max(0.2, min(1.0, base_quality + rng.gauss(0, 0.15)))
+
+ quality = _global_quality_score(sharpness, noise, abs(rotation), contrast)
+
+ return ImageQualityResult(
+ sharpness_score=round(sharpness, 4),
+ noise_level=round(noise, 4),
+ rotation_degrees=round(rotation, 2),
+ contrast_score=round(contrast, 4),
+ quality_score=round(quality, 4),
+ analysis_method="mock",
+ )
+
+
+def aggregate_image_quality(results: list[ImageQualityResult]) -> dict:
+ """Agrège les métriques de qualité image sur un corpus."""
+ if not results:
+ return {}
+
+ valid = [r for r in results if r.error is None]
+ if not valid:
+ return {"error": "Aucune analyse réussie"}
+
+ def _mean(vals: list[float]) -> float:
+ return round(statistics.mean(vals), 4) if vals else 0.0
+
+ quality_scores = [r.quality_score for r in valid]
+ sharpness_scores = [r.sharpness_score for r in valid]
+ noise_levels = [r.noise_level for r in valid]
+
+ # Distribution par tier
+ tiers = {"good": 0, "medium": 0, "poor": 0}
+ for r in valid:
+ tiers[r.quality_tier] += 1
+
+ return {
+ "mean_quality_score": _mean(quality_scores),
+ "mean_sharpness": _mean(sharpness_scores),
+ "mean_noise_level": _mean(noise_levels),
+ "quality_distribution": tiers,
+ "document_count": len(valid),
+ "scores": [r.quality_score for r in valid], # pour scatter plot
+ }
diff --git a/picarones/measurements/incremental_comparison.py b/picarones/measurements/incremental_comparison.py
new file mode 100644
index 0000000000000000000000000000000000000000..03f3ea0ee8a3b3eb7ed90af59349f49cb95386e1
--- /dev/null
+++ b/picarones/measurements/incremental_comparison.py
@@ -0,0 +1,253 @@
+"""Comparaison incrémentale de pipelines composées — Sprint 96 (B.5).
+
+Sprint 96 — B.5 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Avec 5 OCR × 3 reconstructeurs × 4 post-correcteurs × 3
+mappeurs = 180 pipelines à comparer, le rapport noie
+l'information. Il faut un mécanisme de **comparaison
+contrôlée** type design d'expérience.
+
+Méthode
+-------
+Pour mesurer l'effet isolé d'un slot ``varying`` :
+
+1. Fixer les valeurs des autres slots (``fixed``).
+2. Pour chaque combinaison des fixed, comparer les pipelines
+ qui ne diffèrent que sur le slot varying.
+3. Agréger : pour chaque valeur du slot varying, calculer
+ sa moyenne, son écart-type, son rang moyen sur les groupes.
+
+C'est presque un Latin square automatisé. Sans ça, le
+rapport sur 180 pipelines est inutilisable.
+
+Pas de tests statistiques scipy
+-------------------------------
+On ne reconstruit pas Friedman/Nemenyi (déjà dans Sprint 18) ;
+on agrège ici les données nécessaires pour qu'un
+tests statistique externe puisse les consommer. Le rapport
+existant reste libre de brancher
+``picarones.core.statistics.friedman_test`` sur la sortie de
+ce module.
+
+Sortie
+------
+``compare_isolated_effect(runs, varying_slot)`` retourne :
+
+.. code-block:: text
+
+ {
+ "varying_slot": str,
+ "n_runs": int,
+ "n_groups": int, # combinaisons fixed distinctes
+ "values": list[str], # valeurs distinctes du slot
+ "per_value": {value: {
+ "n_observations": int,
+ "mean": float | None,
+ "stdev": float | None,
+ "min": float, "max": float,
+ "mean_rank": float | None,
+ }},
+ "best_value": str | None,
+ "worst_value": str | None,
+ "groups": list[dict], # détail par groupe
+ }
+"""
+
+from __future__ import annotations
+
+import logging
+import statistics
+from dataclasses import dataclass
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class PipelineRun:
+ """Un run de pipeline composée pour la comparaison contrôlée.
+
+ Attributes
+ ----------
+ name:
+ Nom du run (libre — informatif uniquement).
+ slots:
+ Map ``{slot_name: module_name}`` décrivant la pipeline
+ (ex. ``{"ocr": "tess", "llm": "gpt-4o"}``).
+ score:
+ Métrique numérique à comparer (CER moyen typiquement).
+ Plus bas = meilleur par convention sauf si
+ ``higher_is_better=True`` est passé à
+ ``compare_isolated_effect``.
+ """
+
+ name: str
+ slots: dict[str, str]
+ score: float
+
+ def as_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "slots": dict(self.slots),
+ "score": self.score,
+ }
+
+
+def _normalise_runs(runs) -> list[PipelineRun]:
+ """Accepte une liste de ``PipelineRun`` ou de dicts compatibles."""
+ out: list[PipelineRun] = []
+ for r in runs:
+ if isinstance(r, PipelineRun):
+ out.append(r)
+ continue
+ if not isinstance(r, dict):
+ continue
+ slots = r.get("slots") or {}
+ if not isinstance(slots, dict):
+ continue
+ try:
+ score = float(r.get("score"))
+ except (TypeError, ValueError):
+ continue
+ out.append(PipelineRun(
+ name=str(r.get("name") or ""),
+ slots={str(k): str(v) for k, v in slots.items()},
+ score=score,
+ ))
+ return out
+
+
+def compare_isolated_effect(
+ runs,
+ varying_slot: str,
+ *,
+ higher_is_better: bool = False,
+) -> Optional[dict]:
+ """Mesure l'effet isolé du slot ``varying_slot``.
+
+ Parameters
+ ----------
+ runs:
+ Liste de ``PipelineRun`` (ou dicts compatibles).
+ varying_slot:
+ Nom du slot dont on veut isoler l'effet. Les autres
+ slots constituent les groupes de contrôle.
+ higher_is_better:
+ Si ``True``, on inverse la convention de classement
+ (rang 1 = score le plus haut). Défaut ``False`` =
+ rang 1 = score le plus bas (CER).
+
+ Returns
+ -------
+ dict | None
+ ``None`` si moins de 2 runs ou si ``varying_slot``
+ n'est présent dans aucun run.
+ """
+ runs_list = _normalise_runs(runs)
+ if len(runs_list) < 2:
+ return None
+ runs_list = [r for r in runs_list if varying_slot in r.slots]
+ if not runs_list:
+ return None
+
+ # Constitue les groupes par valeurs des slots fixed
+ groups: dict[tuple, list[PipelineRun]] = {}
+ fixed_slot_names: list[str] = []
+ for r in runs_list:
+ other_slots = sorted(k for k in r.slots if k != varying_slot)
+ if not fixed_slot_names:
+ fixed_slot_names = other_slots
+ # Skip runs avec un schéma de slots incompatible
+ if other_slots != fixed_slot_names:
+ continue
+ key = tuple((k, r.slots[k]) for k in other_slots)
+ groups.setdefault(key, []).append(r)
+
+ if not groups:
+ return None
+
+ # Pour chaque groupe : ranking des runs par score
+ per_value: dict[str, dict] = {}
+ group_details: list[dict] = []
+ for key, members in groups.items():
+ members_sorted = sorted(
+ members, key=lambda x: x.score, reverse=higher_is_better,
+ )
+ # Rangs : runs ex aequo partagent la moyenne des rangs
+ ranks: dict[str, float] = {}
+ i = 0
+ while i < len(members_sorted):
+ j = i
+ while (
+ j + 1 < len(members_sorted)
+ and members_sorted[j + 1].score == members_sorted[i].score
+ ):
+ j += 1
+ avg_rank = (i + 1 + j + 1) / 2
+ for k in range(i, j + 1):
+ value = members_sorted[k].slots[varying_slot]
+ ranks[value] = avg_rank
+ i = j + 1
+
+ for r in members:
+ value = r.slots[varying_slot]
+ slot = per_value.setdefault(value, {
+ "scores": [],
+ "ranks": [],
+ })
+ slot["scores"].append(r.score)
+ slot["ranks"].append(ranks[value])
+ group_details.append({
+ "fixed_slots": dict(key),
+ "n_members": len(members),
+ "values": [r.slots[varying_slot] for r in members_sorted],
+ "scores": [r.score for r in members_sorted],
+ })
+
+ # Calcul mean/stdev/min/max + rang moyen par valeur
+ summary: dict[str, dict] = {}
+ for value, slot in per_value.items():
+ scores = slot["scores"]
+ ranks = slot["ranks"]
+ summary[value] = {
+ "n_observations": len(scores),
+ "mean": statistics.fmean(scores) if scores else None,
+ "stdev": (
+ statistics.stdev(scores) if len(scores) >= 2 else None
+ ),
+ "min": min(scores),
+ "max": max(scores),
+ "mean_rank": (
+ statistics.fmean(ranks) if ranks else None
+ ),
+ }
+
+ # Best/worst : sur la mean (convention CER : plus bas = meilleur)
+ by_mean = sorted(
+ ((v, d["mean"]) for v, d in summary.items()
+ if d["mean"] is not None),
+ key=lambda kv: kv[1],
+ reverse=higher_is_better,
+ )
+ best_value = by_mean[0][0] if by_mean else None
+ worst_value = by_mean[-1][0] if by_mean else None
+
+ return {
+ "varying_slot": varying_slot,
+ "n_runs": len(runs_list),
+ "n_groups": len(groups),
+ "values": sorted(per_value.keys()),
+ "per_value": summary,
+ "best_value": best_value,
+ "worst_value": worst_value,
+ "groups": group_details,
+ "higher_is_better": higher_is_better,
+ }
+
+
+__all__ = [
+ "PipelineRun",
+ "compare_isolated_effect",
+]
diff --git a/picarones/measurements/inter_engine.py b/picarones/measurements/inter_engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..68576f0ef9792451092a94aadeafb2c9aea4cf97
--- /dev/null
+++ b/picarones/measurements/inter_engine.py
@@ -0,0 +1,484 @@
+"""Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution).
+
+Deux familles de mesures qui répondent à des questions différentes mais
+liées :
+
+1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`,
+ `taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des
+ erreurs de natures différentes ?* Une divergence élevée signale des
+ moteurs spécialisés sur des classes d'erreurs distinctes (visual vs
+ abréviation vs casse) et donc des candidats pour un voting ensemble.
+
+2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`,
+ `pairwise_disagreement_rate`) — *quel CER serait atteignable si on
+ combinait les moteurs ?* La borne inférieure du CER atteignable par
+ un voting majoritaire token-level est ``1 - oracle_token_recall``.
+ Si elle est très inférieure au CER du meilleur moteur seul, l'effort
+ d'un pipeline d'ensemble se justifie. Sinon non.
+
+Convention de typage
+--------------------
+Toutes les fonctions sont enregistrables dans le registre Sprint 34 si
+on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour
+limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont
+des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne
+correspondent pas au modèle « une jonction = une métrique » du runner.
+Elles sont consommées par les détecteurs narratifs et le rapport HTML.
+
+Note sur l'oracle
+-----------------
+La métrique ``oracle_token_recall`` retournée ici utilise un alignement
+bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie
+borne atteignable par voting majoritaire séquentiel — c'est une borne
+supérieure (proxy optimiste). La vraie borne demanderait un
+alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour
+le diagnostic « ensemble vaut-il le coup ? », le proxy suffit
+largement ; on documente clairement la limite dans le glossaire et le
+rapport.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+from collections import Counter
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Divergence taxonomique (KL / Jensen-Shannon)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _smoothed_distribution(
+ distribution: dict[str, float],
+ keys: list[str],
+ epsilon: float = 1e-12,
+) -> list[float]:
+ """Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros.
+
+ Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement
+ minuscule pour ne pas modifier le résultat de manière sensible.
+ """
+ smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys]
+ total = sum(smoothed)
+ return [v / total for v in smoothed]
+
+
+def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float:
+ """KL-divergence ``D(P||Q)`` en bits, sur l'union des clés.
+
+ Les distributions n'ont pas besoin de partager exactement les mêmes
+ clés ; les clés manquantes sont lissées à ``epsilon`` puis
+ renormalisées.
+
+ Returns
+ -------
+ float
+ ``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas
+ symétrique : ``kl(p, q) != kl(q, p)`` en général.
+ """
+ keys = sorted(set(p.keys()) | set(q.keys()))
+ if not keys:
+ return 0.0
+ p_vec = _smoothed_distribution(p, keys)
+ q_vec = _smoothed_distribution(q, keys)
+ return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec))
+
+
+def jensen_shannon_divergence(
+ p: dict[str, float],
+ q: dict[str, float],
+) -> float:
+ """JS-divergence symétrique en bits, bornée dans ``[0, 1]``.
+
+ ``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``.
+ Symétrique et bornée — préférable à la KL pour construire une
+ matrice triangulaire de divergences entre moteurs.
+ """
+ keys = sorted(set(p.keys()) | set(q.keys()))
+ if not keys:
+ return 0.0
+ p_vec = _smoothed_distribution(p, keys)
+ q_vec = _smoothed_distribution(q, keys)
+ m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)]
+
+ def _kl(a: list[float], b: list[float]) -> float:
+ return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0)
+
+ js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec)
+ # Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les
+ # erreurs d'arrondi flottant.
+ return max(0.0, min(1.0, js))
+
+
+def taxonomy_divergence_matrix(
+ distributions: dict[str, dict[str, float]],
+ metric: str = "js",
+) -> dict[str, dict[str, float]]:
+ """Construit la matrice de divergence triangulaire entre moteurs.
+
+ Parameters
+ ----------
+ distributions:
+ ``{engine_name: {error_class: probability}}``. Chaque
+ distribution doit sommer à environ 1 (pas de validation stricte
+ — les distributions taxonomiques de Picarones sont déjà
+ normalisées par ``aggregate_taxonomy``).
+ metric:
+ ``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique).
+
+ Returns
+ -------
+ dict[str, dict[str, float]]
+ Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour
+ ``js``, asymétrique pour ``kl``. La diagonale vaut 0.
+ """
+ if metric not in ("js", "kl"):
+ raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}")
+ fn = jensen_shannon_divergence if metric == "js" else kl_divergence
+
+ engines = sorted(distributions.keys())
+ matrix: dict[str, dict[str, float]] = {a: {} for a in engines}
+ for a in engines:
+ for b in engines:
+ if a == b:
+ matrix[a][b] = 0.0
+ elif metric == "js" and b in matrix and a in matrix[b]:
+ # Symétrique : recopie pour éviter de recalculer
+ matrix[a][b] = matrix[b][a]
+ else:
+ matrix[a][b] = fn(distributions[a], distributions[b])
+ return matrix
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Complémentarité (oracle token recall)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _word_multiset(text: str) -> Counter[str]:
+ """Décomposition en multiset de tokens (séparateur whitespace)."""
+ return Counter(tok for tok in text.split() if tok)
+
+
+def oracle_token_recall(
+ reference: str,
+ hypotheses: dict[str, str],
+) -> float:
+ """Borne supérieure (proxy bag-of-words) du token-recall atteignable
+ par un voting majoritaire entre tous les moteurs fournis.
+
+ Pour chaque token de la référence (avec sa multiplicité), on
+ considère qu'il est "préservé" par l'ensemble si au moins un moteur
+ en produit une occurrence non encore comptée. Le score est le ratio
+ d'occurrences GT préservées sur le total.
+
+ Parameters
+ ----------
+ reference:
+ Texte GT.
+ hypotheses:
+ ``{engine_name: hypothesis_text}``.
+
+ Returns
+ -------
+ float
+ Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent
+ dans au moins une hypothèse à hauteur de sa multiplicité.
+
+ Note
+ ----
+ Cette borne est **optimiste** (supérieure à la vraie borne par
+ voting séquentiel) car elle ignore l'ordre d'apparition. Pour le
+ diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour
+ une vraie borne il faudrait un alignement séquentiel.
+ """
+ ref_counter = _word_multiset(reference)
+ if not ref_counter or not hypotheses:
+ return 1.0 if not ref_counter else 0.0
+
+ hyp_counters = [_word_multiset(h) for h in hypotheses.values()]
+ total_ref = sum(ref_counter.values())
+ preserved = 0
+ for token, gt_count in ref_counter.items():
+ # Pour chaque moteur, le nombre d'occurrences disponibles, plafonné
+ # à la multiplicité GT. L'oracle prend le max sur les moteurs.
+ best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0)
+ preserved += best
+ return preserved / total_ref
+
+
+def complementarity_gap(
+ reference: str,
+ hypotheses: dict[str, str],
+) -> dict[str, float]:
+ """Compare l'oracle au meilleur moteur seul.
+
+ Returns
+ -------
+ dict
+ ``{
+ "oracle_recall": float, # bag-of-words recall de l'oracle
+ "best_single_recall": float, # meilleur recall token d'un moteur seul
+ "best_engine": str, # nom du moteur correspondant
+ "absolute_gap": float, # oracle - best_single (toujours ≥ 0)
+ "relative_gap": float, # absolute_gap / (1 - best_single + ε)
+ # = fraction des erreurs encore évitables
+ # par un ensemble
+ }``
+ """
+ ref_counter = _word_multiset(reference)
+ total = sum(ref_counter.values())
+ if not total:
+ return {
+ "oracle_recall": 1.0,
+ "best_single_recall": 1.0,
+ "best_engine": "",
+ "absolute_gap": 0.0,
+ "relative_gap": 0.0,
+ }
+
+ def _single_recall(hyp_text: str) -> float:
+ hc = _word_multiset(hyp_text)
+ preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items())
+ return preserved / total
+
+ if not hypotheses:
+ return {
+ "oracle_recall": 0.0,
+ "best_single_recall": 0.0,
+ "best_engine": "",
+ "absolute_gap": 0.0,
+ "relative_gap": 0.0,
+ }
+
+ per_engine = {name: _single_recall(h) for name, h in hypotheses.items()}
+ best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1])
+ oracle = oracle_token_recall(reference, hypotheses)
+
+ absolute_gap = max(0.0, oracle - best_recall)
+ # relative_gap : fraction des erreurs du meilleur moteur que l'ensemble
+ # serait théoriquement capable de récupérer (∈ [0, 1])
+ headroom = max(1.0 - best_recall, 1e-12)
+ relative_gap = min(1.0, absolute_gap / headroom)
+
+ return {
+ "oracle_recall": oracle,
+ "best_single_recall": best_recall,
+ "best_engine": best_engine,
+ "absolute_gap": absolute_gap,
+ "relative_gap": relative_gap,
+ }
+
+
+def pairwise_disagreement_rate(
+ reference: str,
+ hyp_a: str,
+ hyp_b: str,
+) -> float:
+ """Fraction de tokens GT pour lesquels A et B sont en désaccord.
+
+ Un désaccord = (l'un préserve le token, l'autre non) OU
+ (les deux le ratent mais avec des substitutions différentes — non
+ capturé ici, on reste sur la version simple présence/absence).
+
+ Returns
+ -------
+ float
+ Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix
+ (pas de gain d'ensemble). ``1`` = A et B sont toujours en
+ désaccord (gain d'ensemble maximal).
+ """
+ ref_counter = _word_multiset(reference)
+ if not ref_counter:
+ return 0.0
+ a = _word_multiset(hyp_a)
+ b = _word_multiset(hyp_b)
+ total = sum(ref_counter.values())
+ disagree = 0
+ for tok, gt_count in ref_counter.items():
+ a_pres = min(gt_count, a.get(tok, 0))
+ b_pres = min(gt_count, b.get(tok, 0))
+ # Compte les positions où A et B donnent une réponse différente
+ disagree += abs(a_pres - b_pres)
+ return disagree / total
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Agrégation au niveau benchmark (Sprint 36)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def compute_inter_engine_analysis(
+ *,
+ per_engine_outputs: dict[str, dict[str, str]],
+ ground_truths: dict[str, str],
+ taxonomy_distributions: dict[str, dict[str, float]] | None = None,
+ divergence_metric: str = "js",
+) -> dict:
+ """Agrège les métriques inter-moteurs sur l'ensemble du corpus.
+
+ Parameters
+ ----------
+ per_engine_outputs:
+ ``{engine_name: {doc_id: hypothesis_text}}``. Une entrée par
+ moteur, avec une hypothèse par document. Les documents absents
+ d'un moteur (échecs, timeouts) sont simplement ignorés pour ce
+ moteur — l'oracle est calculé sur les moteurs qui ont produit
+ une sortie pour le doc.
+ ground_truths:
+ ``{doc_id: ground_truth_text}``. La GT est la même pour tous
+ les moteurs ; on la passe une seule fois.
+ taxonomy_distributions:
+ ``{engine_name: {error_class: probability}}`` — typiquement
+ ``EngineReport.aggregated_taxonomy["class_distribution"]``. Si
+ ``None`` ou vide, la divergence taxonomique n'est pas calculée.
+ divergence_metric:
+ ``"js"`` (défaut, symétrique) ou ``"kl"``.
+
+ Returns
+ -------
+ dict
+ Structure stable consommable par les détecteurs narratifs et le
+ rapport HTML :
+ ``{
+ "complementarity": {
+ "oracle_recall": float,
+ "best_single_recall": float,
+ "best_engine": str,
+ "absolute_gap": float,
+ "relative_gap": float,
+ "doc_count": int,
+ "per_doc": [{doc_id, oracle, best, gap}, ...] # max 50 docs
+ },
+ "taxonomy_divergence": {
+ "metric": "js"|"kl",
+ "matrix": {engine_a: {engine_b: divergence}},
+ "max_pair": [engine_a, engine_b, value] # paire la plus divergente
+ } | None,
+ "engines": [...], # liste des moteurs analysés (ordre stable)
+ }``
+ """
+ engines = sorted(per_engine_outputs.keys())
+ result: dict = {"engines": engines}
+
+ # ── Complémentarité agrégée doc par doc ──────────────────────────────
+ if not engines:
+ result["complementarity"] = None
+ else:
+ total_oracle_preserved = 0
+ total_ref_tokens = 0
+ per_engine_preserved: dict[str, int] = {name: 0 for name in engines}
+ per_doc_records: list[dict] = []
+
+ for doc_id, gt in ground_truths.items():
+ ref_counter = _word_multiset(gt)
+ ref_total = sum(ref_counter.values())
+ if not ref_total:
+ continue
+ total_ref_tokens += ref_total
+
+ doc_hyps: dict[str, str] = {}
+ for name in engines:
+ hyp = per_engine_outputs.get(name, {}).get(doc_id)
+ if hyp is not None:
+ doc_hyps[name] = hyp
+
+ if not doc_hyps:
+ continue
+
+ hyp_counters = {n: _word_multiset(h) for n, h in doc_hyps.items()}
+
+ doc_oracle = 0
+ doc_best_per_engine: dict[str, int] = {n: 0 for n in doc_hyps}
+ for tok, gt_count in ref_counter.items():
+ # Oracle : meilleur des moteurs sur ce token
+ best_for_token = 0
+ for name, hc in hyp_counters.items():
+ preserved = min(gt_count, hc.get(tok, 0))
+ doc_best_per_engine[name] += preserved
+ if preserved > best_for_token:
+ best_for_token = preserved
+ doc_oracle += best_for_token
+
+ total_oracle_preserved += doc_oracle
+ for name, count in doc_best_per_engine.items():
+ per_engine_preserved[name] += count
+
+ doc_best = max(doc_best_per_engine.values()) if doc_best_per_engine else 0
+ per_doc_records.append({
+ "doc_id": doc_id,
+ "oracle_recall": doc_oracle / ref_total,
+ "best_single_recall": doc_best / ref_total,
+ "absolute_gap": (doc_oracle - doc_best) / ref_total,
+ })
+
+ if total_ref_tokens == 0:
+ result["complementarity"] = None
+ else:
+ oracle_recall = total_oracle_preserved / total_ref_tokens
+ recalls = {
+ name: per_engine_preserved[name] / total_ref_tokens
+ for name in engines
+ }
+ best_engine, best_recall = max(recalls.items(), key=lambda kv: kv[1])
+ absolute_gap = max(0.0, oracle_recall - best_recall)
+ headroom = max(1.0 - best_recall, 1e-12)
+ relative_gap = min(1.0, absolute_gap / headroom)
+
+ # Garder les ``per_doc_records`` les plus instructifs : tri par
+ # gap absolu décroissant, top 50. Les détecteurs narratifs
+ # n'en consomment que quelques-uns.
+ per_doc_records.sort(key=lambda r: r["absolute_gap"], reverse=True)
+ per_doc_top = per_doc_records[:50]
+
+ result["complementarity"] = {
+ "oracle_recall": oracle_recall,
+ "best_single_recall": best_recall,
+ "best_engine": best_engine,
+ "absolute_gap": absolute_gap,
+ "relative_gap": relative_gap,
+ "doc_count": len(per_doc_records),
+ "per_engine_recall": recalls,
+ "per_doc": per_doc_top,
+ }
+
+ # ── Divergence taxonomique ─────────────────────────────────────────
+ if not taxonomy_distributions:
+ result["taxonomy_divergence"] = None
+ else:
+ matrix = taxonomy_divergence_matrix(
+ taxonomy_distributions,
+ metric=divergence_metric,
+ )
+ # Cherche la paire la plus divergente (utile pour la synthèse
+ # narrative qui veut nommer les deux moteurs candidats à
+ # l'ensemble).
+ max_pair: tuple[str, str, float] = ("", "", 0.0)
+ names = sorted(matrix.keys())
+ for i, a in enumerate(names):
+ for b in names[i + 1:]:
+ v = matrix[a][b]
+ if v > max_pair[2]:
+ max_pair = (a, b, v)
+
+ result["taxonomy_divergence"] = {
+ "metric": divergence_metric,
+ "matrix": matrix,
+ "max_pair": list(max_pair) if max_pair[2] > 0 else None,
+ }
+
+ return result
+
+
+__all__ = [
+ "kl_divergence",
+ "jensen_shannon_divergence",
+ "taxonomy_divergence_matrix",
+ "oracle_token_recall",
+ "complementarity_gap",
+ "pairwise_disagreement_rate",
+ "compute_inter_engine_analysis",
+]
diff --git a/picarones/measurements/layout.py b/picarones/measurements/layout.py
new file mode 100644
index 0000000000000000000000000000000000000000..477d247e8b531c1aeafa97ee6b76ac064479904b
--- /dev/null
+++ b/picarones/measurements/layout.py
@@ -0,0 +1,280 @@
+"""Layout F1 par type de région — Sprint 54.
+
+Sprint 54 — A.II.2.2 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Un médiéviste qui édite un manuscrit glosé veut savoir : *« le moteur
+sépare-t-il bien le texte principal de la glose ? »*. Le score de
+structure global de Picarones (Sprint 5) agrège fusion/fragmentation
+de lignes en un seul nombre — utile mais non typé. Ce module
+discrimine par **type de région** ALTO/PAGE (``TextRegion``,
+``MarginNote``, ``Header``, ``Footer``, ``Drop-Cap``...) en
+appliquant le pattern ICDAR layout standard :
+
+- **TP** : région GT et région hypothèse de **même type** avec
+ chevauchement IoU ≥ seuil (alignement greedy par IoU décroissant),
+- **FN** : région GT non matchée,
+- **FP** : région hypothèse non matchée,
+- F1 calculé global et par type.
+
+Le pattern d'alignement est le même que pour le NER (Sprint 38) — on
+réutilise une approche éprouvée plutôt que d'en inventer une nouvelle.
+
+Stratégie de découpage
+----------------------
+Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
+(Sprint 53) : couche de calcul pure d'abord. L'utilisateur fournit
+deux listes de ``Region`` (typiquement extraites de ALTO/PAGE par un
+parser amont — le parser ALTO/PAGE standard de Picarones suivra
+dans un sprint dédié). Pas de câblage runner ni de vue HTML ici.
+
+Convention de coordonnées
+-------------------------
+Une bbox est un tuple ``(x, y, width, height)`` en pixels (origine
+en haut à gauche, axe y vers le bas — convention ALTO et PAGE
+standard). L'IoU est calculée sur l'aire d'intersection / union des
+rectangles.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Iterable
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Modèle de données
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class Region:
+ """Une région ALTO/PAGE alignable sur sa GT.
+
+ Attributs
+ ---------
+ id:
+ Identifiant unique au sein de la séquence (ex. ``"r_1"``,
+ ``"region_main"``). Informatif — l'alignement se fait par IoU,
+ pas par ID.
+ type:
+ Catégorie de la région (``"TextRegion"``, ``"MarginNote"``,
+ ``"Header"``, etc.). Comparaison **case-insensitive**.
+ bbox:
+ Rectangle ``(x, y, width, height)`` en pixels, origine en haut
+ à gauche. Doit avoir width > 0 et height > 0.
+ """
+
+ id: str
+ type: str
+ bbox: tuple[int, int, int, int]
+
+ def __post_init__(self) -> None:
+ x, y, w, h = self.bbox
+ if w <= 0 or h <= 0:
+ raise ValueError(
+ f"Region {self.id!r} : bbox invalide (w={w}, h={h}). "
+ "width et height doivent être strictement positifs."
+ )
+
+ @property
+ def area(self) -> int:
+ _, _, w, h = self.bbox
+ return w * h
+
+
+def _to_region(obj: Region | dict) -> Region:
+ """Coerce un dict en ``Region`` (clés ``id``, ``type``, ``bbox``)."""
+ if isinstance(obj, Region):
+ return obj
+ return Region(
+ id=str(obj["id"]),
+ type=str(obj["type"]),
+ bbox=tuple(obj["bbox"]), # type: ignore[arg-type]
+ )
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# IoU + alignement greedy
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _iou_bbox(a: Region, b: Region) -> float:
+ """Intersection-over-Union de deux bboxes ``(x, y, w, h)``."""
+ ax, ay, aw, ah = a.bbox
+ bx, by, bw, bh = b.bbox
+ inter_x = max(ax, bx)
+ inter_y = max(ay, by)
+ inter_x_end = min(ax + aw, bx + bw)
+ inter_y_end = min(ay + ah, by + bh)
+ inter_w = max(0, inter_x_end - inter_x)
+ inter_h = max(0, inter_y_end - inter_y)
+ inter = inter_w * inter_h
+ if inter == 0:
+ return 0.0
+ union = a.area + b.area - inter
+ if union <= 0:
+ return 0.0
+ return inter / union
+
+
+def _align_regions(
+ references: list[Region],
+ hypotheses: list[Region],
+ iou_threshold: float,
+) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
+ """Appareillage greedy par IoU décroissant ; same type requis.
+
+ Renvoie ``(matches, unmatched_refs, unmatched_hyps)`` —
+ ``matches`` est une liste de ``(idx_ref, idx_hyp, iou)``.
+ """
+ candidates: list[tuple[float, int, int]] = []
+ for i, r in enumerate(references):
+ for j, h in enumerate(hypotheses):
+ if r.type.casefold() != h.type.casefold():
+ continue
+ iou = _iou_bbox(r, h)
+ if iou >= iou_threshold:
+ candidates.append((iou, i, j))
+
+ # Tri stable : IoU décroissant, puis indices croissants pour
+ # déterminisme sur égalités.
+ candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
+
+ matched_refs: set[int] = set()
+ matched_hyps: set[int] = set()
+ matches: list[tuple[int, int, float]] = []
+ for iou, i, j in candidates:
+ if i in matched_refs or j in matched_hyps:
+ continue
+ matched_refs.add(i)
+ matched_hyps.add(j)
+ matches.append((i, j, iou))
+
+ unmatched_refs = set(range(len(references))) - matched_refs
+ unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
+ return matches, unmatched_refs, unmatched_hyps
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Métrique principale
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
+ p = tp / (tp + fp) if (tp + fp) > 0 else 0.0
+ r = tp / (tp + fn) if (tp + fn) > 0 else 0.0
+ f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
+ return {"precision": p, "recall": r, "f1": f1, "support": tp + fn}
+
+
+def compute_layout_metrics(
+ reference_regions: Iterable[Region | dict] | None,
+ hypothesis_regions: Iterable[Region | dict] | None,
+ iou_threshold: float = 0.5,
+) -> dict:
+ """Calcule precision/recall/F1 sur le layout par type de région.
+
+ Parameters
+ ----------
+ reference_regions:
+ Liste de régions GT (``Region`` ou dict ``{id, type, bbox}``).
+ hypothesis_regions:
+ Liste de régions produites par le moteur OCR/HTR ou un
+ layout-detector.
+ iou_threshold:
+ Seuil de chevauchement minimal pour déclarer un appariement
+ (défaut : 0,5 — convention ICDAR).
+
+ Returns
+ -------
+ dict
+ ``{
+ "global": {"precision", "recall", "f1", "support"},
+ "per_type": {type_name: {"precision", ...}},
+ "true_positives": int,
+ "false_positives": int,
+ "false_negatives": int,
+ "missed_regions": list[dict], # GT non matchées
+ "hallucinated_regions": list[dict], # hyp non matchées
+ "iou_threshold": float,
+ }``
+
+ Cas dégénérés
+ -------------
+ - Deux listes vides → F1 = 0 et tous compteurs à 0.
+ - GT vide + hyp non-vide → F1 = 0 (toutes hyp = FP).
+ - hyp vide + GT non-vide → F1 = 0 (toutes GT = FN).
+ """
+ refs = [_to_region(r) for r in (reference_regions or [])]
+ hyps = [_to_region(h) for h in (hypothesis_regions or [])]
+
+ matches, unmatched_refs, unmatched_hyps = _align_regions(
+ refs, hyps, iou_threshold,
+ )
+
+ tp = len(matches)
+ fn = len(unmatched_refs)
+ fp = len(unmatched_hyps)
+
+ cat_tp: dict[str, int] = {}
+ cat_fn: dict[str, int] = {}
+ cat_fp: dict[str, int] = {}
+ for i, _j, _iou in matches:
+ cat = refs[i].type
+ cat_tp[cat] = cat_tp.get(cat, 0) + 1
+ for i in unmatched_refs:
+ cat = refs[i].type
+ cat_fn[cat] = cat_fn.get(cat, 0) + 1
+ for j in unmatched_hyps:
+ cat = hyps[j].type
+ cat_fp[cat] = cat_fp.get(cat, 0) + 1
+
+ all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
+ per_type = {
+ cat: _prf(
+ cat_tp.get(cat, 0),
+ cat_fp.get(cat, 0),
+ cat_fn.get(cat, 0),
+ )
+ for cat in all_categories
+ }
+
+ return {
+ "global": _prf(tp, fp, fn),
+ "per_type": per_type,
+ "true_positives": tp,
+ "false_positives": fp,
+ "false_negatives": fn,
+ "missed_regions": [
+ {"id": refs[i].id, "type": refs[i].type, "bbox": list(refs[i].bbox)}
+ for i in sorted(unmatched_refs)
+ ],
+ "hallucinated_regions": [
+ {"id": hyps[j].id, "type": hyps[j].type, "bbox": list(hyps[j].bbox)}
+ for j in sorted(unmatched_hyps)
+ ],
+ "iou_threshold": iou_threshold,
+ }
+
+
+def layout_f1(
+ reference_regions: Iterable[Region | dict] | None,
+ hypothesis_regions: Iterable[Region | dict] | None,
+ iou_threshold: float = 0.5,
+) -> float:
+ """Raccourci : F1 global du layout."""
+ return compute_layout_metrics(
+ reference_regions, hypothesis_regions, iou_threshold,
+ )["global"]["f1"]
+
+
+__all__ = [
+ "Region",
+ "compute_layout_metrics",
+ "layout_f1",
+]
diff --git a/picarones/measurements/levers.py b/picarones/measurements/levers.py
new file mode 100644
index 0000000000000000000000000000000000000000..47ba0ab9d665f6eb35d0572fdb4c07a2d7b4ea44
--- /dev/null
+++ b/picarones/measurements/levers.py
@@ -0,0 +1,561 @@
+"""Section « Leviers d'amélioration » — Sprint 82 (A.I.9).
+
+Sprint 82 — A.I.9 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce
+qui s'est passé** dans le benchmark : qui gagne, qui s'effondre,
+qui est fragile. Ce sprint répond à une question
+complémentaire : **sur quelle dimension le bénéfice attendu d'une
+amélioration serait-il le plus visible ?**
+
+Pas de prescription
+-------------------
+Picarones est un **outil de recherche**, pas un atelier de
+production. Le module ne dit jamais *« faites X »* ni
+*« utilisez le moteur Y »* ; il agrège des **observations
+factuelles** déjà calculées dans d'autres modules (Sprints 75-81)
+et les présente comme un récapitulatif compact en bas du rapport.
+Le chercheur lit, juge et arbitre.
+
+Exemples de leviers émis
+------------------------
+- *« 65 % des erreurs de Tesseract sont de classe récupérable
+ (case_error, ligature_error, abbreviation_error) — un
+ post-processing trivial absorberait une partie. »*
+- *« 12 % de vos documents concentrent 78 % du CER total
+ (Pareto-CER). »*
+- *« Le déficit projeté du moteur le plus fragile sur le corpus
+ réel est de 4,2 points de CER (Sprint 81). »*
+- *« Le top-3 des tokens GT systématiquement modernisés est
+ maistre, nostre, veoir (Sprint 80). »*
+
+Structure
+---------
+Module parallèle au registre narratif Sprint 19 : `Lever` est la
+dataclass équivalente à `Fact`, `LeverImportance` reprend la
+sémantique de `FactImportance`, `@register_lever` indexe les
+détecteurs. Garde-fou anti-hallucination identique : chaque
+nombre rendu doit être présent dans le `payload` du `Lever`.
+
+Les détecteurs lisent **uniquement** des structures déjà
+construites par le pipeline du benchmark — ils ne calculent rien
+de nouveau, ils synthétisent. C'est pourquoi le module est
+résolument optionnel : si un benchmark n'expose pas
+`taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`,
+`lexical_modernization` ou `robustness_projection`, le détecteur
+correspondant retourne tout simplement `[]`.
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from dataclasses import dataclass
+from enum import Enum
+from typing import Callable
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Modèle
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class LeverType(str, Enum):
+ """Types de leviers détectés."""
+
+ DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class"
+ """Une part importante des erreurs d'un moteur est dans des classes
+ catégorisées « récupérables » (Sprint 77)."""
+
+ PARETO_CONCENTRATION = "pareto_concentration"
+ """Une fraction minoritaire de documents concentre une fraction
+ majoritaire du CER total — l'inspection ciblée est rentable."""
+
+ COMPLEMENTARITY_OBSERVATION = "complementarity_observation"
+ """Le `complementarity_gap` (Sprint 35) entre l'oracle et le
+ meilleur moteur seul est non négligeable — observation factuelle,
+ aucune recommandation d'ensemble."""
+
+ LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation"
+ """Top-N des tokens GT systématiquement modernisés (Sprint 80)."""
+
+ ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation"
+ """Déficit projeté global le plus important pour un moteur sur
+ le corpus réel (Sprint 81)."""
+
+
+class LeverImportance(int, Enum):
+ """Importance éditoriale d'un levier."""
+
+ HIGH = 70
+ MEDIUM = 40
+ LOW = 10
+
+
+@dataclass
+class Lever:
+ """Observation factuelle synthétisable en encart « Leviers ».
+
+ Attributes
+ ----------
+ type:
+ Le type de levier (voir `LeverType`).
+ importance:
+ Score qui décide l'ordre d'affichage.
+ payload:
+ Données brutes — **tout chiffre rendu dans le HTML doit
+ provenir d'ici**, jamais d'un calcul du renderer.
+ engines_involved:
+ Noms des moteurs concernés (peut être vide pour un levier
+ corpus-wide).
+ """
+
+ type: LeverType
+ importance: LeverImportance
+ payload: dict
+ engines_involved: tuple[str, ...] = ()
+
+ def as_dict(self) -> dict:
+ return {
+ "type": self.type.value,
+ "importance": int(self.importance),
+ "payload": self.payload,
+ "engines_involved": list(self.engines_involved),
+ }
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Registre
+# ──────────────────────────────────────────────────────────────────────────
+
+
+LeverDetectorFn = Callable[[dict], list[Lever]]
+
+
+@dataclass(frozen=True)
+class LeverDetectorEntry:
+ lever_type: LeverType
+ fn: LeverDetectorFn
+ priority: int
+
+
+_LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {}
+_LEVER_REGISTRY_LOCK = threading.Lock()
+
+
+def register_lever(
+ lever_type: LeverType,
+ *,
+ priority: int,
+) -> Callable[[LeverDetectorFn], LeverDetectorFn]:
+ """Décorateur : enregistre un détecteur de levier.
+
+ Une seule fonction par type — réenregistrer lève `ValueError`.
+ """
+ def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn:
+ with _LEVER_REGISTRY_LOCK:
+ if lever_type in _LEVER_REGISTRY:
+ raise ValueError(
+ f"Détecteur déjà enregistré pour {lever_type.value!r} : "
+ f"{_LEVER_REGISTRY[lever_type].fn.__name__}."
+ )
+ _LEVER_REGISTRY[lever_type] = LeverDetectorEntry(
+ lever_type=lever_type, fn=fn, priority=int(priority),
+ )
+ return fn
+ return _decorator
+
+
+def unregister_lever(lever_type: LeverType) -> None:
+ with _LEVER_REGISTRY_LOCK:
+ _LEVER_REGISTRY.pop(lever_type, None)
+
+
+def iter_lever_detectors() -> list[LeverDetectorEntry]:
+ with _LEVER_REGISTRY_LOCK:
+ entries = list(_LEVER_REGISTRY.values())
+ entries.sort(key=lambda e: e.priority)
+ return entries
+
+
+def detect_levers(benchmark_data: dict) -> list[Lever]:
+ """Applique tous les détecteurs enregistrés et trie par importance
+ décroissante puis priorité d'enregistrement croissante."""
+ levers: list[Lever] = []
+ for entry in iter_lever_detectors():
+ try:
+ result = entry.fn(benchmark_data)
+ except Exception as e:
+ logger.warning(
+ "[levers.detector.%s] fonctionnalité dégradée : %s",
+ entry.lever_type.value, e,
+ )
+ continue
+ if result:
+ levers.extend(result)
+ # Tri stable : importance décroissante d'abord
+ levers.sort(key=lambda lv: -int(lv.importance))
+ return levers
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Détecteurs
+# ──────────────────────────────────────────────────────────────────────────
+
+
+# Catégorisation reprise du Sprint 77 (taxonomy_comparison.py).
+# Volontairement dupliquée ici pour ne pas introduire d'import
+# circulaire — la sémantique est gelée.
+_RECOVERABILITY: dict[str, str] = {
+ "case_error": "recoverable",
+ "ligature_error": "recoverable",
+ "abbreviation_error": "recoverable",
+ "diacritic_error": "difficult",
+ "visual_confusion": "difficult",
+ "hapax": "difficult",
+ "lacuna": "irrecoverable",
+ "oov_character": "irrecoverable",
+ "segmentation_error": "irrecoverable",
+}
+
+
+@register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10)
+def detect_dominant_recoverable_class(
+ benchmark_data: dict,
+ *,
+ threshold: float = 0.30,
+) -> list[Lever]:
+ """Émet un levier si ≥ `threshold` des erreurs d'un moteur sont
+ classifiées récupérables (catégorisation Sprint 77).
+
+ Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` —
+ structure produite par le runner historique. Si absent, retourne
+ [].
+ """
+ engines = benchmark_data.get("engines") or []
+ out: list[Lever] = []
+ for engine in engines:
+ taxonomy = engine.get("aggregated_taxonomy")
+ if not taxonomy:
+ continue
+ # `taxonomy` peut être {class_name: int} ou un dict avec une
+ # sous-clé "counts" — on accepte les deux conventions.
+ counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy
+ if not isinstance(counts, dict) or not counts:
+ continue
+ try:
+ int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))}
+ except (TypeError, ValueError):
+ continue
+ total = sum(int_counts.values())
+ if total <= 0:
+ continue
+ recoverable_total = sum(
+ v for k, v in int_counts.items()
+ if _RECOVERABILITY.get(k) == "recoverable"
+ )
+ share = recoverable_total / total
+ if share < threshold:
+ continue
+ # Classes récupérables non vides triées par count décroissant
+ breakdown = sorted(
+ (
+ (k, v) for k, v in int_counts.items()
+ if _RECOVERABILITY.get(k) == "recoverable" and v > 0
+ ),
+ key=lambda kv: -kv[1],
+ )
+ importance = (
+ LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM
+ )
+ out.append(Lever(
+ type=LeverType.DOMINANT_RECOVERABLE_CLASS,
+ importance=importance,
+ payload={
+ "engine": engine.get("name") or "?",
+ "share_recoverable": share,
+ "share_recoverable_pct": round(share * 100, 1),
+ "n_recoverable": recoverable_total,
+ "n_total_errors": total,
+ "top_classes": [
+ {"class": k, "count": v} for k, v in breakdown[:3]
+ ],
+ },
+ engines_involved=(engine.get("name") or "?",),
+ ))
+ return out
+
+
+@register_lever(LeverType.PARETO_CONCENTRATION, priority=20)
+def detect_pareto_concentration(
+ benchmark_data: dict,
+ *,
+ top_share: float = 0.20,
+ cer_share_threshold: float = 0.50,
+) -> list[Lever]:
+ """Émet un levier si une fraction minoritaire de documents
+ (`top_share`) concentre plus de `cer_share_threshold` du CER
+ total cumulé sur le moteur leader.
+
+ Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de
+ reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`.
+ Si rien d'exploitable, retourne [].
+ """
+ ranking = benchmark_data.get("ranking") or []
+ if not ranking:
+ return []
+ leader = ranking[0]
+ leader_name = leader.get("engine")
+ if not leader_name:
+ return []
+
+ per_doc_cer: list[float] = []
+ # Voie 1 : structure plate "per_doc_cer"
+ flat = benchmark_data.get("per_doc_cer") or {}
+ if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list):
+ per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))]
+ else:
+ # Voie 2 : engine.per_doc liste de dicts {cer: float}
+ for engine in benchmark_data.get("engines") or []:
+ if engine.get("name") != leader_name:
+ continue
+ per_doc = engine.get("per_doc") or []
+ for entry in per_doc:
+ if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)):
+ per_doc_cer.append(float(entry["cer"]))
+ break
+
+ if not per_doc_cer:
+ return []
+ total_cer = sum(per_doc_cer)
+ if total_cer <= 0:
+ return []
+
+ sorted_cer = sorted(per_doc_cer, reverse=True)
+ n = len(sorted_cer)
+ n_top = max(1, int(round(top_share * n)))
+ top_cer_sum = sum(sorted_cer[:n_top])
+ share_of_total = top_cer_sum / total_cer
+ if share_of_total < cer_share_threshold:
+ return []
+ importance = (
+ LeverImportance.HIGH if share_of_total >= 0.75
+ else LeverImportance.MEDIUM
+ )
+ return [Lever(
+ type=LeverType.PARETO_CONCENTRATION,
+ importance=importance,
+ payload={
+ "engine": leader_name,
+ "n_docs": n,
+ "n_docs_top": n_top,
+ "top_share_pct": round((n_top / n) * 100, 1),
+ "cer_share_of_total": share_of_total,
+ "cer_share_pct": round(share_of_total * 100, 1),
+ },
+ engines_involved=(leader_name,),
+ )]
+
+
+@register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30)
+def detect_complementarity_observation(
+ benchmark_data: dict,
+ *,
+ min_relative_gap: float = 0.20,
+) -> list[Lever]:
+ """Reformule factuellement le `complementarity_gap` (Sprint 35).
+
+ Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne
+ déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune
+ recommandation d'ensemble** — le levier dit factuellement
+ « X points séparent l'oracle du meilleur moteur », c'est tout.
+ """
+ inter = benchmark_data.get("inter_engine_analysis") or {}
+ cgap = inter.get("complementarity_gap") or {}
+ relative_gap = cgap.get("relative_gap")
+ absolute_gap = cgap.get("absolute_gap")
+ if relative_gap is None or absolute_gap is None:
+ return []
+ try:
+ rg = float(relative_gap)
+ ag = float(absolute_gap)
+ except (TypeError, ValueError):
+ return []
+ if rg < min_relative_gap:
+ return []
+ importance = (
+ LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM
+ )
+ payload: dict = {
+ "absolute_gap": ag,
+ "absolute_gap_pct": round(ag * 100, 1),
+ "relative_gap": rg,
+ "relative_gap_pct": round(rg * 100, 1),
+ }
+ best_engine = cgap.get("best_engine") or inter.get("best_engine")
+ best_recall = cgap.get("best_recall") or inter.get("best_engine_recall")
+ oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall")
+ engines_involved: tuple[str, ...] = ()
+ if best_engine:
+ payload["best_engine"] = str(best_engine)
+ engines_involved = (str(best_engine),)
+ if isinstance(best_recall, (int, float)):
+ payload["best_recall"] = float(best_recall)
+ if isinstance(oracle_recall, (int, float)):
+ payload["oracle_recall"] = float(oracle_recall)
+ return [Lever(
+ type=LeverType.COMPLEMENTARITY_OBSERVATION,
+ importance=importance,
+ payload=payload,
+ engines_involved=engines_involved,
+ )]
+
+
+@register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40)
+def detect_lexical_modernization_observation(
+ benchmark_data: dict,
+ *,
+ top_n: int = 3,
+ min_total: int = 3,
+ min_rate: float = 0.50,
+) -> list[Lever]:
+ """Pour chaque moteur disposant de `lexical_modernization`,
+ émet un levier listant les `top_n` tokens GT les plus modernisés.
+
+ Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui
+ suit la forme produite par `compute_lexical_modernization` du
+ Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`).
+ """
+ out: list[Lever] = []
+ for engine in benchmark_data.get("engines") or []:
+ data = engine.get("lexical_modernization")
+ if not isinstance(data, dict):
+ continue
+ tokens = data.get("tokens") or {}
+ if not isinstance(tokens, dict) or not tokens:
+ continue
+ candidates: list[tuple[str, dict]] = []
+ for gt_token, slot in tokens.items():
+ if not isinstance(slot, dict):
+ continue
+ n_total = slot.get("n_total")
+ rate = slot.get("rate_modernized")
+ if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)):
+ continue
+ if int(n_total) < min_total:
+ continue
+ if float(rate) < min_rate:
+ continue
+ candidates.append((gt_token, dict(slot)))
+ if not candidates:
+ continue
+ candidates.sort(
+ key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)),
+ -int(kv[1].get("n_total", 0)),
+ kv[0]),
+ )
+ top = candidates[:top_n]
+ engine_name = engine.get("name") or "?"
+ max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top)
+ importance = (
+ LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM
+ )
+ out.append(Lever(
+ type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION,
+ importance=importance,
+ payload={
+ "engine": engine_name,
+ "top_tokens": [
+ {
+ "gt_token": gt,
+ "n_total": int(slot.get("n_total", 0)),
+ "rate_modernized": float(slot.get("rate_modernized", 0.0)),
+ "rate_modernized_pct": round(
+ float(slot.get("rate_modernized", 0.0)) * 100, 1,
+ ),
+ }
+ for gt, slot in top
+ ],
+ },
+ engines_involved=(engine_name,),
+ ))
+ return out
+
+
+@register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50)
+def detect_robustness_projection_observation(
+ benchmark_data: dict,
+ *,
+ min_total_deficit: float = 0.02,
+) -> list[Lever]:
+ """Lit l'agrégation par moteur de la projection de robustesse
+ (Sprint 81). Émet le levier pour le moteur dont
+ `total_expected_deficit` est ≥ `min_total_deficit` (par défaut
+ 2 points de CER).
+
+ Lit `benchmark_data["robustness_projection_aggregated"]` —
+ structure produite par `aggregate_projection_per_engine`.
+ """
+ agg = benchmark_data.get("robustness_projection_aggregated") or {}
+ if not isinstance(agg, dict) or not agg:
+ return []
+ out: list[Lever] = []
+ for engine_name, info in agg.items():
+ if not isinstance(info, dict):
+ continue
+ total_deficit = info.get("total_expected_deficit")
+ worst_type = info.get("worst_degradation_type")
+ worst_deficit = info.get("worst_degradation_deficit")
+ if not isinstance(total_deficit, (int, float)):
+ continue
+ if float(total_deficit) < min_total_deficit:
+ continue
+ importance = (
+ LeverImportance.HIGH if float(total_deficit) >= 0.05
+ else LeverImportance.MEDIUM
+ )
+ payload: dict = {
+ "engine": engine_name,
+ "total_expected_deficit": float(total_deficit),
+ "total_expected_deficit_pct": round(float(total_deficit) * 100, 1),
+ "n_degradation_types": int(info.get("n_degradation_types") or 0),
+ }
+ if isinstance(worst_type, str):
+ payload["worst_degradation_type"] = worst_type
+ if isinstance(worst_deficit, (int, float)):
+ payload["worst_degradation_deficit"] = float(worst_deficit)
+ payload["worst_degradation_deficit_pct"] = round(
+ float(worst_deficit) * 100, 1,
+ )
+ out.append(Lever(
+ type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION,
+ importance=importance,
+ payload=payload,
+ engines_involved=(engine_name,),
+ ))
+ # Tri par déficit décroissant pour stabilité d'affichage.
+ out.sort(
+ key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0),
+ )
+ return out
+
+
+__all__ = [
+ "Lever",
+ "LeverImportance",
+ "LeverType",
+ "LeverDetectorEntry",
+ "register_lever",
+ "unregister_lever",
+ "iter_lever_detectors",
+ "detect_levers",
+ "detect_dominant_recoverable_class",
+ "detect_pareto_concentration",
+ "detect_complementarity_observation",
+ "detect_lexical_modernization_observation",
+ "detect_robustness_projection_observation",
+]
diff --git a/picarones/measurements/line_metrics.py b/picarones/measurements/line_metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..5204decce03afa16ce9d4fc93e8bbb973d77f475
--- /dev/null
+++ b/picarones/measurements/line_metrics.py
@@ -0,0 +1,286 @@
+"""Distribution des erreurs CER par ligne — Sprint 10.
+
+Métriques calculées
+-------------------
+- CER par ligne : distance d'édition caractère/longueur GT sur chaque paire de lignes
+- Percentiles : p50, p75, p90, p95, p99 sur la distribution des CER ligne
+- Taux catastrophiques : % de lignes dépassant des seuils configurables (30 %, 50 %, 100 %)
+- Coefficient de Gini : concentration des erreurs (0 = uniformes, 1 = toutes concentrées)
+- Carte thermique : CER moyen par tranche de position dans le document
+"""
+
+from __future__ import annotations
+
+import unicodedata
+from dataclasses import dataclass
+from typing import Optional
+
+
+# ---------------------------------------------------------------------------
+# CER d'une paire de lignes (distance d'édition Levenshtein normalisée)
+# ---------------------------------------------------------------------------
+
+def _edit_distance(a: str, b: str) -> int:
+ """Distance de Levenshtein entre deux chaînes."""
+ if not a:
+ return len(b)
+ if not b:
+ return len(a)
+ prev = list(range(len(b) + 1))
+ for i, ca in enumerate(a, 1):
+ curr = [i]
+ for j, cb in enumerate(b, 1):
+ cost = 0 if ca == cb else 1
+ curr.append(min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost))
+ prev = curr
+ return prev[-1]
+
+
+def _line_cer(ref_line: str, hyp_line: str) -> float:
+ """CER pour une paire de lignes. Retourne 1.0 si le GT est vide et que l'hyp ne l'est pas."""
+ ref = unicodedata.normalize("NFC", ref_line.strip())
+ hyp = unicodedata.normalize("NFC", hyp_line.strip())
+ if not ref:
+ return 0.0 if not hyp else 1.0
+ dist = _edit_distance(ref, hyp)
+ return dist / len(ref)
+
+
+# ---------------------------------------------------------------------------
+# Percentiles (implémentation pur-Python, sans numpy)
+# ---------------------------------------------------------------------------
+
+def _percentile(sorted_values: list[float], p: float) -> float:
+ """Retourne le p-ième percentile (0 ≤ p ≤ 100) d'une liste triée."""
+ if not sorted_values:
+ return 0.0
+ n = len(sorted_values)
+ index = p / 100 * (n - 1)
+ lo = int(index)
+ hi = min(lo + 1, n - 1)
+ frac = index - lo
+ return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo])
+
+
+# ---------------------------------------------------------------------------
+# Coefficient de Gini
+# ---------------------------------------------------------------------------
+
+def _gini(values: list[float]) -> float:
+ """Coefficient de Gini des erreurs (0 = uniformes, 1 = toutes concentrées).
+
+ Formule : G = (2 * Σ i*x_i) / (n * Σ x_i) - (n+1)/n
+ sur les valeurs triées par ordre croissant.
+ """
+ if not values:
+ return 0.0
+ xs = sorted(max(v, 0.0) for v in values)
+ n = len(xs)
+ total = sum(xs)
+ if total == 0.0:
+ return 0.0
+ weighted_sum = sum((i + 1) * x for i, x in enumerate(xs))
+ return (2.0 * weighted_sum) / (n * total) - (n + 1) / n
+
+
+# ---------------------------------------------------------------------------
+# Résultat structuré
+# ---------------------------------------------------------------------------
+
+@dataclass
+class LineMetrics:
+ """Distribution des erreurs CER par ligne pour une paire (GT, hypothèse)."""
+
+ cer_per_line: list[float]
+ """CER de chaque ligne (longueur = nombre de lignes GT)."""
+
+ percentiles: dict[str, float]
+ """Percentiles : p50, p75, p90, p95, p99."""
+
+ catastrophic_rate: dict[str, float]
+ """Taux de lignes catastrophiques pour chaque seuil (ex. {0.3: 0.12, 0.5: 0.07, 1.0: 0.02})."""
+
+ gini: float
+ """Coefficient de Gini des erreurs (0 → uniforme, 1 → concentrées)."""
+
+ heatmap: list[float]
+ """CER moyen par tranche de position dans le document (longueur = heatmap_bins)."""
+
+ line_count: int
+ """Nombre de lignes GT traitées."""
+
+ mean_cer: float
+ """CER moyen sur l'ensemble des lignes."""
+
+ def as_dict(self) -> dict:
+ return {
+ "cer_per_line": [round(v, 6) for v in self.cer_per_line],
+ "percentiles": {k: round(v, 6) for k, v in self.percentiles.items()},
+ "catastrophic_rate": {str(k): round(v, 6) for k, v in self.catastrophic_rate.items()},
+ "gini": round(self.gini, 6),
+ "heatmap": [round(v, 6) for v in self.heatmap],
+ "line_count": self.line_count,
+ "mean_cer": round(self.mean_cer, 6),
+ }
+
+ @classmethod
+ def from_dict(cls, d: dict) -> "LineMetrics":
+ return cls(
+ cer_per_line=d.get("cer_per_line", []),
+ percentiles=d.get("percentiles", {}),
+ catastrophic_rate={float(k): v for k, v in d.get("catastrophic_rate", {}).items()},
+ gini=d.get("gini", 0.0),
+ heatmap=d.get("heatmap", []),
+ line_count=d.get("line_count", 0),
+ mean_cer=d.get("mean_cer", 0.0),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Calcul principal
+# ---------------------------------------------------------------------------
+
+def compute_line_metrics(
+ reference: str,
+ hypothesis: str,
+ thresholds: Optional[list[float]] = None,
+ heatmap_bins: int = 10,
+) -> LineMetrics:
+ """Calcule la distribution des erreurs CER ligne par ligne.
+
+ Parameters
+ ----------
+ reference:
+ Texte de vérité terrain (GT) avec sauts de ligne.
+ hypothesis:
+ Texte produit par le moteur OCR.
+ thresholds:
+ Seuils CER pour le taux catastrophique. Défaut : [0.30, 0.50, 1.00].
+ heatmap_bins:
+ Nombre de tranches de position pour la carte thermique.
+
+ Returns
+ -------
+ LineMetrics
+ """
+ if thresholds is None:
+ thresholds = [0.30, 0.50, 1.00]
+
+ ref_lines = reference.splitlines()
+ hyp_lines = hypothesis.splitlines()
+
+ # Aligner les lignes GT / hypothèse — on prend au moins autant de lignes que le GT
+ n = len(ref_lines)
+ if n == 0:
+ # Pas de lignes : retourner des métriques neutres
+ return LineMetrics(
+ cer_per_line=[],
+ percentiles={f"p{p}": 0.0 for p in (50, 75, 90, 95, 99)},
+ catastrophic_rate={t: 0.0 for t in thresholds},
+ gini=0.0,
+ heatmap=[0.0] * heatmap_bins,
+ line_count=0,
+ mean_cer=0.0,
+ )
+
+ # Aligner en ignorant les lignes d'hypothèse supplémentaires
+ # Si l'hypothèse a moins de lignes, les lignes manquantes comptent comme supprimées (CER = 1.0)
+ cer_per_line: list[float] = []
+ for i, ref_line in enumerate(ref_lines):
+ hyp_line = hyp_lines[i] if i < len(hyp_lines) else ""
+ cer_per_line.append(min(_line_cer(ref_line, hyp_line), 1.0))
+
+ sorted_cer = sorted(cer_per_line)
+
+ # Percentiles
+ percentiles = {
+ f"p{p}": _percentile(sorted_cer, p)
+ for p in (50, 75, 90, 95, 99)
+ }
+
+ # Taux catastrophiques
+ catastrophic_rate: dict[float, float] = {}
+ for t in thresholds:
+ count = sum(1 for v in cer_per_line if v > t)
+ catastrophic_rate[t] = count / n
+
+ # Gini
+ gini = _gini(cer_per_line)
+
+ # Carte thermique par tranche de position
+ bins = heatmap_bins
+ heatmap: list[float] = []
+ for b in range(bins):
+ start = int(b * n / bins)
+ end = int((b + 1) * n / bins)
+ slice_ = cer_per_line[start:end]
+ heatmap.append(sum(slice_) / len(slice_) if slice_ else 0.0)
+
+ mean_cer = sum(cer_per_line) / n
+
+ return LineMetrics(
+ cer_per_line=cer_per_line,
+ percentiles=percentiles,
+ catastrophic_rate=catastrophic_rate,
+ gini=gini,
+ heatmap=heatmap,
+ line_count=n,
+ mean_cer=mean_cer,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Agrégation sur un corpus
+# ---------------------------------------------------------------------------
+
+def aggregate_line_metrics(results: list[LineMetrics]) -> dict:
+ """Agrège les métriques de distribution par ligne sur un corpus.
+
+ Returns
+ -------
+ dict
+ Statistiques agrégées : Gini moyen, percentiles moyens, taux catastrophiques moyens.
+ """
+ if not results:
+ return {}
+
+ import statistics as _stats
+
+ gini_values = [r.gini for r in results]
+ mean_cer_values = [r.mean_cer for r in results]
+
+ # Percentiles moyens
+ pct_keys = ["p50", "p75", "p90", "p95", "p99"]
+ avg_percentiles = {}
+ for k in pct_keys:
+ vals = [r.percentiles.get(k, 0.0) for r in results]
+ avg_percentiles[k] = round(sum(vals) / len(vals), 6) if vals else 0.0
+
+ # Taux catastrophiques moyens (union des seuils)
+ all_thresholds: set[float] = set()
+ for r in results:
+ all_thresholds.update(r.catastrophic_rate.keys())
+ avg_catastrophic: dict[str, float] = {}
+ for t in sorted(all_thresholds):
+ vals = [r.catastrophic_rate.get(t, 0.0) for r in results]
+ avg_catastrophic[str(t)] = round(sum(vals) / len(vals), 6) if vals else 0.0
+
+ # Heatmap moyenne (longueur = max des longueurs)
+ if results and results[0].heatmap:
+ n_bins = len(results[0].heatmap)
+ heatmap_avg = []
+ for b in range(n_bins):
+ vals = [r.heatmap[b] for r in results if b < len(r.heatmap)]
+ heatmap_avg.append(round(sum(vals) / len(vals), 6) if vals else 0.0)
+ else:
+ heatmap_avg = []
+
+ return {
+ "gini_mean": round(sum(gini_values) / len(gini_values), 6),
+ "gini_stdev": round(_stats.stdev(gini_values), 6) if len(gini_values) > 1 else 0.0,
+ "mean_cer_mean": round(sum(mean_cer_values) / len(mean_cer_values), 6),
+ "percentiles": avg_percentiles,
+ "catastrophic_rate": avg_catastrophic,
+ "heatmap": heatmap_avg,
+ "document_count": len(results),
+ }
diff --git a/picarones/measurements/longitudinal.py b/picarones/measurements/longitudinal.py
new file mode 100644
index 0000000000000000000000000000000000000000..26fe91c4530a99793c87e35fef81ffb5716df174
--- /dev/null
+++ b/picarones/measurements/longitudinal.py
@@ -0,0 +1,373 @@
+"""Métriques longitudinales — Sprint 92 (A.II.9).
+
+Sprint 92 — A.II.9 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+L'historique SQLite (`core/history.py`, Sprint 8) collecte les
+résultats de chaque run de benchmark, mais aucune métrique
+n'en sortait dans le rapport. Ce module exploite la série
+temporelle des CER d'un moteur pour répondre à deux
+questions :
+
+1. **Y a-t-il une tendance ?** Régression linéaire simple
+ (méthode des moindres carrés) sur ``(t, CER)`` — pente,
+ ordonnée à l'origine, R², n_runs. Une pente > 0 signale
+ une régression progressive ; une pente < 0 une amélioration.
+
+2. **Y a-t-il un point de rupture ?** Algorithme de
+ change-point pur Python (différence de moyennes maximale,
+ variante de Pettitt simplifiée). Identifie l'index où la
+ série se sépare en deux segments avec moyennes les plus
+ différentes — typiquement le run où un modèle a changé de
+ comportement.
+
+Pas de scipy
+------------
+Pour rester sans dépendance lourde, on implémente :
+- la régression linéaire en pur Python (closed-form OLS) ;
+- le change-point par balayage exhaustif (O(N) pour de petits
+ N — l'historique d'une institution dépasse rarement quelques
+ centaines de runs).
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import statistics
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Iterable, Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class LinearTrend:
+ """Résultat d'une régression linéaire sur une série CER."""
+ slope: float
+ """Pente (CER par jour). Positif = régression."""
+ intercept: float
+ """Ordonnée à l'origine."""
+ r_squared: float
+ """Qualité de l'ajustement, ∈ [0, 1]."""
+ n_runs: int
+ """Nombre de points utilisés."""
+
+ def as_dict(self) -> dict:
+ return {
+ "slope": self.slope,
+ "intercept": self.intercept,
+ "r_squared": self.r_squared,
+ "n_runs": self.n_runs,
+ }
+
+
+@dataclass
+class ChangePointResult:
+ """Résultat d'une détection de point de rupture."""
+ index: int
+ """Index de la rupture (0-based, le segment 1 est [0:index],
+ le segment 2 est [index:N])."""
+ timestamp: str
+ """Timestamp du run à la rupture."""
+ mean_before: float
+ mean_after: float
+ delta: float
+ """``mean_after - mean_before``. Positif = régression."""
+ n_before: int
+ n_after: int
+
+ def as_dict(self) -> dict:
+ return {
+ "index": self.index,
+ "timestamp": self.timestamp,
+ "mean_before": self.mean_before,
+ "mean_after": self.mean_after,
+ "delta": self.delta,
+ "n_before": self.n_before,
+ "n_after": self.n_after,
+ }
+
+
+def _parse_timestamp(ts: str) -> Optional[float]:
+ """Parse un ISO timestamp en jour ordinal float.
+
+ Tolère ``YYYY-MM-DD`` et ``YYYY-MM-DDTHH:MM:SS``. Retourne
+ ``None`` si non parsable.
+ """
+ if not ts:
+ return None
+ formats = (
+ "%Y-%m-%dT%H:%M:%S.%f",
+ "%Y-%m-%dT%H:%M:%S",
+ "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%d",
+ )
+ for fmt in formats:
+ try:
+ dt = datetime.strptime(ts.split("+")[0].split("Z")[0], fmt)
+ return dt.toordinal() + (
+ dt.hour * 3600 + dt.minute * 60 + dt.second
+ ) / 86400.0
+ except ValueError:
+ continue
+ return None
+
+
+def compute_linear_trend(
+ cer_series: Iterable[tuple[str, float]],
+) -> Optional[LinearTrend]:
+ """Régression linéaire OLS sur une série temporelle de CER.
+
+ Parameters
+ ----------
+ cer_series:
+ Itérable de ``(timestamp_iso, cer)``. Au moins 2 points
+ valides requis.
+
+ Returns
+ -------
+ LinearTrend | None
+ ``None`` si moins de 2 points ou si tous les timestamps
+ sont identiques (variance nulle sur t).
+ """
+ points: list[tuple[float, float]] = []
+ for ts, cer in cer_series:
+ t = _parse_timestamp(ts)
+ if t is None or cer is None:
+ continue
+ try:
+ cer_f = float(cer)
+ except (TypeError, ValueError):
+ continue
+ points.append((t, cer_f))
+ n = len(points)
+ if n < 2:
+ return None
+ xs = [p[0] for p in points]
+ ys = [p[1] for p in points]
+ x_mean = statistics.fmean(xs)
+ y_mean = statistics.fmean(ys)
+ sxx = sum((x - x_mean) ** 2 for x in xs)
+ sxy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys))
+ if sxx == 0:
+ return None
+ slope = sxy / sxx
+ intercept = y_mean - slope * x_mean
+ syy = sum((y - y_mean) ** 2 for y in ys)
+ if syy == 0:
+ # Tous les CER sont égaux → R² mathématiquement indéfini ;
+ # on retourne 1.0 (parfaite "non-tendance").
+ r_squared = 1.0
+ else:
+ ss_res = sum(
+ (y - (slope * x + intercept)) ** 2
+ for x, y in zip(xs, ys)
+ )
+ r_squared = max(0.0, 1.0 - ss_res / syy)
+ return LinearTrend(
+ slope=slope,
+ intercept=intercept,
+ r_squared=r_squared,
+ n_runs=n,
+ )
+
+
+def detect_change_point(
+ cer_series: Iterable[tuple[str, float]],
+ min_segment_size: int = 3,
+) -> Optional[ChangePointResult]:
+ """Détecte le point de rupture maximisant l'écart de moyennes.
+
+ Algorithme : balayage des indices ``i`` où la série se
+ sépare en deux segments d'au moins ``min_segment_size``
+ points chacun ; on retient l'index où ``|mean_after -
+ mean_before|`` est maximal. Variante simplifiée de Pettitt.
+
+ Parameters
+ ----------
+ cer_series:
+ Itérable de ``(timestamp_iso, cer)``.
+ min_segment_size:
+ Taille minimale des deux segments. Défaut 3.
+
+ Returns
+ -------
+ ChangePointResult | None
+ ``None`` si la série a moins de ``2 × min_segment_size``
+ points valides.
+ """
+ points: list[tuple[str, float, float]] = []
+ for ts, cer in cer_series:
+ t = _parse_timestamp(ts)
+ if t is None or cer is None:
+ continue
+ try:
+ cer_f = float(cer)
+ except (TypeError, ValueError):
+ continue
+ points.append((ts, t, cer_f))
+ if len(points) < 2 * min_segment_size:
+ return None
+ points.sort(key=lambda p: p[1])
+ n = len(points)
+ best_index = -1
+ best_abs_delta = -1.0
+ best_delta = 0.0
+ best_mean_before = 0.0
+ best_mean_after = 0.0
+ for i in range(min_segment_size, n - min_segment_size + 1):
+ before = [p[2] for p in points[:i]]
+ after = [p[2] for p in points[i:]]
+ mean_b = statistics.fmean(before)
+ mean_a = statistics.fmean(after)
+ delta = mean_a - mean_b
+ abs_delta = abs(delta)
+ if abs_delta > best_abs_delta:
+ best_abs_delta = abs_delta
+ best_index = i
+ best_delta = delta
+ best_mean_before = mean_b
+ best_mean_after = mean_a
+ if best_index < 0:
+ return None
+ return ChangePointResult(
+ index=best_index,
+ timestamp=points[best_index][0],
+ mean_before=best_mean_before,
+ mean_after=best_mean_after,
+ delta=best_delta,
+ n_before=best_index,
+ n_after=n - best_index,
+ )
+
+
+def compute_engine_longitudinal(
+ history_entries: Iterable,
+ engine_name: str,
+ corpus_name: Optional[str] = None,
+ *,
+ min_runs_for_trend: int = 3,
+ min_segment_size: int = 3,
+ change_point_threshold: float = 0.01,
+) -> Optional[dict]:
+ """Calcule trend + change_point pour un moteur.
+
+ Parameters
+ ----------
+ history_entries:
+ Liste de ``HistoryEntry`` (ou dicts compatibles).
+ engine_name:
+ Filtre sur le nom du moteur.
+ corpus_name:
+ Filtre optionnel sur le corpus. ``None`` (défaut) : tous
+ les corpus.
+ min_runs_for_trend:
+ Minimum de runs pour calculer une tendance.
+ min_segment_size:
+ Taille minimale des segments pour le change-point.
+ change_point_threshold:
+ Magnitude absolue minimale du delta (en CER) pour
+ retenir le change-point. Défaut 0.01 (1 point de CER).
+
+ Returns
+ -------
+ dict | None
+ ``{
+ "engine_name", "corpus_name", "n_runs", "trend",
+ "change_point", # ou None
+ "first_timestamp", "last_timestamp",
+ "first_cer", "last_cer", "absolute_delta_pct",
+ }`` ou ``None`` si moins de ``min_runs_for_trend`` runs.
+ """
+ series: list[tuple[str, float]] = []
+ for entry in history_entries:
+ if hasattr(entry, "as_dict"):
+ data = entry.as_dict()
+ else:
+ data = entry
+ if data.get("engine_name") != engine_name:
+ continue
+ if corpus_name is not None and data.get("corpus_name") != corpus_name:
+ continue
+ cer = data.get("cer_mean")
+ ts = data.get("timestamp")
+ if cer is None or ts is None:
+ continue
+ series.append((ts, float(cer)))
+ if len(series) < min_runs_for_trend:
+ return None
+ series.sort(key=lambda p: _parse_timestamp(p[0]) or 0.0)
+ trend = compute_linear_trend(series)
+ cp = detect_change_point(series, min_segment_size=min_segment_size)
+ if cp is not None and abs(cp.delta) < change_point_threshold:
+ cp = None
+ first_ts, first_cer = series[0]
+ last_ts, last_cer = series[-1]
+ return {
+ "engine_name": engine_name,
+ "corpus_name": corpus_name,
+ "n_runs": len(series),
+ "trend": trend.as_dict() if trend else None,
+ "change_point": cp.as_dict() if cp else None,
+ "first_timestamp": first_ts,
+ "last_timestamp": last_ts,
+ "first_cer": first_cer,
+ "last_cer": last_cer,
+ "absolute_delta": last_cer - first_cer,
+ "absolute_delta_pct": round((last_cer - first_cer) * 100, 2),
+ }
+
+
+def compute_corpus_longitudinal(
+ history_entries: Iterable,
+ corpus_name: Optional[str] = None,
+ *,
+ min_runs_for_trend: int = 3,
+ min_segment_size: int = 3,
+ change_point_threshold: float = 0.01,
+) -> list[dict]:
+ """Pour chaque moteur présent dans l'historique sur ``corpus_name``,
+ calcule trend + change_point.
+
+ Returns
+ -------
+ list[dict]
+ Une entrée par moteur (filtrée), liste vide si rien.
+ """
+ entries = list(history_entries)
+ engines: set[str] = set()
+ for entry in entries:
+ data = entry.as_dict() if hasattr(entry, "as_dict") else entry
+ if corpus_name is not None and data.get("corpus_name") != corpus_name:
+ continue
+ name = data.get("engine_name")
+ if name:
+ engines.add(name)
+ out: list[dict] = []
+ for engine in sorted(engines):
+ result = compute_engine_longitudinal(
+ entries, engine, corpus_name=corpus_name,
+ min_runs_for_trend=min_runs_for_trend,
+ min_segment_size=min_segment_size,
+ change_point_threshold=change_point_threshold,
+ )
+ if result is not None:
+ out.append(result)
+ return out
+
+
+__all__ = [
+ "LinearTrend",
+ "ChangePointResult",
+ "compute_linear_trend",
+ "detect_change_point",
+ "compute_engine_longitudinal",
+ "compute_corpus_longitudinal",
+]
+
+
+# Marqueur d'évitement d'import inutilisé (math)
+_ = math
diff --git a/picarones/measurements/marginal_cost.py b/picarones/measurements/marginal_cost.py
new file mode 100644
index 0000000000000000000000000000000000000000..4d1c59bf324ede3d6bf0e2fcf91c59d9dae9d0de
--- /dev/null
+++ b/picarones/measurements/marginal_cost.py
@@ -0,0 +1,142 @@
+"""Coût marginal par erreur évitée — Sprint 91 (A.II.6 chantier 2).
+
+Sprint 91 — A.II.6 chantier 2 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+La vue Pareto (Sprint 20) trace CER vs coût mais n'arbitre pas
+quel surcoût est *raisonnable* pour quelle réduction d'erreur.
+Une institution avec un budget contraint a besoin d'une
+réponse opérationnelle :
+
+ *« Passer de Tesseract à Mistral OCR coûte 0,83 € par
+ erreur évitée — décider selon votre budget par millier
+ d'erreurs corrigées. »*
+
+Formule
+-------
+Pour deux moteurs A et B où B fait **moins** d'erreurs que A
+(donc B est plus précis) :
+
+.. code::
+
+ coût_marginal = (coût_B − coût_A) / (errors_A − errors_B)
+
+- Si ``cost_B > cost_A`` et ``errors_B < errors_A`` :
+ ``cost_per_avoided_error > 0`` (cas standard, B coûte plus
+ pour moins d'erreurs).
+- Si ``cost_B ≤ cost_A`` et ``errors_B < errors_A`` :
+ ``cost_per_avoided_error ≤ 0`` (cas idéal, B est strictement
+ meilleur).
+- Si ``errors_B ≥ errors_A`` : non comparable dans ce sens
+ (B n'évite pas d'erreur), retourne ``None``.
+
+Sortie
+------
+``compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)``
+retourne ``{cost_per_avoided_error, n_errors_avoided,
+cost_delta, dominated}`` ou ``None`` si non comparable.
+
+``compute_marginal_cost_matrix(per_engine)`` retourne, pour
+chaque paire ordonnée ``(A → B)`` où B est plus précis, le
+coût marginal correspondant. Trié par coût marginal croissant
+(meilleur ratio en tête).
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+def compute_marginal_cost(
+ cost_a: float,
+ errors_a: float,
+ cost_b: float,
+ errors_b: float,
+) -> Optional[dict]:
+ """Coût marginal du passage A → B (B plus précis).
+
+ Retourne ``None`` si :
+ - ``errors_b >= errors_a`` (B n'évite pas d'erreur) ;
+ - les valeurs ne sont pas finies.
+ """
+ try:
+ ca = float(cost_a)
+ cb = float(cost_b)
+ ea = float(errors_a)
+ eb = float(errors_b)
+ except (TypeError, ValueError):
+ return None
+ if ea <= eb:
+ # B ne fait pas mieux que A → pas de gain à mesurer.
+ return None
+ n_avoided = ea - eb
+ cost_delta = cb - ca
+ cost_per_avoided = cost_delta / n_avoided
+ dominated = cost_delta <= 0 # B aussi cher ou moins → cas idéal
+ return {
+ "cost_per_avoided_error": cost_per_avoided,
+ "n_errors_avoided": n_avoided,
+ "cost_delta": cost_delta,
+ "dominated": dominated,
+ }
+
+
+def compute_marginal_cost_matrix(
+ per_engine: dict[str, dict],
+) -> Optional[dict]:
+ """Pour chaque paire A → B où B fait moins d'erreurs, calcule
+ le coût marginal.
+
+ Parameters
+ ----------
+ per_engine:
+ Map ``{engine_name: {"cost": float, "errors": float}}``.
+
+ Returns
+ -------
+ dict | None
+ ``{
+ "pairs": list[
+ {"engine_a", "engine_b", "cost_per_avoided_error",
+ "n_errors_avoided", "cost_delta", "dominated"}
+ ], # triée par cost_per_avoided_error croissant
+ }``
+ ou ``None`` si moins de 2 moteurs.
+ """
+ if not per_engine or len(per_engine) < 2:
+ return None
+ engines = sorted(per_engine.keys())
+ pairs: list[dict] = []
+ for a in engines:
+ for b in engines:
+ if a == b:
+ continue
+ data_a = per_engine[a]
+ data_b = per_engine[b]
+ try:
+ ca = float(data_a.get("cost"))
+ ea = float(data_a.get("errors"))
+ cb = float(data_b.get("cost"))
+ eb = float(data_b.get("errors"))
+ except (TypeError, ValueError):
+ continue
+ result = compute_marginal_cost(ca, ea, cb, eb)
+ if result is None:
+ continue
+ entry = {"engine_a": a, "engine_b": b}
+ entry.update(result)
+ pairs.append(entry)
+ if not pairs:
+ return None
+ pairs.sort(key=lambda p: p["cost_per_avoided_error"])
+ return {"pairs": pairs}
+
+
+__all__ = [
+ "compute_marginal_cost",
+ "compute_marginal_cost_matrix",
+]
diff --git a/picarones/measurements/narrative/__init__.py b/picarones/measurements/narrative/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..12b7ce32122e4d51748a79c79e99220951cdcc81
--- /dev/null
+++ b/picarones/measurements/narrative/__init__.py
@@ -0,0 +1,78 @@
+"""Moteur narratif factuel — génération de synthèse déterministe.
+
+Extrait des faits saillants d'un ``BenchmarkResult`` et les rend en phrases
+courtes via des templates externes YAML. Aucun LLM : chaque nombre ou nom
+apparaissant dans la synthèse est traçable au JSON de résultats en entrée.
+
+API publique
+------------
+- ``Fact``, ``FactType``, ``FactImportance`` : modèle de données
+- ``DetectorRegistry`` : registre des détecteurs
+- ``detect_all(data)`` : applique le registre par défaut
+- ``select_facts(facts, max_facts=5)`` : arbitre de sélection
+- ``render_synthesis(facts, lang="fr")`` : rend en liste de phrases
+- ``build_synthesis(data, lang="fr")`` : pipeline complet (Sprint 4)
+"""
+
+from picarones.measurements.narrative.facts import (
+ Fact,
+ FactType,
+ FactImportance,
+ DetectorRegistry,
+ detect_all,
+ _DEFAULT_REGISTRY,
+)
+from picarones.measurements.narrative.arbiter import select_facts
+from picarones.measurements.narrative.renderer import (
+ render_fact,
+ render_synthesis,
+ extract_numbers,
+)
+from picarones.measurements.narrative.detectors import (
+ register_default_detectors,
+ DETECTORS_BY_TYPE,
+)
+
+
+# Activer le registre par défaut — Sprint 4
+register_default_detectors(_DEFAULT_REGISTRY)
+
+
+def build_synthesis(
+ benchmark_data: dict,
+ lang: str = "fr",
+ max_facts: int = 5,
+) -> dict:
+ """Pipeline complet : détection → arbitre → rendu.
+
+ Returns
+ -------
+ dict avec :
+ - ``sentences`` : liste de phrases prêtes à l'affichage
+ - ``facts`` : liste de dicts ``Fact.as_dict()`` pour traçabilité
+ - ``lang`` : langue utilisée
+ """
+ all_facts = detect_all(benchmark_data)
+ selected = select_facts(all_facts, max_facts=max_facts)
+ sentences = render_synthesis(selected, lang=lang)
+ return {
+ "sentences": sentences,
+ "facts": [f.as_dict() for f in selected],
+ "lang": lang,
+ }
+
+
+__all__ = [
+ "Fact",
+ "FactType",
+ "FactImportance",
+ "DetectorRegistry",
+ "detect_all",
+ "select_facts",
+ "render_fact",
+ "render_synthesis",
+ "extract_numbers",
+ "build_synthesis",
+ "register_default_detectors",
+ "DETECTORS_BY_TYPE",
+]
diff --git a/picarones/measurements/narrative/arbiter.py b/picarones/measurements/narrative/arbiter.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b0625d3afcf7e0ff670ea28667344b3d867060e
--- /dev/null
+++ b/picarones/measurements/narrative/arbiter.py
@@ -0,0 +1,227 @@
+"""Arbitre de sélection des faits narratifs.
+
+L'arbitre transforme une liste potentiellement longue de ``Fact`` détectés
+en une synthèse courte (3 à 5 phrases) adaptée à l'ouverture du rapport.
+
+Règles de sélection :
+ 1. Tri par importance décroissante, puis par type (ordre canonique).
+ 2. Non-redondance : un seul fait par moteur, sauf si les types sont
+ complémentaires (ex. ``GLOBAL_LEADER_CER`` + ``SIGNIFICANT_GAP``
+ concernent le leader mais apportent une information différente).
+ 3. Limite : au maximum ``max_facts`` faits retenus (défaut 5).
+ 4. Déterminisme : tri stable sur (−importance, ordre canonique du type,
+ noms des moteurs) pour garantir une sortie bit-à-bit identique.
+
+Les détecteurs peuvent émettre plusieurs faits du même type (ex. plusieurs
+``STATISTICAL_TIE`` si plusieurs groupes distincts). L'arbitre ne fusionne
+pas mais peut limiter par type.
+"""
+
+from __future__ import annotations
+
+from typing import Iterable, Sequence
+
+from picarones.measurements.narrative.facts import Fact, FactImportance, FactType
+
+
+# Ordre canonique des types pour départager les ex-aequo à l'importance égale.
+#
+# Politique éditoriale — exposée et documentée dans
+# ``docs/developer/narrative-engine.md`` § Editorial policy.
+# L'ordre encode quels faits sont remontés en priorité quand plusieurs ont
+# la même ``FactImportance``. Surchargeable via le paramètre ``type_order``
+# de ``select_facts`` sans patcher le code.
+#
+# Sprint 29 : la valeur n'est plus codée en dur ici — elle est dérivée du
+# registre déclaratif (``@register_detector(..., priority=N)``). Ajouter
+# un détecteur en bonne position se fait donc en éditant **un seul**
+# fichier (``detectors.py``) au lieu de quatre comme avant.
+def _compute_default_type_order() -> tuple[FactType, ...]:
+ # Import local pour éviter la dépendance circulaire au chargement.
+ from picarones.measurements.narrative.registry import default_type_order
+ order = default_type_order()
+ # Filet de sécurité : tant que les détecteurs n'ont pas été importés
+ # (cas des tests qui mockent le registre), on retombe sur un ordre
+ # canonique gravé pour ne pas planter ``select_facts``.
+ if not order:
+ return _FALLBACK_TYPE_ORDER
+ return order
+
+
+# Ordre statique gardé en mémoire : utilisé si jamais le registre est vide
+# au moment où ``arbiter`` est chargé (chargement partiel par les tests).
+_FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
+ FactType.GLOBAL_LEADER_CER,
+ FactType.STATISTICAL_TIE,
+ FactType.SIGNIFICANT_GAP,
+ FactType.STRATUM_WINNER,
+ # Sprint 46 — priority 45, juste après STRATUM_WINNER (40),
+ # avant STRATUM_COLLAPSE (50). La recommandation de stratification
+ # nuance directement les autres faits par strate.
+ FactType.STRATIFICATION_RECOMMENDED,
+ FactType.STRATUM_COLLAPSE,
+ FactType.ERROR_PROFILE_OUTLIER,
+ FactType.LLM_HALLUCINATION_FLAG,
+ FactType.ROBUSTNESS_FRAGILE,
+ FactType.PARETO_ALTERNATIVE,
+ FactType.SPEED_WINNER,
+ FactType.COST_OUTLIER,
+ FactType.CONFIDENCE_WARNING,
+ FactType.ENSEMBLE_OPPORTUNITY,
+ FactType.MEDIAN_MEAN_GAP_WARNING,
+ # Sprint 73 — priority 150, après MEDIAN_MEAN_GAP_WARNING (140).
+ # Le détecteur off-baseline donne le contexte historique, qui
+ # vient en fin de synthèse comme « note ».
+ FactType.ENGINE_OFF_BASELINE,
+ # Sprint 90 — priority 160, ferme la synthèse avec la mise en
+ # garde sur la reproductibilité. Une instabilité multi-runs
+ # discrédite toute autre conclusion sur ce moteur ; on la
+ # remonte en dernier pour ne pas l'enterrer.
+ FactType.ENGINE_UNSTABLE,
+ # Sprint 92 — priority 170, après ENGINE_UNSTABLE. La
+ # régression historique complète A.I.3 (off-baseline) en
+ # caractérisant la tendance : l'écart courant est-il une
+ # dégradation graduelle, une rupture brutale, ou un bruit ?
+ FactType.REGRESSION_IN_HISTORY,
+)
+
+
+# ``DEFAULT_TYPE_ORDER`` reste un attribut module accessible. On le calcule
+# à l'import si possible, sinon on prend le fallback ; ``select_facts``
+# recalcule à chaque appel pour absorber les ajouts de détecteurs après
+# l'import initial (extensions tierces).
+DEFAULT_TYPE_ORDER: tuple[FactType, ...] = _compute_default_type_order()
+
+# Alias rétro-compatible.
+_TYPE_ORDER = DEFAULT_TYPE_ORDER
+_TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)}
+
+
+# Paires de types qui ne sont PAS considérées comme redondantes même quand
+# elles concernent le même moteur. Tout autre couple → un seul fait retenu
+# pour le moteur (le plus important).
+_COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.SIGNIFICANT_GAP}),
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.SPEED_WINNER}),
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.CONFIDENCE_WARNING}),
+ frozenset({FactType.STATISTICAL_TIE, FactType.SPEED_WINNER}),
+ # Sprint 44 — l'avertissement d'asymétrie nuance le leader
+ # plutôt que de le doubler : on veut les deux phrases ensemble.
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.MEDIAN_MEAN_GAP_WARNING}),
+ # Sprint 46 — la recommandation de stratification est un méta-conseil
+ # qui s'ajoute au leader sans le contredire ; les deux peuvent
+ # cohabiter même quand ils concernent le même moteur.
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.STRATIFICATION_RECOMMENDED}),
+ # Sprint 90 — l'instabilité multi-runs nuance les conclusions
+ # sur le moteur leader sans les contredire : un moteur peut être
+ # leader **et** instable, et c'est précisément l'information
+ # critique pour la reproductibilité scientifique.
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.ENGINE_UNSTABLE}),
+ # Sprint 92 — la régression historique caractérise la tendance
+ # du leader : un leader peut être en régression progressive,
+ # info critique pour décider quand re-tester.
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.REGRESSION_IN_HISTORY}),
+ # Off-baseline (Sprint 73) dit "écart anormal sur ce corpus" ;
+ # regression-in-history (Sprint 92) dit "tendance dans le
+ # temps" — les deux se complètent sans se redonder.
+ frozenset({FactType.ENGINE_OFF_BASELINE, FactType.REGRESSION_IN_HISTORY}),
+})
+
+
+def _sort_key(fact: Fact, type_index: dict[FactType, int]) -> tuple:
+ """Clé de tri stable : importance (desc), type canonique, moteurs."""
+ return (
+ -int(fact.importance),
+ type_index.get(fact.type, len(type_index)),
+ tuple(sorted(fact.engines_involved)),
+ fact.stratum or "",
+ )
+
+
+def _is_redundant(candidate: Fact, kept: Fact) -> bool:
+ """Vrai si ``candidate`` apporte trop peu par rapport à ``kept``.
+
+ Deux faits sont redondants s'ils concernent exactement le même moteur,
+ ont le même type, et la même strate (s'il y en a une). Des types
+ différents sur le même moteur ne sont considérés redondants que s'ils
+ n'appartiennent pas aux paires complémentaires (ex : un leader peut
+ aussi être rapide ; c'est complémentaire).
+ """
+ if candidate.type == kept.type and candidate.stratum == kept.stratum:
+ return set(candidate.engines_involved) == set(kept.engines_involved)
+ if set(candidate.engines_involved) == set(kept.engines_involved):
+ pair = frozenset({candidate.type, kept.type})
+ return pair not in _COMPLEMENTARY_PAIRS
+ return False
+
+
+def _remove_contradictions(facts: list[Fact]) -> list[Fact]:
+ """Supprime les faits incohérents sur le plan statistique.
+
+ Règle centrale : si Nemenyi (post-hoc corrigé pour comparaisons multiples)
+ place deux moteurs dans le même groupe d'ex-aequo, alors un ``SIGNIFICANT_GAP``
+ basé sur Wilcoxon non corrigé entre ces deux mêmes moteurs est trompeur
+ pour un lecteur non statisticien. Nemenyi l'emporte.
+ """
+ tied_groups: list[set[str]] = []
+ for f in facts:
+ if f.type == FactType.STATISTICAL_TIE:
+ tied_groups.append(set(f.engines_involved))
+
+ def _is_contradicted(fact: Fact) -> bool:
+ if fact.type != FactType.SIGNIFICANT_GAP:
+ return False
+ pair = set(fact.engines_involved)
+ return any(pair <= group for group in tied_groups)
+
+ return [f for f in facts if not _is_contradicted(f)]
+
+
+def select_facts(
+ facts: Iterable[Fact],
+ max_facts: int = 5,
+ min_importance: FactImportance = FactImportance.MEDIUM,
+ type_order: Sequence[FactType] | None = None,
+) -> list[Fact]:
+ """Sélectionne la synthèse finale à partir d'une liste brute de faits.
+
+ Parameters
+ ----------
+ facts:
+ Liste de ``Fact`` brute issue de ``DetectorRegistry.run``.
+ max_facts:
+ Nombre maximal de faits retenus (défaut : 5).
+ min_importance:
+ Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut.
+ type_order:
+ Surcharge optionnelle de l'ordre canonique des types pour départager
+ les faits d'égale importance. ``None`` (défaut) utilise
+ ``DEFAULT_TYPE_ORDER``. Une institution peut passer son propre ordre
+ sans patcher le code — voir ``docs/developer/narrative-engine.md``.
+
+ Returns
+ -------
+ Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
+ """
+ if type_order is None:
+ # Sprint 29 — recalcul à chaque appel pour absorber les détecteurs
+ # enregistrés après l'import d'arbiter (extensions tierces qui
+ # font ``@register_detector`` dans un module utilisateur).
+ from picarones.measurements.narrative.registry import default_type_order
+ live_order = default_type_order() or _FALLBACK_TYPE_ORDER
+ type_index = {t: i for i, t in enumerate(live_order)}
+ else:
+ type_index = {t: i for i, t in enumerate(type_order)}
+
+ facts_list = [f for f in facts if int(f.importance) >= int(min_importance)]
+ facts_list = _remove_contradictions(facts_list)
+ ranked = sorted(facts_list, key=lambda f: _sort_key(f, type_index))
+
+ selected: list[Fact] = []
+ for fact in ranked:
+ if any(_is_redundant(fact, kept) for kept in selected):
+ continue
+ selected.append(fact)
+ if len(selected) >= max_facts:
+ break
+ return selected
diff --git a/picarones/measurements/narrative/detectors/__init__.py b/picarones/measurements/narrative/detectors/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc71abb34614697b6e11e8af7eba2ecd1503b1c1
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/__init__.py
@@ -0,0 +1,129 @@
+"""Détecteurs narratifs — package thématique (chantier 5 post-Sprint 97).
+
+Avant le chantier 5, ce module était un fichier monolithe de 1229 lignes
+(``narrative/detectors.py``) contenant 18 détecteurs. Pour aligner la
+structure de code avec celle du registre déclaratif (Sprint 29), les
+détecteurs ont été regroupés par **famille thématique** :
+
+- :mod:`ranking` — global leader, statistical tie, significant gap,
+ speed winner, median/mean gap warning (5 détecteurs)
+- :mod:`pareto` — Pareto alternative, cost outlier (2 détecteurs)
+- :mod:`stratum` — stratum winner / collapse, stratification
+ recommended (3 détecteurs)
+- :mod:`quality` — error profile outlier, LLM hallucination flag,
+ robustness fragile, confidence warning (4 détecteurs)
+- :mod:`history` — engine off baseline, engine unstable, regression
+ in history (3 détecteurs)
+- :mod:`ensemble` — ensemble opportunity (1 détecteur)
+
+Total : 18 détecteurs (≠ "12" mentionné dans CLAUDE.md historique —
+le chantier 5 corrige ce comptage).
+
+Rétrocompatibilité absolue
+--------------------------
+Tous les noms exportés par l'ancien fichier ``detectors.py``
+(``detect_*``, ``DETECTORS_BY_TYPE``, ``register_default_detectors``)
+restent accessibles via ``from picarones.measurements.narrative.detectors
+import ...``. Les tests Sprints 20, 23, 29, 36, 44, 46, 73 importent
+directement ces noms et continuent à fonctionner sans modification.
+
+L'enregistrement automatique des détecteurs via ``@register_detector``
+se fait à l'import de ce package — chaque sous-module est importé ici
+en cascade.
+"""
+
+from __future__ import annotations
+
+# Imports en cascade des 6 sous-modules : déclenche l'enregistrement
+# automatique via les décorateurs ``@register_detector`` au chargement.
+from picarones.measurements.narrative.detectors.ranking import (
+ detect_global_leader_cer,
+ detect_median_mean_gap_warning,
+ detect_significant_gap,
+ detect_speed_winner,
+ detect_statistical_tie,
+)
+from picarones.measurements.narrative.detectors.pareto import (
+ detect_cost_outlier,
+ detect_pareto_alternative,
+)
+from picarones.measurements.narrative.detectors.stratum import (
+ detect_stratification_recommended,
+ detect_stratum_collapse,
+ detect_stratum_winner,
+)
+from picarones.measurements.narrative.detectors.quality import (
+ detect_confidence_warning,
+ detect_error_profile_outlier,
+ detect_llm_hallucination_flag,
+ detect_robustness_fragile,
+)
+from picarones.measurements.narrative.detectors.history import (
+ detect_engine_off_baseline,
+ detect_engine_unstable,
+ detect_regression_in_history,
+)
+from picarones.measurements.narrative.detectors.ensemble import (
+ detect_ensemble_opportunity,
+)
+
+# Snapshot du registre + helper d'enregistrement legacy — déplacés
+# verbatim depuis l'ancien ``detectors.py`` (lignes 1193-1229).
+from picarones.measurements.narrative.facts import DetectorFn, FactType
+from picarones.measurements.narrative.registry import (
+ iter_detectors as _iter_detectors,
+ populate_legacy_registry as _populate_legacy_registry,
+)
+
+
+def _build_detectors_by_type() -> dict[FactType, DetectorFn]:
+ """Snapshot du registre déclaratif vers un dict ``{type: fn}``."""
+ return {entry.fact_type: entry.fn for entry in _iter_detectors()}
+
+
+# Vue figée à l'import — utile pour les tests qui parcourent les types
+# enregistrés sans instancier un ``DetectorRegistry``.
+DETECTORS_BY_TYPE = _build_detectors_by_type()
+
+
+def register_default_detectors(registry) -> None:
+ """Enregistre les détecteurs du registre déclaratif dans un
+ ``DetectorRegistry`` historique.
+
+ Sprint 29 : la source de vérité est maintenant le décorateur
+ ``@register_detector`` ; cette fonction se contente de pousser
+ le contenu du registre vers l'objet ``DetectorRegistry`` que les
+ consommateurs externes (``DetectorRegistry.run``) instancient.
+ """
+ _populate_legacy_registry(registry)
+
+
+__all__ = [
+ # ranking
+ "detect_global_leader_cer",
+ "detect_median_mean_gap_warning",
+ "detect_significant_gap",
+ "detect_speed_winner",
+ "detect_statistical_tie",
+ # pareto
+ "detect_cost_outlier",
+ "detect_pareto_alternative",
+ # stratum
+ "detect_stratification_recommended",
+ "detect_stratum_collapse",
+ "detect_stratum_winner",
+ # quality
+ "detect_confidence_warning",
+ "detect_error_profile_outlier",
+ "detect_llm_hallucination_flag",
+ "detect_robustness_fragile",
+ # history
+ "detect_engine_off_baseline",
+ "detect_engine_unstable",
+ "detect_regression_in_history",
+ # ensemble
+ "detect_ensemble_opportunity",
+ # legacy
+ "DETECTORS_BY_TYPE",
+ "register_default_detectors",
+]
diff --git a/picarones/measurements/narrative/detectors/_helpers.py b/picarones/measurements/narrative/detectors/_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..ea669174f360b5295814d52e346b6a8491504606
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/_helpers.py
@@ -0,0 +1,55 @@
+"""Helpers internes partagés par les détecteurs narratifs.
+
+Chantier 5 du plan d'évolution post-Sprint 97 — découpage de
+``picarones/core/narrative/detectors.py`` (1229 lignes, 18 détecteurs)
+en 6 sous-modules thématiques + ce module d'helpers communs.
+
+Ces fonctions étaient privées (préfixe ``_``) au module historique.
+Elles sont conservées telles quelles ici ; les sous-modules les
+importent.
+"""
+
+from __future__ import annotations
+
+from typing import Optional
+
+
+def _engines_summary(data: dict) -> list[dict]:
+ """Accès normalisé à la liste des résumés moteur."""
+ return data.get("engines", []) or []
+
+
+def _engine_by_name(data: dict, name: str) -> Optional[dict]:
+ for e in _engines_summary(data):
+ if e.get("name") == name:
+ return e
+ return None
+
+
+def _n_docs(data: dict) -> int:
+ meta = data.get("meta", {}) or {}
+ return int(meta.get("document_count") or 0)
+
+
+def _mean_duration_per_engine(data: dict) -> dict[str, float]:
+ """Retourne ``{engine_name: mean_duration_seconds}`` quand disponible.
+
+ Lit ``benchmark_data["engines"][i]["mean_duration"]`` (renseigné
+ par le runner depuis ``durations_by_engine`` Sprint 4). Filtre les
+ durées non-numériques.
+ """
+ out: dict[str, float] = {}
+ for e in _engines_summary(data):
+ name = e.get("name")
+ if not name:
+ continue
+ dur = e.get("mean_duration")
+ if dur is None:
+ continue
+ try:
+ dur_f = float(dur)
+ except (TypeError, ValueError):
+ continue
+ if dur_f > 0:
+ out[name] = dur_f
+ return out
diff --git a/picarones/measurements/narrative/detectors/ensemble.py b/picarones/measurements/narrative/detectors/ensemble.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a34f597d20c57ca0b79e481cd18832b982bc062
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/ensemble.py
@@ -0,0 +1,96 @@
+"""Détecteurs narratifs liés à l'*opportunité d'ensemble inter-moteurs* (chantier 5).
+
+1 détecteur déplacé depuis ``narrative/detectors.py`` :
+
+- :func:`detect_ensemble_opportunity` (Sprint 36)
+"""
+
+from __future__ import annotations
+
+import statistics as _stats
+from typing import Optional
+
+from picarones.measurements.narrative.facts import Fact, FactImportance, FactType
+from picarones.measurements.narrative.registry import register_detector
+
+from picarones.measurements.narrative.detectors._helpers import (
+ _engine_by_name,
+ _engines_summary,
+ _n_docs,
+)
+
+
+@register_detector(
+ FactType.ENSEMBLE_OPPORTUNITY,
+ priority=130,
+ importance=FactImportance.MEDIUM,
+)
+def detect_ensemble_opportunity(benchmark_data: dict) -> list[Fact]:
+ """Deux moteurs très complémentaires : un voting majoritaire entre eux
+ pourrait améliorer significativement le CER token-level.
+
+ Lit la structure ``inter_engine_analysis`` produite par le runner
+ (Sprint 35-36) et déclenche si la fraction d'erreurs du meilleur
+ moteur récupérable par un ensemble dépasse 25 %.
+
+ L'importance monte à ``HIGH`` quand le gap relatif dépasse 50 %
+ (ensemble franchement profitable) — sinon reste à ``MEDIUM``.
+ """
+ iea = benchmark_data.get("inter_engine_analysis") or {}
+ comp = iea.get("complementarity") or {}
+ if not comp:
+ return []
+
+ relative_gap = float(comp.get("relative_gap") or 0.0)
+ if relative_gap < 0.25:
+ # En deçà de 25 %, l'ensemble n'apporterait quasi rien — on ne
+ # remonte pas le fait pour ne pas bruiter la synthèse.
+ return []
+
+ best_engine = comp.get("best_engine") or ""
+ if not best_engine:
+ return []
+
+ payload: dict = {
+ "best_engine": best_engine,
+ "best_recall_pct": round(float(comp.get("best_single_recall") or 0.0) * 100, 2),
+ "oracle_recall_pct": round(float(comp.get("oracle_recall") or 0.0) * 100, 2),
+ "absolute_gap_pct": round(float(comp.get("absolute_gap") or 0.0) * 100, 2),
+ "relative_gap_pct": round(relative_gap * 100, 1),
+ "doc_count": int(comp.get("doc_count") or 0),
+ }
+
+ # Paire la plus complémentaire — la divergence taxonomique, quand
+ # disponible, fournit deux moteurs « candidats naturels ». Sinon on
+ # tombe sur le best + le second-best en recall individuel.
+ div = iea.get("taxonomy_divergence") or {}
+ pair = div.get("max_pair") or []
+ pair_a = ""
+ pair_b = ""
+ divergence_value: Optional[float] = None
+ if pair and len(pair) >= 3 and isinstance(pair[2], (int, float)) and pair[2] > 0:
+ pair_a, pair_b, divergence_value = str(pair[0]), str(pair[1]), float(pair[2])
+ else:
+ # Fallback : best engine + second-best engine par recall individuel
+ per_engine = comp.get("per_engine_recall") or {}
+ if len(per_engine) >= 2:
+ ranked = sorted(per_engine.items(), key=lambda kv: kv[1], reverse=True)
+ pair_a, pair_b = ranked[0][0], ranked[1][0]
+
+ payload["pair_a"] = pair_a
+ payload["pair_b"] = pair_b
+ payload["divergence"] = round(divergence_value, 3) if divergence_value is not None else 0.0
+ payload["divergence_metric"] = (div.get("metric") or "js")
+
+ importance = (
+ FactImportance.HIGH if relative_gap >= 0.5 else FactImportance.MEDIUM
+ )
+ engines_involved: tuple[str, ...] = (
+ (pair_a, pair_b) if pair_a and pair_b else (best_engine,)
+ )
+ return [Fact(
+ type=FactType.ENSEMBLE_OPPORTUNITY,
+ importance=importance,
+ payload=payload,
+ engines_involved=engines_involved,
+ )]
diff --git a/picarones/measurements/narrative/detectors/history.py b/picarones/measurements/narrative/detectors/history.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9bf2a7912b176c6c1fe317223e5c87d0701ad19
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/history.py
@@ -0,0 +1,280 @@
+"""Détecteurs narratifs liés à *l'historique SQLite + multi-runs* (chantier 5).
+
+3 détecteurs déplacés depuis ``narrative/detectors.py`` :
+
+- :func:`detect_engine_off_baseline` (Sprint 73)
+- :func:`detect_engine_unstable` (Sprint 90)
+- :func:`detect_regression_in_history` (Sprint 92)
+"""
+
+from __future__ import annotations
+
+import statistics as _stats
+from typing import Optional
+
+from picarones.measurements.narrative.facts import Fact, FactImportance, FactType
+from picarones.measurements.narrative.registry import register_detector
+
+from picarones.measurements.narrative.detectors._helpers import (
+ _engine_by_name,
+ _engines_summary,
+ _n_docs,
+)
+
+
+@register_detector(
+ FactType.ENGINE_OFF_BASELINE,
+ priority=150,
+ importance=FactImportance.MEDIUM,
+)
+def detect_engine_off_baseline(benchmark_data: dict) -> list[Fact]:
+ """Émet un Fact pour chaque moteur dont le CER courant s'écarte
+ significativement de sa moyenne historique sur le **même corpus**.
+
+ Lit ``benchmark_data["baseline_comparisons"]`` (liste de dicts
+ produits par ``compute_engine_baseline`` du module
+ ``baseline_comparison`` Sprint 73). Si la clé est absente ou
+ vide, le détecteur reste silencieux — typiquement le cas quand
+ aucun historique SQLite n'a été chargé.
+
+ Garde-fous :
+
+ - Si ``n_runs < 5`` (déjà filtré par ``compute_engine_baseline``
+ qui retourne ``None`` dans ce cas).
+ - Si ``relative_delta`` n'est pas calculable (baseline = 0).
+ - Importance ``HIGH`` si ``|relative_delta| ≥ 50 %``, sinon
+ ``MEDIUM``.
+ """
+ comparisons = benchmark_data.get("baseline_comparisons") or []
+ if not isinstance(comparisons, (list, tuple)):
+ return []
+ facts: list[Fact] = []
+ for comp in comparisons:
+ if not isinstance(comp, dict):
+ continue
+ if not comp.get("off_baseline"):
+ continue
+ rel = comp.get("relative_delta")
+ if rel is None:
+ continue
+ engine = comp.get("engine_name")
+ cer_current = comp.get("cer_current")
+ cer_hist_mean = comp.get("cer_historical_mean")
+ n_runs = comp.get("n_runs")
+ if engine is None or cer_current is None or cer_hist_mean is None:
+ continue
+ importance = (
+ FactImportance.HIGH if abs(float(rel)) >= 0.50
+ else FactImportance.MEDIUM
+ )
+ facts.append(Fact(
+ type=FactType.ENGINE_OFF_BASELINE,
+ importance=importance,
+ payload={
+ "engine": engine,
+ "cer_current_pct": round(float(cer_current) * 100, 2),
+ "cer_historical_mean_pct": round(
+ float(cer_hist_mean) * 100, 2,
+ ),
+ "n_runs": int(n_runs or 0),
+ "relative_delta_pct": round(float(rel) * 100, 1),
+ "direction": "higher" if float(rel) > 0 else "lower",
+ },
+ engines_involved=(engine,),
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.ENGINE_UNSTABLE,
+ priority=160,
+ importance=FactImportance.HIGH,
+)
+def detect_engine_unstable(benchmark_data: dict) -> list[Fact]:
+ """Émet un Fact pour chaque moteur dont la stabilité multi-runs
+ est insuffisante (Sprint 83 + 90).
+
+ Lit ``benchmark_data["multirun_stability"]`` : liste de dicts
+ avec ``engine_name`` + champs de ``compute_multirun_stability``
+ (cer_cv, identical_run_rate, n_runs, etc.). Si la clé est
+ absente ou vide, le détecteur reste silencieux — typiquement
+ le cas quand l'utilisateur n'a pas exécuté `--repeats N`.
+
+ Garde-fous :
+
+ - ``n_runs ≥ 2`` (déjà filtré par
+ ``compute_multirun_stability`` qui retourne ``None``).
+ - Déclenche si ``cer_cv > 0.10`` (variance relative > 10 % du
+ CER moyen) **ou** ``identical_run_rate < 0.50`` (moins
+ d'une paire de runs sur deux est identique).
+ - Importance ``HIGH`` (l'instabilité discrédite les
+ conclusions).
+ """
+ stabilities = benchmark_data.get("multirun_stability") or []
+ if not isinstance(stabilities, (list, tuple)):
+ return []
+ facts: list[Fact] = []
+ for stab in stabilities:
+ if not isinstance(stab, dict):
+ continue
+ engine = stab.get("engine_name") or stab.get("engine")
+ if not engine:
+ continue
+ n_runs = stab.get("n_runs")
+ if not isinstance(n_runs, int) or n_runs < 2:
+ continue
+ cer_cv = stab.get("cer_cv")
+ identical_rate = stab.get("identical_run_rate")
+ # Critères de déclenchement
+ cv_high = (
+ isinstance(cer_cv, (int, float)) and float(cer_cv) > 0.10
+ )
+ runs_diverge = (
+ isinstance(identical_rate, (int, float))
+ and float(identical_rate) < 0.50
+ )
+ if not (cv_high or runs_diverge):
+ continue
+ payload: dict = {
+ "engine": engine,
+ "n_runs": int(n_runs),
+ }
+ if isinstance(cer_cv, (int, float)):
+ payload["cer_cv"] = float(cer_cv)
+ payload["cer_cv_pct"] = round(float(cer_cv) * 100, 1)
+ if isinstance(identical_rate, (int, float)):
+ payload["identical_run_rate"] = float(identical_rate)
+ payload["identical_run_rate_pct"] = round(
+ float(identical_rate) * 100, 1,
+ )
+ # Champs additionnels pour la phrase de synthèse
+ cer_mean = stab.get("cer_mean")
+ cer_stdev = stab.get("cer_stdev")
+ if isinstance(cer_mean, (int, float)):
+ payload["cer_mean_pct"] = round(float(cer_mean) * 100, 2)
+ if isinstance(cer_stdev, (int, float)):
+ payload["cer_stdev_pct"] = round(float(cer_stdev) * 100, 2)
+ n_distinct = stab.get("n_distinct_outputs")
+ if isinstance(n_distinct, int):
+ payload["n_distinct_outputs"] = int(n_distinct)
+ facts.append(Fact(
+ type=FactType.ENGINE_UNSTABLE,
+ importance=FactImportance.HIGH,
+ payload=payload,
+ engines_involved=(engine,),
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.REGRESSION_IN_HISTORY,
+ priority=170,
+ importance=FactImportance.MEDIUM,
+)
+def detect_regression_in_history(benchmark_data: dict) -> list[Fact]:
+ """Émet un Fact pour chaque moteur dont l'historique montre
+ une dégradation : pente positive significative ou rupture
+ brutale (Sprint 92).
+
+ Lit ``benchmark_data["longitudinal_trends"]`` : liste de
+ dicts produits par ``compute_corpus_longitudinal`` du module
+ ``longitudinal``. Si la clé est absente ou vide, le
+ détecteur reste silencieux — typiquement le cas quand
+ aucun historique n'a été chargé ou que la série est trop
+ courte.
+
+ Garde-fous :
+
+ - ``n_runs ≥ 3`` (déjà filtré par
+ ``compute_engine_longitudinal``).
+ - Déclenche si **soit** ``trend.slope`` traduit une
+ régression d'au moins ``slope_threshold`` (en CER/jour,
+ défaut équivalent à +1 point CER sur 365 jours), **soit**
+ ``change_point.delta > change_threshold`` (défaut
+ 0.01 = +1 point de CER d'un segment à l'autre).
+ - Importance ``HIGH`` si la dégradation cumulée
+ ``absolute_delta`` ≥ 5 points de CER.
+ """
+ trends = benchmark_data.get("longitudinal_trends") or []
+ if not isinstance(trends, (list, tuple)):
+ return []
+ slope_threshold = (
+ 0.01 / 365.0 # +1 point de CER sur 365 jours minimum
+ )
+ change_threshold = 0.01
+ facts: list[Fact] = []
+ for entry in trends:
+ if not isinstance(entry, dict):
+ continue
+ engine = entry.get("engine_name")
+ if not engine:
+ continue
+ n_runs = entry.get("n_runs")
+ if not isinstance(n_runs, int) or n_runs < 3:
+ continue
+ trend = entry.get("trend") or {}
+ cp = entry.get("change_point")
+ slope = trend.get("slope")
+ slope_high = (
+ isinstance(slope, (int, float))
+ and float(slope) > slope_threshold
+ )
+ cp_high = (
+ isinstance(cp, dict)
+ and isinstance(cp.get("delta"), (int, float))
+ and float(cp["delta"]) > change_threshold
+ )
+ if not (slope_high or cp_high):
+ continue
+ absolute_delta = entry.get("absolute_delta") or 0.0
+ importance = (
+ FactImportance.HIGH
+ if isinstance(absolute_delta, (int, float))
+ and abs(float(absolute_delta)) >= 0.05
+ else FactImportance.MEDIUM
+ )
+ payload: dict = {
+ "engine": engine,
+ "n_runs": int(n_runs),
+ "absolute_delta_pct": round(
+ float(absolute_delta) * 100, 2,
+ ) if isinstance(absolute_delta, (int, float)) else 0.0,
+ "first_cer_pct": round(
+ float(entry.get("first_cer") or 0.0) * 100, 2,
+ ),
+ "last_cer_pct": round(
+ float(entry.get("last_cer") or 0.0) * 100, 2,
+ ),
+ }
+ if slope_high:
+ payload["slope_per_year_pct"] = round(
+ float(slope) * 365 * 100, 2,
+ )
+ payload["r_squared"] = round(
+ float(trend.get("r_squared") or 0.0), 3,
+ )
+ payload["pattern"] = "trend"
+ if cp_high:
+ payload["change_point_timestamp"] = str(
+ cp.get("timestamp") or "?",
+ )
+ payload["change_delta_pct"] = round(
+ float(cp["delta"]) * 100, 2,
+ )
+ payload["mean_before_pct"] = round(
+ float(cp.get("mean_before") or 0.0) * 100, 2,
+ )
+ payload["mean_after_pct"] = round(
+ float(cp.get("mean_after") or 0.0) * 100, 2,
+ )
+ # Si on a aussi une rupture, le pattern domine
+ payload["pattern"] = (
+ "trend_and_change_point" if slope_high else "change_point"
+ )
+ facts.append(Fact(
+ type=FactType.REGRESSION_IN_HISTORY,
+ importance=importance,
+ payload=payload,
+ engines_involved=(engine,),
+ ))
+ return facts
diff --git a/picarones/measurements/narrative/detectors/pareto.py b/picarones/measurements/narrative/detectors/pareto.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f0a73db2eac796775159585d04931a710d29bc8
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/pareto.py
@@ -0,0 +1,136 @@
+"""Détecteurs narratifs orientés *coût/performance Pareto* (chantier 5).
+
+2 détecteurs déplacés depuis ``narrative/detectors.py`` :
+
+- :func:`detect_pareto_alternative` (Sprint 19) — alternative coût/qualité
+- :func:`detect_cost_outlier` (Sprint 19) — moteur dont le coût est aberrant
+"""
+
+from __future__ import annotations
+
+import statistics as _stats
+from typing import Optional
+
+from picarones.measurements.narrative.facts import Fact, FactImportance, FactType
+from picarones.measurements.narrative.registry import register_detector
+
+from picarones.measurements.narrative.detectors._helpers import (
+ _engine_by_name,
+ _engines_summary,
+ _n_docs,
+)
+
+
+@register_detector(
+ FactType.PARETO_ALTERNATIVE,
+ priority=90,
+ importance=FactImportance.HIGH,
+)
+def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]:
+ """Moteur Pareto-dominant différent du leader CER.
+
+ Lit ``benchmark_data["pareto"]["cost"]`` (Sprint 19) et émet un Fact si
+ la frontière contient un moteur autre que le leader CER, pour souligner
+ l'existence d'un compromis coût/qualité intéressant.
+ """
+ pareto = (benchmark_data.get("pareto") or {}).get("cost") or {}
+ front = pareto.get("front") or []
+ points = pareto.get("points") or []
+ if len(front) < 2:
+ return []
+
+ ranking = benchmark_data.get("ranking") or []
+ if not ranking:
+ return []
+ leader = ranking[0].get("engine")
+
+ # Le moteur le moins cher sur le front (hors leader)
+ alt: Optional[dict] = None
+ for p in points:
+ if p.get("engine") == leader:
+ continue
+ if p.get("engine") not in front:
+ continue
+ if alt is None or float(p.get("cost") or 0.0) < float(alt.get("cost") or 0.0):
+ alt = p
+ if alt is None:
+ return []
+
+ leader_point = next((p for p in points if p.get("engine") == leader), None)
+ if leader_point is None:
+ return []
+
+ alt_cer = float(alt.get("cer") or 0.0)
+ alt_cost = float(alt.get("cost") or 0.0)
+ leader_cer = float(leader_point.get("cer") or 0.0)
+ leader_cost = float(leader_point.get("cost") or 0.0)
+ if alt_cost >= leader_cost or alt_cost <= 0:
+ return [] # pas réellement moins cher — pas intéressant à remonter
+
+ return [Fact(
+ type=FactType.PARETO_ALTERNATIVE,
+ importance=FactImportance.HIGH,
+ payload={
+ "engine": alt["engine"],
+ "leader": leader,
+ "cer": round(alt_cer, 4),
+ "cer_pct": round(alt_cer * 100, 2),
+ "cost": round(alt_cost, 2),
+ "leader_cer": round(leader_cer, 4),
+ "leader_cer_pct": round(leader_cer * 100, 2),
+ "leader_cost": round(leader_cost, 2),
+ "cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None,
+ "delta_cer_pct": round((alt_cer - leader_cer) * 100, 2),
+ # Unité du coût — propagée pour traçabilité (le template ne
+ # hardcode plus "1000 pages").
+ "cost_unit_pages": 1000,
+ },
+ engines_involved=(alt["engine"],),
+ )]
+
+
+@register_detector(
+ FactType.COST_OUTLIER,
+ priority=110,
+ importance=FactImportance.MEDIUM,
+)
+def detect_cost_outlier(benchmark_data: dict) -> list[Fact]:
+ """Moteur dont le coût est très disproportionné par rapport à son apport.
+
+ Flag un moteur dont le coût ≥ 5× la médiane ET qui n'est pas sur le
+ front Pareto (donc dominé par moins cher OU meilleur CER).
+ """
+ pareto = (benchmark_data.get("pareto") or {}).get("cost") or {}
+ points = pareto.get("points") or []
+ front = set(pareto.get("front") or [])
+ if len(points) < 3:
+ return []
+
+ costs = [float(p["cost"]) for p in points if p.get("cost") is not None]
+ if not costs:
+ return []
+ median_cost = _stats.median(costs)
+ if median_cost <= 0:
+ return []
+
+ facts: list[Fact] = []
+ for p in points:
+ c = float(p.get("cost") or 0.0)
+ if c < 5.0 * median_cost:
+ continue
+ if p["engine"] in front:
+ continue # sur le front → coût justifié par une qualité unique
+ facts.append(Fact(
+ type=FactType.COST_OUTLIER,
+ importance=FactImportance.MEDIUM,
+ payload={
+ "engine": p["engine"],
+ "cost": round(c, 2),
+ "median_cost": round(median_cost, 2),
+ "ratio_to_median": round(c / median_cost, 1),
+ "cer_pct": round(float(p.get("cer") or 0.0) * 100, 2),
+ "cost_unit_pages": 1000,
+ },
+ engines_involved=(p["engine"],),
+ ))
+ return facts
diff --git a/picarones/measurements/narrative/detectors/quality.py b/picarones/measurements/narrative/detectors/quality.py
new file mode 100644
index 0000000000000000000000000000000000000000..7c2e49dfee0d277a0fef4201757a11bf3cc53e86
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/quality.py
@@ -0,0 +1,251 @@
+"""Détecteurs narratifs liés à la *qualité texte / fiabilité* (chantier 5).
+
+4 détecteurs déplacés depuis ``narrative/detectors.py`` :
+
+- :func:`detect_error_profile_outlier` (Sprint 4)
+- :func:`detect_llm_hallucination_flag` (Sprint 4)
+- :func:`detect_robustness_fragile` (Sprint 4)
+- :func:`detect_confidence_warning` (Sprint 4)
+"""
+
+from __future__ import annotations
+
+import statistics as _stats
+from typing import Optional
+
+from picarones.measurements.narrative.facts import Fact, FactImportance, FactType
+from picarones.measurements.narrative.registry import register_detector
+
+from picarones.measurements.narrative.detectors._helpers import (
+ _engine_by_name,
+ _engines_summary,
+ _n_docs,
+)
+
+
+@register_detector(
+ FactType.ERROR_PROFILE_OUTLIER,
+ priority=60,
+ importance=FactImportance.MEDIUM,
+)
+def detect_error_profile_outlier(benchmark_data: dict) -> list[Fact]:
+ """Moteur au profil taxonomique atypique.
+
+ Émet un Fact si, pour un moteur et une classe d'erreur, la part relative
+ est au moins 2× plus élevée que la médiane des autres moteurs (et > 15 %
+ du total pour éviter les strates marginales).
+ """
+ engines = _engines_summary(benchmark_data)
+ # {engine: {class_name: proportion}}
+ profiles: dict[str, dict[str, float]] = {}
+ for e in engines:
+ tax = e.get("aggregated_taxonomy") or {}
+ distribution = tax.get("distribution") or tax.get("proportions") or {}
+ if not distribution:
+ continue
+ profiles[e["name"]] = {k: float(v) for k, v in distribution.items()}
+ if len(profiles) < 2:
+ return []
+
+ # Collecter toutes les classes rencontrées
+ all_classes: set[str] = set()
+ for p in profiles.values():
+ all_classes.update(p.keys())
+
+ facts: list[Fact] = []
+ for cls in all_classes:
+ values = [(name, p.get(cls, 0.0)) for name, p in profiles.items()]
+ props = [v for _, v in values]
+ if not props:
+ continue
+ median_prop = _stats.median(props)
+ for name, v in values:
+ if v < 0.15: # trop marginal pour être notable
+ continue
+ if median_prop <= 0:
+ continue
+ if v >= 2.0 * median_prop:
+ facts.append(Fact(
+ type=FactType.ERROR_PROFILE_OUTLIER,
+ importance=FactImportance.HIGH,
+ payload={
+ "engine": name,
+ "error_class": cls,
+ "proportion": round(v, 4),
+ "proportion_pct": round(v * 100, 1),
+ "median_proportion": round(median_prop, 4),
+ "median_proportion_pct": round(median_prop * 100, 1),
+ "ratio_to_median": round(v / median_prop, 2) if median_prop else None,
+ },
+ engines_involved=(name,),
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.LLM_HALLUCINATION_FLAG,
+ priority=70,
+ importance=FactImportance.HIGH,
+)
+def detect_llm_hallucination_flag(benchmark_data: dict) -> list[Fact]:
+ """LLM/VLM au taux d'hallucination notablement élevé.
+
+ Déclenché si ``hallucinating_doc_rate`` > 30 % OU ``anchor_score_mean`` < 0,6
+ pour un moteur dont le champ ``is_pipeline`` ou ``is_vlm`` est ``True``.
+ """
+ facts: list[Fact] = []
+ for e in _engines_summary(benchmark_data):
+ agg = e.get("aggregated_hallucination") or {}
+ if not agg:
+ continue
+ rate = agg.get("hallucinating_doc_rate")
+ anchor = agg.get("anchor_score_mean")
+ length_ratio = agg.get("length_ratio_mean")
+ # Signal seulement si c'est un pipeline LLM ou un VLM
+ is_llm = bool(e.get("is_pipeline")) or bool(e.get("is_vlm"))
+ if not is_llm:
+ continue
+
+ flagged = False
+ reasons = []
+ if rate is not None and float(rate) > 0.30:
+ flagged = True
+ reasons.append("taux de documents hallucinés")
+ if anchor is not None and float(anchor) < 0.60:
+ flagged = True
+ reasons.append("ancrage faible")
+ if length_ratio is not None and float(length_ratio) > 1.30:
+ flagged = True
+ reasons.append("sortie anormalement longue")
+ if not flagged:
+ continue
+
+ facts.append(Fact(
+ type=FactType.LLM_HALLUCINATION_FLAG,
+ importance=FactImportance.HIGH,
+ payload={
+ "engine": e["name"],
+ "hallucinating_rate": round(float(rate or 0.0), 4),
+ "hallucinating_rate_pct": round(float(rate or 0.0) * 100, 1),
+ "anchor_score": round(float(anchor), 3) if anchor is not None else None,
+ "length_ratio": round(float(length_ratio), 3) if length_ratio is not None else None,
+ "reasons": reasons,
+ "reasons_list": ", ".join(reasons),
+ },
+ engines_involved=(e["name"],),
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.ROBUSTNESS_FRAGILE,
+ priority=80,
+ importance=FactImportance.MEDIUM,
+)
+def detect_robustness_fragile(benchmark_data: dict) -> list[Fact]:
+ """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou.
+
+ Activé si les données de robustesse sont embarquées dans
+ ``benchmark_data["robustness"]`` (hors scope du benchmark classique,
+ produit par ``picarones robustness`` et injecté optionnellement).
+ """
+ robustness = benchmark_data.get("robustness")
+ if not robustness:
+ return []
+
+ facts: list[Fact] = []
+ curves = robustness.get("curves") or robustness.get("engines") or []
+ # Structure attendue : [{engine, degradation_type, points: [{level, cer}]}]
+ # Flag : CER à niveau max > 3× CER au niveau min.
+ for entry in curves:
+ engine = entry.get("engine")
+ dtype = entry.get("degradation_type")
+ points = entry.get("points") or []
+ if not engine or not points or len(points) < 2:
+ continue
+ try:
+ sorted_pts = sorted(points, key=lambda p: float(p["level"]))
+ except (KeyError, TypeError, ValueError):
+ continue
+ first, last = sorted_pts[0], sorted_pts[-1]
+ c0 = float(first.get("cer") or 0.0)
+ c1 = float(last.get("cer") or 0.0)
+ if c0 <= 0.01: # éviter division par quasi-zéro
+ continue
+ if c1 >= 3.0 * c0 and c1 > 0.15:
+ facts.append(Fact(
+ type=FactType.ROBUSTNESS_FRAGILE,
+ importance=FactImportance.HIGH,
+ payload={
+ "engine": engine,
+ "degradation": dtype,
+ "cer_baseline": round(c0, 4),
+ "cer_baseline_pct": round(c0 * 100, 1),
+ "cer_degraded": round(c1, 4),
+ "cer_degraded_pct": round(c1 * 100, 1),
+ "ratio": round(c1 / c0, 1),
+ "level_max": float(last.get("level") or 0),
+ },
+ engines_involved=(engine,),
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.CONFIDENCE_WARNING,
+ priority=120,
+ importance=FactImportance.MEDIUM,
+)
+def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
+ """Intervalle de confiance large → classement peu fiable.
+
+ Déclenché si, pour le leader ou le runner-up, la largeur de l'IC 95 %
+ est plus du triple de l'écart |leader − runner-up| OU > 5 points de CER.
+ """
+ stats = benchmark_data.get("statistics", {}) or {}
+ cis = stats.get("bootstrap_cis") or []
+ if len(cis) < 2:
+ return []
+
+ ranking = benchmark_data.get("ranking") or []
+ valid = [r for r in ranking if r.get("mean_cer") is not None]
+ if len(valid) < 2:
+ return []
+
+ by_name = {c["engine"]: c for c in cis if "engine" in c}
+ leader = valid[0]["engine"]
+ runner_up = valid[1]["engine"]
+ leader_ci = by_name.get(leader)
+ runner_ci = by_name.get(runner_up)
+ if not leader_ci or not runner_ci:
+ return []
+
+ gap = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"]))
+ facts: list[Fact] = []
+ for engine_name, ci in ((leader, leader_ci), (runner_up, runner_ci)):
+ lo = float(ci.get("ci_lower") or 0.0)
+ hi = float(ci.get("ci_upper") or 0.0)
+ width = hi - lo
+ wide_vs_gap = gap > 0 and width > 3.0 * gap
+ wide_absolute = width > 0.05
+ if wide_vs_gap or wide_absolute:
+ facts.append(Fact(
+ type=FactType.CONFIDENCE_WARNING,
+ importance=FactImportance.MEDIUM,
+ payload={
+ "engine": engine_name,
+ "ci_lower": round(lo, 4),
+ "ci_upper": round(hi, 4),
+ "ci_width": round(width, 4),
+ "ci_width_pct": round(width * 100, 2),
+ "mean_cer": round(float(ci.get("mean") or 0.0), 4),
+ "mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2),
+ "gap_to_runner_up_pct": round(gap * 100, 2),
+ # Niveau de confiance des bornes — propagé pour traçabilité
+ # anti-hallucination (le template ne hardcode plus "95 %").
+ "confidence_level": 95,
+ },
+ engines_involved=(engine_name,),
+ ))
+ break # un seul avertissement suffit
+ return facts
diff --git a/picarones/measurements/narrative/detectors/ranking.py b/picarones/measurements/narrative/detectors/ranking.py
new file mode 100644
index 0000000000000000000000000000000000000000..4910591229a0809128025610dead58ad305011b7
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/ranking.py
@@ -0,0 +1,280 @@
+"""Détecteurs narratifs orientés *classement* (chantier 5).
+
+5 détecteurs déplacés depuis ``narrative/detectors.py`` :
+
+- :func:`detect_global_leader_cer` (Sprint 4)
+- :func:`detect_statistical_tie` (Sprint 18)
+- :func:`detect_significant_gap` (Sprint 4)
+- :func:`detect_speed_winner` (Sprint 4)
+- :func:`detect_median_mean_gap_warning` (Sprint 44)
+
+Comportement et signature inchangés. Tous restent enregistrés
+automatiquement via ``@register_detector`` à l'import.
+"""
+
+from __future__ import annotations
+
+import statistics as _stats
+from typing import Optional
+
+from picarones.measurements.narrative.facts import Fact, FactImportance, FactType
+from picarones.measurements.narrative.registry import register_detector
+
+from picarones.measurements.narrative.detectors._helpers import (
+ _engine_by_name,
+ _engines_summary,
+ _mean_duration_per_engine,
+ _n_docs,
+)
+
+
+@register_detector(
+ FactType.GLOBAL_LEADER_CER,
+ priority=10,
+ importance=FactImportance.CRITICAL,
+)
+def detect_global_leader_cer(benchmark_data: dict) -> list[Fact]:
+ """Moteur avec le CER moyen le plus bas sur l'ensemble du corpus.
+
+ Émet un Fact CRITICAL si au moins 2 moteurs sont comparés, en attachant
+ aussi le 2ᵉ pour permettre à l'arbitre de fusionner avec ``significant_gap``.
+ """
+ ranking = benchmark_data.get("ranking") or []
+ # Éliminer les entrées sans CER calculé
+ valid = [r for r in ranking if r.get("mean_cer") is not None]
+ if len(valid) < 1:
+ return []
+
+ leader = valid[0]
+ runner_up = valid[1] if len(valid) >= 2 else None
+
+ payload = {
+ "engine": leader["engine"],
+ "cer": float(leader["mean_cer"]),
+ "cer_pct": round(float(leader["mean_cer"]) * 100, 2),
+ "n_engines": len(valid),
+ "n_docs": _n_docs(benchmark_data),
+ }
+ if runner_up is not None:
+ payload["runner_up"] = runner_up["engine"]
+ payload["runner_up_cer"] = float(runner_up["mean_cer"])
+ payload["runner_up_cer_pct"] = round(float(runner_up["mean_cer"]) * 100, 2)
+
+ return [Fact(
+ type=FactType.GLOBAL_LEADER_CER,
+ importance=FactImportance.CRITICAL,
+ payload=payload,
+ engines_involved=(leader["engine"],),
+ )]
+
+
+@register_detector(
+ FactType.STATISTICAL_TIE,
+ priority=20,
+ importance=FactImportance.CRITICAL,
+)
+def detect_statistical_tie(benchmark_data: dict) -> list[Fact]:
+ """Groupes de moteurs statistiquement indiscernables (Nemenyi)."""
+ nemenyi = benchmark_data.get("statistics", {}).get("nemenyi", {})
+ if not nemenyi or nemenyi.get("error"):
+ return []
+
+ tied_groups = nemenyi.get("tied_groups", [])
+ mean_ranks = nemenyi.get("mean_ranks", {})
+ cd = nemenyi.get("critical_distance", 0.0)
+ alpha = nemenyi.get("alpha", 0.05)
+ n_blocks = nemenyi.get("n_blocks", 0)
+
+ facts: list[Fact] = []
+ for group in tied_groups:
+ if len(group) < 2:
+ continue
+ is_leader_tie = min(mean_ranks.get(n, 999) for n in group) == min(
+ mean_ranks.values(), default=0
+ )
+ importance = FactImportance.CRITICAL if is_leader_tie else FactImportance.HIGH
+
+ facts.append(Fact(
+ type=FactType.STATISTICAL_TIE,
+ importance=importance,
+ payload={
+ "engines": list(group),
+ "engines_list": ", ".join(group),
+ "mean_ranks": {n: mean_ranks.get(n) for n in group},
+ "critical_distance": round(cd, 3),
+ "alpha": alpha,
+ "n_blocks": n_blocks,
+ "includes_leader": is_leader_tie,
+ "n_tied": len(group),
+ },
+ engines_involved=tuple(group),
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.SIGNIFICANT_GAP,
+ priority=30,
+ importance=FactImportance.HIGH,
+)
+def detect_significant_gap(benchmark_data: dict) -> list[Fact]:
+ """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement.
+
+ Lit la matrice de Wilcoxon pairwise et vérifie si la paire (leader,
+ runner-up) y apparaît avec ``significant = True``.
+ """
+ ranking = benchmark_data.get("ranking") or []
+ valid = [r for r in ranking if r.get("mean_cer") is not None]
+ if len(valid) < 2:
+ return []
+
+ leader = valid[0]["engine"]
+ runner_up = valid[1]["engine"]
+
+ pairwise = benchmark_data.get("statistics", {}).get("pairwise_wilcoxon") or []
+ match = None
+ for p in pairwise:
+ names = {p.get("engine_a"), p.get("engine_b")}
+ if names == {leader, runner_up}:
+ match = p
+ break
+ if match is None:
+ return []
+
+ if not match.get("significant"):
+ return [] # pas d'écart significatif — rien à signaler ici
+
+ delta_cer = abs(float(valid[0]["mean_cer"]) - float(valid[1]["mean_cer"]))
+ return [Fact(
+ type=FactType.SIGNIFICANT_GAP,
+ importance=FactImportance.CRITICAL,
+ payload={
+ "leader": leader,
+ "runner_up": runner_up,
+ "p_value": float(match.get("p_value", 0.0)),
+ "delta_cer": round(delta_cer, 4),
+ "delta_cer_pct": round(delta_cer * 100, 2),
+ "n_pairs": int(match.get("n_pairs", 0)),
+ },
+ engines_involved=(leader, runner_up),
+ )]
+
+
+@register_detector(
+ FactType.SPEED_WINNER,
+ priority=100,
+ importance=FactImportance.MEDIUM,
+)
+def detect_speed_winner(benchmark_data: dict) -> list[Fact]:
+ """Moteur significativement plus rapide pour une qualité comparable.
+
+ Déclenché si un moteur est au moins 3× plus rapide que la médiane ET que
+ son CER n'est pas significativement pire (dans le même groupe Nemenyi que
+ le leader OU CER ≤ 1,1 × CER du leader).
+ """
+ durations = _mean_duration_per_engine(benchmark_data)
+ if len(durations) < 2:
+ return []
+
+ values = list(durations.values())
+ median_dur = _stats.median(values)
+ if median_dur <= 0:
+ return []
+
+ ranking = benchmark_data.get("ranking") or []
+ valid = [r for r in ranking if r.get("mean_cer") is not None]
+ if not valid:
+ return []
+ leader_cer = float(valid[0]["mean_cer"])
+ quality_ceiling = max(0.01, leader_cer * 1.10)
+
+ tied_groups = benchmark_data.get("statistics", {}).get("nemenyi", {}).get("tied_groups") or []
+ leader_group: set[str] = set()
+ for g in tied_groups:
+ if valid[0]["engine"] in g:
+ leader_group = set(g)
+ break
+
+ facts: list[Fact] = []
+ candidates = sorted(durations.items(), key=lambda kv: kv[1])
+ for engine, dur in candidates:
+ if dur * 3.0 > median_dur:
+ break # les suivants sont encore plus lents
+ summary = _engine_by_name(benchmark_data, engine) or {}
+ engine_cer = summary.get("cer")
+ if engine_cer is None:
+ continue
+ acceptable_quality = (
+ engine in leader_group or float(engine_cer) <= quality_ceiling
+ )
+ if not acceptable_quality:
+ continue
+ facts.append(Fact(
+ type=FactType.SPEED_WINNER,
+ importance=FactImportance.MEDIUM,
+ payload={
+ "engine": engine,
+ "mean_duration": round(dur, 3),
+ "median_duration": round(median_dur, 3),
+ "speedup": round(median_dur / dur, 1) if dur > 0 else None,
+ "cer": round(float(engine_cer), 4),
+ "cer_pct": round(float(engine_cer) * 100, 2),
+ },
+ engines_involved=(engine,),
+ ))
+ return facts[:1] # seulement le plus rapide — éviter le bruit
+
+
+@register_detector(
+ FactType.MEDIAN_MEAN_GAP_WARNING,
+ priority=140,
+ importance=FactImportance.MEDIUM,
+)
+def detect_median_mean_gap_warning(benchmark_data: dict) -> list[Fact]:
+ """Avertit quand le ratio ``|moyenne - médiane| / médiane`` du leader
+ dépasse 30 %, ce qui indique une distribution fortement asymétrique
+ où la moyenne masque les performances réelles.
+
+ Sprint 44 — A.I.2 du plan d'évolution. Cohérent avec le passage du
+ tri par défaut sur la médiane : si la moyenne du leader diverge
+ fortement de la médiane, l'utilisateur doit le savoir pour
+ interpréter correctement les chiffres.
+ """
+ ranking = benchmark_data.get("ranking") or []
+ valid = [
+ r for r in ranking
+ if r.get("median_cer") is not None
+ and r.get("mean_cer") is not None
+ ]
+ if not valid:
+ return []
+
+ leader = valid[0]
+ median_cer = float(leader["median_cer"])
+ mean_cer = float(leader["mean_cer"])
+
+ if median_cer <= 0:
+ # Médiane nulle (corpus très facile pour ce moteur) — l'écart
+ # relatif n'est pas calculable de manière utile, on s'abstient.
+ return []
+
+ relative_gap = abs(mean_cer - median_cer) / median_cer
+ if relative_gap < 0.30:
+ return []
+
+ importance = (
+ FactImportance.HIGH if relative_gap >= 1.0 else FactImportance.MEDIUM
+ )
+
+ return [Fact(
+ type=FactType.MEDIAN_MEAN_GAP_WARNING,
+ importance=importance,
+ payload={
+ "engine": leader["engine"],
+ "median_cer_pct": round(median_cer * 100, 2),
+ "mean_cer_pct": round(mean_cer * 100, 2),
+ "relative_gap_pct": round(relative_gap * 100, 1),
+ "n_docs": int(leader.get("documents") or 0),
+ },
+ engines_involved=(leader["engine"],),
+ )]
diff --git a/picarones/measurements/narrative/detectors/stratum.py b/picarones/measurements/narrative/detectors/stratum.py
new file mode 100644
index 0000000000000000000000000000000000000000..63d3952008a146e00bc0d3a6941ad92f647f5ff5
--- /dev/null
+++ b/picarones/measurements/narrative/detectors/stratum.py
@@ -0,0 +1,203 @@
+"""Détecteurs narratifs liés à la *stratification corpus* (chantier 5).
+
+3 détecteurs + 1 helper (``_stratum_cer_by_engine``) déplacés depuis
+``narrative/detectors.py`` :
+
+- :func:`detect_stratum_winner` (Sprint 4)
+- :func:`detect_stratum_collapse` (Sprint 4)
+- :func:`detect_stratification_recommended` (Sprint 45)
+"""
+
+from __future__ import annotations
+
+import statistics as _stats
+from typing import Optional
+
+from picarones.measurements.narrative.facts import Fact, FactImportance, FactType
+from picarones.measurements.narrative.registry import register_detector
+
+from picarones.measurements.narrative.detectors._helpers import (
+ _engine_by_name,
+ _engines_summary,
+ _n_docs,
+)
+
+
+def _stratum_cer_by_engine(benchmark_data: dict) -> dict[str, dict[str, list[float]]]:
+ """Agrège les CER par (moteur, strate).
+
+ Strate = ``document["script_type"]`` si présent. Retourne ``{}`` si aucun
+ document n'expose de strate (pas d'émission possible).
+ """
+ out: dict[str, dict[str, list[float]]] = {}
+ for doc in benchmark_data.get("documents") or []:
+ stratum = doc.get("script_type")
+ if not stratum:
+ continue
+ for er in doc.get("engine_results") or []:
+ if er.get("error"):
+ continue
+ cer = er.get("cer")
+ if cer is None:
+ continue
+ name = er.get("engine")
+ out.setdefault(name, {}).setdefault(stratum, []).append(float(cer))
+ return out
+
+
+@register_detector(
+ FactType.STRATUM_WINNER,
+ priority=40,
+ importance=FactImportance.MEDIUM,
+)
+def detect_stratum_winner(benchmark_data: dict) -> list[Fact]:
+ """Moteur qui domine nettement sur une strate (≥ 3 documents, CER
+ au moins 25 % plus bas que le second sur cette strate).
+ """
+ agg = _stratum_cer_by_engine(benchmark_data)
+ if not agg:
+ return []
+
+ # Inverser : {stratum: {engine: mean_cer}}
+ by_stratum: dict[str, dict[str, float]] = {}
+ for engine, strata in agg.items():
+ for stratum, vals in strata.items():
+ if len(vals) < 3:
+ continue
+ by_stratum.setdefault(stratum, {})[engine] = sum(vals) / len(vals)
+
+ facts: list[Fact] = []
+ for stratum, engine_cer in by_stratum.items():
+ if len(engine_cer) < 2:
+ continue
+ ordered = sorted(engine_cer.items(), key=lambda kv: kv[1])
+ best_name, best_cer = ordered[0]
+ second_cer = ordered[1][1]
+ if second_cer == 0:
+ continue
+ if best_cer < second_cer * 0.75: # dominance ≥ 25 %
+ facts.append(Fact(
+ type=FactType.STRATUM_WINNER,
+ importance=FactImportance.HIGH,
+ payload={
+ "engine": best_name,
+ "stratum": stratum,
+ "cer": round(best_cer, 4),
+ "cer_pct": round(best_cer * 100, 2),
+ "second_engine": ordered[1][0],
+ "second_cer": round(second_cer, 4),
+ "second_cer_pct": round(second_cer * 100, 2),
+ "n_docs_stratum": len(agg[best_name][stratum]),
+ },
+ engines_involved=(best_name,),
+ stratum=stratum,
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.STRATUM_COLLAPSE,
+ priority=50,
+ importance=FactImportance.HIGH,
+)
+def detect_stratum_collapse(benchmark_data: dict) -> list[Fact]:
+ """Moteur globalement compétitif qui s'effondre sur une strate.
+
+ Déclenché si, pour un moteur, le CER moyen sur une strate ≥ 3 documents
+ est plus du double du CER global du même moteur.
+ """
+ agg = _stratum_cer_by_engine(benchmark_data)
+ if not agg:
+ return []
+
+ facts: list[Fact] = []
+ for engine_name, strata in agg.items():
+ summary = _engine_by_name(benchmark_data, engine_name) or {}
+ global_cer = summary.get("cer")
+ if global_cer is None:
+ continue
+ global_cer = float(global_cer)
+ if global_cer <= 0:
+ continue
+ for stratum, vals in strata.items():
+ if len(vals) < 3:
+ continue
+ local_cer = sum(vals) / len(vals)
+ if local_cer > 2.0 * global_cer and (local_cer - global_cer) > 0.05:
+ facts.append(Fact(
+ type=FactType.STRATUM_COLLAPSE,
+ importance=FactImportance.HIGH,
+ payload={
+ "engine": engine_name,
+ "stratum": stratum,
+ "local_cer": round(local_cer, 4),
+ "local_cer_pct": round(local_cer * 100, 2),
+ "global_cer": round(global_cer, 4),
+ "global_cer_pct": round(global_cer * 100, 2),
+ "delta_cer_pct": round((local_cer - global_cer) * 100, 2),
+ "n_docs_stratum": len(vals),
+ },
+ engines_involved=(engine_name,),
+ stratum=stratum,
+ ))
+ return facts
+
+
+@register_detector(
+ FactType.STRATIFICATION_RECOMMENDED,
+ priority=45, # juste après STRATUM_WINNER (40), avant STRATUM_COLLAPSE (50)
+ importance=FactImportance.HIGH,
+)
+def detect_stratification_recommended(benchmark_data: dict) -> list[Fact]:
+ """Avertit quand le corpus est hétérogène et que la vue stratifiée
+ apporte un éclairage qualitativement différent du classement global.
+
+ Critère : ``corpus_homogeneity.max_inter_strata_gap > 5 points`` de
+ CER médian sur le moteur leader. Au-delà de 10 points, importance
+ ``HIGH`` (situation très hétérogène où le seul classement global
+ serait trompeur).
+
+ Lit ``benchmark_data["corpus_homogeneity"]`` exposé par
+ ``BenchmarkResult.as_dict()`` (Sprint 45).
+ """
+ homog = benchmark_data.get("corpus_homogeneity")
+ if not homog:
+ return []
+
+ gap = homog.get("max_inter_strata_gap")
+ if gap is None:
+ return []
+
+ gap = float(gap)
+ if gap < 0.05:
+ return [] # 5 points de CER : seuil de pertinence éditoriale
+
+ leader = str(homog.get("leader") or "")
+ n_strata = int(homog.get("n_strata") or 0)
+ pair = homog.get("leader_max_gap_strata") or ["", ""]
+ if len(pair) < 2:
+ return []
+ min_strat, max_strat = str(pair[0]), str(pair[1])
+
+ leader_per_stratum = homog.get("leader_per_stratum_median") or {}
+ min_med = float(leader_per_stratum.get(min_strat, 0.0))
+ max_med = float(leader_per_stratum.get(max_strat, 0.0))
+
+ importance = (
+ FactImportance.HIGH if gap >= 0.10 else FactImportance.MEDIUM
+ )
+
+ return [Fact(
+ type=FactType.STRATIFICATION_RECOMMENDED,
+ importance=importance,
+ payload={
+ "leader": leader,
+ "n_strata": n_strata,
+ "gap_pct": round(gap * 100, 1),
+ "min_stratum": min_strat,
+ "max_stratum": max_strat,
+ "min_stratum_cer_pct": round(min_med * 100, 2),
+ "max_stratum_cer_pct": round(max_med * 100, 2),
+ },
+ engines_involved=(leader,) if leader else (),
+ )]
diff --git a/picarones/measurements/narrative/facts.py b/picarones/measurements/narrative/facts.py
new file mode 100644
index 0000000000000000000000000000000000000000..f06c2289173a203616ea64b658a4ae21368b15af
--- /dev/null
+++ b/picarones/measurements/narrative/facts.py
@@ -0,0 +1,212 @@
+"""Modèle de données du moteur narratif.
+
+Un ``Fact`` est une observation structurée extraite d'un ``BenchmarkResult``.
+Chaque détecteur retourne zéro, un ou plusieurs ``Fact`` typés. L'arbitre
+(Sprint 4) trie par ``importance`` et sélectionne les faits à afficher.
+
+Règle d'or (à vérifier par tests) : chaque valeur numérique ou nom d'entité
+présent dans ``payload`` doit provenir directement du JSON d'entrée, jamais
+d'une génération. C'est ce qui rend la synthèse reproductible bit-à-bit et
+immune à l'hallucination par construction.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Callable, Optional
+
+
+class FactType(str, Enum):
+ """Types de faits détectables.
+
+ L'ajout d'un nouveau type se fait ici + un détecteur dans ``detectors.py``
+ + un template dans ``narrative/templates_{lang}.yaml`` (Sprint 4).
+ """
+
+ GLOBAL_LEADER_CER = "global_leader_cer"
+ """Moteur avec le CER médian le plus bas sur l'ensemble du corpus."""
+
+ STATISTICAL_TIE = "statistical_tie"
+ """Top-N moteurs statistiquement indiscernables (Nemenyi, Sprint 3)."""
+
+ SIGNIFICANT_GAP = "significant_gap"
+ """Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement."""
+
+ PARETO_ALTERNATIVE = "pareto_alternative"
+ """Moteur sur la frontière Pareto différent du leader CER pur (Sprint 5)."""
+
+ STRATUM_WINNER = "stratum_winner"
+ """Moteur qui domine sur une strate spécifique (siècle, langue, type)."""
+
+ STRATUM_COLLAPSE = "stratum_collapse"
+ """Moteur globalement bon qui s'effondre sur une strate spécifique."""
+
+ ERROR_PROFILE_OUTLIER = "error_profile_outlier"
+ """Moteur avec un profil taxonomique atypique (ex : 3× plus d'erreurs d'abréviation)."""
+
+ LLM_HALLUCINATION_FLAG = "llm_hallucination_flag"
+ """LLM avec un taux d'hallucination notablement supérieur aux autres."""
+
+ ROBUSTNESS_FRAGILE = "robustness_fragile"
+ """Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou."""
+
+ COST_OUTLIER = "cost_outlier"
+ """Moteur au ratio coût/qualité très défavorable (Sprint 5)."""
+
+ SPEED_WINNER = "speed_winner"
+ """Moteur significativement plus rapide pour une qualité comparable."""
+
+ CONFIDENCE_WARNING = "confidence_warning"
+ """Intervalle de confiance très large : classement peu fiable."""
+
+ ENSEMBLE_OPPORTUNITY = "ensemble_opportunity"
+ """Deux moteurs sont fortement complémentaires : un voting majoritaire
+ pourrait améliorer significativement le CER (Sprint 36)."""
+
+ MEDIAN_MEAN_GAP_WARNING = "median_mean_gap_warning"
+ """Distribution des CER fortement asymétrique sur le corpus —
+ la moyenne du leader est tirée par quelques documents catastrophiques
+ et masque les performances réelles. La médiane (utilisée pour le tri
+ par défaut depuis Sprint 44) est plus représentative."""
+
+ STRATIFICATION_RECOMMENDED = "stratification_recommended"
+ """Le corpus est hétérogène du point de vue script_type : le moteur
+ leader varie fortement selon la strate. Le lecteur doit consulter
+ la vue stratifiée plutôt que de se fier au seul classement global
+ (Sprint 46)."""
+
+ ENGINE_OFF_BASELINE = "engine_off_baseline"
+ """Le CER courant d'un moteur s'écarte significativement de sa
+ moyenne historique sur le même corpus (lue depuis l'historique
+ SQLite, Sprint 8). Lit ``BenchmarkHistory`` via le module
+ ``baseline_comparison`` (Sprint 73). Garde-fous : ≥ 5 runs
+ historiques même corpus + |delta_relatif| > 20 %."""
+
+ ENGINE_UNSTABLE = "engine_unstable"
+ """Un moteur LLM/VLM exécuté plusieurs fois sur les mêmes
+ documents produit des sorties différentes au-delà d'un seuil
+ de variance (Sprint 90). Lit ``compute_multirun_stability``
+ (Sprint 83). Garde-fous : ≥ 2 runs et seuil sur le coefficient
+ de variation du CER (>10 % par défaut) ou sur le rappel de
+ runs identiques (<50 %)."""
+
+ REGRESSION_IN_HISTORY = "regression_in_history"
+ """Un moteur montre une tendance ou une rupture défavorable
+ sur l'historique SQLite : son CER moyen s'est dégradé sur
+ les N derniers runs (Sprint 92). Lit
+ ``compute_corpus_longitudinal`` du module ``longitudinal``.
+ Garde-fous : ≥ 3 runs historiques et soit pente > seuil
+ (régression progressive), soit change-point avec delta >
+ seuil (rupture brutale)."""
+
+
+class FactImportance(int, Enum):
+ """Score d'importance d'un fait — décide l'ordre et la sélection."""
+
+ CRITICAL = 100
+ """À remonter systématiquement en synthèse (ex : leader + écart significatif)."""
+
+ HIGH = 70
+ """À remonter sauf si déjà redondant avec un fait critique."""
+
+ MEDIUM = 40
+ """À remonter si la synthèse a encore de la place."""
+
+ LOW = 10
+ """Informatif, remonté uniquement en vue détaillée."""
+
+
+@dataclass
+class Fact:
+ """Observation structurée extraite d'un benchmark.
+
+ Attributes
+ ----------
+ type:
+ Type de fait (voir ``FactType``).
+ importance:
+ Priorité de sélection (voir ``FactImportance``).
+ payload:
+ Dict de données brutes sérialisables. **Toutes les valeurs doivent
+ provenir du JSON d'entrée** — c'est le garde-fou anti-hallucination.
+ engines_involved:
+ Noms des moteurs concernés. Utilisé par l'arbitre pour détecter
+ les redondances (deux faits sur le même moteur = fusion ou sélection).
+ stratum:
+ Strate concernée (ex : "XVIIe siècle", "latin médiéval") ou None.
+ """
+
+ type: FactType
+ importance: FactImportance
+ payload: dict
+ engines_involved: tuple[str, ...] = ()
+ stratum: Optional[str] = None
+
+ def as_dict(self) -> dict:
+ return {
+ "type": self.type.value,
+ "importance": int(self.importance),
+ "payload": self.payload,
+ "engines_involved": list(self.engines_involved),
+ "stratum": self.stratum,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Registre de détecteurs
+# ---------------------------------------------------------------------------
+
+# Signature d'un détecteur : prend le dict JSON du benchmark, retourne une liste
+# de Fact (potentiellement vide). Doit être pure et déterministe.
+DetectorFn = Callable[[dict], list[Fact]]
+
+
+@dataclass
+class DetectorRegistry:
+ """Registre central des détecteurs de faits.
+
+ Un détecteur est enregistré via ``register(fact_type, fn)``. ``detect_all``
+ appelle tous les détecteurs enregistrés et renvoie la liste consolidée.
+ """
+
+ _detectors: dict[FactType, DetectorFn] = field(default_factory=dict)
+
+ def register(self, fact_type: FactType, fn: DetectorFn) -> None:
+ self._detectors[fact_type] = fn
+
+ def unregister(self, fact_type: FactType) -> None:
+ self._detectors.pop(fact_type, None)
+
+ def registered_types(self) -> tuple[FactType, ...]:
+ return tuple(self._detectors.keys())
+
+ def run(self, benchmark_data: dict) -> list[Fact]:
+ facts: list[Fact] = []
+ for fact_type, fn in self._detectors.items():
+ try:
+ result = fn(benchmark_data)
+ except Exception as e:
+ import logging
+ logging.getLogger(__name__).warning(
+ "[narrative.detector.%s] fonctionnalité dégradée : %s",
+ fact_type.value, e,
+ )
+ continue
+ if result:
+ facts.extend(result)
+ return facts
+
+
+def detect_all(benchmark_data: dict, registry: Optional[DetectorRegistry] = None) -> list[Fact]:
+ """Applique tous les détecteurs enregistrés au benchmark donné.
+
+ Point d'entrée du Sprint 4. Pour Sprint 1, le registre par défaut est vide :
+ les détecteurs concrets sont ajoutés sprint par sprint.
+ """
+ if registry is None:
+ registry = _DEFAULT_REGISTRY
+ return registry.run(benchmark_data)
+
+
+_DEFAULT_REGISTRY = DetectorRegistry()
diff --git a/picarones/measurements/narrative/registry.py b/picarones/measurements/narrative/registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..511ed2ed360a178b407183c05ab6ca7adf46dd8b
--- /dev/null
+++ b/picarones/measurements/narrative/registry.py
@@ -0,0 +1,217 @@
+"""Registre déclaratif des détecteurs narratifs (Sprint 29).
+
+Avant le Sprint 29, ajouter un nouveau type de fait imposait de toucher
+**quatre** fichiers :
+
+ 1. ``facts.py`` — ajouter une valeur à ``FactType`` ;
+ 2. ``detectors.py`` — écrire ``def detect_xxx(data) -> list[Fact]`` ;
+ 3. ``detectors.py`` — l'inscrire dans le dict ``DETECTORS_BY_TYPE`` ;
+ 4. ``arbiter.py`` — ajouter le type à la séquence ``DEFAULT_TYPE_ORDER``
+ au bon endroit pour la priorité éditoriale.
+
+Sprint 29 ramène le nombre de modifications à **deux** :
+
+ 1. ``facts.py`` — toujours nécessaire pour le type énuméré ;
+ 2. ``detectors.py`` — décorer la fonction avec ``@register_detector(...)``.
+
+Le décorateur :
+ - enregistre la fonction dans un registre global trié par ``priority`` ;
+ - vérifie qu'aucun détecteur ne se réenregistre sur le même ``FactType`` ;
+ - laisse la fonction utilisable telle quelle (rétrocompatibilité) ;
+ - alimente automatiquement ``arbiter.DEFAULT_TYPE_ORDER``.
+
+Conventions de priorité (« politique éditoriale » du rapport)
+-------------------------------------------------------------
+Plus la valeur est petite, plus le fait remonte tôt en synthèse à
+importance égale. Pour conserver l'ordre historique du Sprint 23, on
+utilise un pas de 10 pour laisser de la place à des insertions futures :
+
+ 10 GLOBAL_LEADER_CER qui gagne globalement
+ 20 STATISTICAL_TIE y a-t-il un ex-aequo
+ 30 SIGNIFICANT_GAP à quel point l'écart est solide
+ 40 STRATUM_WINNER qui domine sur quel sous-corpus
+ 50 STRATUM_COLLAPSE qui s'effondre sur quoi
+ 60 ERROR_PROFILE_OUTLIER qui se trompe différemment
+ 70 LLM_HALLUCINATION_FLAG hallucinations VLM
+ 80 ROBUSTNESS_FRAGILE sensibilité aux dégradations
+ 90 PARETO_ALTERNATIVE compromis coût/qualité
+ 100 SPEED_WINNER vitesse
+ 110 COST_OUTLIER coût aberrant
+ 120 CONFIDENCE_WARNING mise en garde sur la fiabilité
+
+Le décorateur n'impose **pas** de pas — un détecteur tiers peut très
+bien utiliser ``priority=42`` pour s'insérer entre STRATUM_WINNER et
+STRATUM_COLLAPSE par exemple.
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+from picarones.measurements.narrative.facts import (
+ DetectorFn,
+ DetectorRegistry,
+ FactImportance,
+ FactType,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Métadonnées d'un détecteur
+# ---------------------------------------------------------------------------
+
+@dataclass(frozen=True)
+class DetectorEntry:
+ """Métadonnées d'un détecteur enregistré."""
+ fact_type: FactType
+ fn: DetectorFn
+ priority: int
+ importance: FactImportance
+
+
+# ---------------------------------------------------------------------------
+# Registre global
+# ---------------------------------------------------------------------------
+
+_REGISTRY: dict[FactType, DetectorEntry] = {}
+_REGISTRY_LOCK = threading.Lock()
+
+
+def register_detector(
+ fact_type: FactType,
+ *,
+ priority: int,
+ importance: FactImportance = FactImportance.MEDIUM,
+) -> Callable[[DetectorFn], DetectorFn]:
+ """Décorateur d'enregistrement.
+
+ Usage::
+
+ @register_detector(FactType.GLOBAL_LEADER_CER, priority=10,
+ importance=FactImportance.CRITICAL)
+ def detect_global_leader_cer(data: dict) -> list[Fact]:
+ ...
+
+ Le décorateur :
+ - vérifie qu'aucun autre détecteur n'est déjà enregistré sur
+ ``fact_type`` (sinon ``ValueError``) ;
+ - vérifie que ``priority`` est un entier ;
+ - retourne la fonction inchangée pour ne pas casser les imports
+ existants.
+
+ L'``importance`` mémorisée ici sert de **métadonnée** au registre :
+ chaque détecteur reste libre d'émettre des ``Fact`` avec une
+ importance différente selon le contexte (ex. CRITICAL si l'écart
+ est gigantesque, HIGH sinon).
+ """
+ def _decorator(fn: DetectorFn) -> DetectorFn:
+ with _REGISTRY_LOCK:
+ if fact_type in _REGISTRY:
+ raise ValueError(
+ f"Détecteur déjà enregistré pour {fact_type.value!r} : "
+ f"{_REGISTRY[fact_type].fn.__name__}. Désenregistrer "
+ "explicitement avant de réassigner."
+ )
+ entry = DetectorEntry(
+ fact_type=fact_type,
+ fn=fn,
+ priority=int(priority),
+ importance=importance,
+ )
+ _REGISTRY[fact_type] = entry
+ logger.debug(
+ "[narrative.registry] enregistré %s priority=%s importance=%s",
+ fact_type.value, priority, importance.name,
+ )
+ return fn
+
+ return _decorator
+
+
+def unregister(fact_type: FactType) -> None:
+ """Retire un détecteur du registre — utilisé par les tests."""
+ with _REGISTRY_LOCK:
+ _REGISTRY.pop(fact_type, None)
+
+
+def iter_detectors() -> list[DetectorEntry]:
+ """Retourne tous les détecteurs enregistrés, triés par ``priority``.
+
+ Le tri est stable : à ``priority`` égale, l'ordre d'enregistrement
+ est préservé (utile en présence d'extensions tierces).
+ """
+ with _REGISTRY_LOCK:
+ entries = list(_REGISTRY.values())
+ entries.sort(key=lambda e: e.priority)
+ return entries
+
+
+def detector_for(fact_type: FactType) -> Optional[DetectorEntry]:
+ with _REGISTRY_LOCK:
+ return _REGISTRY.get(fact_type)
+
+
+def clear_registry() -> None:
+ """Vide le registre — réservé aux tests d'isolation."""
+ with _REGISTRY_LOCK:
+ _REGISTRY.clear()
+
+
+def default_type_order() -> tuple[FactType, ...]:
+ """Calcule l'ordre canonique des types depuis le registre courant.
+
+ Source de vérité de ``arbiter.DEFAULT_TYPE_ORDER`` depuis le Sprint 29.
+ """
+ return tuple(e.fact_type for e in iter_detectors())
+
+
+# ---------------------------------------------------------------------------
+# Pont avec ``DetectorRegistry`` historique
+# ---------------------------------------------------------------------------
+
+def populate_legacy_registry(registry: DetectorRegistry) -> None:
+ """Synchronise le ``DetectorRegistry`` historique depuis le décorateur.
+
+ L'objet ``DetectorRegistry`` reste l'API publique pour les
+ consommateurs externes (cf. ``DetectorRegistry.run``) ; cette
+ fonction l'alimente depuis le registre déclaratif courant.
+ """
+ for entry in iter_detectors():
+ registry.register(entry.fact_type, entry.fn)
+
+
+__all__ = [
+ "DetectorEntry",
+ "register_detector",
+ "unregister",
+ "iter_detectors",
+ "detector_for",
+ "clear_registry",
+ "default_type_order",
+ "populate_legacy_registry",
+]
+
+
+# ---------------------------------------------------------------------------
+# Sentinel — sans usage direct ; vérifie au build qu'on n'introduit pas
+# de valeur ``priority`` dupliquée par accident parmi les builtins.
+# ---------------------------------------------------------------------------
+
+def _verify_unique_priorities() -> None:
+ seen: dict[int, FactType] = {}
+ for entry in iter_detectors():
+ if entry.priority in seen:
+ logger.warning(
+ "[narrative.registry] priority %s dupliquée : "
+ "%s et %s — ordre indéterministe à priorité égale.",
+ entry.priority,
+ seen[entry.priority].value,
+ entry.fact_type.value,
+ )
+ else:
+ seen[entry.priority] = entry.fact_type
diff --git a/picarones/measurements/narrative/renderer.py b/picarones/measurements/narrative/renderer.py
new file mode 100644
index 0000000000000000000000000000000000000000..cbb5d3da3f91db658339c78eb6be946177dfb0f9
--- /dev/null
+++ b/picarones/measurements/narrative/renderer.py
@@ -0,0 +1,105 @@
+"""Rendu des faits narratifs en texte lisible.
+
+Les templates sont chargés depuis ``templates/{lang}.yaml`` au premier accès.
+Le rendu utilise ``str.format_map`` sur le ``payload`` du ``Fact``. Aucun LLM,
+aucune génération : la sortie est la concaténation de templates remplis avec
+des valeurs venant strictement du JSON d'entrée.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from pathlib import Path
+from typing import Iterable
+
+import yaml
+
+from picarones.measurements.narrative.facts import Fact
+
+logger = logging.getLogger(__name__)
+
+_TEMPLATES_DIR = Path(__file__).parent / "templates"
+_TEMPLATES_CACHE: dict[str, dict[str, str]] = {}
+
+
+def _load_templates(lang: str) -> dict[str, str]:
+ """Charge et met en cache les templates de la langue demandée.
+
+ Fallback : si la langue n'existe pas, retourne les templates FR. Si FR
+ est également absent (incident d'installation), retourne un dict vide.
+ """
+ if lang in _TEMPLATES_CACHE:
+ return _TEMPLATES_CACHE[lang]
+
+ path = _TEMPLATES_DIR / f"{lang}.yaml"
+ if not path.exists():
+ if lang != "fr":
+ return _load_templates("fr")
+ _TEMPLATES_CACHE[lang] = {}
+ return _TEMPLATES_CACHE[lang]
+
+ try:
+ with path.open(encoding="utf-8") as fh:
+ data = yaml.safe_load(fh) or {}
+ if not isinstance(data, dict):
+ logger.warning("[narrative] %s n'est pas un dict YAML — ignoré", path)
+ _TEMPLATES_CACHE[lang] = {}
+ else:
+ _TEMPLATES_CACHE[lang] = {str(k): str(v).strip() for k, v in data.items()}
+ except yaml.YAMLError as e:
+ logger.warning("[narrative] échec parsing %s : %s", path, e)
+ _TEMPLATES_CACHE[lang] = {}
+
+ return _TEMPLATES_CACHE[lang]
+
+
+class _SafeFormatMap(dict):
+ """Dict qui retourne ``'?'`` pour les clés manquantes dans un template.
+
+ Évite qu'un détecteur mal documenté fasse crasher le rendu. En pratique
+ les tests couvrent les clés attendues, mais la robustesse prévaut.
+ """
+
+ def __missing__(self, key: str) -> str:
+ logger.warning("[narrative] clé manquante dans payload : %r", key)
+ return "?"
+
+
+def render_fact(fact: Fact, lang: str = "fr") -> str:
+ """Rend un Fact en une phrase selon la langue.
+
+ Retourne ``""`` si le template est absent pour ce type.
+ """
+ templates = _load_templates(lang)
+ tpl = templates.get(fact.type.value)
+ if not tpl:
+ return ""
+
+ try:
+ return tpl.format_map(_SafeFormatMap(fact.payload))
+ except (ValueError, KeyError) as e:
+ logger.warning(
+ "[narrative] rendu impossible pour %s : %s", fact.type.value, e,
+ )
+ return ""
+
+
+def render_synthesis(facts: Iterable[Fact], lang: str = "fr") -> list[str]:
+ """Rend une liste de Fact en liste de phrases (ordre préservé)."""
+ out: list[str] = []
+ for fact in facts:
+ phrase = render_fact(fact, lang)
+ phrase = re.sub(r"\s+", " ", phrase).strip()
+ if phrase:
+ out.append(phrase)
+ return out
+
+
+def extract_numbers(text: str) -> list[str]:
+ """Extrait les nombres (décimaux ou entiers) présents dans une phrase.
+
+ Utilisé par le test de traçabilité : chaque nombre remonté en synthèse
+ doit être présent dans le JSON d'entrée.
+ """
+ return re.findall(r"\d+(?:[.,]\d+)?", text)
diff --git a/picarones/measurements/narrative/templates/en.yaml b/picarones/measurements/narrative/templates/en.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..40e2af6c48583a04243034dd742cc2568f2133e5
--- /dev/null
+++ b/picarones/measurements/narrative/templates/en.yaml
@@ -0,0 +1,96 @@
+# Narrative rendering templates — English.
+# Anti-hallucination rule: never introduce a number or entity name that is not
+# already in the Fact ``payload``. Tests verify traceability of every number
+# appearing in the rendered synthesis.
+
+global_leader_cer: >-
+ On this corpus of {n_docs} documents, {engine} achieves the lowest mean CER
+ ({cer_pct} %).
+
+statistical_tie: >-
+ Engines {engines_list} are not statistically distinguishable
+ (Friedman-Nemenyi, α = {alpha}, n = {n_blocks} documents, CD = {critical_distance}).
+
+significant_gap: >-
+ The gap between {leader} and {runner_up} is statistically significant
+ (Wilcoxon, p = {p_value:.4f}, Δ CER = {delta_cer_pct} points over {n_pairs} pairs).
+
+stratum_winner: >-
+ On stratum "{stratum}" ({n_docs_stratum} documents), {engine} achieves
+ the lowest CER ({cer_pct} % vs. {second_cer_pct} % for {second_engine}).
+
+stratum_collapse: >-
+ {engine} is globally competitive ({global_cer_pct} %) but collapses on
+ stratum "{stratum}" ({local_cer_pct} % over {n_docs_stratum} documents,
+ i.e. {delta_cer_pct} points above its own average).
+
+error_profile_outlier: >-
+ {engine} has an atypical error profile: {proportion_pct} % of errors fall
+ into class "{error_class}", vs. a median of {median_proportion_pct} % across
+ other engines (×{ratio_to_median} the median).
+
+llm_hallucination_flag: >-
+ Hallucination signal on {engine} ({reasons_list}) —
+ {hallucinating_rate_pct} % of documents above alert thresholds.
+
+robustness_fragile: >-
+ {engine} is fragile under "{degradation}" degradation: its CER rises from
+ {cer_baseline_pct} % to {cer_degraded_pct} % at maximum level (×{ratio}).
+
+speed_winner: >-
+ {engine} is the fastest ({mean_duration} s/doc, ×{speedup} faster than the
+ median) for comparable quality (CER {cer_pct} %).
+
+confidence_warning: >-
+ High statistical uncertainty: the {confidence_level} % confidence interval of
+ {engine} spans {ci_width_pct} CER points, compared with a gap of
+ {gap_to_runner_up_pct} points to the runner-up.
+
+pareto_alternative: >-
+ At much lower cost, {engine} offers an interesting trade-off ({cer_pct} %
+ CER for {cost} €/{cost_unit_pages} pages, vs {leader_cer_pct} % / {leader_cost} € for
+ {leader}, i.e. ×{cost_saving_ratio} cheaper).
+
+cost_outlier: >-
+ Disproportionate cost for {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median}
+ the median) without a compensating quality advantage (CER {cer_pct} %).
+
+ensemble_opportunity: >-
+ Engines {pair_a} and {pair_b} have divergent error profiles
+ ({divergence_metric}={divergence}). On this corpus of {doc_count} documents,
+ {best_engine} preserves {best_recall_pct} % of tokens; a majority vote
+ among the engines would preserve {oracle_recall_pct} % — i.e.
+ {absolute_gap_pct} points recoverable ({relative_gap_pct} % of the best
+ engine's errors).
+
+median_mean_gap_warning: >-
+ Asymmetric distribution for {engine}: median CER {median_cer_pct} %
+ vs mean {mean_cer_pct} % across {n_docs} documents (relative gap
+ {relative_gap_pct} %). The mean is pulled by a few catastrophic
+ documents — the median (now used for default ranking) is more
+ representative.
+
+stratification_recommended: >-
+ Heterogeneous corpus ({n_strata} strata): {leader} performs very
+ differently depending on document type — median CER
+ {min_stratum_cer_pct} % on "{min_stratum}" vs
+ {max_stratum_cer_pct} % on "{max_stratum}", a gap of {gap_pct}
+ points. The global ranking hides this disparity; consult the
+ stratified view.
+
+engine_off_baseline: >-
+ {engine} achieved {cer_current_pct} % CER here, vs {cer_historical_mean_pct} %
+ on average over the last {n_runs} runs of your institution on this
+ same corpus (relative delta {relative_delta_pct} %). This corpus is
+ harder for it than usual.
+
+engine_unstable: >-
+ Over {n_runs} successive runs, {engine} produces variable outputs
+ (CER CV {cer_cv_pct} %, identical-run pair rate {identical_run_rate_pct} %).
+ Reproducibility is limited — interpret the average CER with caution.
+
+regression_in_history: >-
+ Over the {n_runs} historical runs for {engine}, the average CER
+ moved from {first_cer_pct} % to {last_cer_pct} %
+ (cumulative change {absolute_delta_pct} points). Investigate what
+ changed in the pipeline or the models.
diff --git a/picarones/measurements/narrative/templates/fr.yaml b/picarones/measurements/narrative/templates/fr.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d3a858abfd5dcb5462007859458a8a59d43fafce
--- /dev/null
+++ b/picarones/measurements/narrative/templates/fr.yaml
@@ -0,0 +1,101 @@
+# Templates de rendu narratif — français.
+#
+# Chaque clé correspond à une valeur de ``FactType``. La valeur est un template
+# Python ``.format()`` qui consomme les champs du ``Fact.payload``.
+#
+# Règle anti-hallucination : n'introduire aucune valeur numérique ou nom
+# d'entité qui ne soit pas dans le ``payload``. Les tests parsent la synthèse
+# rendue et vérifient la traçabilité.
+
+global_leader_cer: >-
+ Sur ce corpus de {n_docs} documents, {engine} obtient le CER moyen le plus
+ bas ({cer_pct} %).
+
+statistical_tie: >-
+ Les moteurs {engines_list} ne sont pas statistiquement distinguables
+ (Friedman-Nemenyi, α = {alpha}, n = {n_blocks} documents, CD = {critical_distance}).
+
+significant_gap: >-
+ L'écart entre {leader} et {runner_up} est statistiquement significatif
+ (Wilcoxon, p = {p_value:.4f}, Δ CER = {delta_cer_pct} points sur {n_pairs} paires).
+
+stratum_winner: >-
+ Sur la strate « {stratum} » ({n_docs_stratum} documents), {engine}
+ obtient le CER le plus bas ({cer_pct} % contre {second_cer_pct} %
+ pour {second_engine}).
+
+stratum_collapse: >-
+ {engine} est globalement compétitif ({global_cer_pct} %) mais s'effondre sur
+ la strate « {stratum} » ({local_cer_pct} % sur {n_docs_stratum} documents,
+ soit {delta_cer_pct} points au-dessus de sa moyenne).
+
+error_profile_outlier: >-
+ Le profil d'erreurs de {engine} est atypique : {proportion_pct} % de la
+ classe « {error_class} », contre une médiane de {median_proportion_pct} %
+ sur les autres moteurs (ratio ×{ratio_to_median}).
+
+llm_hallucination_flag: >-
+ Signal d'hallucination sur {engine} ({reasons_list}) —
+ {hallucinating_rate_pct} % de documents au-dessus des seuils d'alerte.
+
+robustness_fragile: >-
+ {engine} est fragile à la dégradation « {degradation} » : son CER passe de
+ {cer_baseline_pct} % à {cer_degraded_pct} % au niveau maximal (ratio ×{ratio}).
+
+speed_winner: >-
+ {engine} est le plus rapide ({mean_duration} s / doc, ×{speedup} plus vite
+ que la médiane) pour un CER comparable ({cer_pct} %).
+
+confidence_warning: >-
+ Incertitude statistique élevée : l'intervalle de confiance à {confidence_level} %
+ de {engine} s'étend sur {ci_width_pct} points de CER, à comparer à l'écart de
+ {gap_to_runner_up_pct} points avec le second.
+
+pareto_alternative: >-
+ À coût sensiblement inférieur, {engine} offre un compromis intéressant
+ ({cer_pct} % de CER pour {cost} €/{cost_unit_pages} pages, contre {leader_cer_pct} % /
+ {leader_cost} € pour {leader}, soit ×{cost_saving_ratio} moins cher).
+
+cost_outlier: >-
+ Coût disproportionné pour {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median}
+ la médiane) sans avantage de qualité compensatoire (CER {cer_pct} %).
+
+ensemble_opportunity: >-
+ Les moteurs {pair_a} et {pair_b} ont des profils d'erreurs divergents
+ ({divergence_metric}={divergence}). Sur ce corpus de {doc_count} documents,
+ {best_engine} préserve {best_recall_pct} % des tokens ; un voting majoritaire
+ entre les moteurs en préserverait {oracle_recall_pct} %, soit
+ {absolute_gap_pct} points récupérables ({relative_gap_pct} % des erreurs
+ du meilleur moteur).
+
+median_mean_gap_warning: >-
+ Distribution asymétrique pour {engine} : médiane CER {median_cer_pct} %
+ vs moyenne {mean_cer_pct} % sur {n_docs} documents (écart relatif
+ {relative_gap_pct} %). La moyenne est tirée par quelques documents
+ catastrophiques — la médiane (utilisée pour le tri par défaut) est
+ plus représentative.
+
+stratification_recommended: >-
+ Corpus hétérogène ({n_strata} strates) : {leader} performe très
+ différemment selon le type de document — médiane CER
+ {min_stratum_cer_pct} % sur « {min_stratum} » contre
+ {max_stratum_cer_pct} % sur « {max_stratum} », soit {gap_pct} points
+ d'écart. Le classement global masque cette disparité ; consulter la
+ vue stratifiée.
+
+engine_off_baseline: >-
+ {engine} a obtenu {cer_current_pct} % CER ici, vs {cer_historical_mean_pct} %
+ en moyenne sur les {n_runs} runs précédents de votre institution sur
+ ce même corpus (écart relatif {relative_delta_pct} %). Ce corpus lui
+ est plus difficile que d'habitude.
+
+engine_unstable: >-
+ Sur {n_runs} runs successifs, {engine} produit des sorties variables
+ (CV CER {cer_cv_pct} %, paires de runs identiques {identical_run_rate_pct} %).
+ La reproductibilité est limitée — interpréter le CER moyen avec prudence.
+
+regression_in_history: >-
+ Sur les {n_runs} runs historiques pour {engine}, le CER moyen
+ est passé de {first_cer_pct} % à {last_cer_pct} %
+ (variation cumulée {absolute_delta_pct} points). Vérifier ce qui
+ a changé dans le pipeline ou les modèles.
diff --git a/picarones/measurements/ner.py b/picarones/measurements/ner.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d3bbe8892ede723311146db19ae43a47d83c771
--- /dev/null
+++ b/picarones/measurements/ner.py
@@ -0,0 +1,309 @@
+"""Calcul des métriques de précision sur entités nommées (NER).
+
+Sprint 38 — A.II.1.a du plan d'évolution 2026 : couche de calcul pure.
+
+Pourquoi ce module
+------------------
+Pour un médiéviste, un archiviste ou un économiste-historien,
+l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui
+compte c'est de savoir si les **entités nommées** (personnes, lieux,
+dates, organisations) ont survécu à la transcription. Un CER de 5 %
+qui rate 80 % des noms propres est inutilisable pour l'indexation
+prosopographique.
+
+Stratégie de découpage en sprints
+---------------------------------
+Comme pour la divergence taxonomique (Sprints 35-37), on découpe :
+
+- **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre
+ deux listes d'entités, calcul de Precision/Recall/F1 par catégorie
+ et global, détection des hallucinations d'entité. Aucune dépendance
+ externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont
+ fournies en entrée. Un test de l'enregistrement dans le registre
+ typé Sprint 34 garantit l'intégration.
+- **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et
+ câblage runner+narratif+HTML.
+
+Format des entités
+------------------
+Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un
+dictionnaire ``{"label": str, "start": int, "end": int, "text": str}``
+où ``start``/``end`` sont des offsets caractère.
+
+Convention d'alignement
+-----------------------
+Une entité hypothèse "matche" une entité de référence si :
+
+1. les **labels sont identiques** (case-insensitive),
+2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans
+ caractère est ``≥ iou_threshold`` (défaut : 0,5).
+
+Une entité de référence non matchée → faux négatif (recall pénalisé).
+Une entité hypothèse non matchée → faux positif (précision pénalisée).
+Un faux positif est aussi compté comme **hallucination d'entité**, ce
+qui est utile pour les VLM/LLM qui inventent.
+
+Limites
+-------
+- L'alignement bag-of-spans : une entité peut être matchée par au plus
+ une entité de l'autre côté (sinon double-comptage).
+- Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique
+ mesure conjointement OCR + NER. Documenter explicitement.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Iterable
+
+from picarones.core.metric_registry import register_metric
+from picarones.core.modules import ArtifactType
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Modèle de données
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class Entity:
+ """Entité nommée alignée sur un texte.
+
+ Attributs
+ ---------
+ label:
+ Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``).
+ La comparaison se fait en *case-insensitive*.
+ start, end:
+ Offsets caractère (inclus, exclu) sur le texte de référence.
+ text:
+ Forme de surface — informative, **non utilisée pour
+ l'alignement** (deux entités peuvent matcher même si leur
+ forme de surface diffère, du moment que leurs spans
+ chevauchent suffisamment).
+ """
+
+ label: str
+ start: int
+ end: int
+ text: str = ""
+
+ def __post_init__(self) -> None:
+ if self.start > self.end:
+ raise ValueError(
+ f"Entity span invalide : start={self.start} > end={self.end}"
+ )
+
+ @property
+ def length(self) -> int:
+ return max(0, self.end - self.start)
+
+
+def _to_entity(obj: Entity | dict) -> Entity:
+ """Coerce un dict (format EntitiesGT) en ``Entity``."""
+ if isinstance(obj, Entity):
+ return obj
+ return Entity(
+ label=str(obj["label"]),
+ start=int(obj["start"]),
+ end=int(obj["end"]),
+ text=str(obj.get("text", "")),
+ )
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Alignement par IoU
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _iou(a: Entity, b: Entity) -> float:
+ """Intersection-over-Union sur les spans caractère."""
+ inter_start = max(a.start, b.start)
+ inter_end = min(a.end, b.end)
+ inter = max(0, inter_end - inter_start)
+ union = a.length + b.length - inter
+ if union <= 0:
+ return 0.0
+ return inter / union
+
+
+def _align(
+ references: list[Entity],
+ hypotheses: list[Entity],
+ iou_threshold: float,
+) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
+ """Aligne deux listes d'entités par IoU décroissant (greedy).
+
+ Returns
+ -------
+ matches:
+ Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU
+ décroissant — chaque entité n'apparaît qu'une fois.
+ unmatched_refs:
+ Indices des entités GT non matchées (faux négatifs).
+ unmatched_hyps:
+ Indices des entités hypothèse non matchées (faux positifs).
+ """
+ candidates: list[tuple[float, int, int]] = []
+ for i, r in enumerate(references):
+ for j, h in enumerate(hypotheses):
+ if r.label.casefold() != h.label.casefold():
+ continue
+ score = _iou(r, h)
+ if score >= iou_threshold:
+ candidates.append((score, i, j))
+
+ # Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires
+ # pour garantir un tri stable et déterministe.
+ candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
+
+ matched_refs: set[int] = set()
+ matched_hyps: set[int] = set()
+ matches: list[tuple[int, int, float]] = []
+ for score, i, j in candidates:
+ if i in matched_refs or j in matched_hyps:
+ continue
+ matched_refs.add(i)
+ matched_hyps.add(j)
+ matches.append((i, j, score))
+
+ unmatched_refs = set(range(len(references))) - matched_refs
+ unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
+ return matches, unmatched_refs, unmatched_hyps
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Calcul des métriques
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
+ """Précision / rappel / F1 à partir des comptes."""
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
+ f1 = (
+ 2 * precision * recall / (precision + recall)
+ if (precision + recall) > 0
+ else 0.0
+ )
+ return {
+ "precision": precision,
+ "recall": recall,
+ "f1": f1,
+ "support": tp + fn,
+ }
+
+
+def compute_ner_metrics(
+ reference_entities: Iterable[Entity | dict],
+ hypothesis_entities: Iterable[Entity | dict],
+ iou_threshold: float = 0.5,
+) -> dict:
+ """Calcule la précision/rappel/F1 sur entités nommées.
+
+ Parameters
+ ----------
+ reference_entities:
+ Liste d'entités GT (format ``Entity`` ou dict de
+ ``EntitiesGT``).
+ hypothesis_entities:
+ Liste d'entités produites par le NER sur la sortie OCR.
+ iou_threshold:
+ Seuil de chevauchement caractère pour qu'un appariement
+ soit valide (défaut : 0,5 — convention CoNLL/HIPE).
+
+ Returns
+ -------
+ dict
+ ``{
+ "global": {"precision", "recall", "f1", "support"},
+ "per_category": {label: {"precision", ...}},
+ "true_positives": int,
+ "false_positives": int,
+ "false_negatives": int,
+ "hallucinated_entities": list[dict], # entités OCR sans GT
+ "missed_entities": list[dict], # entités GT non détectées
+ "iou_threshold": float,
+ }``
+ """
+ refs = [_to_entity(e) for e in reference_entities]
+ hyps = [_to_entity(e) for e in hypothesis_entities]
+
+ matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold)
+
+ tp = len(matches)
+ fn = len(unmatched_refs)
+ fp = len(unmatched_hyps)
+
+ # Comptes par catégorie
+ cat_tp: dict[str, int] = {}
+ cat_fn: dict[str, int] = {}
+ cat_fp: dict[str, int] = {}
+ for i, _j, _score in matches:
+ cat = refs[i].label
+ cat_tp[cat] = cat_tp.get(cat, 0) + 1
+ for i in unmatched_refs:
+ cat = refs[i].label
+ cat_fn[cat] = cat_fn.get(cat, 0) + 1
+ for j in unmatched_hyps:
+ cat = hyps[j].label
+ cat_fp[cat] = cat_fp.get(cat, 0) + 1
+
+ all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
+ per_category = {
+ cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0))
+ for cat in all_categories
+ }
+
+ return {
+ "global": _prf(tp, fp, fn),
+ "per_category": per_category,
+ "true_positives": tp,
+ "false_positives": fp,
+ "false_negatives": fn,
+ "hallucinated_entities": [
+ {"label": hyps[j].label, "start": hyps[j].start,
+ "end": hyps[j].end, "text": hyps[j].text}
+ for j in sorted(unmatched_hyps)
+ ],
+ "missed_entities": [
+ {"label": refs[i].label, "start": refs[i].start,
+ "end": refs[i].end, "text": refs[i].text}
+ for i in sorted(unmatched_refs)
+ ],
+ "iou_threshold": iou_threshold,
+ }
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Enregistrement dans le registre typé (Sprint 34)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@register_metric(
+ name="ner_f1",
+ input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES),
+ description=(
+ "F1 global sur les entités nommées (alignement IoU ≥ 0,5, "
+ "labels case-insensitive). Pour le détail par catégorie, "
+ "utiliser compute_ner_metrics directement."
+ ),
+ higher_is_better=True,
+ tags={"downstream", "ner", "structure"},
+)
+def ner_f1(
+ reference_entities: Iterable[Entity | dict],
+ hypothesis_entities: Iterable[Entity | dict],
+) -> float:
+ """F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``."""
+ return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"]
+
+
+__all__ = [
+ "Entity",
+ "compute_ner_metrics",
+ "ner_f1",
+]
diff --git a/picarones/measurements/ner_backends.py b/picarones/measurements/ner_backends.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e944df927dec5919a3f89fc840d5e05b5af189e
--- /dev/null
+++ b/picarones/measurements/ner_backends.py
@@ -0,0 +1,227 @@
+"""Backends d'extraction d'entités nommées (Sprint 40).
+
+Suite directe du Sprint 38 : la couche de calcul (`compute_ner_metrics`)
+prend deux listes d'entités, ce module fournit le moyen d'**obtenir** la
+liste d'entités d'un côté à partir d'un texte (généralement la sortie
+OCR du moteur).
+
+Architecture
+------------
+- ``EntityExtractor`` : Protocol Python qui décrit l'interface ; tout
+ callable ``(text: str) -> list[dict]`` est un extracteur valide. Le
+ format de sortie est compatible ``EntitiesGT`` (Sprint 32) et
+ ``compute_ner_metrics`` (Sprint 38).
+- ``SpacyEntityExtractor`` : implémentation par défaut, lazy-import de
+ spaCy. Si spaCy n'est pas installé OU si le modèle n'est pas
+ téléchargé, retourne ``[]`` avec un ``logger.warning`` explicite
+ (cf. règle CLAUDE.md : pas de ``except: pass``).
+- ``SPACY_PROFILES`` : dict de profils nommés vers noms de modèles
+ spaCy (FR, EN, multilingue, HIPE pour les corpus historiques).
+- ``get_extractor(profile)`` : factory qui retourne l'extracteur
+ correspondant au profil demandé.
+
+Découplage runner ↔ backend
+---------------------------
+Le runner reçoit un ``EntityExtractor`` en paramètre — il n'importe
+**jamais** spaCy directement. Cela permet :
+
+1. de **tester** sans dépendance externe (le test injecte un callable
+ qui simule l'extraction) ;
+2. de **brancher** des backends alternatifs (Stanza, HIPE custom,
+ modèle fine-tuné maison) sans modifier le runner ;
+3. de **désactiver** la métrique en passant ``None`` — comportement
+ par défaut, rétrocompat stricte.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Protocol
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Interface
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class EntityExtractor(Protocol):
+ """Tout callable ``(text) -> list[dict]`` est un extracteur valide.
+
+ Format de sortie attendu : liste de dicts
+ ``{"label": str, "start": int, "end": int, "text": str}``
+ compatibles avec ``compute_ner_metrics`` (Sprint 38) et
+ ``EntitiesGT`` (Sprint 32).
+ """
+
+ def __call__(self, text: str) -> list[dict[str, Any]]: ...
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Profils spaCy nommés
+# ──────────────────────────────────────────────────────────────────────────
+
+
+SPACY_PROFILES: dict[str, str] = {
+ "fr": "fr_core_news_sm",
+ "fr_lg": "fr_core_news_lg",
+ "en": "en_core_web_sm",
+ "en_lg": "en_core_web_lg",
+ "multilingual": "xx_ent_wiki_sm",
+ # HIPE 2022 — modèle historique multilingue (Hugging Face). Pas
+ # toujours disponible via ``spacy.load`` direct ; documenté pour
+ # mémoire, l'utilisateur peut le wrapper dans un EntityExtractor
+ # custom si besoin.
+ "hipe": "fr_core_news_lg",
+}
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Backend spaCy
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class SpacyEntityExtractor:
+ """Extracteur d'entités basé sur spaCy.
+
+ Lazy-import : ``spacy`` n'est importé qu'au premier appel. Le
+ modèle est chargé une seule fois et mis en cache sur l'instance.
+
+ Si spaCy n'est pas installé OU si le modèle demandé n'est pas
+ téléchargé, l'extracteur tombe en mode dégradé (retourne ``[]``
+ pour chaque appel) et émet un ``logger.warning`` au premier
+ appel.
+
+ Parameters
+ ----------
+ model_name:
+ Nom du modèle spaCy à charger (ex. ``"fr_core_news_sm"``).
+ label_mapping:
+ Dict optionnel ``{spacy_label: target_label}`` pour
+ normaliser les labels (ex. spaCy utilise ``"PERSON"``,
+ on veut ``"PER"``). Si ``None``, garde les labels tels
+ quels.
+
+ Examples
+ --------
+ >>> extractor = SpacyEntityExtractor("fr_core_news_sm")
+ >>> entities = extractor("Marie de Bourgogne, en 1477.")
+ >>> # liste de dicts {label, start, end, text}, ou [] si spaCy absent
+ """
+
+ # Mapping par défaut spaCy → conventions HIPE/CoNLL courtes
+ DEFAULT_LABEL_MAPPING: dict[str, str] = {
+ "PERSON": "PER",
+ "PER": "PER",
+ "LOC": "LOC",
+ "GPE": "LOC", # Geo-Political Entity → LOC
+ "ORG": "ORG",
+ "DATE": "DATE",
+ "TIME": "DATE",
+ "MISC": "MISC",
+ }
+
+ def __init__(
+ self,
+ model_name: str = "fr_core_news_sm",
+ label_mapping: dict[str, str] | None = None,
+ ) -> None:
+ self.model_name = model_name
+ self.label_mapping = (
+ dict(label_mapping)
+ if label_mapping is not None
+ else dict(self.DEFAULT_LABEL_MAPPING)
+ )
+ self._nlp: Any | None = None
+ self._loaded: bool = False
+ self._available: bool = False
+
+ def _load(self) -> None:
+ """Charge spaCy + modèle au premier appel. Idempotent."""
+ if self._loaded:
+ return
+ self._loaded = True
+ try:
+ import spacy # type: ignore[import-untyped]
+ except ImportError as exc:
+ logger.warning(
+ "[ner_backends] spaCy non installé (%s) — extraction NER "
+ "désactivée. Installer avec `pip install picarones[ner]`.",
+ exc,
+ )
+ return
+ try:
+ self._nlp = spacy.load(self.model_name)
+ self._available = True
+ except OSError as exc:
+ logger.warning(
+ "[ner_backends] Modèle spaCy %r introuvable (%s) — extraction "
+ "NER désactivée. Télécharger avec `python -m spacy download %s`.",
+ self.model_name, exc, self.model_name,
+ )
+
+ @property
+ def available(self) -> bool:
+ """``True`` si spaCy + le modèle sont chargés et utilisables."""
+ if not self._loaded:
+ self._load()
+ return self._available
+
+ def __call__(self, text: str) -> list[dict[str, Any]]:
+ if not text:
+ return []
+ if not self.available or self._nlp is None:
+ return []
+ doc = self._nlp(text)
+ results: list[dict[str, Any]] = []
+ for ent in doc.ents:
+ label = self.label_mapping.get(ent.label_, ent.label_)
+ results.append({
+ "label": label,
+ "start": int(ent.start_char),
+ "end": int(ent.end_char),
+ "text": ent.text,
+ })
+ return results
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Factory
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def get_extractor(profile: str = "fr") -> SpacyEntityExtractor:
+ """Retourne un extracteur spaCy pour le profil demandé.
+
+ Le profil peut être :
+
+ - une clé de ``SPACY_PROFILES`` (ex. ``"fr"``, ``"en"``,
+ ``"multilingual"``)
+ - un nom de modèle spaCy direct (ex. ``"fr_core_news_lg"``)
+
+ L'extracteur est instancié paresseusement (le modèle n'est chargé
+ qu'au premier appel). Si le modèle n'est pas disponible,
+ l'extracteur tombe en mode dégradé silencieux (retourne ``[]``).
+ """
+ model_name = SPACY_PROFILES.get(profile, profile)
+ return SpacyEntityExtractor(model_name=model_name)
+
+
+def is_spacy_available() -> bool:
+ """``True`` si la librairie ``spacy`` est importable, sans charger
+ de modèle."""
+ try:
+ import spacy # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+__all__ = [
+ "EntityExtractor",
+ "SpacyEntityExtractor",
+ "SPACY_PROFILES",
+ "get_extractor",
+ "is_spacy_available",
+]
diff --git a/picarones/measurements/normalization.py b/picarones/measurements/normalization.py
new file mode 100644
index 0000000000000000000000000000000000000000..6c33b33d4752d0c00715e8dfd6b068b75c773498
--- /dev/null
+++ b/picarones/measurements/normalization.py
@@ -0,0 +1,420 @@
+"""Profils de normalisation unicode pour le calcul du CER diplomatique.
+
+La normalisation diplomatique permet de calculer un CER tenant compte des
+équivalences graphiques propres aux documents historiques : ſ=s, u=v, i=j, etc.
+
+En appliquant la même table aux deux textes (GT et OCR), on mesure les erreurs
+"substantielles" (transcription erronée) en ignorant les variations graphiques
+codifiées connues.
+
+Trois niveaux de normalisation sont disponibles :
+
+1. NFC : normalisation Unicode canonique (décomposition+recomposition)
+2. caseless : NFC + pliage de casse (casefold)
+3. diplomatic: NFC + table de correspondances historiques configurables
+
+Les profils préconfigurés couvrent les cas d'usage patrimoniaux courants.
+Ils sont également chargeables depuis un fichier YAML.
+
+Exemple YAML
+------------
+name: medieval_custom
+caseless: false
+diplomatic:
+ ſ: s
+ u: v
+ i: j
+ y: i
+ æ: ae
+ œ: oe
+"""
+
+from __future__ import annotations
+
+import unicodedata
+from dataclasses import dataclass, field
+from pathlib import Path
+
+
+# ---------------------------------------------------------------------------
+# Tables de correspondances diplomatiques préconfigurées
+# ---------------------------------------------------------------------------
+
+#: Français médiéval (XIIe–XVe siècle)
+DIPLOMATIC_FR_MEDIEVAL: dict[str, str] = {
+ "ſ": "s", # s long → s
+ "u": "v", # u/v interchangeables en position initiale
+ "i": "j", # i/j interchangeables
+ "y": "i", # y vocalique → i
+ "æ": "ae", # ligature æ
+ "œ": "oe", # ligature œ
+ "ꝑ": "per", # abréviation per/par
+ "ꝓ": "pro", # abréviation pro
+ "\u0026": "et", # & → et
+}
+
+#: Français moderne / imprimés anciens (XVIe–XVIIIe siècle)
+DIPLOMATIC_FR_EARLY_MODERN: dict[str, str] = {
+ "ſ": "s", # s long
+ "æ": "ae",
+ "œ": "oe",
+ "\u0026": "et",
+ "ỹ": "yn", # y tilde
+}
+
+#: Latin médiéval
+DIPLOMATIC_LATIN_MEDIEVAL: dict[str, str] = {
+ "ſ": "s",
+ "u": "v",
+ "i": "j",
+ "y": "i",
+ "æ": "ae",
+ "œ": "oe",
+ "ꝑ": "per",
+ "ꝓ": "pro",
+ "ꝗ": "que", # q barré → que
+ "\u0026": "et",
+}
+
+#: Profil minimal — uniquement NFC + s long
+DIPLOMATIC_MINIMAL: dict[str, str] = {
+ "ſ": "s",
+}
+
+#: Anglais moderne / imprimés anciens (XVIe–XVIIIe siècle)
+#: Orthographe «early modern» : ſ=s, u/v, i/j, vv=w, þ=th, ð=th, ȝ=y
+DIPLOMATIC_EN_EARLY_MODERN: dict[str, str] = {
+ "ſ": "s", # s long → s
+ "u": "v", # u/v interchangeables (vpon → upon)
+ "i": "j", # i/j interchangeables (ioy → joy)
+ "vv": "w", # vv → w (vvhich → which)
+ "þ": "th", # thorn → th
+ "ð": "th", # eth → th
+ "ȝ": "y", # yogh → y
+ "æ": "ae", # ligature æ
+ "œ": "oe", # ligature œ
+ "\u0026": "and", # & → and
+}
+
+#: Anglais médiéval (XIIe–XVe siècle) — abréviations manuscrites incluses
+DIPLOMATIC_EN_MEDIEVAL: dict[str, str] = {
+ "ſ": "s",
+ "u": "v",
+ "i": "j",
+ "vv": "w",
+ "þ": "th",
+ "ð": "th",
+ "ȝ": "y",
+ "æ": "ae",
+ "œ": "oe",
+ "\u0026": "and",
+ # Abréviations courantes dans les manuscrits anglais médiévaux
+ "ꝑ": "per", # p barré → per/par
+ "ꝓ": "pro", # p crocheté → pro
+ "ꝗ": "que", # q barré → que
+ "\ua75b": "r", # lettre r rotunda → r
+}
+
+#: Écriture secrétaire (XVIe–XVIIe siècle) — secretary hand
+#: Confusions visuelles propres à l'écriture cursive anglaise
+DIPLOMATIC_EN_SECRETARY: dict[str, str] = {
+ "ſ": "s",
+ "u": "v",
+ "i": "j",
+ "vv": "w",
+ "þ": "th",
+ "ð": "th",
+ "ȝ": "y",
+ "\u0026": "and",
+ # Confusions visuelles typiques : e/c, n/u, m/w en secrétaire
+ # Note : ne pas normaliser e/c automatiquement (trop agressif) ;
+ # on se limite aux substituts graphiques historiquement documentés
+}
+
+
+# ---------------------------------------------------------------------------
+# Profil de normalisation
+# ---------------------------------------------------------------------------
+
+@dataclass
+class NormalizationProfile:
+ """Décrit une stratégie de normalisation pour le calcul du CER diplomatique.
+
+ Parameters
+ ----------
+ name:
+ Identifiant lisible du profil (ex : ``"medieval_french"``).
+ nfc:
+ Applique la normalisation Unicode NFC (recommandé, activé par défaut).
+ caseless:
+ Pliage de casse (casefold) après NFC.
+ diplomatic_table:
+ Table de correspondances graphiques historiques appliquée caractère
+ par caractère sur les deux textes avant calcul du CER.
+ exclude_chars:
+ Ensemble de caractères supprimés des deux textes (GT et OCR) avant
+ tout calcul de métriques (CER, WER, MER, WIL et CER diplomatique).
+ Utile pour ignorer la ponctuation ou les apostrophes.
+ description:
+ Description courte du profil (affichée dans le rapport HTML).
+ """
+
+ name: str
+ nfc: bool = True
+ caseless: bool = False
+ diplomatic_table: dict[str, str] = field(default_factory=dict)
+ exclude_chars: frozenset = field(default_factory=frozenset)
+ description: str = ""
+
+ def normalize(self, text: str) -> str:
+ """Applique le profil de normalisation à un texte."""
+ if self.exclude_chars:
+ text = "".join(c for c in text if c not in self.exclude_chars)
+ if self.nfc:
+ text = unicodedata.normalize("NFC", text)
+ if self.caseless:
+ text = text.casefold()
+ if self.diplomatic_table:
+ text = _apply_diplomatic_table(text, self.diplomatic_table)
+ return text
+
+ def as_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "nfc": self.nfc,
+ "caseless": self.caseless,
+ "diplomatic_table": self.diplomatic_table,
+ "exclude_chars": sorted(self.exclude_chars),
+ "description": self.description,
+ }
+
+ @classmethod
+ def from_yaml(cls, path: str | Path) -> "NormalizationProfile":
+ """Charge un profil depuis un fichier YAML.
+
+ Le fichier YAML doit contenir les clés ``name``, optionnellement
+ ``caseless``, ``description``, ``diplomatic`` (dict str→str) et
+ ``exclude_chars`` (liste ou chaîne de caractères à ignorer).
+
+ Example
+ -------
+ .. code-block:: yaml
+
+ name: medieval_custom
+ caseless: false
+ description: Français médiéval personnalisé
+ exclude_chars: ".,;:!?"
+ diplomatic:
+ ſ: s
+ u: v
+ """
+ try:
+ import yaml
+ except ImportError as exc:
+ raise RuntimeError(
+ "Le package 'pyyaml' est requis pour charger les profils YAML. "
+ "Installez-le avec : pip install pyyaml"
+ ) from exc
+
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
+ return cls(
+ name=data.get("name", Path(path).stem),
+ nfc=bool(data.get("nfc", True)),
+ caseless=bool(data.get("caseless", False)),
+ diplomatic_table=data.get("diplomatic", {}),
+ exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")),
+ description=data.get("description", ""),
+ )
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "NormalizationProfile":
+ """Charge un profil depuis un dictionnaire (ex : section YAML inline)."""
+ return cls(
+ name=data.get("name", "custom"),
+ nfc=bool(data.get("nfc", True)),
+ caseless=bool(data.get("caseless", False)),
+ diplomatic_table=data.get("diplomatic", {}),
+ exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")),
+ description=data.get("description", ""),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Profils préconfigurés
+# ---------------------------------------------------------------------------
+
+NORMALIZATION_PROFILES: dict[str, NormalizationProfile] = {
+ "nfc": NormalizationProfile(
+ name="nfc",
+ nfc=True,
+ caseless=False,
+ diplomatic_table={},
+ description="Normalisation NFC uniquement",
+ ),
+ "caseless": NormalizationProfile(
+ name="caseless",
+ nfc=True,
+ caseless=True,
+ diplomatic_table={},
+ description="NFC + insensible à la casse",
+ ),
+ "minimal": NormalizationProfile(
+ name="minimal",
+ nfc=True,
+ caseless=False,
+ diplomatic_table=DIPLOMATIC_MINIMAL,
+ description="Minimal : NFC + s long seulement",
+ ),
+ "medieval_french": NormalizationProfile(
+ name="medieval_french",
+ nfc=True,
+ caseless=False,
+ diplomatic_table=DIPLOMATIC_FR_MEDIEVAL,
+ description="Français médiéval (XIIe–XVe) : ſ=s, u=v, i=j, æ=ae, œ=oe",
+ ),
+ "early_modern_french": NormalizationProfile(
+ name="early_modern_french",
+ nfc=True,
+ caseless=False,
+ diplomatic_table=DIPLOMATIC_FR_EARLY_MODERN,
+ description="Imprimés anciens (XVIe–XVIIIe) : ſ=s, æ=ae, œ=oe",
+ ),
+ "medieval_latin": NormalizationProfile(
+ name="medieval_latin",
+ nfc=True,
+ caseless=False,
+ diplomatic_table=DIPLOMATIC_LATIN_MEDIEVAL,
+ description="Latin médiéval : ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro",
+ ),
+ "early_modern_english": NormalizationProfile(
+ name="early_modern_english",
+ nfc=True,
+ caseless=False,
+ diplomatic_table=DIPLOMATIC_EN_EARLY_MODERN,
+ description="Early Modern English (XVIth–XVIIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y",
+ ),
+ "medieval_english": NormalizationProfile(
+ name="medieval_english",
+ nfc=True,
+ caseless=False,
+ diplomatic_table=DIPLOMATIC_EN_MEDIEVAL,
+ description="Medieval English (XIIth–XVth c.): ſ=s, u=v, i=j, þ=th, ȝ=y, ꝑ=per, ꝓ=pro",
+ ),
+ "secretary_hand": NormalizationProfile(
+ name="secretary_hand",
+ nfc=True,
+ caseless=False,
+ diplomatic_table=DIPLOMATIC_EN_SECRETARY,
+ description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y",
+ ),
+ # ── Profils d'exclusion de caractères ────────────────────────────────
+ "sans_ponctuation": NormalizationProfile(
+ name="sans_ponctuation",
+ nfc=True,
+ caseless=False,
+ diplomatic_table={},
+ exclude_chars=frozenset(". , ; : ! ? ' \u2019 \" - \u2013 \u2014 ( ) [ ]".split()),
+ description="NFC + suppression de la ponctuation courante : . , ; : ! ? ' \" - – — ( ) [ ]",
+ ),
+ "sans_apostrophes": NormalizationProfile(
+ name="sans_apostrophes",
+ nfc=True,
+ caseless=False,
+ diplomatic_table={},
+ exclude_chars=frozenset(["'", "\u2019"]), # apostrophe droite + apostrophe typographique
+ description="NFC + suppression des apostrophes droite (') et typographique (\u2019)",
+ ),
+}
+
+
+def get_builtin_profile(name: str) -> NormalizationProfile:
+ """Retourne un profil préconfigurée par son identifiant.
+
+ Identifiants disponibles
+ ------------------------
+ - ``"medieval_french"`` : français médiéval XIIe–XVe (ſ=s, u=v, i=j, æ=ae, œ=oe…)
+ - ``"early_modern_french"`` : imprimés anciens XVIe–XVIIIe (ſ=s, œ=oe, æ=ae…)
+ - ``"medieval_latin"`` : latin médiéval (ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro…)
+ - ``"early_modern_english"`` : anglais imprimé XVIe–XVIIIe (ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y)
+ - ``"medieval_english"`` : anglais manuscrit XIIe–XVe (+ abréviations ꝑ, ꝓ…)
+ - ``"secretary_hand"`` : écriture secrétaire anglaise XVIe–XVIIe (cursive administrative)
+ - ``"minimal"`` : uniquement NFC + s long
+ - ``"nfc"`` : NFC seul (sans table diplomatique)
+ - ``"caseless"`` : NFC + pliage de casse
+
+ Raises
+ ------
+ KeyError
+ Si le nom n'est pas reconnu.
+ """
+ if name not in NORMALIZATION_PROFILES:
+ raise KeyError(
+ f"Profil de normalisation inconnu : '{name}'. "
+ f"Disponibles : {', '.join(NORMALIZATION_PROFILES)}"
+ )
+ return NORMALIZATION_PROFILES[name]
+
+
+# ---------------------------------------------------------------------------
+# Fonctions utilitaires
+# ---------------------------------------------------------------------------
+
+def _parse_exclude_chars(value: "str | list | None") -> frozenset:
+ """Convertit une liste de caractères (str ou list) en frozenset.
+
+ Accepte :
+ - Une chaîne de caractères séparés par une virgule+espace (ex. ``"', -, –"``)
+ ou simplement concaténés sans séparateur (ex. ``".,;:!?"``)
+ - Une liste Python/YAML de chaînes (chacune un caractère)
+ - None ou chaîne vide → frozenset vide
+
+ Règle de désambiguïsation : si la chaîne contient la séquence ``", "``
+ (virgule suivie d'un espace), on découpe par ``", "``. Sinon, chaque
+ caractère Unicode est un item distinct.
+ """
+ if not value:
+ return frozenset()
+ if isinstance(value, (list, tuple)):
+ return frozenset(str(c) for c in value if c)
+ raw = str(value)
+ # Désambiguïsation : séparer par ", " si présent (format lisible)
+ if ", " in raw:
+ return frozenset(c.strip() for c in raw.split(",") if c.strip())
+ # Sinon, chaque caractère Unicode est un item distinct
+ return frozenset(raw)
+
+
+def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str:
+ """Applique une table de correspondances diplomatiques en un seul pass.
+
+ Les clés multi-caractères (ex : ``"ae"`` → ``"æ"``) sont gérées en priorité
+ sur les correspondances simples. Le remplacement est fait en un seul pass
+ via regex pour éviter les remplacements en cascade (ex : ``"ſ"→"s"`` puis
+ ``"s"→"z"`` donnerait ``"z"`` au lieu de ``"s"``).
+ """
+ if not table:
+ return text
+
+ import re
+
+ # Séparer les clés simples (1 char) des clés multi-chars
+ multi_keys = sorted(
+ (k for k in table if len(k) > 1), key=len, reverse=True
+ )
+ simple_table = {k: v for k, v in table.items() if len(k) == 1}
+
+ if multi_keys:
+ # Single-pass : construire un pattern regex avec toutes les clés multi-chars
+ # triées par longueur décroissante pour matcher les plus longues d'abord
+ pattern = re.compile("|".join(re.escape(k) for k in multi_keys))
+ text = pattern.sub(lambda m: table[m.group(0)], text)
+
+ # Remplacements char par char (single-pass via itération)
+ if simple_table:
+ text = "".join(simple_table.get(c, c) for c in text)
+
+ return text
+
+
+# Profil par défaut utilisé pour le CER diplomatique intégré
+DEFAULT_DIPLOMATIC_PROFILE: NormalizationProfile = get_builtin_profile("medieval_french")
diff --git a/picarones/measurements/numerical_sequences.py b/picarones/measurements/numerical_sequences.py
new file mode 100644
index 0000000000000000000000000000000000000000..5698b4017fa693cec17a6dd1671bfed7d1cab38c
--- /dev/null
+++ b/picarones/measurements/numerical_sequences.py
@@ -0,0 +1,422 @@
+"""Précision sur séquences numériques — Sprint 85 (A.II.5b).
+
+Sprint 85 — A.II.5b du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Pour un économiste-historien, un éditeur de chartes ou un
+archiviste, la **fidélité aux séquences numériques** est un
+proxy direct de la qualité éditoriale. Un OCR qui rate
+*« 1789 »* dans une charte révolutionnaire ou *« f. 12v »*
+dans une cote d'archives produit un corpus inutilisable pour la
+recherche fine, même si le CER global est respectable.
+
+Catégories couvertes
+--------------------
+1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789``
+ (le module détecte les **années** sur 4 chiffres dans la
+ plage [1000-2099]).
+2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``.
+ Réutilise ``picarones.core.roman_numerals`` (Sprint 60).
+3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``,
+ ``p. 5``, ``pp. 12-15``, ``n° 42``.
+4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``,
+ ``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime
+ (``l.``, ``s.``, ``d.``).
+5. **Années régnales** : ``an III``, ``l'an V``, ``an de
+ grâce 1450``, ``an de la République``.
+
+Méthode
+-------
+Pour chaque catégorie, on extrait les occurrences (regex
+spécialisée) en GT et en hypothèse. On classe ensuite chaque
+GT en **3 statuts** :
+
+- ``strict_preserved`` : forme exacte présente dans
+ l'hypothèse (sensible à la casse seulement pour la
+ foliotation, sinon la convention est documentée par
+ catégorie) ;
+- ``value_preserved`` : la **valeur** apparaît même si la
+ forme diffère (ex. ``XIV`` GT et ``14`` hypothèse —
+ considéré comme valeur préservée mais forme non) ;
+- ``lost`` : aucune trace exploitable.
+
+Sortie
+------
+``compute_numerical_sequence_metrics(reference, hypothesis)``
+retourne :
+
+```
+{
+ "global_strict_score": float, # ∈ [0, 1]
+ "global_value_score": float, # ∈ [0, 1]
+ "n_total": int,
+ "per_category": {
+ "year": {"n_total": int, "strict": int, "value": int,
+ "strict_score": float, "value_score": float,
+ "lost_items": list[str]},
+ "roman": {...},
+ "foliation": {...},
+ "currency": {...},
+ "regnal": {...},
+ },
+}
+```
+
+Limites
+-------
+- Les regex sont **conservatrices** : on rate quelques
+ formes rares plutôt que de produire des faux positifs (par
+ exemple, ``mil cinq cens`` en français médiéval n'est pas
+ détecté comme année — la couche calcul s'en tient aux
+ formes les plus reconnaissables). Pour un corpus
+ spécifique, l'utilisateur peut composer ses propres
+ détecteurs et les passer via ``custom_detectors``.
+- ``value_preserved`` exige une équivalence de **valeur
+ numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ;
+ ``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la
+ foliotation (recto/verso est une information distincte).
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Optional
+
+from picarones.core.metric_registry import register_metric
+from picarones.core.modules import ArtifactType
+from picarones.core.roman_numerals import (
+ detect_roman_numerals,
+ roman_to_int,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Constantes / catégories
+# ──────────────────────────────────────────────────────────────────────────
+
+
+CATEGORIES = ("year", "roman", "foliation", "currency", "regnal")
+
+
+# Dates arabes — 4 chiffres dans la plage [1000-2099].
+# On exige une frontière de mot pour ne pas attraper
+# « 12345 » (volume) ou « 0001 » (numéro de page).
+_RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b")
+
+
+# Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42
+# La capture conserve la forme intégrale (avec ponctuation et
+# r/v) parce que recto/verso est une information distincte.
+_RE_FOLIATION = re.compile(
+ r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n°
+ r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15)
+ r"\s*([rvRV])?", # suffixe optionnel r/v
+ re.UNICODE,
+)
+
+
+# Montants : nombre suivi d'une unité monétaire.
+# On accepte espaces multiples mais pas de saut de ligne.
+_RE_CURRENCY = re.compile(
+ r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal)
+ r"(livres?|sols?|deniers?|écus?|florins?|francs?|"
+ r"l\.|s\.|d\.|£|€|₣)" # unité
+ r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole
+ re.UNICODE | re.IGNORECASE,
+)
+
+
+# Années régnales : « an III », « an de grâce 1450 »,
+# « l'an V de la République ».
+# Capture le numéral (romain ou arabe).
+_RE_REGNAL = re.compile(
+ r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?"
+ r"([IVXLCDMivxlcdm]+|\d{1,4})\b",
+ re.UNICODE,
+)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Détection par catégorie
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _detect_years(text: str) -> list[tuple[str, int]]:
+ """Retourne [(forme, valeur)] pour chaque année 4 chiffres."""
+ if not text:
+ return []
+ return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)]
+
+
+def _detect_romans_with_values(text: str) -> list[tuple[str, int]]:
+ """Numéraux romains accompagnés de leur valeur entière.
+ Délègue à ``roman_numerals.detect_roman_numerals`` (Sprint 60),
+ qui retourne ``(start, form, value)``.
+ """
+ if not text:
+ return []
+ out: list[tuple[str, int]] = []
+ for _start, form, value in detect_roman_numerals(text, min_length=2):
+ if value is not None:
+ out.append((form, value))
+ return out
+
+
+def _detect_foliations(text: str) -> list[tuple[str, str]]:
+ """Foliotation. Retourne [(forme_complète, clé_normalisée)] où la
+ clé inclut le suffixe r/v normalisé (recto/verso).
+ """
+ if not text:
+ return []
+ out: list[tuple[str, str]] = []
+ for m in _RE_FOLIATION.finditer(text):
+ full = m.group(0).strip()
+ nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15"
+ suffix = (m.group(2) or "").lower()
+ key = f"{nums}{suffix}"
+ out.append((full, key))
+ return out
+
+
+def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]:
+ """Montants. Clé = (montant_normalisé, unité_canonique).
+
+ L'unité canonique compresse les variantes (« livres » et
+ « livre » → « livre » ; « £ » reste « £ »).
+ """
+ if not text:
+ return []
+ canon = {
+ "livre": "livre", "livres": "livre", "l.": "livre",
+ "sol": "sol", "sols": "sol", "s.": "sol",
+ "denier": "denier", "deniers": "denier", "d.": "denier",
+ "écu": "écu", "écus": "écu",
+ "florin": "florin", "florins": "florin",
+ "franc": "franc", "francs": "franc",
+ "£": "£", "€": "€", "₣": "₣",
+ }
+ out: list[tuple[str, tuple[str, str]]] = []
+ for m in _RE_CURRENCY.finditer(text):
+ amount = m.group(1).replace(",", ".")
+ unit_raw = m.group(2).lower()
+ unit = canon.get(unit_raw, unit_raw)
+ out.append((m.group(0), (amount, unit)))
+ return out
+
+
+def _detect_regnal(text: str) -> list[tuple[str, int]]:
+ """Années régnales. Retourne [(forme, valeur_int)] avec la
+ valeur extraite (romain → int ou arabe → int).
+ """
+ if not text:
+ return []
+ out: list[tuple[str, int]] = []
+ for m in _RE_REGNAL.finditer(text):
+ numeral = m.group(1)
+ value: Optional[int]
+ if numeral.isdigit():
+ value = int(numeral)
+ else:
+ value = roman_to_int(numeral)
+ if value is not None:
+ out.append((m.group(0), value))
+ return out
+
+
+_DETECTORS = {
+ "year": _detect_years,
+ "roman": _detect_romans_with_values,
+ "foliation": _detect_foliations,
+ "currency": _detect_currencies,
+ "regnal": _detect_regnal,
+}
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Calcul principal
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _classify_per_category(
+ gt_items: list,
+ hyp_items: list,
+ *,
+ form_extractor,
+ value_extractor,
+) -> dict:
+ """Pour chaque item GT, le classe en strict_preserved /
+ value_preserved / lost.
+
+ Multiplicité respectée : un item hypothèse ne peut servir
+ qu'à un seul match (forme prioritaire sur valeur).
+ """
+ hyp_used = [False] * len(hyp_items)
+ n_strict = 0
+ n_value = 0
+ lost: list[str] = []
+ # Première passe : matchs stricts (forme exacte)
+ matched: list[bool] = [False] * len(gt_items)
+ for gi, gt_item in enumerate(gt_items):
+ gt_form = form_extractor(gt_item)
+ for hi, hyp_item in enumerate(hyp_items):
+ if hyp_used[hi]:
+ continue
+ if form_extractor(hyp_item) == gt_form:
+ hyp_used[hi] = True
+ matched[gi] = True
+ n_strict += 1
+ break
+ # Deuxième passe : matchs sur valeur (forme différente)
+ for gi, gt_item in enumerate(gt_items):
+ if matched[gi]:
+ n_value += 1 # strict implique value
+ continue
+ gt_val = value_extractor(gt_item)
+ for hi, hyp_item in enumerate(hyp_items):
+ if hyp_used[hi]:
+ continue
+ if value_extractor(hyp_item) == gt_val:
+ hyp_used[hi] = True
+ matched[gi] = True
+ n_value += 1
+ break
+ if not matched[gi]:
+ lost.append(form_extractor(gt_item))
+ n_total = len(gt_items)
+ return {
+ "n_total": n_total,
+ "strict": n_strict,
+ "value": n_value,
+ "strict_score": n_strict / n_total if n_total else 0.0,
+ "value_score": n_value / n_total if n_total else 0.0,
+ "lost_items": lost,
+ }
+
+
+def compute_numerical_sequence_metrics(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+) -> dict:
+ """Calcule la précision sur séquences numériques.
+
+ Returns
+ -------
+ dict
+ Voir docstring du module. Si ``reference`` est vide
+ ou ne contient aucune séquence détectée, retourne
+ ``{n_total: 0, ...}`` avec scores à 0 (pas None).
+ """
+ ref = reference or ""
+ hyp = hypothesis or ""
+
+ # Spécifications par catégorie : (gt_items, hyp_items,
+ # extractor de forme, extractor de valeur).
+ specs: dict[str, dict] = {}
+ # year : (form="1789", value=1789)
+ specs["year"] = {
+ "gt": _detect_years(ref),
+ "hyp": _detect_years(hyp),
+ "form": lambda it: it[0],
+ "value": lambda it: it[1],
+ }
+ # roman : (form="MDCLXVIII", value=1668)
+ specs["roman"] = {
+ "gt": _detect_romans_with_values(ref),
+ "hyp": _detect_romans_with_values(hyp),
+ "form": lambda it: it[0],
+ "value": lambda it: it[1],
+ }
+ # foliation : (form="f. 12r", value="12r")
+ specs["foliation"] = {
+ "gt": _detect_foliations(ref),
+ "hyp": _detect_foliations(hyp),
+ "form": lambda it: it[0],
+ "value": lambda it: it[1],
+ }
+ # currency : (form="12 livres", value=("12", "livre"))
+ specs["currency"] = {
+ "gt": _detect_currencies(ref),
+ "hyp": _detect_currencies(hyp),
+ "form": lambda it: it[0],
+ "value": lambda it: it[1],
+ }
+ # regnal : (form="an III", value=3)
+ specs["regnal"] = {
+ "gt": _detect_regnal(ref),
+ "hyp": _detect_regnal(hyp),
+ "form": lambda it: it[0],
+ "value": lambda it: it[1],
+ }
+
+ per_category: dict[str, dict] = {}
+ total = 0
+ total_strict = 0
+ total_value = 0
+ for cat, spec in specs.items():
+ breakdown = _classify_per_category(
+ spec["gt"], spec["hyp"],
+ form_extractor=spec["form"],
+ value_extractor=spec["value"],
+ )
+ per_category[cat] = breakdown
+ total += breakdown["n_total"]
+ total_strict += breakdown["strict"]
+ total_value += breakdown["value"]
+
+ return {
+ "n_total": total,
+ "global_strict_score": (
+ total_strict / total if total else 0.0
+ ),
+ "global_value_score": (
+ total_value / total if total else 0.0
+ ),
+ "per_category": per_category,
+ }
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Enregistrement registre typé
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@register_metric(
+ name="numerical_sequence_strict_score",
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
+ description=(
+ "Précision sur séquences numériques en mode strict (forme "
+ "préservée). Couvre années arabes, numéraux romains, "
+ "foliotation, montants Ancien Régime, années régnales."
+ ),
+)
+def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float:
+ return compute_numerical_sequence_metrics(
+ reference, hypothesis,
+ )["global_strict_score"]
+
+
+@register_metric(
+ name="numerical_sequence_value_score",
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
+ description=(
+ "Précision sur séquences numériques en mode valeur "
+ "(la valeur est préservée même si la forme diffère, "
+ "ex. XIV → 14)."
+ ),
+)
+def numerical_sequence_value_score(reference: str, hypothesis: str) -> float:
+ return compute_numerical_sequence_metrics(
+ reference, hypothesis,
+ )["global_value_score"]
+
+
+__all__ = [
+ "CATEGORIES",
+ "compute_numerical_sequence_metrics",
+ "numerical_sequence_strict_score",
+ "numerical_sequence_value_score",
+]
diff --git a/picarones/measurements/numerical_sequences_runner.py b/picarones/measurements/numerical_sequences_runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..c1d6d1f429e85c11efa6a2a5a6db632c0566c976
--- /dev/null
+++ b/picarones/measurements/numerical_sequences_runner.py
@@ -0,0 +1,102 @@
+"""Câblage runner des séquences numériques (Sprint 86).
+
+Sprint 86 — A.II.5b (vue HTML + câblage runner).
+
+Le module ``picarones/core/numerical_sequences.py`` (Sprint 85)
+a livré la couche de calcul. Ce helper prépare la donnée
+adaptative pour le runner et agrège les compteurs par moteur.
+
+Adaptive masking
+----------------
+On ne stocke le résultat que si la GT contient au moins une
+séquence numérique détectée — sinon le module n'apparaît pas
+dans le rapport.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Iterable, Optional
+
+from picarones.core.numerical_sequences import (
+ CATEGORIES,
+ compute_numerical_sequence_metrics,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def compute_numerical_sequence_metrics_adaptive(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+) -> Optional[dict]:
+ """Calcule les métriques séquences numériques avec masquage
+ adaptatif : retourne ``None`` si la GT n'en contient
+ aucune."""
+ if not reference:
+ return None
+ result = compute_numerical_sequence_metrics(reference, hypothesis or "")
+ if (result.get("n_total") or 0) == 0:
+ return None
+ return result
+
+
+def aggregate_numerical_sequence_metrics(
+ per_doc: Iterable[Optional[dict]],
+) -> Optional[dict]:
+ """Agrège par moteur : somme les compteurs par catégorie et
+ recalcule les scores globaux et per-category.
+
+ Format de sortie identique à ``compute_numerical_sequence_metrics``
+ pour faciliter le rendu HTML symétrique.
+ """
+ docs = [d for d in per_doc if d]
+ if not docs:
+ return None
+ total_n = 0
+ total_strict = 0
+ total_value = 0
+ per_cat: dict[str, dict] = {}
+ for cat in CATEGORIES:
+ per_cat[cat] = {
+ "n_total": 0,
+ "strict": 0,
+ "value": 0,
+ "lost_items": [],
+ }
+ for d in docs:
+ for cat in CATEGORIES:
+ cat_data = (d.get("per_category") or {}).get(cat) or {}
+ per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0)
+ per_cat[cat]["strict"] += int(cat_data.get("strict") or 0)
+ per_cat[cat]["value"] += int(cat_data.get("value") or 0)
+ per_cat[cat]["lost_items"].extend(
+ cat_data.get("lost_items") or [],
+ )
+ total_n += int(d.get("n_total") or 0)
+ # Recalcul des scores
+ for cat, slot in per_cat.items():
+ n = slot["n_total"]
+ slot["strict_score"] = slot["strict"] / n if n else 0.0
+ slot["value_score"] = slot["value"] / n if n else 0.0
+ # Cap des lost_items à 50 par catégorie
+ slot["lost_items"] = slot["lost_items"][:50]
+ total_strict += slot["strict"]
+ total_value += slot["value"]
+ return {
+ "n_docs": len(docs),
+ "n_total": total_n,
+ "global_strict_score": (
+ total_strict / total_n if total_n else 0.0
+ ),
+ "global_value_score": (
+ total_value / total_n if total_n else 0.0
+ ),
+ "per_category": per_cat,
+ }
+
+
+__all__ = [
+ "compute_numerical_sequence_metrics_adaptive",
+ "aggregate_numerical_sequence_metrics",
+]
diff --git a/picarones/measurements/pricing.py b/picarones/measurements/pricing.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ebf1bea27ba9b17d80ba2f6d3f2e1c84e7192d3
--- /dev/null
+++ b/picarones/measurements/pricing.py
@@ -0,0 +1,309 @@
+"""Modélisation des coûts — APIs cloud et temps d'inférence local.
+
+Sert uniquement à la vue Pareto coût/qualité du rapport (Sprint 5).
+Les prix sont indicatifs et vieillissent vite : voir ``picarones/data/pricing.yaml``
+pour les hypothèses, dates et URLs de référence.
+
+Conventions
+-----------
+- Unité monétaire : EUR (conversion indicative depuis USD quand applicable).
+- Coût exprimé par **1 000 pages** traitées.
+- Coût local = temps moyen d'inférence × taux horaire (paramétrable).
+- Empreinte carbone optionnelle : kWh × intensité g CO₂/kWh du réseau
+ d'exécution (mix France bas carbone par défaut pour le local,
+ moyenne cloud hyperscaler pour les APIs).
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+import yaml
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_PRICING_PATH = Path(__file__).parent.parent / "data" / "pricing.yaml"
+
+
+@dataclass(frozen=True)
+class PricingDefaults:
+ """Valeurs par défaut du fichier de prix (section ``meta``)."""
+
+ last_updated: Optional[str] = None
+ currency: str = "EUR"
+ hourly_rate_local_cpu_eur: float = 0.08
+ hourly_rate_local_gpu_eur: float = 1.20
+ grid_intensity_local: float = 58.0
+ grid_intensity_cloud: float = 380.0
+
+
+@dataclass
+class EngineCost:
+ """Coût estimé d'un moteur sur 1 000 pages, avec traçabilité des hypothèses.
+
+ La représentation est immuable après construction : une fois que l'utilisateur
+ a choisi un taux horaire local, toutes les instances partagent cette
+ hypothèse par injection explicite dans ``build_costs_for_benchmark``.
+ """
+
+ engine_key: str
+ """Nom ou modèle servant de clé dans la table (ex. ``"gpt-4o"``, ``"tesseract"``)."""
+
+ type: str # "local" | "cloud_api" | "unknown"
+
+ cost_per_1k_pages_eur: Optional[float] = None
+ """Coût par 1 000 pages en euros. ``None`` si les données sont insuffisantes."""
+
+ currency: str = "EUR"
+
+ # Source / date
+ pricing_source_url: Optional[str] = None
+ pricing_date: Optional[str] = None
+
+ # Pour les APIs cloud : prix brut
+ api_price_per_1k_pages: Optional[float] = None
+
+ # Pour le local : temps d'inférence et taux horaire utilisés
+ local_mean_seconds_per_page: Optional[float] = None
+ hourly_rate_eur: Optional[float] = None
+
+ # Empreinte carbone (estimation — étiquetée "expérimentale" dans le rapport)
+ kwh_per_1k_pages: Optional[float] = None
+ grid_intensity_g_co2_per_kwh: Optional[float] = None
+ co2_per_1k_pages_g: Optional[float] = None
+
+ notes: Optional[str] = None
+
+ assumptions: list[str] = field(default_factory=list)
+ """Liste d'hypothèses textuelles à afficher sous le graphique."""
+
+ def as_dict(self) -> dict:
+ return {
+ "engine_key": self.engine_key,
+ "type": self.type,
+ "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur,
+ "currency": self.currency,
+ "pricing_source_url": self.pricing_source_url,
+ "pricing_date": self.pricing_date,
+ "api_price_per_1k_pages": self.api_price_per_1k_pages,
+ "local_mean_seconds_per_page": self.local_mean_seconds_per_page,
+ "hourly_rate_eur": self.hourly_rate_eur,
+ "kwh_per_1k_pages": self.kwh_per_1k_pages,
+ "grid_intensity_g_co2_per_kwh": self.grid_intensity_g_co2_per_kwh,
+ "co2_per_1k_pages_g": self.co2_per_1k_pages_g,
+ "notes": self.notes,
+ "assumptions": list(self.assumptions),
+ }
+
+
+def load_pricing_database(path: Optional[Path] = None) -> tuple[PricingDefaults, dict]:
+ """Charge la table de prix YAML.
+
+ Retourne ``(defaults, engines_table)`` où ``engines_table`` est un dict
+ ``{engine_key: raw_entry}``.
+ """
+ path = Path(path) if path else _DEFAULT_PRICING_PATH
+ if not path.exists():
+ logger.warning("[pricing] fichier %s introuvable", path)
+ return PricingDefaults(), {}
+ try:
+ with path.open(encoding="utf-8") as fh:
+ data = yaml.safe_load(fh) or {}
+ except yaml.YAMLError as e:
+ logger.warning("[pricing] échec parsing %s : %s", path, e)
+ return PricingDefaults(), {}
+
+ meta = data.get("meta", {}) or {}
+ defaults = PricingDefaults(
+ last_updated=meta.get("last_updated"),
+ currency=meta.get("currency", "EUR"),
+ hourly_rate_local_cpu_eur=float(meta.get("default_hourly_rate_local_cpu_eur", 0.08)),
+ hourly_rate_local_gpu_eur=float(meta.get("default_hourly_rate_local_gpu_eur", 1.20)),
+ grid_intensity_local=float(meta.get("default_grid_intensity_g_co2_per_kwh", 58.0)),
+ grid_intensity_cloud=float(meta.get("cloud_grid_intensity_g_co2_per_kwh", 380.0)),
+ )
+ engines_table = data.get("engines", {}) or {}
+ return defaults, engines_table
+
+
+def _match_key(engine_name: str, llm_model: Optional[str], table: dict) -> Optional[str]:
+ """Cherche la meilleure clé pour ce moteur dans la table.
+
+ Stratégie : d'abord le nom du modèle LLM (pour les pipelines), puis le
+ nom OCR, puis un match partiel (substring) comme filet de sécurité.
+ """
+ candidates = [llm_model, engine_name]
+ for c in candidates:
+ if c and c in table:
+ return c
+ # Matching partiel — utile pour "tesseract → gpt-4o" ou "gpt-4o-vision"
+ for c in candidates:
+ if not c:
+ continue
+ for key in table:
+ if key in c:
+ return key
+ return None
+
+
+def estimate_cost(
+ engine_name: str,
+ *,
+ llm_model: Optional[str] = None,
+ is_pipeline: bool = False,
+ measured_seconds_per_page: Optional[float] = None,
+ table: Optional[dict] = None,
+ defaults: Optional[PricingDefaults] = None,
+ hourly_rate_override_eur: Optional[float] = None,
+) -> EngineCost:
+ """Calcule le ``EngineCost`` pour un moteur donné.
+
+ Parameters
+ ----------
+ engine_name:
+ Nom public du moteur (ex. ``"tesseract"``, ``"tesseract → gpt-4o"``).
+ llm_model:
+ Si pipeline OCR+LLM, le modèle LLM utilisé — prioritaire pour la
+ lookup car c'est lui qui domine le coût.
+ is_pipeline:
+ Indique un pipeline OCR+LLM (change la sémantique de lookup).
+ measured_seconds_per_page:
+ Temps moyen observé sur le benchmark courant. Remplace la valeur
+ indicative de la table si fournie (plus fiable).
+ table, defaults:
+ Overrides pour tests ou usage institutionnel.
+ hourly_rate_override_eur:
+ Taux horaire à utiliser pour le calcul local (sinon valeur table
+ ou défaut).
+ """
+ if table is None or defaults is None:
+ _defaults, _table = load_pricing_database()
+ defaults = defaults or _defaults
+ table = table or _table
+
+ key = _match_key(engine_name, llm_model if is_pipeline else None, table)
+ if key is None:
+ return EngineCost(
+ engine_key=engine_name,
+ type="unknown",
+ assumptions=["Aucune entrée dans la table de prix pour ce moteur."],
+ )
+
+ entry = table[key]
+ etype = str(entry.get("type", "unknown"))
+ notes = entry.get("notes")
+ assumptions: list[str] = []
+ currency = defaults.currency
+
+ cost_eur: Optional[float] = None
+ api_price: Optional[float] = None
+ local_seconds = measured_seconds_per_page
+ hourly_rate = None
+
+ if etype == "cloud_api":
+ api_price = entry.get("api_price_per_1k_pages")
+ if api_price is not None:
+ cost_eur = float(api_price)
+ assumptions.append(
+ f"Prix API indicatif : {cost_eur:.2f} €/1000 pages "
+ f"(source : {entry.get('pricing_source_url', '—')}, {entry.get('pricing_date', 'date inconnue')})."
+ )
+ elif etype == "local":
+ indicative_seconds = entry.get("local_mean_seconds_per_page")
+ if local_seconds is None and indicative_seconds is not None:
+ local_seconds = float(indicative_seconds)
+ assumptions.append(
+ f"Temps d'inférence indicatif : {local_seconds:.1f} s/page (non mesuré sur ce benchmark)."
+ )
+ elif local_seconds is not None:
+ assumptions.append(
+ f"Temps d'inférence mesuré : {local_seconds:.1f} s/page (moyenne sur le corpus)."
+ )
+
+ hourly_rate = (
+ hourly_rate_override_eur
+ if hourly_rate_override_eur is not None
+ else entry.get("hourly_rate_override_eur")
+ )
+ if hourly_rate is None:
+ # Heuristique : si l'entrée précise un override GPU, sinon CPU
+ hourly_rate = (
+ defaults.hourly_rate_local_gpu_eur
+ if "gpu" in str(notes or "").lower()
+ else defaults.hourly_rate_local_cpu_eur
+ )
+ hourly_rate = float(hourly_rate)
+
+ if local_seconds is not None and hourly_rate is not None:
+ cost_eur = (local_seconds / 3600.0) * hourly_rate * 1000.0
+ assumptions.append(
+ f"Taux horaire appliqué : {hourly_rate:.2f} €/h "
+ f"(défaut {'GPU' if hourly_rate >= 0.5 else 'CPU'})."
+ )
+
+ # Empreinte carbone optionnelle
+ kwh_1k = entry.get("kwh_per_1k_pages")
+ grid = (
+ entry.get("grid_intensity_g_co2_per_kwh")
+ or (defaults.grid_intensity_cloud if etype == "cloud_api" else defaults.grid_intensity_local)
+ )
+ co2_g = None
+ if kwh_1k is not None and grid is not None:
+ co2_g = float(kwh_1k) * float(grid)
+
+ return EngineCost(
+ engine_key=key,
+ type=etype,
+ cost_per_1k_pages_eur=cost_eur,
+ currency=currency,
+ pricing_source_url=entry.get("pricing_source_url"),
+ pricing_date=entry.get("pricing_date"),
+ api_price_per_1k_pages=api_price,
+ local_mean_seconds_per_page=local_seconds,
+ hourly_rate_eur=hourly_rate,
+ kwh_per_1k_pages=float(kwh_1k) if kwh_1k is not None else None,
+ grid_intensity_g_co2_per_kwh=float(grid) if grid is not None else None,
+ co2_per_1k_pages_g=co2_g,
+ notes=notes,
+ assumptions=assumptions,
+ )
+
+
+def build_costs_for_benchmark(
+ engines_summary: list[dict],
+ durations_by_engine: dict[str, float],
+ *,
+ hourly_rate_local_eur: Optional[float] = None,
+ pricing_path: Optional[Path] = None,
+) -> dict[str, dict]:
+ """Calcule le coût de chaque moteur d'un benchmark.
+
+ Returns
+ -------
+ dict ``{engine_name: EngineCost.as_dict()}``.
+ """
+ defaults, table = load_pricing_database(pricing_path)
+ out: dict[str, dict] = {}
+ for e in engines_summary:
+ name = e.get("name")
+ if not name:
+ continue
+ measured = durations_by_engine.get(name)
+ llm_model = None
+ pipeline_info = e.get("pipeline_info") or {}
+ if pipeline_info:
+ llm_model = pipeline_info.get("llm_model")
+ cost = estimate_cost(
+ engine_name=name,
+ llm_model=llm_model,
+ is_pipeline=bool(e.get("is_pipeline")),
+ measured_seconds_per_page=measured,
+ table=table,
+ defaults=defaults,
+ hourly_rate_override_eur=hourly_rate_local_eur,
+ )
+ out[name] = cost.as_dict()
+ return out
diff --git a/picarones/measurements/rare_tokens.py b/picarones/measurements/rare_tokens.py
new file mode 100644
index 0000000000000000000000000000000000000000..69f320e2c1b1922285c16f708f74240b51713709
--- /dev/null
+++ b/picarones/measurements/rare_tokens.py
@@ -0,0 +1,254 @@
+"""Rare-token recall — Sprint 71 (A.I.1 chantier 2 du plan 2026).
+
+Pourquoi ce module
+------------------
+Le CER global d'un moteur peut sembler bon (ex. 5 %) tout en
+masquant des **erreurs systématiques sur les tokens rares** : noms
+propres, toponymes peu fréquents, mots techniques, formules latines
+récurrentes mais pas dominantes. Pour un usage prosopographique
+(indexation de noms, recherche généalogique), ce sont précisément
+ces tokens-là qui comptent.
+
+Ce module mesure le **rappel sur les tokens rares** d'un corpus —
+défaut : tokens dont la fréquence corpus-wide est ≤ 2 (hapax +
+dis legomena, terminologie de lexicométrie classique).
+
+Hypothèse à valider expérimentalement
+-------------------------------------
+La conjecture du plan A.I.1 : *« cette métrique discrimine plus
+les moteurs que le CER global »*. Si confirmée sur un corpus
+patrimonial réel, elle gagne sa place dans le tableau de
+classement principal — décision laissée au chercheur après
+observation.
+
+Stratégie de découpage
+----------------------
+Cohérente avec NER (38), Flesch (52), philologie (55-60) : couche
+de calcul pure d'abord, sans intégration runner. La vue HTML
+« worst lines / rare tokens manqués » suit dans un sprint dédié.
+
+Pas d'enregistrement dans le registre typé Sprint 34
+----------------------------------------------------
+La métrique exige **trois entrées** (reference, hypothesis, set
+des tokens rares) et le set des rares est calculé corpus-wide
+(donc connu seulement après itération sur tout le corpus). La
+signature ne rentre pas dans ``(TEXT, TEXT)``. L'utilisateur
+appelle explicitement ``compute_rare_token_recall`` avec le set
+qu'il a calculé.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from collections import Counter
+from typing import Iterable, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Tokenisation Unicode-aware
+# ──────────────────────────────────────────────────────────────────────────
+
+# Token = séquence maximale de caractères de mot Unicode (\w en
+# Python 3 utilise déjà la table Unicode), incluant l'apostrophe
+# typographique '’' à l'intérieur (« l'an », « d’une ») et les
+# tirets internes (« peut-être »). La ponctuation isolée et les
+# espaces sont des séparateurs.
+
+_TOKEN_RE = re.compile(
+ r"\w+(?:[’'\-]\w+)*",
+ flags=re.UNICODE,
+)
+
+
+def tokenize(text: Optional[str]) -> list[str]:
+ """Tokenisation Unicode-aware.
+
+ Conserve les contractions (``l'an``, ``d’une``) et les mots
+ composés (``peut-être``, ``c'est-à-dire``) comme un seul token.
+ Casse préservée — l'utilisateur normalise lui-même via
+ ``case_sensitive=False`` dans les fonctions aval s'il le veut.
+ """
+ if not text:
+ return []
+ return _TOKEN_RE.findall(text)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Distribution de fréquence corpus-wide
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def frequency_distribution(
+ documents: Iterable[str],
+ *,
+ case_sensitive: bool = False,
+) -> Counter[str]:
+ """Calcule ``{token: count}`` sur l'ensemble du corpus.
+
+ Parameters
+ ----------
+ documents:
+ Itérable de textes (typiquement les ``ground_truth`` des
+ documents du corpus).
+ case_sensitive:
+ Si ``False`` (défaut), tous les tokens sont mis en
+ minuscule avant comptage.
+ """
+ counter: Counter[str] = Counter()
+ for doc in documents:
+ tokens = tokenize(doc)
+ if not case_sensitive:
+ tokens = [t.lower() for t in tokens]
+ counter.update(tokens)
+ return counter
+
+
+def extract_rare_tokens(
+ documents: Iterable[str],
+ *,
+ max_freq: int = 2,
+ case_sensitive: bool = False,
+) -> frozenset[str]:
+ """Retourne l'ensemble des tokens dont la fréquence
+ corpus-wide est ``≤ max_freq``.
+
+ Convention de lexicométrie : ``max_freq=1`` retourne uniquement
+ les hapax legomena (1 occurrence) ; ``max_freq=2`` retourne
+ hapax + dis legomena (≤ 2 occurrences) — défaut.
+
+ Les tokens qui n'apparaissent **jamais** dans le corpus ne sont
+ évidemment pas inclus (le ``Counter`` ne les liste pas).
+ """
+ if max_freq < 1:
+ raise ValueError("max_freq doit être ≥ 1")
+ counter = frequency_distribution(
+ documents, case_sensitive=case_sensitive,
+ )
+ return frozenset(t for t, c in counter.items() if c <= max_freq)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Calcul du rappel par document
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def compute_rare_token_recall(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+ rare_tokens: Iterable[str],
+ *,
+ case_sensitive: bool = False,
+) -> dict:
+ """Calcule le rappel sur les tokens rares présents dans la GT.
+
+ Parameters
+ ----------
+ reference:
+ Texte GT du document.
+ hypothesis:
+ Texte produit par l'OCR.
+ rare_tokens:
+ Itérable des tokens rares — typiquement le résultat de
+ ``extract_rare_tokens`` sur le corpus complet.
+ case_sensitive:
+ Si ``False`` (défaut), la comparaison se fait sur les
+ formes minuscules.
+
+ Returns
+ -------
+ dict
+ ``{
+ "n_rare_tokens_in_reference": int,
+ # nombre d'**occurrences** de tokens rares dans la GT
+ # (multiplicité préservée — un token rare présent 2
+ # fois compte 2)
+ "n_rare_tokens_recalled": int,
+ # nombre d'occurrences correctement présentes dans hyp
+ # (alignement bag-of-tokens : min(count_ref, count_hyp))
+ "recall": float,
+ # ratio dans [0, 1], ou 0.0 si aucun rare en GT
+ "missed_tokens": list[str],
+ # liste des tokens rares **manqués** (avec multiplicité,
+ # ex. "Dupont" présent 2 fois en GT et 1 fois en hyp →
+ # missed_tokens contient ["Dupont"] une fois)
+ }``
+
+ Cas dégénérés
+ -------------
+ - GT vide ou aucun token rare présent → recall = 0.0, listes
+ vides (convention : on ne récompense pas l'absence de
+ tokens rares).
+ - Hyp vide avec rares en GT → tous manqués, recall = 0.0.
+ """
+ ref = reference or ""
+ hyp = hypothesis or ""
+
+ if case_sensitive:
+ rare_set = frozenset(rare_tokens)
+ ref_tokens = tokenize(ref)
+ hyp_tokens = tokenize(hyp)
+ else:
+ rare_set = frozenset(t.lower() for t in rare_tokens)
+ ref_tokens = [t.lower() for t in tokenize(ref)]
+ hyp_tokens = [t.lower() for t in tokenize(hyp)]
+
+ # Multiplicité : on compte uniquement les rares présents dans la GT
+ ref_rare_counts: Counter[str] = Counter(
+ t for t in ref_tokens if t in rare_set
+ )
+ n_rare_in_ref = sum(ref_rare_counts.values())
+ if n_rare_in_ref == 0:
+ return {
+ "n_rare_tokens_in_reference": 0,
+ "n_rare_tokens_recalled": 0,
+ "recall": 0.0,
+ "missed_tokens": [],
+ }
+
+ # Bag-of-tokens dans hyp pour les tokens rares uniquement
+ hyp_rare_counts: Counter[str] = Counter(
+ t for t in hyp_tokens if t in rare_set
+ )
+ # Recall multiplicitaire : pour chaque token, min(ref_count, hyp_count)
+ n_recalled = 0
+ missed: list[str] = []
+ for token, ref_count in ref_rare_counts.items():
+ hyp_count = hyp_rare_counts.get(token, 0)
+ recalled = min(ref_count, hyp_count)
+ n_recalled += recalled
+ missed_count = ref_count - recalled
+ if missed_count > 0:
+ missed.extend([token] * missed_count)
+
+ return {
+ "n_rare_tokens_in_reference": n_rare_in_ref,
+ "n_rare_tokens_recalled": n_recalled,
+ "recall": n_recalled / n_rare_in_ref,
+ "missed_tokens": missed,
+ }
+
+
+def rare_token_recall(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+ rare_tokens: Iterable[str],
+ *,
+ case_sensitive: bool = False,
+) -> float:
+ """Raccourci : retourne uniquement le rappel ∈ [0, 1]."""
+ return compute_rare_token_recall(
+ reference, hypothesis, rare_tokens,
+ case_sensitive=case_sensitive,
+ )["recall"]
+
+
+__all__ = [
+ "tokenize",
+ "frequency_distribution",
+ "extract_rare_tokens",
+ "compute_rare_token_recall",
+ "rare_token_recall",
+]
diff --git a/picarones/measurements/readability.py b/picarones/measurements/readability.py
new file mode 100644
index 0000000000000000000000000000000000000000..941709c25b9f539366eb9c1a15a8625a97820d88
--- /dev/null
+++ b/picarones/measurements/readability.py
@@ -0,0 +1,252 @@
+"""Métriques de lisibilité (Flesch) — Sprint 52.
+
+Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure
+de la métrique Flesch, indépendante de tout alignement OCR/GT.
+
+Pourquoi ce module
+------------------
+Les LLM produisent du texte plus « lisse » que les manuscrits
+historiques. Cette tendance à la modernisation est mesurable par la
+différence de score de lisibilité entre la GT et la sortie OCR/LLM —
+**indépendamment des classes taxonomiques** et **sans alignement
+caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne
+même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte
+moderne plausible mais déconnecté de la GT).
+
+Stratégie de découpage
+----------------------
+Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
+découpe :
+
+- **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
+ ``flesch_delta``. Aucune dépendance externe ; les heuristiques de
+ comptage de syllabes sont en pur Python, déterministes, testées.
+- **Sprints suivants** — câblage runner pour calculer
+ ``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
+
+Formules
+--------
+- **Anglais** (Flesch original 1948) :
+ ``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)``
+- **Français** (Kandel-Moles 1958) :
+ ``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)``
+
+Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire »,
+0 ↔ « très difficile ». Une **augmentation** du score quand on passe
+de la GT à l'OCR signale une simplification (typique des LLM
+modernisants). Une **chute** signale une dégradation OCR.
+
+Limites documentées
+-------------------
+- Le comptage de syllabes est heuristique. En français, des règles
+ comme « -ier non final = 2 syllabes » ne sont pas appliquées
+ finement. Acceptable pour une métrique de **comparaison relative**
+ (delta GT vs OCR), pas pour publier une absolue.
+- Sur des textes très courts (< 20 mots), la formule perd en
+ fiabilité. Le seuil minimal est documenté.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Literal
+
+from picarones.core.metric_registry import register_metric
+from picarones.core.modules import ArtifactType
+
+logger = logging.getLogger(__name__)
+
+
+Language = Literal["fr", "en"]
+
+# Coefficients de la formule Flesch selon la langue.
+_FLESCH_COEFFS: dict[str, tuple[float, float, float]] = {
+ "en": (206.835, 1.015, 84.6), # Flesch 1948
+ "fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958
+}
+
+# Voyelles utilisées pour l'heuristique de comptage de syllabes.
+# On utilise un set qui inclut les diacritiques courantes en FR/EN.
+_VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ")
+
+# Regex de découpage en phrases : ponctuation finale + espace ou fin.
+# Tolère les multiples points (« ... ») et garde un découpage robuste.
+_SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)")
+
+# Regex de tokenisation simple (mots) : séquences de caractères "lettres".
+_WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Compteurs de base
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def count_words(text: str) -> int:
+ """Nombre de mots (tokens alphanumériques) dans ``text``."""
+ if not text:
+ return 0
+ return len(_WORD_RE.findall(text))
+
+
+def count_sentences(text: str) -> int:
+ """Nombre de phrases dans ``text``.
+
+ Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``).
+ Renvoie au minimum 1 si ``text`` contient au moins un mot, pour
+ éviter une division par zéro dans la formule de Flesch sur les
+ textes sans ponctuation finale.
+ """
+ if not text:
+ return 0
+ parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
+ n = len(parts)
+ if n == 0 and count_words(text) > 0:
+ return 1
+ return n
+
+
+def count_syllables_word(word: str) -> int:
+ """Heuristique de comptage de syllabes pour un mot isolé.
+
+ Règle : on compte les **groupes de voyelles consécutives** (en
+ incluant ``y`` et les diacritiques courantes). C'est une
+ approximation grossière mais déterministe et testable.
+
+ Cas limites :
+ - mot vide → 0
+ - mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``)
+ - mot d'une seule voyelle isolée → 1
+ """
+ if not word:
+ return 0
+ word = word.lower()
+ in_vowel_group = False
+ count = 0
+ for ch in word:
+ if ch in _VOWELS:
+ if not in_vowel_group:
+ count += 1
+ in_vowel_group = True
+ else:
+ in_vowel_group = False
+ return count or 1
+
+
+def count_syllables(text: str) -> int:
+ """Somme des syllabes de tous les mots de ``text``."""
+ if not text:
+ return 0
+ return sum(count_syllables_word(w) for w in _WORD_RE.findall(text))
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Score Flesch
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def flesch_score(text: str, lang: Language = "fr") -> float:
+ """Calcule le score de lisibilité Flesch pour ``text``.
+
+ Parameters
+ ----------
+ text:
+ Texte à évaluer. Peut contenir ponctuation, accents, etc.
+ lang:
+ ``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948).
+
+ Returns
+ -------
+ float
+ Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte
+ vide ou sans mot exploitable.
+
+ Notes
+ -----
+ Le score chute fortement avec :
+ - longues phrases (mots/phrases élevé)
+ - mots polysyllabiques (syllabes/mots élevé)
+ Une montée du score lors du passage GT → OCR signale qu'un LLM a
+ « lissé » la langue (phrases plus courtes, mots plus communs).
+ """
+ if lang not in _FLESCH_COEFFS:
+ raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.")
+
+ n_words = count_words(text)
+ if n_words == 0:
+ return 0.0
+ n_sentences = max(1, count_sentences(text))
+ n_syllables = count_syllables(text)
+ if n_syllables == 0:
+ return 0.0
+
+ base, k_words, k_syll = _FLESCH_COEFFS[lang]
+ raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words)
+ return max(0.0, min(100.0, raw))
+
+
+def flesch_delta(
+ reference: str,
+ hypothesis: str,
+ lang: Language = "fr",
+) -> float:
+ """Différence ``flesch_score(hypothesis) - flesch_score(reference)``.
+
+ Interprétation
+ --------------
+ - **Positif** : l'hypothèse OCR est plus lisible que la GT —
+ signal d'**over-normalisation** (typique des LLM qui modernisent
+ des textes anciens).
+ - **Négatif** : l'OCR est moins lisible — signal de dégradation
+ (caractères mal reconnus brisent la fluidité).
+ - **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique.
+
+ Borné dans ``[-100, +100]``.
+ """
+ return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Enregistrement dans le registre typé (Sprint 34)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@register_metric(
+ name="flesch_delta_fr",
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
+ description=(
+ "Différence de score Flesch (Kandel-Moles, FR) entre la sortie "
+ "OCR et la GT. Positif = OCR plus lisible (signal "
+ "d'over-normalisation LLM). Aucun alignement requis."
+ ),
+ higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant
+ tags={"text", "readability", "over_normalization"},
+)
+def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float:
+ return flesch_delta(reference, hypothesis, lang="fr")
+
+
+@register_metric(
+ name="flesch_delta_en",
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
+ description=(
+ "Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. "
+ "Positive = OCR easier to read than GT (LLM smoothing signal). "
+ "No alignment required."
+ ),
+ higher_is_better=False,
+ tags={"text", "readability", "over_normalization"},
+)
+def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float:
+ return flesch_delta(reference, hypothesis, lang="en")
+
+
+__all__ = [
+ "flesch_score",
+ "flesch_delta",
+ "count_words",
+ "count_sentences",
+ "count_syllables",
+ "count_syllables_word",
+]
diff --git a/picarones/measurements/readability_runner.py b/picarones/measurements/readability_runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..073b5f16fca2a59993e08453c191f915d8f75405
--- /dev/null
+++ b/picarones/measurements/readability_runner.py
@@ -0,0 +1,114 @@
+"""Câblage runner du delta Flesch (Sprint 87 — A.II.2).
+
+Sprint 87 — A.II.2 (vue HTML + câblage runner du delta Flesch
+livré par le Sprint 52).
+
+Pourquoi ce module
+------------------
+Le ``flesch_delta`` mesure la différence de lisibilité entre la
+GT et la sortie OCR. Un score positif signale une *over-
+normalisation* typique des LLM/VLM qui modernisent un texte
+ancien (le Flesch monte parce que les mots sont plus simples) ;
+un score négatif signale une dégradation OCR brutale.
+
+Cette métrique est calculée **automatiquement** par le runner
+sur chaque document, agrégée par moteur, et présentée dans le
+rapport.
+
+Adaptive masking
+----------------
+On ne calcule que si la GT contient ≥ 5 mots — en dessous, le
+Flesch est trop instable pour être informatif.
+
+Langue
+------
+Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour
+les corpus mixtes, l'utilisateur peut passer une langue
+explicite à l'orchestrateur.
+"""
+
+from __future__ import annotations
+
+import logging
+import statistics
+from typing import Iterable, Optional
+
+from picarones.core.readability import (
+ Language,
+ count_words,
+ flesch_delta,
+ flesch_score,
+)
+
+logger = logging.getLogger(__name__)
+
+
+_MIN_WORDS_FOR_FLESCH = 5
+
+
+def compute_readability_metrics(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+ *,
+ lang: Language = "fr",
+) -> Optional[dict]:
+ """Calcule le delta Flesch d'un document avec adaptive masking.
+
+ Retourne ``None`` si la GT contient moins de
+ ``_MIN_WORDS_FOR_FLESCH`` mots.
+ """
+ ref = reference or ""
+ n_ref_words = count_words(ref)
+ if n_ref_words < _MIN_WORDS_FOR_FLESCH:
+ return None
+ hyp = hypothesis or ""
+ flesch_ref = flesch_score(ref, lang=lang)
+ flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None
+ delta = (
+ flesch_delta(ref, hyp, lang=lang) if hyp else None
+ )
+ return {
+ "lang": lang,
+ "flesch_reference": flesch_ref,
+ "flesch_hypothesis": flesch_hyp,
+ "flesch_delta": delta,
+ "n_words_reference": n_ref_words,
+ }
+
+
+def aggregate_readability_metrics(
+ per_doc: Iterable[Optional[dict]],
+) -> Optional[dict]:
+ """Agrège : moyenne/médiane des deltas + part de docs
+ « over-normalisés » (delta > +5 points).
+ """
+ docs = [d for d in per_doc if d]
+ if not docs:
+ return None
+ deltas = [
+ float(d["flesch_delta"]) for d in docs
+ if isinstance(d.get("flesch_delta"), (int, float))
+ ]
+ if not deltas:
+ return None
+ over_norm = sum(1 for d in deltas if d > 5.0)
+ under_norm = sum(1 for d in deltas if d < -5.0)
+ lang = docs[0].get("lang") or "fr"
+ return {
+ "lang": lang,
+ "n_docs": len(docs),
+ "n_docs_with_delta": len(deltas),
+ "delta_mean": statistics.fmean(deltas),
+ "delta_median": statistics.median(deltas),
+ "delta_min": min(deltas),
+ "delta_max": max(deltas),
+ "n_over_normalized": over_norm,
+ "n_under_normalized": under_norm,
+ "over_normalized_rate": over_norm / len(deltas),
+ }
+
+
+__all__ = [
+ "compute_readability_metrics",
+ "aggregate_readability_metrics",
+]
diff --git a/picarones/measurements/reading_order.py b/picarones/measurements/reading_order.py
new file mode 100644
index 0000000000000000000000000000000000000000..655557dcffc56ed40554a1ea2369542f635859a1
--- /dev/null
+++ b/picarones/measurements/reading_order.py
@@ -0,0 +1,196 @@
+"""Reading order F1 (ICDAR 2015, Antonacopoulos) — Sprint 53.
+
+Sprint 53 — A.II.2.1 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Sur un manuscrit glosé, un journal multi-colonnes ou un registre
+paroissial complexe, le **classement des moteurs en CER** peut être
+trompeur : un moteur peut avoir un excellent CER caractère et un
+**ordre de lecture catastrophique**. Le résultat est inutilisable
+pour la recherche plein texte (Elastic, Solr) ou pour reconstituer
+une narration linéaire.
+
+La métrique standard est définie par Antonacopoulos et al. dans
+ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions
+ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède
+``b`` dans la GT :
+
+- **TP** si ``a`` précède aussi ``b`` dans l'hypothèse,
+- **FN** si la paire est manquante (régions absentes ou ordre
+ inversé) côté hypothèse,
+- **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que
+ la GT n'a pas cet ordre (régions hallucinées ou inversion).
+
+Le F1 est la moyenne harmonique des deux.
+
+Stratégie de découpage
+----------------------
+Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch
+(Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit
+deux listes ordonnées d'IDs de régions (typiquement extraites de
+ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML
+suivent dans des sprints dédiés.
+
+Compatible directement avec ``ReadingOrderGT`` du Sprint 32 :
+``ReadingOrderGT.region_order`` est exactement le format attendu.
+
+Convention sur les régions
+--------------------------
+- Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.).
+- Les **doublons** sont ignorés au calcul des paires ordonnées
+ (chaque ID compte une fois par séquence).
+- Une région présente dans la GT mais absente de l'hypothèse
+ contribue aux paires FN.
+- Une région présente dans l'hypothèse mais absente de la GT
+ contribue aux paires FP.
+- Si une séquence a < 2 régions distinctes, aucune paire n'est
+ émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux
+ séquences soient identiques.
+"""
+
+from __future__ import annotations
+
+import logging
+from itertools import combinations
+from typing import Iterable
+
+from picarones.core.metric_registry import register_metric
+from picarones.core.modules import ArtifactType
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Helpers
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]:
+ """Retourne l'ensemble des paires ``(a, b)`` telles que ``a``
+ précède strictement ``b`` dans ``sequence``.
+
+ Doublons : chaque ID est traité une seule fois (première occurrence
+ dans la séquence). Cohérent avec ICDAR 2015 où les régions ont
+ des IDs uniques.
+ """
+ seen: list[str] = []
+ seen_set: set[str] = set()
+ for r in sequence:
+ if r not in seen_set:
+ seen.append(r)
+ seen_set.add(r)
+ return set(combinations(seen, 2))
+
+
+def _normalize_input(value: Iterable[str] | None) -> list[str]:
+ """Coerce une entrée en list[str], en filtrant les valeurs vides."""
+ if value is None:
+ return []
+ return [str(v) for v in value if v is not None and str(v).strip()]
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Métrique principale
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def compute_reading_order_metrics(
+ reference_order: Iterable[str] | None,
+ hypothesis_order: Iterable[str] | None,
+) -> dict:
+ """Calcule precision / recall / F1 sur l'ordre relatif des régions.
+
+ Parameters
+ ----------
+ reference_order:
+ Séquence ordonnée d'IDs de régions issue de la GT (typiquement
+ ``ReadingOrderGT.region_order`` du Sprint 32).
+ hypothesis_order:
+ Séquence ordonnée d'IDs de régions produite par un moteur
+ OCR/HTR ou un reconstructeur ALTO.
+
+ Returns
+ -------
+ dict
+ ``{"precision", "recall", "f1", "true_positives",
+ "false_positives", "false_negatives", "n_ref_pairs",
+ "n_hyp_pairs", "common_regions", "ref_only_regions",
+ "hyp_only_regions"}``.
+
+ Comportements aux bornes
+ ------------------------
+ - Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0.
+ - Ordre strictement inversé → F1 = 0.0 (toutes les paires
+ relatives sont fausses).
+ - Une séquence vide vs une séquence non vide → F1 = 0.0.
+ - Deux séquences vides → F1 = 0.0 et tous les compteurs à 0
+ (convention : on ne récompense pas l'absence).
+ """
+ ref = _normalize_input(reference_order)
+ hyp = _normalize_input(hypothesis_order)
+
+ ref_pairs = _ordered_pairs(ref)
+ hyp_pairs = _ordered_pairs(hyp)
+
+ tp = len(ref_pairs & hyp_pairs)
+ fn = len(ref_pairs - hyp_pairs)
+ fp = len(hyp_pairs - ref_pairs)
+
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
+ f1 = (
+ 2 * precision * recall / (precision + recall)
+ if (precision + recall) > 0
+ else 0.0
+ )
+
+ ref_set = set(ref)
+ hyp_set = set(hyp)
+ return {
+ "precision": precision,
+ "recall": recall,
+ "f1": f1,
+ "true_positives": tp,
+ "false_positives": fp,
+ "false_negatives": fn,
+ "n_ref_pairs": len(ref_pairs),
+ "n_hyp_pairs": len(hyp_pairs),
+ "common_regions": sorted(ref_set & hyp_set),
+ "ref_only_regions": sorted(ref_set - hyp_set),
+ "hyp_only_regions": sorted(hyp_set - ref_set),
+ }
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Enregistrement dans le registre typé (Sprint 34)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@register_metric(
+ name="reading_order_f1",
+ input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
+ description=(
+ "F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, "
+ "Antonacopoulos). Pour chaque paire (a,b) où a précède b dans "
+ "la GT, vérifie que a précède aussi b dans l'hypothèse."
+ ),
+ higher_is_better=True,
+ tags={"structure", "icdar", "alto", "page"},
+)
+def reading_order_f1(
+ reference: Iterable[str] | None,
+ hypothesis: Iterable[str] | None,
+) -> float:
+ """Raccourci : retourne uniquement le F1 global.
+
+ Pour les détails par paire (TP/FP/FN, régions communes, etc.),
+ appeler ``compute_reading_order_metrics`` directement.
+ """
+ return compute_reading_order_metrics(reference, hypothesis)["f1"]
+
+
+__all__ = [
+ "compute_reading_order_metrics",
+ "reading_order_f1",
+]
diff --git a/picarones/measurements/reliability.py b/picarones/measurements/reliability.py
new file mode 100644
index 0000000000000000000000000000000000000000..116bdc28d8312cc1a702d6d0caf156d2db78e0b9
--- /dev/null
+++ b/picarones/measurements/reliability.py
@@ -0,0 +1,360 @@
+"""Métriques de fiabilité — Sprint 83 (A.II.4).
+
+Sprint 83 — A.II.4 du plan d'évolution 2026 (Étape 4).
+
+Pourquoi ce module
+------------------
+Une publication scientifique qui rapporte un CER LLM sans
+stabilité est méthodologiquement faible. Et un benchmark qui
+ignore le plafond humain (« deux paléographes ne sont pas même
+d'accord ») crée des classements faussement optimistes. Ce
+module livre deux familles complémentaires :
+
+1. **Inter-annotator agreement (IAA)** — quand un document a
+ plusieurs GT (deux paléographes, par ex.), Cohen κ et
+ Krippendorff α mesurent l'accord au niveau caractère.
+ Lecture : *« le CER de Pero (4,2 %) approche le plafond
+ humain (κ = 0,89). »*
+
+2. **Stabilité multi-runs** — quand on relance la même
+ pipeline LLM N fois sur les mêmes documents, on mesure :
+ variance du CER, taux de tokens divergents entre runs,
+ CER pairwise moyen.
+
+Périmètre Sprint 83
+-------------------
+**Couche de calcul uniquement** — fonctions pures, pas
+d'intégration runner ni de vue HTML. L'extension du loader
+pour accepter ``doc_001.gt.A.txt`` / ``doc_001.gt.B.txt`` est
+documentée comme dépendance future ; en attendant le sprint
+dédié, on prend deux strings GT en entrée.
+
+Méthode
+-------
+*IAA caractère par caractère.* On aligne les deux GT par
+``difflib.SequenceMatcher`` au niveau caractère et on construit
+une table de contingence ``(annotator_a_char, annotator_b_char)``
+sur les positions ``equal`` ou ``replace``. Cohen κ utilise
+cette table directement. Krippendorff α utilise la version
+matricielle (différence binaire pour le mode nominal).
+
+*Stabilité multi-runs.* ``compute_multirun_stability(runs)``
+prend une liste de N transcriptions du **même** document et
+renvoie variance/écart-type/coefficient de variation du CER si
+référence fournie ; sinon, taux pairwise de divergence
+(intersection-vs-union des tokens).
+"""
+
+from __future__ import annotations
+
+import logging
+import statistics
+from typing import Optional, Sequence
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Helpers d'alignement caractère par caractère
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _aligned_char_pairs(
+ text_a: str, text_b: str,
+) -> list[tuple[str, str]]:
+ """Aligne ``text_a`` et ``text_b`` caractère par caractère.
+
+ Retourne la liste des paires alignées sur les segments
+ ``equal`` et ``replace`` de ``SequenceMatcher`` (les ``insert``
+ et ``delete`` sont ignorés — pas d'alignement valide).
+ """
+ if not text_a and not text_b:
+ return []
+ import difflib
+ matcher = difflib.SequenceMatcher(None, text_a, text_b, autojunk=False)
+ pairs: list[tuple[str, str]] = []
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == "equal":
+ for k in range(i2 - i1):
+ pairs.append((text_a[i1 + k], text_b[j1 + k]))
+ elif tag == "replace":
+ paired = min(i2 - i1, j2 - j1)
+ for k in range(paired):
+ pairs.append((text_a[i1 + k], text_b[j1 + k]))
+ # insert/delete : pas d'alignement bilatéral exploitable
+ return pairs
+
+
+__all__: list[str] = []
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 1. Cohen's kappa (deux annotateurs, accord nominal)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def cohen_kappa(
+ annotations_a: Sequence,
+ annotations_b: Sequence,
+) -> Optional[float]:
+ """Cohen's κ entre deux annotateurs sur des observations
+ appariées.
+
+ Définition :
+
+ κ = (po - pe) / (1 - pe)
+
+ où ``po`` est l'accord observé (proportion de paires égales)
+ et ``pe`` l'accord attendu par hasard (somme sur les classes
+ de p_a(c) × p_b(c)).
+
+ Conventions :
+ - retourne ``None`` si les deux séquences sont vides ou de
+ tailles incompatibles ;
+ - κ = 1.0 quand l'accord est parfait, 0.0 quand il égale le
+ hasard, négatif si pire que le hasard ;
+ - quand ``pe == 1`` (un seul label dans les deux séquences),
+ retourne 1.0 si les séquences sont identiques, 0.0 sinon
+ (κ est mathématiquement indéfini, on choisit une
+ convention transparente documentée).
+ """
+ if len(annotations_a) != len(annotations_b):
+ return None
+ n = len(annotations_a)
+ if n == 0:
+ return None
+ # Accord observé
+ agree = sum(1 for a, b in zip(annotations_a, annotations_b) if a == b)
+ p_o = agree / n
+ # Accord attendu par hasard
+ from collections import Counter
+ count_a = Counter(annotations_a)
+ count_b = Counter(annotations_b)
+ classes = set(count_a) | set(count_b)
+ p_e = sum(
+ (count_a.get(c, 0) / n) * (count_b.get(c, 0) / n)
+ for c in classes
+ )
+ if p_e >= 1.0 - 1e-12:
+ # Indéfini ; convention : 1 si identité totale, 0 sinon
+ return 1.0 if p_o >= 1.0 - 1e-12 else 0.0
+ return (p_o - p_e) / (1.0 - p_e)
+
+
+__all__.append("cohen_kappa")
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 2. Krippendorff's alpha (généralisation à N annotateurs)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def krippendorff_alpha(
+ annotations_per_unit: Sequence[Sequence],
+) -> Optional[float]:
+ """Krippendorff's α en mode nominal pour N annotateurs.
+
+ Parameters
+ ----------
+ annotations_per_unit:
+ Liste d'unités, chaque unité étant la liste des
+ annotations produites par les différents annotateurs sur
+ cette unité. ``None`` dans une cellule = annotation
+ manquante (autorisée).
+
+ Définition (Krippendorff 1980, équation pour métrique
+ nominale) :
+
+ α = 1 - D_o / D_e
+
+ où ``D_o`` est le désaccord observé (paires en désaccord
+ intra-unité, normalisées) et ``D_e`` le désaccord attendu
+ par hasard. ``α = 1`` accord parfait, ``α = 0`` hasard,
+ négatif si pire.
+
+ Conventions :
+ - unités avec moins de 2 annotations valides : ignorées
+ (Krippendorff convention) ;
+ - retourne ``None`` si moins d'une unité utilisable ou
+ ``D_e == 0`` (un seul label dans tout le corpus).
+ """
+ from collections import Counter
+ # Valeurs observées au niveau corpus
+ value_counts: Counter = Counter()
+ pair_disagree = 0.0
+ pair_total = 0.0
+ for unit in annotations_per_unit:
+ valid = [v for v in unit if v is not None]
+ m = len(valid)
+ if m < 2:
+ continue
+ # paires intra-unité (sans repetition, ordonné)
+ for i in range(m):
+ for j in range(m):
+ if i == j:
+ continue
+ pair_total += 1.0 / (m - 1)
+ if valid[i] != valid[j]:
+ pair_disagree += 1.0 / (m - 1)
+ for v in valid:
+ value_counts[v] += 1
+ if pair_total == 0:
+ return None
+ n_total = sum(value_counts.values())
+ if n_total < 2:
+ return None
+ # Désaccord attendu (sur paires aléatoires sans remise)
+ expected_disagree = 0.0
+ for v_a, c_a in value_counts.items():
+ for v_b, c_b in value_counts.items():
+ if v_a != v_b:
+ expected_disagree += c_a * c_b
+ expected_disagree /= n_total * (n_total - 1)
+ if expected_disagree <= 1e-12:
+ return None
+ d_o = pair_disagree / pair_total
+ return 1.0 - (d_o / expected_disagree)
+
+
+__all__.append("krippendorff_alpha")
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 3. Helpers IAA caractère
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def compute_iaa(
+ transcription_a: str,
+ transcription_b: str,
+) -> Optional[dict]:
+ """Calcule κ et α au niveau caractère entre deux
+ transcriptions du même document.
+
+ Aligne via ``_aligned_char_pairs`` puis :
+ - κ : sur la liste des paires alignées ;
+ - α : sur les unités à 2 annotations (équivalent à κ sur ce
+ cas, mais le cadre généralise à N annotateurs).
+
+ Retourne ``None`` si pas d'alignement possible (transcriptions
+ vides ou totalement disjointes).
+ """
+ pairs = _aligned_char_pairs(transcription_a, transcription_b)
+ if not pairs:
+ return None
+ kappa = cohen_kappa([a for a, _ in pairs], [b for _, b in pairs])
+ alpha = krippendorff_alpha([[a, b] for a, b in pairs])
+ return {
+ "n_aligned_chars": len(pairs),
+ "cohen_kappa": kappa,
+ "krippendorff_alpha": alpha,
+ "agreement_rate": (
+ sum(1 for a, b in pairs if a == b) / len(pairs)
+ ),
+ }
+
+
+__all__.append("compute_iaa")
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 4. Stabilité multi-runs (variance CER, divergence pairwise)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _split_words(text: str) -> list[str]:
+ return text.split() if text else []
+
+
+def compute_multirun_stability(
+ runs: Sequence[str],
+ *,
+ reference: Optional[str] = None,
+) -> Optional[dict]:
+ """Mesure la stabilité de N runs successifs d'une même
+ pipeline (typiquement LLM/VLM non déterministe) sur un
+ document.
+
+ Parameters
+ ----------
+ runs:
+ Liste des transcriptions produites à chaque run (≥ 2).
+ reference:
+ Transcription de référence (GT). Si fournie, on calcule
+ ``cer_per_run``, leur variance et leur coefficient de
+ variation.
+
+ Returns
+ -------
+ dict | None
+ ``{
+ "n_runs": int,
+ "pairwise_disagreement_mean": float, # divergence moyenne
+ "pairwise_disagreement_max": float,
+ "identical_run_rate": float, # paires identiques / total
+ "cer_per_run": Optional[list[float]],
+ "cer_mean": Optional[float],
+ "cer_stdev": Optional[float],
+ "cer_cv": Optional[float], # cv = stdev / mean
+ "n_distinct_outputs": int,
+ }``
+ ou ``None`` si moins de 2 runs.
+ """
+ if len(runs) < 2:
+ return None
+ runs_list = list(runs)
+ # Divergence pairwise (token-level Jaccard distance)
+ n = len(runs_list)
+ n_pairs = 0
+ sum_disagree = 0.0
+ max_disagree = 0.0
+ n_identical = 0
+ for i in range(n):
+ for j in range(i + 1, n):
+ n_pairs += 1
+ tokens_i = set(_split_words(runs_list[i]))
+ tokens_j = set(_split_words(runs_list[j]))
+ union = tokens_i | tokens_j
+ if not union:
+ disagree = 0.0
+ else:
+ disagree = 1.0 - len(tokens_i & tokens_j) / len(union)
+ sum_disagree += disagree
+ if disagree > max_disagree:
+ max_disagree = disagree
+ if runs_list[i] == runs_list[j]:
+ n_identical += 1
+ pairwise_mean = sum_disagree / n_pairs if n_pairs else 0.0
+ identical_rate = n_identical / n_pairs if n_pairs else 0.0
+ distinct = len(set(runs_list))
+
+ cer_per_run: Optional[list[float]] = None
+ cer_mean: Optional[float] = None
+ cer_stdev: Optional[float] = None
+ cer_cv: Optional[float] = None
+ if reference is not None:
+ from picarones.core.metrics import _cer_from_strings
+ cer_per_run = [_cer_from_strings(reference, r) for r in runs_list]
+ cer_per_run = [v for v in cer_per_run if v is not None]
+ if cer_per_run:
+ cer_mean = statistics.fmean(cer_per_run)
+ if len(cer_per_run) >= 2:
+ cer_stdev = statistics.stdev(cer_per_run)
+ cer_cv = (
+ cer_stdev / cer_mean if cer_mean and cer_mean > 0
+ else None
+ )
+ return {
+ "n_runs": n,
+ "pairwise_disagreement_mean": pairwise_mean,
+ "pairwise_disagreement_max": max_disagree,
+ "identical_run_rate": identical_rate,
+ "n_distinct_outputs": distinct,
+ "cer_per_run": cer_per_run,
+ "cer_mean": cer_mean,
+ "cer_stdev": cer_stdev,
+ "cer_cv": cer_cv,
+ }
+
+
+__all__.append("compute_multirun_stability")
diff --git a/picarones/measurements/robustness.py b/picarones/measurements/robustness.py
new file mode 100644
index 0000000000000000000000000000000000000000..27f407234709718dab9752114a00250c0fc2db60
--- /dev/null
+++ b/picarones/measurements/robustness.py
@@ -0,0 +1,731 @@
+"""Analyse de robustesse des moteurs OCR face aux dégradations d'image.
+
+Fonctionnement
+--------------
+1. Génération de versions dégradées des images du corpus à différents niveaux :
+ - Bruit gaussien (sigma croissant)
+ - Flou gaussien (kernel size croissant)
+ - Rotation (angle croissant)
+ - Réduction de résolution (facteur de downscaling)
+ - Binarisation (seuillage Otsu ou fixe)
+2. Exécution du moteur OCR sur chaque version dégradée
+3. Calcul du CER pour chaque niveau de dégradation
+4. Génération de courbes de robustesse (CER en fonction du niveau)
+5. Identification du seuil critique (niveau à partir duquel CER > seuil)
+
+Usage
+-----
+>>> from picarones.core.robustness import RobustnessAnalyzer
+>>> analyzer = RobustnessAnalyzer(engine, degradation_types=["noise", "blur"])
+>>> report = analyzer.analyze(corpus)
+>>> print(report.critical_thresholds)
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import os
+import tempfile
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from picarones.core.corpus import Corpus, Document
+ from picarones.engines.base import BaseOCREngine
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Paramètres de dégradation
+# ---------------------------------------------------------------------------
+
+# Niveaux de dégradation pour chaque type
+DEGRADATION_LEVELS: dict[str, list] = {
+ "noise": [0, 5, 15, 30, 50, 80], # sigma du bruit gaussien
+ "blur": [0, 1, 2, 3, 5, 8], # rayon du flou gaussien (pixels)
+ "rotation": [0, 1, 2, 5, 10, 20], # angle de rotation (degrés)
+ "resolution": [1.0, 0.75, 0.5, 0.33, 0.25, 0.1], # facteur de résolution
+ "binarization": [0, 64, 96, 128, 160, 192], # seuil de binarisation (0 = Otsu)
+}
+
+DEGRADATION_LABELS: dict[str, list[str]] = {
+ "noise": ["original", "σ=5", "σ=15", "σ=30", "σ=50", "σ=80"],
+ "blur": ["original", "r=1", "r=2", "r=3", "r=5", "r=8"],
+ "rotation": ["0°", "1°", "2°", "5°", "10°", "20°"],
+ "resolution": ["100%", "75%", "50%", "33%", "25%", "10%"],
+ "binarization": ["original", "seuil=64", "seuil=96", "seuil=128", "seuil=160", "seuil=192"],
+}
+
+ALL_DEGRADATION_TYPES = list(DEGRADATION_LEVELS.keys())
+
+
+# ---------------------------------------------------------------------------
+# Dégradation d'image (pure Python + stdlib, optionnellement Pillow/NumPy)
+# ---------------------------------------------------------------------------
+
+def _apply_gaussian_noise(pixels: list[list[list[int]]], sigma: float, rng_seed: int = 0) -> list[list[list[int]]]:
+ """Applique du bruit gaussien (pure Python)."""
+ import random
+ rng = random.Random(rng_seed)
+ h = len(pixels)
+ w = len(pixels[0]) if h > 0 else 0
+ result = []
+ for y in range(h):
+ row = []
+ for x in range(w):
+ pixel = []
+ for c in pixels[y][x]:
+ noise = rng.gauss(0, sigma)
+ val = int(c + noise)
+ pixel.append(max(0, min(255, val)))
+ row.append(pixel)
+ result.append(row)
+ return result
+
+
+def _apply_box_blur(pixels: list[list[list[int]]], radius: int) -> list[list[list[int]]]:
+ """Applique un flou de boîte (approximation du flou gaussien, pure Python)."""
+ if radius <= 0:
+ return pixels
+ h = len(pixels)
+ w = len(pixels[0]) if h > 0 else 0
+ channels = len(pixels[0][0]) if h > 0 and w > 0 else 3
+
+ def blur_pass(data: list[list[list[int]]]) -> list[list[list[int]]]:
+ out = []
+ for y in range(h):
+ row = []
+ for x in range(w):
+ totals = [0] * channels
+ count = 0
+ for dy in range(-radius, radius + 1):
+ for dx in range(-radius, radius + 1):
+ ny, nx = y + dy, x + dx
+ if 0 <= ny < h and 0 <= nx < w:
+ for c in range(channels):
+ totals[c] += data[ny][nx][c]
+ count += 1
+ row.append([t // count for t in totals])
+ out.append(row)
+ return out
+
+ return blur_pass(pixels)
+
+
+def _apply_rotation_simple(pixels: list[list[list[int]]], angle_deg: float) -> list[list[list[int]]]:
+ """Rotation avec interpolation au plus proche voisin (pure Python).
+
+ Pour des angles faibles, l'effet est réaliste.
+ """
+ if angle_deg == 0:
+ return pixels
+ h = len(pixels)
+ w = len(pixels[0]) if h > 0 else 0
+ channels = len(pixels[0][0]) if h > 0 and w > 0 else 3
+
+ angle_rad = math.radians(angle_deg)
+ cos_a = math.cos(angle_rad)
+ sin_a = math.sin(angle_rad)
+ cx, cy = w / 2, h / 2
+
+ result = [[[245, 240, 232][:channels] for _ in range(w)] for _ in range(h)]
+ for y in range(h):
+ for x in range(w):
+ # Coordonnées source
+ sx = cos_a * (x - cx) + sin_a * (y - cy) + cx
+ sy = -sin_a * (x - cx) + cos_a * (y - cy) + cy
+ ix, iy = int(round(sx)), int(round(sy))
+ if 0 <= ix < w and 0 <= iy < h:
+ result[y][x] = list(pixels[iy][ix])
+ return result
+
+
+def _apply_resolution_reduction(
+ pixels: list[list[list[int]]], factor: float
+) -> list[list[list[int]]]:
+ """Réduit la résolution puis remonte à la taille originale (pixelisation)."""
+ if factor >= 1.0:
+ return pixels
+ h = len(pixels)
+ w = len(pixels[0]) if h > 0 else 0
+ new_h = max(1, int(h * factor))
+ new_w = max(1, int(w * factor))
+
+ # Downscale
+ small = []
+ for y in range(new_h):
+ row = []
+ src_y = int(y / factor)
+ for x in range(new_w):
+ src_x = int(x / factor)
+ row.append(list(pixels[min(src_y, h - 1)][min(src_x, w - 1)]))
+ small.append(row)
+
+ # Upscale (nearest-neighbor)
+ result = []
+ for y in range(h):
+ row = []
+ src_y = min(int(y * factor), new_h - 1)
+ for x in range(w):
+ src_x = min(int(x * factor), new_w - 1)
+ row.append(list(small[src_y][src_x]))
+ result.append(row)
+ return result
+
+
+def _apply_binarization(
+ pixels: list[list[list[int]]], threshold: int
+) -> list[list[list[int]]]:
+ """Binarise l'image (seuillage fixe sur luminosité)."""
+ h = len(pixels)
+ w = len(pixels[0]) if h > 0 else 0
+ result = []
+
+ # Calculer le seuil Otsu si threshold == 0
+ if threshold == 0:
+ histogram = [0] * 256
+ total = h * w
+ for y in range(h):
+ for x in range(w):
+ p = pixels[y][x]
+ lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0]
+ histogram[lum] += 1
+ # Otsu simplifié
+ best_thresh = 128
+ best_var = -1.0
+ total_sum = sum(i * histogram[i] for i in range(256))
+ w0, w1, sum0 = 0, total, 0.0
+ for t in range(256):
+ w0 += histogram[t]
+ if w0 == 0:
+ continue
+ w1 = total - w0
+ if w1 == 0:
+ break
+ sum0 += t * histogram[t]
+ mean0 = sum0 / w0
+ mean1 = (total_sum - sum0) / w1
+ var = w0 * w1 * (mean0 - mean1) ** 2
+ if var > best_var:
+ best_var = var
+ best_thresh = t
+ threshold = best_thresh
+
+ for y in range(h):
+ row = []
+ for x in range(w):
+ p = pixels[y][x]
+ lum = int(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) if len(p) >= 3 else p[0]
+ val = 255 if lum >= threshold else 0
+ row.append([val] * len(p))
+ result.append(row)
+ return result
+
+
+def degrade_image_bytes(
+ png_bytes: bytes,
+ degradation_type: str,
+ level: float,
+) -> bytes:
+ """Dégrade une image PNG et retourne les bytes PNG modifiés.
+
+ Utilise Pillow si disponible, sinon utilise l'implémentation pure Python.
+
+ Parameters
+ ----------
+ png_bytes:
+ Bytes de l'image PNG source.
+ degradation_type:
+ Type de dégradation (``"noise"``, ``"blur"``, ``"rotation"``,
+ ``"resolution"``, ``"binarization"``).
+ level:
+ Niveau de dégradation (valeur numérique selon le type).
+
+ Returns
+ -------
+ bytes
+ Bytes de l'image PNG dégradée.
+ """
+ try:
+ return _degrade_pillow(png_bytes, degradation_type, level)
+ except ImportError:
+ return _degrade_pure_python(png_bytes, degradation_type, level)
+
+
+def _degrade_pillow(png_bytes: bytes, degradation_type: str, level: float) -> bytes:
+ """Dégradation avec Pillow (meilleure qualité)."""
+ import io
+ from PIL import Image, ImageFilter
+
+ img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
+
+ if degradation_type == "noise":
+ if level > 0:
+ import random
+ # RGB : 3 octets par pixel, tobytes() reste stable Pillow 10 → 14+
+ raw = img.tobytes()
+ rng = random.Random(0)
+ noisy = []
+ for i in range(0, len(raw), 3):
+ r, g, b = raw[i], raw[i + 1], raw[i + 2]
+ noisy.append((
+ max(0, min(255, int(r + rng.gauss(0, level)))),
+ max(0, min(255, int(g + rng.gauss(0, level)))),
+ max(0, min(255, int(b + rng.gauss(0, level)))),
+ ))
+ img.putdata(noisy)
+
+ elif degradation_type == "blur":
+ if level > 0:
+ img = img.filter(ImageFilter.GaussianBlur(radius=level))
+
+ elif degradation_type == "rotation":
+ if level != 0:
+ img = img.rotate(-level, expand=False, fillcolor=(245, 240, 232))
+
+ elif degradation_type == "resolution":
+ if level < 1.0:
+ w, h = img.size
+ new_w, new_h = max(1, int(w * level)), max(1, int(h * level))
+ img = img.resize((new_w, new_h), Image.NEAREST)
+ img = img.resize((w, h), Image.NEAREST)
+
+ elif degradation_type == "binarization":
+ img = img.convert("L") # niveaux de gris
+ if level == 0:
+ # Seuillage Otsu : calcul du seuil optimal
+ histogram = img.histogram()
+ total = img.size[0] * img.size[1]
+ best_thresh, best_var = 128, -1.0
+ total_sum = sum(i * histogram[i] for i in range(256))
+ w0, sum0 = 0, 0.0
+ for t in range(256):
+ w0 += histogram[t]
+ if w0 == 0:
+ continue
+ w1 = total - w0
+ if w1 == 0:
+ break
+ sum0 += t * histogram[t]
+ var = w0 * w1 * (sum0 / w0 - (total_sum - sum0) / w1) ** 2
+ if var > best_var:
+ best_var = var
+ best_thresh = t
+ threshold = best_thresh
+ else:
+ threshold = int(level)
+ img = img.point(lambda p: 255 if p >= threshold else 0, "1").convert("RGB")
+
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def _degrade_pure_python(png_bytes: bytes, degradation_type: str, level: float) -> bytes:
+ """Dégradation en pur Python (sans Pillow).
+
+ Décode le PNG, applique la transformation, ré-encode en PNG.
+ Note : n'implémente pas le décodage PNG complet — utilise des stubs.
+ """
+ # Pour l'implémentation pure Python, on applique des transformations
+ # minimales sur les bytes bruts en créant une image de test synthétique.
+ # En pratique, Pillow est presque toujours disponible dans l'environnement Picarones.
+ logger.warning(
+ "Pillow non disponible : dégradation '%s' appliquée en mode dégradé (stub)",
+ degradation_type,
+ )
+ # Retourner l'image originale légèrement modifiée (simulation)
+ return png_bytes
+
+
+# ---------------------------------------------------------------------------
+# Structures de résultats
+# ---------------------------------------------------------------------------
+
+@dataclass
+class DegradationCurve:
+ """Courbe CER vs niveau de dégradation pour un moteur et un type de dégradation."""
+ engine_name: str
+ degradation_type: str
+ levels: list[float]
+ labels: list[str]
+ cer_values: list[Optional[float]]
+ """CER moyen (0-1) à chaque niveau. None si calcul impossible."""
+ critical_threshold_level: Optional[float] = None
+ """Niveau à partir duquel CER > cer_threshold."""
+ cer_threshold: float = 0.20
+ """Seuil de CER utilisé pour déterminer le niveau critique."""
+
+ def as_dict(self) -> dict:
+ return {
+ "engine_name": self.engine_name,
+ "degradation_type": self.degradation_type,
+ "levels": self.levels,
+ "labels": self.labels,
+ "cer_values": self.cer_values,
+ "critical_threshold_level": self.critical_threshold_level,
+ "cer_threshold": self.cer_threshold,
+ }
+
+
+@dataclass
+class RobustnessReport:
+ """Rapport complet d'analyse de robustesse pour un ou plusieurs moteurs."""
+ engine_names: list[str]
+ corpus_name: str
+ degradation_types: list[str]
+ curves: list[DegradationCurve]
+ summary: dict = field(default_factory=dict)
+ """Résumé : moteur le plus robuste par type de dégradation, seuils critiques…"""
+
+ def get_curves_for_engine(self, engine_name: str) -> list[DegradationCurve]:
+ return [c for c in self.curves if c.engine_name == engine_name]
+
+ def get_curves_for_type(self, degradation_type: str) -> list[DegradationCurve]:
+ return [c for c in self.curves if c.degradation_type == degradation_type]
+
+ def as_dict(self) -> dict:
+ return {
+ "engine_names": self.engine_names,
+ "corpus_name": self.corpus_name,
+ "degradation_types": self.degradation_types,
+ "curves": [c.as_dict() for c in self.curves],
+ "summary": self.summary,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Analyseur de robustesse
+# ---------------------------------------------------------------------------
+
+class RobustnessAnalyzer:
+ """Lance une analyse de robustesse sur un corpus.
+
+ Parameters
+ ----------
+ engines:
+ Un ou plusieurs moteurs OCR (``BaseOCREngine``).
+ degradation_types:
+ Liste des types de dégradation à tester.
+ Par défaut : tous (``"noise"``, ``"blur"``, ``"rotation"``,
+ ``"resolution"``, ``"binarization"``).
+ cer_threshold:
+ Seuil de CER pour définir le niveau critique (défaut : 0.20 = 20%).
+ custom_levels:
+ Niveaux personnalisés par type (remplace les valeurs par défaut).
+
+ Examples
+ --------
+ >>> from picarones.engines.tesseract import TesseractEngine
+ >>> from picarones.core.robustness import RobustnessAnalyzer
+ >>> engine = TesseractEngine(config={"lang": "fra"})
+ >>> analyzer = RobustnessAnalyzer([engine], degradation_types=["noise", "blur"])
+ >>> report = analyzer.analyze(corpus)
+ """
+
+ def __init__(
+ self,
+ engines: "list[BaseOCREngine]",
+ degradation_types: Optional[list[str]] = None,
+ cer_threshold: float = 0.20,
+ custom_levels: Optional[dict[str, list]] = None,
+ ) -> None:
+ if not isinstance(engines, list):
+ engines = [engines]
+ self.engines = engines
+ self.degradation_types = degradation_types or ALL_DEGRADATION_TYPES
+ self.cer_threshold = cer_threshold
+ self.levels = dict(DEGRADATION_LEVELS)
+ if custom_levels:
+ self.levels.update(custom_levels)
+
+ def analyze(
+ self,
+ corpus: "Corpus",
+ show_progress: bool = True,
+ max_docs: int = 10,
+ ) -> RobustnessReport:
+ """Lance l'analyse de robustesse sur le corpus.
+
+ Parameters
+ ----------
+ corpus:
+ Corpus Picarones avec images et GT.
+ show_progress:
+ Affiche la progression.
+ max_docs:
+ Nombre maximum de documents à traiter (pour la rapidité).
+
+ Returns
+ -------
+ RobustnessReport
+ """
+ from picarones.core.metrics import compute_metrics
+
+ docs = corpus.documents[:max_docs]
+ curves: list[DegradationCurve] = []
+
+ for engine in self.engines:
+ for deg_type in self.degradation_types:
+ levels = self.levels[deg_type]
+ labels = DEGRADATION_LABELS.get(deg_type, [str(lv) for lv in levels])
+
+ cer_per_level: list[Optional[float]] = []
+
+ if show_progress:
+ try:
+ from tqdm import tqdm
+ level_iter = tqdm(
+ list(enumerate(levels)),
+ desc=f"{engine.name} / {deg_type}",
+ )
+ except ImportError:
+ level_iter = enumerate(levels)
+ else:
+ level_iter = enumerate(levels)
+
+ for lvl_idx, level in level_iter:
+ doc_cers: list[float] = []
+
+ for doc in docs:
+ gt = doc.ground_truth.strip()
+ if not gt:
+ continue
+
+ # Obtenir l'image (fichier ou data URI)
+ degraded_bytes = self._get_degraded_image(
+ doc, deg_type, level
+ )
+ if degraded_bytes is None:
+ continue
+
+ # Sauvegarder temporairement et OCR
+ with tempfile.NamedTemporaryFile(
+ suffix=".png", delete=False
+ ) as tmp:
+ tmp.write(degraded_bytes)
+ tmp_path = tmp.name
+
+ try:
+ ocr_result = engine.run(tmp_path)
+ hypothesis = ocr_result.text
+ metrics = compute_metrics(gt, hypothesis)
+ doc_cers.append(metrics.cer)
+ except Exception as exc:
+ logger.debug(
+ "Erreur OCR %s niveau %s=%s: %s",
+ engine.name, deg_type, level, exc
+ )
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+ if doc_cers:
+ cer_per_level.append(sum(doc_cers) / len(doc_cers))
+ else:
+ cer_per_level.append(None)
+
+ # Calculer le niveau critique
+ critical = self._find_critical_level(
+ levels, cer_per_level, self.cer_threshold
+ )
+
+ curves.append(DegradationCurve(
+ engine_name=engine.name,
+ degradation_type=deg_type,
+ levels=levels,
+ labels=labels[:len(levels)],
+ cer_values=cer_per_level,
+ critical_threshold_level=critical,
+ cer_threshold=self.cer_threshold,
+ ))
+
+ summary = self._build_summary(curves)
+
+ return RobustnessReport(
+ engine_names=[e.name for e in self.engines],
+ corpus_name=corpus.name,
+ degradation_types=self.degradation_types,
+ curves=curves,
+ summary=summary,
+ )
+
+ def _get_degraded_image(
+ self,
+ doc: "Document",
+ degradation_type: str,
+ level: float,
+ ) -> Optional[bytes]:
+ """Retourne les bytes PNG de l'image dégradée."""
+ # Charger l'image originale
+ original_bytes = self._load_image(doc)
+ if original_bytes is None:
+ return None
+
+ # Niveau 0 = image originale (sauf binarisation à 0 = Otsu)
+ if (degradation_type == "noise" and level == 0) or \
+ (degradation_type == "blur" and level == 0) or \
+ (degradation_type == "rotation" and level == 0) or \
+ (degradation_type == "resolution" and level >= 1.0):
+ return original_bytes
+
+ return degrade_image_bytes(original_bytes, degradation_type, level)
+
+ def _load_image(self, doc: "Document") -> Optional[bytes]:
+ """Charge les bytes PNG de l'image d'un document."""
+ img_path = doc.image_path
+
+ # Data URI (base64)
+ if img_path.startswith("data:image/"):
+ import base64
+ try:
+ _, b64 = img_path.split(",", 1)
+ return base64.b64decode(b64)
+ except Exception as exc:
+ logger.debug("Impossible de décoder data URI: %s", exc)
+ return None
+
+ # Fichier local
+ path = Path(img_path)
+ if path.exists():
+ return path.read_bytes()
+
+ logger.debug("Image introuvable : %s", img_path)
+ return None
+
+ @staticmethod
+ def _find_critical_level(
+ levels: list[float],
+ cer_values: list[Optional[float]],
+ threshold: float,
+ ) -> Optional[float]:
+ """Trouve le niveau à partir duquel CER dépasse le seuil."""
+ for level, cer in zip(levels, cer_values):
+ if cer is not None and cer > threshold:
+ return level
+ return None
+
+ @staticmethod
+ def _build_summary(curves: list[DegradationCurve]) -> dict:
+ """Construit le résumé de l'analyse."""
+ summary: dict = {}
+
+ # Par type de dégradation : moteur le plus robuste
+ by_type: dict[str, dict[str, list]] = {}
+ for curve in curves:
+ dt = curve.degradation_type
+ if dt not in by_type:
+ by_type[dt] = {}
+ valid_cers = [c for c in curve.cer_values if c is not None]
+ if valid_cers:
+ by_type[dt][curve.engine_name] = valid_cers
+
+ for dt, engine_cers in by_type.items():
+ if not engine_cers:
+ continue
+ # Robustesse = CER moyen sur tous les niveaux (plus bas = plus robuste)
+ best_engine = min(engine_cers, key=lambda e: sum(engine_cers[e]) / len(engine_cers[e]))
+ summary[f"most_robust_{dt}"] = best_engine
+
+ # Seuils critiques par moteur
+ for curve in curves:
+ key = f"critical_{curve.engine_name}_{curve.degradation_type}"
+ summary[key] = curve.critical_threshold_level
+
+ return summary
+
+
+# ---------------------------------------------------------------------------
+# Données de démonstration de robustesse
+# ---------------------------------------------------------------------------
+
+def generate_demo_robustness_report(
+ engine_names: Optional[list[str]] = None,
+ seed: int = 42,
+) -> RobustnessReport:
+ """Génère un rapport de robustesse fictif mais réaliste pour la démo.
+
+ Parameters
+ ----------
+ engine_names:
+ Noms des moteurs à simuler (défaut : tesseract, pero_ocr).
+ seed:
+ Graine aléatoire.
+
+ Returns
+ -------
+ RobustnessReport
+ """
+ import random
+ rng = random.Random(seed)
+
+ if engine_names is None:
+ engine_names = ["tesseract", "pero_ocr"]
+
+ # CER de base par moteur
+ base_cer = {
+ "tesseract": 0.12,
+ "pero_ocr": 0.07,
+ "ancien_moteur": 0.25,
+ }
+
+ # Sensibilité par type de dégradation (facteur multiplicatif par niveau)
+ sensitivity = {
+ "tesseract": {
+ "noise": 0.04, "blur": 0.05, "rotation": 0.06,
+ "resolution": 0.12, "binarization": 0.03,
+ },
+ "pero_ocr": {
+ "noise": 0.02, "blur": 0.03, "rotation": 0.04,
+ "resolution": 0.08, "binarization": 0.02,
+ },
+ "ancien_moteur": {
+ "noise": 0.06, "blur": 0.08, "rotation": 0.10,
+ "resolution": 0.15, "binarization": 0.05,
+ },
+ }
+
+ deg_types = ALL_DEGRADATION_TYPES
+ curves: list[DegradationCurve] = []
+
+ for engine_name in engine_names:
+ cer_base = base_cer.get(engine_name, 0.15)
+ sens = sensitivity.get(engine_name, {dt: 0.05 for dt in deg_types})
+
+ for deg_type in deg_types:
+ levels = DEGRADATION_LEVELS[deg_type]
+ labels = DEGRADATION_LABELS[deg_type]
+ s = sens.get(deg_type, 0.05)
+
+ cer_values = []
+ for i, level in enumerate(levels):
+ noise = rng.gauss(0, 0.005)
+ cer = min(1.0, cer_base + s * i + noise)
+ cer_values.append(round(max(0.0, cer), 4))
+
+ critical = RobustnessAnalyzer._find_critical_level(levels, cer_values, 0.20)
+
+ curves.append(DegradationCurve(
+ engine_name=engine_name,
+ degradation_type=deg_type,
+ levels=list(levels),
+ labels=labels[:len(levels)],
+ cer_values=cer_values,
+ critical_threshold_level=critical,
+ cer_threshold=0.20,
+ ))
+
+ summary = RobustnessAnalyzer._build_summary(curves)
+
+ return RobustnessReport(
+ engine_names=engine_names,
+ corpus_name="Corpus de démonstration — Chroniques médiévales",
+ degradation_types=deg_types,
+ curves=curves,
+ summary=summary,
+ )
diff --git a/picarones/measurements/robustness_projection.py b/picarones/measurements/robustness_projection.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc6c66a0a62c62e6a70839288e08c85a415a7c0c
--- /dev/null
+++ b/picarones/measurements/robustness_projection.py
@@ -0,0 +1,287 @@
+"""Projection de robustesse synthétique sur le corpus réel —
+Sprint 81 (A.I.8).
+
+Sprint 81 — A.I.8 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Le module ``picarones/core/robustness.py`` (Sprint 8) génère des
+courbes CER vs niveau de dégradation **synthétique** (bruit, flou,
+rotation, résolution). ``picarones/core/image_quality.py`` mesure
+le bruit/flou/contraste **réels** des images du corpus. Ce
+sprint **projette** les caractéristiques réelles sur les courbes
+synthétiques pour estimer le **déficit attendu de CER** sur le
+corpus dans son état actuel.
+
+Lecture concrète
+----------------
+*« 30 % de vos documents ont un bruit équivalent à σ=15 où
+Tesseract perd 8 points de CER — soit un déficit attendu global
+de 2,4 points (30 % × 8 points). »*
+
+Méthode
+-------
+1. Pour chaque document, on extrait la valeur de qualité réelle
+ (``noise_level``, ``blur_score``, ``contrast_score``…) depuis
+ ``ImageQualityResult``.
+2. Pour chaque type de dégradation, on interpole linéairement la
+ ``DegradationCurve`` synthétique : CER attendu à ce niveau.
+3. On agrège : CER moyen attendu, % docs au-dessus du seuil
+ critique de la courbe, déficit projeté = CER_attendu -
+ CER_baseline (niveau nul).
+
+Sortie
+------
+``project_robustness_on_corpus(curves, image_qualities)`` retourne
+``{engine_name: {degradation_type: {expected_cer_mean,
+deficit_vs_baseline, n_docs_above_critical, n_docs}}}``.
+
+Limites
+-------
+- Mapping ``image_quality → degradation level`` : on suppose que
+ ``noise_level`` (ImageQualityResult) correspond à σ
+ (DegradationCurve), et idem pour ``blur_score`` ↔ rayon de
+ flou. Si un corpus expose ces valeurs avec une échelle
+ différente, le mapping est documenté et l'utilisateur peut
+ passer ``quality_to_level`` custom.
+- Interpolation **linéaire** entre les points de la courbe. Au-
+ delà des bornes, on **clip** au point extrême (pas
+ d'extrapolation hasardeuse).
+"""
+
+from __future__ import annotations
+
+import logging
+import statistics
+from typing import Callable, Iterable, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# Mapping par défaut entre attributs ImageQualityResult et types
+# de dégradation synthétique. L'utilisateur peut passer un dict
+# custom pour modifier ce mapping.
+_DEFAULT_QUALITY_FIELD: dict[str, str] = {
+ "noise": "noise_level", # σ
+ "blur": "blur_score", # Variance laplacienne (inverse)
+ "contrast": "contrast_score",
+ "rotation": "rotation_angle",
+ "resolution": "resolution_score", # peut être absent
+}
+
+
+def _interpolate_cer(
+ levels: list[float],
+ cer_values: list[Optional[float]],
+ target_level: float,
+) -> Optional[float]:
+ """Interpolation linéaire : retourne CER attendu à
+ ``target_level``.
+
+ - Si ``target_level`` est en-dessous du minimum de levels,
+ retourne le CER au minimum (clip).
+ - Si au-dessus du maximum, retourne le CER au maximum.
+ - Sinon, interpolation linéaire entre les deux points
+ encadrants.
+ - Retourne ``None`` si aucun ``cer_value`` valide.
+ """
+ if not levels:
+ return None
+ # Filtrer les paires (level, cer) où cer est None
+ pairs = [
+ (lvl, cer) for lvl, cer in zip(levels, cer_values)
+ if cer is not None
+ ]
+ if not pairs:
+ return None
+ pairs.sort(key=lambda p: p[0])
+ # Clip
+ if target_level <= pairs[0][0]:
+ return pairs[0][1]
+ if target_level >= pairs[-1][0]:
+ return pairs[-1][1]
+ # Interpolation
+ for i in range(len(pairs) - 1):
+ lo_lvl, lo_cer = pairs[i]
+ hi_lvl, hi_cer = pairs[i + 1]
+ if lo_lvl <= target_level <= hi_lvl:
+ if hi_lvl == lo_lvl:
+ return lo_cer
+ ratio = (target_level - lo_lvl) / (hi_lvl - lo_lvl)
+ return lo_cer + (hi_cer - lo_cer) * ratio
+ return None # ne devrait pas arriver
+
+
+def _extract_quality_value(
+ quality: dict, degradation_type: str,
+ custom_mapping: Optional[dict[str, str]] = None,
+) -> Optional[float]:
+ """Extrait la valeur de qualité pertinente pour un type de
+ dégradation depuis un ``ImageQualityResult.as_dict()``."""
+ mapping = custom_mapping or _DEFAULT_QUALITY_FIELD
+ field = mapping.get(degradation_type)
+ if field is None:
+ return None
+ value = quality.get(field)
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def project_robustness_on_corpus(
+ curves: Iterable,
+ image_qualities: list[dict],
+ *,
+ quality_to_level: Optional[Callable[[dict, str], Optional[float]]] = None,
+ critical_threshold: Optional[float] = None,
+) -> dict:
+ """Projette les courbes de robustesse sur les qualités réelles.
+
+ Parameters
+ ----------
+ curves:
+ Itérable de ``DegradationCurve`` (ou dicts compatibles
+ avec ``engine_name``, ``degradation_type``, ``levels``,
+ ``cer_values``, ``critical_threshold_level``).
+ image_qualities:
+ Liste de dicts ``ImageQualityResult.as_dict()`` (un par
+ document). Si vide, retourne une projection vide.
+ quality_to_level:
+ Fonction custom ``(quality_dict, degradation_type) →
+ Optional[float]`` pour adapter le mapping qualité→niveau.
+ Par défaut, utilise ``_DEFAULT_QUALITY_FIELD``.
+ critical_threshold:
+ Override pour le seuil critique de CER (défaut : utilise
+ ``DegradationCurve.cer_threshold``).
+
+ Returns
+ -------
+ dict
+ ``{
+ engine_name: {
+ degradation_type: {
+ "n_docs": int,
+ "n_docs_with_data": int, # qualité disponible
+ "expected_cer_mean": float, # moyenne CER attendu
+ "expected_cer_median": float,
+ "baseline_cer": float, # CER à niveau min
+ "deficit_vs_baseline": float,
+ "n_docs_above_critical": int,
+ "critical_threshold_level": float | None,
+ "critical_threshold_cer": float,
+ },
+ },
+ }``
+ """
+ extractor = quality_to_level or (
+ lambda q, dt: _extract_quality_value(q, dt)
+ )
+ out: dict[str, dict] = {}
+
+ for curve in curves:
+ # Accepter dict ou DegradationCurve
+ if hasattr(curve, "as_dict"):
+ data = curve.as_dict()
+ else:
+ data = curve
+ engine = data.get("engine_name")
+ deg_type = data.get("degradation_type")
+ levels = data.get("levels") or []
+ cer_values = data.get("cer_values") or []
+ crit_lvl = data.get("critical_threshold_level")
+ crit_cer = (
+ critical_threshold
+ if critical_threshold is not None
+ else data.get("cer_threshold", 0.20)
+ )
+ if not engine or not deg_type:
+ continue
+
+ per_doc_cer: list[float] = []
+ n_docs_with_data = 0
+ n_above_critical = 0
+ for quality in image_qualities:
+ level = extractor(quality, deg_type)
+ if level is None:
+ continue
+ n_docs_with_data += 1
+ cer = _interpolate_cer(levels, cer_values, level)
+ if cer is None:
+ continue
+ per_doc_cer.append(cer)
+ if cer > crit_cer:
+ n_above_critical += 1
+
+ if not per_doc_cer:
+ continue
+
+ # Baseline = CER au niveau minimum (sans dégradation)
+ baseline = _interpolate_cer(
+ levels, cer_values,
+ min(levels) if levels else 0.0,
+ )
+ expected_mean = statistics.fmean(per_doc_cer)
+ expected_median = statistics.median(per_doc_cer)
+ deficit = (
+ expected_mean - baseline
+ if baseline is not None else None
+ )
+
+ out.setdefault(engine, {})[deg_type] = {
+ "n_docs": len(image_qualities),
+ "n_docs_with_data": n_docs_with_data,
+ "expected_cer_mean": expected_mean,
+ "expected_cer_median": expected_median,
+ "baseline_cer": baseline,
+ "deficit_vs_baseline": deficit,
+ "n_docs_above_critical": n_above_critical,
+ "critical_threshold_level": crit_lvl,
+ "critical_threshold_cer": crit_cer,
+ }
+ return out
+
+
+def aggregate_projection_per_engine(projection: dict) -> dict:
+ """Pour chaque moteur, agrège le déficit projeté en sommant
+ sur tous les types de dégradation.
+
+ Lecture : *« déficit total attendu pour Tesseract = 5,2 points
+ de CER si on considère les 4 dégradations indépendamment »*.
+
+ Note : la sommation **suppose l'indépendance** des
+ dégradations, ce qui n'est pas strictement vrai mais reste
+ une approximation utile pour le diagnostic.
+ """
+ out: dict[str, dict] = {}
+ for engine, per_type in projection.items():
+ total_deficit = 0.0
+ n_types_with_data = 0
+ max_deficit_type: Optional[tuple[str, float]] = None
+ for deg_type, stats in per_type.items():
+ deficit = stats.get("deficit_vs_baseline")
+ if deficit is None:
+ continue
+ total_deficit += deficit
+ n_types_with_data += 1
+ if max_deficit_type is None or deficit > max_deficit_type[1]:
+ max_deficit_type = (deg_type, deficit)
+ out[engine] = {
+ "total_expected_deficit": total_deficit,
+ "n_degradation_types": n_types_with_data,
+ "worst_degradation_type": (
+ max_deficit_type[0] if max_deficit_type else None
+ ),
+ "worst_degradation_deficit": (
+ max_deficit_type[1] if max_deficit_type else None
+ ),
+ }
+ return out
+
+
+__all__ = [
+ "project_robustness_on_corpus",
+ "aggregate_projection_per_engine",
+]
diff --git a/picarones/measurements/searchability.py b/picarones/measurements/searchability.py
new file mode 100644
index 0000000000000000000000000000000000000000..efe9209835a81a051db26fe478fe16db24a88e64
--- /dev/null
+++ b/picarones/measurements/searchability.py
@@ -0,0 +1,225 @@
+"""Recherchabilité fuzzy — Sprint 84 (A.II.5).
+
+Sprint 84 — A.II.5 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Le CER mesure les erreurs caractère par caractère. Mais pour
+un usage *recherche plein-texte* (ce que font Elastic, Solr en
+mode fuzzy, ou la recherche full-text de Gallica), la question
+réelle est :
+
+ *« Combien de mots de ma GT sont retrouvables dans la
+ sortie OCR, à orthographe approchée près ? »*
+
+Un CER de 8 % peut donner 95 % de findability si les erreurs
+sont concentrées sur des caractères non-significatifs ou sur
+quelques mots aberrants ; à l'inverse, 4 % de CER mais
+distribué sur tous les noms propres rend le corpus inutilisable
+pour l'indexation prosopographique.
+
+Méthode
+-------
+Pour chaque token GT, on regarde s'il existe au moins un token
+hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut
+2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5
+caractères). Le **rappel** est la proportion de tokens GT
+ainsi retrouvés.
+
+Multiplicité
+------------
+Si la GT contient *« le »* deux fois et l'hypothèse une fois,
+seul un token GT est compté comme retrouvé (alignement
+multi-set, comme ``rare_token_recall`` Sprint 71).
+
+Sortie
+------
+``compute_searchability(reference, hypothesis)`` retourne
+``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
+
+Limites documentées
+-------------------
+- Tokenisation par split sur whitespace (cohérent avec le reste
+ du codebase). Pas de stemming ni de lemmatisation.
+- Levenshtein non pondéré — substitution = insertion = suppression
+ = 1. Pour un poids différent (par ex. faute classique
+ diacritique = 0,5), passer une fonction custom.
+- Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la
+ similarité sémantique, voir des modules futurs (BERTScore).
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from picarones.core.metric_registry import register_metric
+from picarones.core.modules import ArtifactType
+
+logger = logging.getLogger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Tokenisation et distance d'édition
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def _split_words(text: Optional[str]) -> list[str]:
+ """Tokenisation par whitespace — cohérent avec
+ ``lexical_modernization.py``, ``rare_tokens.py``, etc."""
+ if not text:
+ return []
+ return text.split()
+
+
+def levenshtein_distance(a: str, b: str) -> int:
+ """Distance de Levenshtein (substitution=insertion=suppression=1).
+
+ Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)).
+ """
+ if a == b:
+ return 0
+ if len(a) < len(b):
+ a, b = b, a
+ # |a| ≥ |b|
+ if not b:
+ return len(a)
+ previous = list(range(len(b) + 1))
+ for i, ca in enumerate(a, start=1):
+ current = [i] + [0] * len(b)
+ for j, cb in enumerate(b, start=1):
+ cost = 0 if ca == cb else 1
+ current[j] = min(
+ current[j - 1] + 1, # insertion
+ previous[j] + 1, # suppression
+ previous[j - 1] + cost, # substitution
+ )
+ previous = current
+ return previous[-1]
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Calcul principal
+# ──────────────────────────────────────────────────────────────────────────
+
+
+def compute_searchability(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+ *,
+ max_distance: int = 2,
+ case_sensitive: bool = False,
+) -> dict:
+ """Recherchabilité fuzzy de ``reference`` dans ``hypothesis``.
+
+ Parameters
+ ----------
+ reference, hypothesis:
+ Transcriptions GT et OCR.
+ max_distance:
+ Seuil de distance de Levenshtein (≤ pour considérer un
+ token comme retrouvé). Défaut 2 — convention
+ ``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères.
+ case_sensitive:
+ Si False (défaut), casse insensible côté match — la
+ sortie ``missed_tokens`` reste avec la casse GT
+ originale.
+
+ Returns
+ -------
+ dict
+ ``{
+ "n_gt_tokens": int,
+ "n_searchable": int,
+ "recall": float | None, # None si n_gt_tokens == 0
+ "missed_tokens": list[str],
+ "max_distance": int,
+ }``
+ """
+ if max_distance < 0:
+ raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
+ gt_tokens = _split_words(reference)
+ hyp_tokens = _split_words(hypothesis)
+ n_gt = len(gt_tokens)
+ if n_gt == 0:
+ return {
+ "n_gt_tokens": 0,
+ "n_searchable": 0,
+ "recall": None,
+ "missed_tokens": [],
+ "max_distance": max_distance,
+ }
+ # Multi-set : un token hypothèse ne peut servir qu'une fois.
+ # Tri par longueur croissante pour matcher d'abord les
+ # tokens GT les plus courts (où ε-fautes sont plus rares).
+ if case_sensitive:
+ gt_for_match = list(gt_tokens)
+ hyp_for_match = list(hyp_tokens)
+ else:
+ gt_for_match = [t.lower() for t in gt_tokens]
+ hyp_for_match = [t.lower() for t in hyp_tokens]
+
+ hyp_used = [False] * len(hyp_for_match)
+ n_searchable = 0
+ missed: list[str] = []
+ for gi, gt_match in enumerate(gt_for_match):
+ # Court-circuit si match exact disponible
+ best_idx = -1
+ best_dist = max_distance + 1
+ for hi, used in enumerate(hyp_used):
+ if used:
+ continue
+ hyp_match = hyp_for_match[hi]
+ # Court-circuit longueur (Levenshtein ≥ |Δlen|)
+ if abs(len(hyp_match) - len(gt_match)) > max_distance:
+ continue
+ d = levenshtein_distance(gt_match, hyp_match)
+ if d < best_dist:
+ best_dist = d
+ best_idx = hi
+ if d == 0:
+ break # match exact, inutile de chercher mieux
+ if best_idx >= 0 and best_dist <= max_distance:
+ hyp_used[best_idx] = True
+ n_searchable += 1
+ else:
+ missed.append(gt_tokens[gi])
+ recall = n_searchable / n_gt
+ return {
+ "n_gt_tokens": n_gt,
+ "n_searchable": n_searchable,
+ "recall": recall,
+ "missed_tokens": missed,
+ "max_distance": max_distance,
+ }
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# Enregistrement registre typé (Sprint 34)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+@register_metric(
+ name="searchability_recall",
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
+ description=(
+ "Recherchabilité fuzzy : proportion de tokens GT retrouvés "
+ "dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de "
+ "la qualité pour la recherche plein-texte (Elastic, Solr)."
+ ),
+)
+def searchability_recall_metric(reference: str, hypothesis: str) -> float:
+ """Variante scalaire pour le registre typé : retourne le
+ rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
+ cohérente avec rare_token_recall Sprint 71).
+ """
+ result = compute_searchability(reference, hypothesis)
+ recall = result.get("recall")
+ return 0.0 if recall is None else recall
+
+
+__all__ = [
+ "levenshtein_distance",
+ "compute_searchability",
+ "searchability_recall_metric",
+]
diff --git a/picarones/measurements/searchability_runner.py b/picarones/measurements/searchability_runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..cf822338cd0fb86c4a58301f136568a77080a346
--- /dev/null
+++ b/picarones/measurements/searchability_runner.py
@@ -0,0 +1,81 @@
+"""Câblage runner de la recherchabilité (Sprint 86).
+
+Sprint 86 — A.II.5a (vue HTML + câblage runner).
+
+Le module ``picarones/core/searchability.py`` (Sprint 84) a livré
+la couche de calcul. Ce helper prépare la donnée pour le runner
+historique et l'agrégation par moteur.
+
+Adaptive masking
+----------------
+Comme pour les modules philologiques (Sprint 61), on ne calcule
+le rappel que si la GT contient au moins un token — pas de
+calcul vide qui produirait du bruit dans le rapport.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Iterable, Optional
+
+from picarones.core.searchability import (
+ _split_words,
+ compute_searchability,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def compute_searchability_metrics(
+ reference: Optional[str],
+ hypothesis: Optional[str],
+ *,
+ max_distance: int = 2,
+) -> Optional[dict]:
+ """Recherchabilité d'un document (adaptive).
+
+ Retourne ``None`` si la GT est vide ou ne contient aucun
+ token — ce qui déclenche l'adaptive masking côté HTML.
+ """
+ if not reference or not _split_words(reference):
+ return None
+ return compute_searchability(
+ reference, hypothesis or "", max_distance=max_distance,
+ )
+
+
+def aggregate_searchability_metrics(
+ per_doc: Iterable[Optional[dict]],
+) -> Optional[dict]:
+ """Agrège les métriques par-doc en un score corpus-wide.
+
+ Convention : on somme les ``n_gt_tokens`` et ``n_searchable``
+ et on recalcule un rappel **micro** (cohérent avec ECE/MCE
+ Sprint 39 et NER Sprint 38).
+ """
+ docs = [d for d in per_doc if d]
+ if not docs:
+ return None
+ n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
+ n_search = sum(int(d.get("n_searchable") or 0) for d in docs)
+ if n_gt == 0:
+ return None
+ # On garde l'union des missed_tokens (capped pour ne pas
+ # exploser le JSON sur de gros corpus)
+ missed: list[str] = []
+ for d in docs:
+ missed.extend(d.get("missed_tokens") or [])
+ return {
+ "n_docs": len(docs),
+ "n_gt_tokens": n_gt,
+ "n_searchable": n_search,
+ "recall": n_search / n_gt,
+ "missed_tokens_sample": missed[:50],
+ "max_distance": docs[0].get("max_distance", 2),
+ }
+
+
+__all__ = [
+ "compute_searchability_metrics",
+ "aggregate_searchability_metrics",
+]
diff --git a/picarones/measurements/specialization.py b/picarones/measurements/specialization.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3f251c56c578701544d9b57aea1f9d21554f033
--- /dev/null
+++ b/picarones/measurements/specialization.py
@@ -0,0 +1,187 @@
+"""Score de spécialisation inter-moteurs — Sprint 89 (A.II.8b).
+
+Sprint 89 — A.II.8b du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+La matrice de divergence taxonomique (Sprint 35
+``inter_engine.taxonomy_divergence_matrix``) répond à *« à quel
+point ces moteurs se trompent-ils différemment ? »*. Ce
+sprint la transforme en un **score de spécialisation** lisible
+et complète la lecture par :
+
+- une **classification** discrète (similar / distinct /
+ highly_specialized) que le chercheur peut consommer sans
+ avoir à interpréter une distance ;
+- un **top-N des paires** les plus spécialisées, qui répond
+ directement à la question *« quels moteurs sont les meilleurs
+ candidats pour un voting ensemble ? »*.
+
+Ce module **ne recommande pas** de pipeline d'ensemble — il
+fournit l'observation factuelle et laisse le chercheur arbitrer.
+
+Convention de score
+-------------------
+On utilise la **Jensen-Shannon divergence** déjà calculée par
+``inter_engine.jensen_shannon_divergence`` : elle est
+symétrique, bornée dans [0, 1], et son interprétation est
+intuitive :
+
+- ≈ 0 → profils taxonomiques identiques
+- 1 → distributions totalement disjointes
+
+Dépendances
+-----------
+S'appuie strictement sur ``picarones.core.inter_engine`` (Sprint
+35) — pas de double calcul, pas de logique nouvelle de
+divergence.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from picarones.core.inter_engine import jensen_shannon_divergence
+
+logger = logging.getLogger(__name__)
+
+
+# Seuils par convention éditoriale. La roadmap ne fixe rien :
+# ces seuils sont des **guides de lecture**, pas des verdicts.
+# Le chercheur peut les surcharger via ``classify_specialization``.
+DEFAULT_THRESHOLDS = (
+ ("similar", 0.10),
+ ("distinct", 0.30),
+ ("highly_specialized", 1.01), # tout score ≥ 0.30
+)
+
+
+def compute_specialization_score(
+ taxonomy_a: dict[str, float],
+ taxonomy_b: dict[str, float],
+) -> float:
+ """Score de spécialisation entre deux moteurs ∈ [0, 1].
+
+ 0 = mêmes erreurs, 1 = erreurs totalement disjointes.
+ Délègue à ``jensen_shannon_divergence`` (Sprint 35).
+ """
+ return jensen_shannon_divergence(taxonomy_a, taxonomy_b)
+
+
+def classify_specialization(
+ score: float,
+ thresholds: Optional[tuple[tuple[str, float], ...]] = None,
+) -> str:
+ """Classe le score en catégorie discrète.
+
+ Convention :
+ - score < 0.10 → ``similar``
+ - 0.10 ≤ score < 0.30 → ``distinct``
+ - score ≥ 0.30 → ``highly_specialized``
+
+ L'utilisateur peut passer ses propres ``thresholds`` (liste
+ triée par valeur croissante de tuples ``(label, max_score)``).
+ """
+ rules = thresholds or DEFAULT_THRESHOLDS
+ for label, max_score in rules:
+ if score < max_score:
+ return label
+ # Garde-fou : si aucun seuil ne match, dernière catégorie
+ return rules[-1][0]
+
+
+def compute_specialization_matrix(
+ taxonomies: dict[str, dict[str, float]],
+) -> Optional[dict]:
+ """Matrice de spécialisation symétrique entre tous les moteurs.
+
+ Parameters
+ ----------
+ taxonomies:
+ Map ``{engine_name: {error_class: count_or_proportion}}``.
+
+ Returns
+ -------
+ dict | None
+ ``{
+ "engines": list[str],
+ "matrix": list[list[float]], # carrée, symétrique
+ "n_pairs": int, # paires distinctes
+ "max_score": float,
+ "max_pair": (str, str) | None,
+ }`` ; ``None`` si moins de 2 moteurs.
+ """
+ if not taxonomies or len(taxonomies) < 2:
+ return None
+ engines = sorted(taxonomies.keys())
+ n = len(engines)
+ matrix = [[0.0] * n for _ in range(n)]
+ n_pairs = 0
+ max_score = 0.0
+ max_pair: Optional[tuple[str, str]] = None
+ for i in range(n):
+ for j in range(i + 1, n):
+ score = compute_specialization_score(
+ taxonomies[engines[i]], taxonomies[engines[j]],
+ )
+ matrix[i][j] = score
+ matrix[j][i] = score
+ n_pairs += 1
+ if score > max_score:
+ max_score = score
+ max_pair = (engines[i], engines[j])
+ return {
+ "engines": engines,
+ "matrix": matrix,
+ "n_pairs": n_pairs,
+ "max_score": max_score,
+ "max_pair": max_pair,
+ }
+
+
+def top_specialized_pairs(
+ matrix_data: Optional[dict],
+ n: int = 5,
+ *,
+ min_score: float = 0.0,
+) -> list[dict]:
+ """Top-N paires de moteurs triées par score décroissant.
+
+ Returns
+ -------
+ list[dict]
+ Une liste de ``{
+ "engine_a": str, "engine_b": str,
+ "score": float, "category": str,
+ }`` triée par score décroissant. Liste vide si
+ ``matrix_data`` est ``None`` ou que toutes les paires
+ sont sous ``min_score``.
+ """
+ if not matrix_data:
+ return []
+ engines = matrix_data["engines"]
+ matrix = matrix_data["matrix"]
+ pairs: list[dict] = []
+ for i, engine_a in enumerate(engines):
+ for j in range(i + 1, len(engines)):
+ score = matrix[i][j]
+ if score < min_score:
+ continue
+ pairs.append({
+ "engine_a": engine_a,
+ "engine_b": engines[j],
+ "score": score,
+ "category": classify_specialization(score),
+ })
+ pairs.sort(key=lambda p: -p["score"])
+ return pairs[:n]
+
+
+__all__ = [
+ "DEFAULT_THRESHOLDS",
+ "compute_specialization_score",
+ "classify_specialization",
+ "compute_specialization_matrix",
+ "top_specialized_pairs",
+]
diff --git a/picarones/measurements/statistics.py b/picarones/measurements/statistics.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5a6a8d6ee204d2e06fc87ff67458a68d950785c
--- /dev/null
+++ b/picarones/measurements/statistics.py
@@ -0,0 +1,1127 @@
+"""Tests statistiques et clustering d'erreurs pour Picarones.
+
+Fonctions fournies
+------------------
+- wilcoxon_test(a, b) : Wilcoxon signé-rangé (2 moteurs appariés)
+- bootstrap_ci(values, ...) : intervalle de confiance à 95 % par bootstrap
+- compute_pairwise_stats(...) : matrice de Wilcoxon entre toutes les paires
+- friedman_test(engine_cer_map) : Friedman (k moteurs, n documents) [Sprint 17]
+- nemenyi_posthoc(engine_cer_map) : post-hoc Nemenyi avec critical distance [Sprint 17]
+- build_critical_difference_svg(...) : rendu SVG du CDD (Demšar 2006) [Sprint 17]
+- compute_pareto_front(points, ...) : frontière de Pareto multi-objectifs [Sprint 19]
+- cluster_errors(...) : regroupement des patterns d'erreurs
+- compute_correlation_matrix(...) : matrice de corrélation des métriques
+- compute_reliability_curve(...) : courbe CER vs. % docs les plus faciles
+- compute_venn_data(...) : diagramme de Venn 2/3 moteurs
+"""
+
+from __future__ import annotations
+
+import math
+import random
+import re
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import Optional
+
+# Import optionnel de scipy — utilisé pour le test de Wilcoxon si disponible
+# (méthode exacte pour n ≤ 25, approximation normale pour n > 25).
+# En son absence, l'implémentation native (approximation normale pour n ≥ 10)
+# est utilisée automatiquement.
+try:
+ from scipy.stats import wilcoxon as _scipy_wilcoxon # type: ignore[import-untyped]
+ _SCIPY_AVAILABLE = True
+except ImportError:
+ _SCIPY_AVAILABLE = False
+
+
+# ---------------------------------------------------------------------------
+# Bootstrap CI
+# ---------------------------------------------------------------------------
+
+def bootstrap_ci(
+ values: list[float],
+ n_iter: int = 1000,
+ ci: float = 0.95,
+ seed: int = 42,
+) -> tuple[float, float]:
+ """Intervalle de confiance par bootstrap.
+
+ Parameters
+ ----------
+ values : liste des valeurs (ex. CER par document)
+ n_iter : nombre d'itérations bootstrap (défaut 1000)
+ ci : niveau de confiance (défaut 0.95 → 95 %)
+ seed : graine RNG pour reproductibilité
+
+ Returns
+ -------
+ (lower, upper) — les bornes de l'IC à ``ci`` %
+ """
+ if not values:
+ return (0.0, 0.0)
+ rng = random.Random(seed)
+ n = len(values)
+ means = []
+ for _ in range(n_iter):
+ sample = [values[rng.randint(0, n - 1)] for _ in range(n)]
+ means.append(sum(sample) / n)
+ means.sort()
+ alpha = (1.0 - ci) / 2.0
+ lo_idx = max(0, int(alpha * n_iter))
+ hi_idx = min(n_iter - 1, int((1.0 - alpha) * n_iter))
+ return (means[lo_idx], means[hi_idx])
+
+
+# ---------------------------------------------------------------------------
+# Test de Wilcoxon signé-rangé (implémentation pure Python)
+# ---------------------------------------------------------------------------
+
+def wilcoxon_test(
+ a: list[float],
+ b: list[float],
+ zero_method: str = "wilcox",
+) -> dict:
+ """Test de Wilcoxon signé-rangé entre deux séries de CER appariées.
+
+ Retourne un dict avec :
+ - statistic : W = min(W⁺, W⁻)
+ - p_value : p-value bilatérale
+ - significant : bool (p < 0.05)
+ - interpretation : phrase lisible
+ - n_pairs : nombre de paires utilisées (après retrait des zéros)
+ - W_plus : somme des rangs des différences positives
+ - W_minus : somme des rangs des différences négatives
+
+ Hypothèses et limites
+ ---------------------
+ * Les observations sont appariées (même corpus, deux moteurs différents).
+ * Le test est non-paramétrique : aucune hypothèse de normalité des CER.
+ * ``zero_method="wilcox"`` (défaut) : les paires sans différence (aᵢ = bᵢ)
+ sont simplement exclues. Les autres méthodes (``"pratt"``, ``"zsplit"``)
+ nécessitent scipy.
+ * **Approximation normale** (implémentation native, n ≥ 10) :
+ L'approximation est raisonnable pour n ≥ 10 et converge vers la
+ distribution exacte. Pour n < 10, une table critique simplifiée est
+ utilisée (p ∈ {0.04, 0.20}) — résultat **conservateur**.
+ * **scipy** (si installé) : ``scipy.stats.wilcoxon`` est utilisé à la place
+ de l'approximation native. scipy utilise la méthode exacte pour n ≤ 25
+ et l'approximation normale pour n > 25, ce qui est plus précis.
+ * **Validité** : le test suppose la symétrie de la distribution des
+ différences. Avec de très petits n (< 5), les résultats sont peu fiables
+ quelle que soit la méthode.
+
+ Parameters
+ ----------
+ a, b : séries de CER (même longueur, même ordre de documents)
+ zero_method : gestion des paires nulles (défaut : ``"wilcox"``)
+ """
+ if len(a) != len(b):
+ raise ValueError("Les deux listes doivent avoir la même longueur")
+
+ diffs = [x - y for x, y in zip(a, b)]
+
+ # Retirer les zéros (méthode "wilcox")
+ if zero_method == "wilcox":
+ diffs = [d for d in diffs if d != 0.0]
+
+ n = len(diffs)
+ if n == 0:
+ return {
+ "statistic": 0.0,
+ "p_value": 1.0,
+ "significant": False,
+ "interpretation": "Aucune différence entre les deux concurrents.",
+ "n_pairs": 0,
+ }
+
+ # Rangs des valeurs absolues
+ abs_diffs = [abs(d) for d in diffs]
+ indexed = sorted(enumerate(abs_diffs), key=lambda x: x[1])
+
+ # Gestion des ex-aequo : rang moyen
+ ranks = [0.0] * n
+ i = 0
+ while i < n:
+ j = i
+ while j < n and abs_diffs[indexed[j][0]] == abs_diffs[indexed[i][0]]:
+ j += 1
+ avg_rank = (i + j + 1) / 2.0 # rang moyen (1-based)
+ for k in range(i, j):
+ ranks[indexed[k][0]] = avg_rank
+ i = j
+
+ W_plus = sum(ranks[k] for k in range(n) if diffs[k] > 0)
+ W_minus = sum(ranks[k] for k in range(n) if diffs[k] < 0)
+ W = min(W_plus, W_minus)
+
+ # Calcul de la p-value : scipy si disponible, sinon approximation native
+ if _SCIPY_AVAILABLE:
+ try:
+ scipy_res = _scipy_wilcoxon(diffs, zero_method=zero_method)
+ p_value = float(scipy_res.pvalue)
+ except Exception:
+ # Repli sur l'implémentation native en cas d'erreur scipy
+ p_value = _native_p_value(n, W)
+ else:
+ p_value = _native_p_value(n, W)
+
+ significant = p_value < 0.05
+
+ if significant:
+ better = "premier" if W_plus < W_minus else "second"
+ interpretation = (
+ f"Différence statistiquement significative (p = {p_value:.4f} < 0.05). "
+ f"Le {better} concurrent obtient de meilleurs scores."
+ )
+ else:
+ interpretation = (
+ f"Différence non significative (p = {p_value:.4f} ≥ 0.05). "
+ "On ne peut pas conclure que l'un surpasse l'autre."
+ )
+
+ return {
+ "statistic": round(W, 4),
+ "p_value": round(p_value, 6),
+ "significant": significant,
+ "interpretation": interpretation,
+ "n_pairs": n,
+ "W_plus": round(W_plus, 4),
+ "W_minus": round(W_minus, 4),
+ }
+
+
+def _normal_sf(z: float) -> float:
+ """Survival function de la loi normale standard (1 - CDF)."""
+ # Approximation Abramowitz & Stegun 26.2.17
+ t = 1.0 / (1.0 + 0.2316419 * abs(z))
+ poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937
+ + t * (-1.821255978 + t * 1.330274429))))
+ phi_z = math.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi)
+ p = phi_z * poly
+ return p if z >= 0 else 1.0 - p
+
+
+# Table des valeurs critiques de W pour α=0.05 bilatéral (test exact, source : tables de Wilcoxon)
+_W_CRITICAL = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 2, 8: 3, 9: 5}
+
+
+def _wilcoxon_exact_p(n: int, w: float) -> float:
+ """P-value approximée pour petits n (< 10) via table critique simplifiée.
+
+ Note : résultat **conservateur** — seules deux valeurs sont retournées :
+ 0.04 (significatif à 5 %) ou 0.20 (non significatif).
+ Préférer scipy pour des p-values exactes.
+ """
+ critical = _W_CRITICAL.get(n, 0)
+ if w <= critical:
+ return 0.04 # significatif à 5 %
+ return 0.20 # non significatif (approximation conservative)
+
+
+def _native_p_value(n: int, W: float) -> float:
+ """Calcule la p-value via l'approximation normale (n ≥ 10) ou la table exacte (n < 10)."""
+ if n >= 10:
+ mu = n * (n + 1) / 4.0
+ sigma2 = n * (n + 1) * (2 * n + 1) / 24.0
+ if sigma2 <= 0:
+ return 1.0
+ z = abs((W + 0.5) - mu) / math.sqrt(sigma2) # correction de continuité
+ return 2.0 * _normal_sf(z) # test bilatéral
+ return _wilcoxon_exact_p(n, W)
+
+
+# ---------------------------------------------------------------------------
+# Matrice des tests pairwise
+# ---------------------------------------------------------------------------
+
+def compute_pairwise_stats(
+ engine_cer_map: dict[str, list[float]],
+) -> list[dict]:
+ """Calcule les tests de Wilcoxon entre toutes les paires de concurrents.
+
+ Parameters
+ ----------
+ engine_cer_map : dict {engine_name → [cer_doc1, cer_doc2, ...]}
+
+ Returns
+ -------
+ Liste de dicts, un par paire :
+ - engine_a, engine_b, statistic, p_value, significant, interpretation
+ """
+ names = list(engine_cer_map.keys())
+ results = []
+ for i in range(len(names)):
+ for j in range(i + 1, len(names)):
+ a_name, b_name = names[i], names[j]
+ a_vals = engine_cer_map[a_name]
+ b_vals = engine_cer_map[b_name]
+ # Aligner les longueurs
+ min_len = min(len(a_vals), len(b_vals))
+ if min_len < 2:
+ continue
+ res = wilcoxon_test(a_vals[:min_len], b_vals[:min_len])
+ results.append({
+ "engine_a": a_name,
+ "engine_b": b_name,
+ **res,
+ })
+ return results
+
+
+# ---------------------------------------------------------------------------
+# Test de Friedman + post-hoc Nemenyi (Sprint 17)
+# ---------------------------------------------------------------------------
+#
+# Référence : Demšar, J. (2006), "Statistical Comparisons of Classifiers over
+# Multiple Data Sets", Journal of Machine Learning Research 7:1-30. Standard
+# de facto pour comparer plusieurs systèmes sur plusieurs datasets — ici :
+# plusieurs moteurs OCR sur plusieurs documents. Le CDD (critical difference
+# diagram) issu de Nemenyi est le rendu canonique.
+
+# Valeurs critiques de la distribution du Studentized Range divisées par √2,
+# pour df = ∞ (approximation usuelle pour Nemenyi). Source : tables de Tukey.
+# Clé : nombre de traitements k ; valeur : q_α pour α ∈ {0.05, 0.01}.
+_NEMENYI_Q_TABLE = {
+ # k q_0.05 q_0.01
+ 2: (1.960, 2.576),
+ 3: (2.343, 2.913),
+ 4: (2.569, 3.113),
+ 5: (2.728, 3.255),
+ 6: (2.850, 3.364),
+ 7: (2.949, 3.452),
+ 8: (3.031, 3.526),
+ 9: (3.102, 3.590),
+ 10: (3.164, 3.646),
+ 11: (3.219, 3.696),
+ 12: (3.268, 3.741),
+ 13: (3.313, 3.781),
+ 14: (3.354, 3.818),
+ 15: (3.391, 3.853),
+ 16: (3.426, 3.886),
+ 17: (3.458, 3.916),
+ 18: (3.489, 3.944),
+ 19: (3.517, 3.970),
+ 20: (3.544, 3.995),
+ 25: (3.658, 4.095),
+ 30: (3.739, 4.167),
+ 40: (3.858, 4.272),
+ 50: (3.945, 4.349),
+}
+
+
+def _chi_square_sf(x: float, df: int) -> float:
+ """Survival function de la loi chi², 1 - CDF(x).
+
+ Utilise scipy si disponible (méthode exacte), sinon Wilson-Hilferty
+ (approximation normale précise dès df ≥ 3).
+ """
+ if x <= 0 or df <= 0:
+ return 1.0
+ try:
+ from scipy.stats import chi2 as _chi2 # type: ignore[import-untyped]
+ return float(_chi2.sf(x, df))
+ except ImportError:
+ pass
+ # Wilson-Hilferty : transforme chi² en approximation normale
+ z = (((x / df) ** (1.0 / 3.0)) - (1.0 - 2.0 / (9.0 * df))) / math.sqrt(2.0 / (9.0 * df))
+ return _normal_sf(z)
+
+
+def _rank_row(values: list[float]) -> list[float]:
+ """Rangs d'une ligne — petit = rang 1. Ex-aequo : rangs moyens."""
+ n = len(values)
+ indexed = sorted(range(n), key=lambda i: values[i])
+ ranks = [0.0] * n
+ i = 0
+ while i < n:
+ j = i
+ while j < n and values[indexed[j]] == values[indexed[i]]:
+ j += 1
+ avg_rank = (i + j + 1) / 2.0 # 1-based
+ for k in range(i, j):
+ ranks[indexed[k]] = avg_rank
+ i = j
+ return ranks
+
+
+def _aligned_cer_matrix(
+ engine_cer_map: dict[str, list[float]],
+) -> tuple[list[str], list[list[float]]]:
+ """Construit la matrice (k moteurs × n documents) alignée sur la longueur
+ minimale. Retourne ``(noms, matrice_colonne_par_moteur)``.
+
+ Friedman exige des blocs (documents) complets : si les moteurs n'ont pas
+ tous été exécutés sur les mêmes documents, on tronque à la longueur
+ minimale, documentée dans le résultat via ``n_blocks``.
+ """
+ names = list(engine_cer_map.keys())
+ if not names:
+ return [], []
+ min_len = min(len(v) for v in engine_cer_map.values())
+ if min_len == 0:
+ return names, []
+ matrix = [engine_cer_map[n][:min_len] for n in names]
+ return names, matrix
+
+
+def friedman_test(engine_cer_map: dict[str, list[float]]) -> dict:
+ """Test de Friedman — k moteurs sur n documents appariés.
+
+ Test non-paramétrique équivalent à l'ANOVA à mesures répétées pour des
+ données ordinales. Hypothèse nulle : tous les moteurs ont la même
+ performance moyenne. Rejet → au moins un moteur diffère des autres.
+
+ Parameters
+ ----------
+ engine_cer_map:
+ Dict ``{engine_name → [cer_doc1, cer_doc2, ...]}``. Tous les moteurs
+ doivent avoir été évalués sur les mêmes documents (dans le même ordre).
+
+ Returns
+ -------
+ dict avec :
+ - ``statistic`` : Q corrigé pour les ex-aequo
+ - ``p_value`` : p-value (scipy si dispo, sinon Wilson-Hilferty)
+ - ``significant`` : bool, p < 0.05
+ - ``df`` : degrés de liberté = k - 1
+ - ``n_blocks`` : nombre de documents (blocs) utilisés
+ - ``n_engines`` : nombre de moteurs (k)
+ - ``mean_ranks`` : dict ``{engine: rang_moyen}``
+ - ``interpretation``: phrase lisible
+ - ``error`` : message si le test n'est pas applicable
+ """
+ names, matrix = _aligned_cer_matrix(engine_cer_map)
+ k = len(names)
+ n = len(matrix[0]) if matrix else 0
+
+ if k < 2:
+ return {
+ "statistic": 0.0, "p_value": 1.0, "significant": False,
+ "df": 0, "n_blocks": n, "n_engines": k,
+ "mean_ranks": {names[0]: 1.0} if k == 1 else {},
+ "interpretation": "Test de Friedman non applicable : il faut au moins 2 moteurs.",
+ "error": "not_enough_engines",
+ }
+ if n < 2:
+ return {
+ "statistic": 0.0, "p_value": 1.0, "significant": False,
+ "df": k - 1, "n_blocks": n, "n_engines": k,
+ "mean_ranks": {name: 1.0 for name in names},
+ "interpretation": "Test de Friedman non applicable : il faut au moins 2 documents communs.",
+ "error": "not_enough_blocks",
+ }
+
+ # Rangs par bloc (document) : pour chaque doc, ranger les k moteurs
+ ranks_by_engine: list[list[float]] = [[] for _ in range(k)]
+ for j in range(n):
+ row = [matrix[i][j] for i in range(k)]
+ row_ranks = _rank_row(row)
+ for i in range(k):
+ ranks_by_engine[i].append(row_ranks[i])
+
+ rank_sums = [sum(r) for r in ranks_by_engine]
+ mean_ranks = {names[i]: rank_sums[i] / n for i in range(k)}
+
+ # Statistique Q non-corrigée (sans ex-aequo)
+ # Q = 12 / (n·k·(k+1)) · Σ R_j² − 3·n·(k+1)
+ Q = (12.0 / (n * k * (k + 1))) * sum(rs ** 2 for rs in rank_sums) - 3.0 * n * (k + 1)
+
+ # Correction pour les ex-aequo (ties factor) — ajuste si des rangs sont
+ # partagés dans certains blocs. Formule : Q_corr = Q / (1 - T/(n·(k³−k)))
+ # où T = Σ (tⱼ³ − tⱼ) sur tous les groupes d'ex-aequo.
+ tie_correction = 0.0
+ for j in range(n):
+ row = [matrix[i][j] for i in range(k)]
+ sorted_row = sorted(row)
+ i = 0
+ while i < len(sorted_row):
+ count = 1
+ while i + count < len(sorted_row) and sorted_row[i + count] == sorted_row[i]:
+ count += 1
+ if count > 1:
+ tie_correction += count ** 3 - count
+ i += count
+ denom = 1.0 - tie_correction / (n * (k ** 3 - k)) if k >= 2 else 1.0
+ if denom > 0:
+ Q = Q / denom
+
+ df = k - 1
+ p_value = _chi_square_sf(Q, df)
+ significant = p_value < 0.05
+
+ if significant:
+ interpretation = (
+ f"Test de Friedman significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). "
+ f"Au moins un moteur diffère des autres — utiliser le post-hoc Nemenyi "
+ f"pour identifier les paires distinguables."
+ )
+ else:
+ interpretation = (
+ f"Test de Friedman non significatif (Q = {Q:.3f}, df = {df}, p = {p_value:.4f}). "
+ f"Aucune différence globale détectée entre les moteurs sur ce corpus."
+ )
+
+ return {
+ "statistic": round(Q, 4),
+ "p_value": round(p_value, 6),
+ "significant": significant,
+ "df": df,
+ "n_blocks": n,
+ "n_engines": k,
+ "mean_ranks": {k_: round(v, 4) for k_, v in mean_ranks.items()},
+ "interpretation": interpretation,
+ }
+
+
+def _nemenyi_critical_value(k: int, alpha: float = 0.05) -> Optional[float]:
+ """Valeur critique q_α pour k traitements, df = ∞.
+
+ Retourne ``None`` si k est hors table (< 2 ou > 50).
+ """
+ if k < 2:
+ return None
+ if k in _NEMENYI_Q_TABLE:
+ q05, q01 = _NEMENYI_Q_TABLE[k]
+ return q05 if alpha == 0.05 else q01 if alpha == 0.01 else q05
+ # Au-delà de la table : borne supérieure (conservateur)
+ max_k = max(_NEMENYI_Q_TABLE.keys())
+ if k > max_k:
+ q05, q01 = _NEMENYI_Q_TABLE[max_k]
+ return q05 if alpha == 0.05 else q01
+ # Entre deux clés : interpolation linéaire
+ keys = sorted(_NEMENYI_Q_TABLE.keys())
+ for i in range(len(keys) - 1):
+ if keys[i] < k < keys[i + 1]:
+ lo, hi = keys[i], keys[i + 1]
+ q_lo = _NEMENYI_Q_TABLE[lo][0 if alpha == 0.05 else 1]
+ q_hi = _NEMENYI_Q_TABLE[hi][0 if alpha == 0.05 else 1]
+ frac = (k - lo) / (hi - lo)
+ return q_lo + frac * (q_hi - q_lo)
+ return None
+
+
+def nemenyi_posthoc(
+ engine_cer_map: dict[str, list[float]],
+ alpha: float = 0.05,
+) -> dict:
+ """Post-hoc de Nemenyi — identifie les paires de moteurs statistiquement
+ indiscernables après un test de Friedman.
+
+ Calcule la *critical distance* CD = q_α · √(k·(k+1) / (6·n)). Deux moteurs
+ dont les rangs moyens diffèrent de moins que CD ne sont **pas**
+ statistiquement distinguables au seuil α.
+
+ Returns
+ -------
+ dict avec :
+ - ``alpha`` : seuil utilisé
+ - ``critical_distance`` : CD calculée
+ - ``q_alpha`` : valeur critique q_α issue de la table
+ - ``n_blocks``, ``n_engines``
+ - ``mean_ranks`` : rangs moyens par moteur (dict)
+ - ``engines_sorted`` : liste des moteurs triés par rang croissant
+ - ``significant_matrix`` : matrice bool (list[list[bool]]),
+ ``True`` = paire significativement différente
+ - ``tied_groups`` : liste de listes de moteurs indiscernables
+ (groupes maximaux d'ex-aequo pratiques)
+ - ``error`` : présent si le test n'est pas applicable
+ """
+ names, matrix = _aligned_cer_matrix(engine_cer_map)
+ k = len(names)
+ n = len(matrix[0]) if matrix else 0
+
+ if k < 2 or n < 2:
+ return {
+ "alpha": alpha,
+ "critical_distance": 0.0,
+ "q_alpha": 0.0,
+ "n_blocks": n,
+ "n_engines": k,
+ "mean_ranks": {name: 1.0 for name in names},
+ "engines_sorted": list(names),
+ "significant_matrix": [[False] * k for _ in range(k)],
+ "tied_groups": [list(names)] if names else [],
+ "error": "not_enough_data",
+ }
+
+ # Friedman fournit les rangs moyens — on les recalcule ici pour rester
+ # autonome (sans forcer l'utilisateur à chaîner les deux appels).
+ ranks_by_engine: list[list[float]] = [[] for _ in range(k)]
+ for j in range(n):
+ row = [matrix[i][j] for i in range(k)]
+ row_ranks = _rank_row(row)
+ for i in range(k):
+ ranks_by_engine[i].append(row_ranks[i])
+
+ mean_ranks_list = [sum(r) / n for r in ranks_by_engine]
+ mean_ranks = {names[i]: round(mean_ranks_list[i], 4) for i in range(k)}
+
+ q_alpha = _nemenyi_critical_value(k, alpha) or 0.0
+ critical_distance = q_alpha * math.sqrt(k * (k + 1) / (6.0 * n))
+
+ # Matrice de significativité : paire (i,j) significative si |R_i - R_j| > CD
+ significant_matrix = [
+ [
+ (i != j) and (abs(mean_ranks_list[i] - mean_ranks_list[j]) > critical_distance)
+ for j in range(k)
+ ]
+ for i in range(k)
+ ]
+
+ # Groupes d'ex-aequo pratiques : fenêtre glissante sur les rangs triés.
+ # Deux moteurs sont dans le même groupe si leur écart ≤ CD.
+ order = sorted(range(k), key=lambda i: mean_ranks_list[i])
+ sorted_names = [names[i] for i in order]
+ sorted_ranks = [mean_ranks_list[i] for i in order]
+
+ tied_groups: list[list[str]] = []
+ i = 0
+ while i < len(sorted_names):
+ # étendre le groupe tant que le moteur suivant est à ≤ CD du premier du groupe
+ j = i
+ while j + 1 < len(sorted_names) and (sorted_ranks[j + 1] - sorted_ranks[i]) <= critical_distance:
+ j += 1
+ tied_groups.append(sorted_names[i:j + 1])
+ i = j + 1 if j > i else i + 1
+
+ return {
+ "alpha": alpha,
+ "critical_distance": round(critical_distance, 4),
+ "q_alpha": round(q_alpha, 4),
+ "n_blocks": n,
+ "n_engines": k,
+ "mean_ranks": mean_ranks,
+ "engines_sorted": sorted_names,
+ "significant_matrix": significant_matrix,
+ "tied_groups": tied_groups,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Critical Difference Diagram — rendu SVG (Sprint 17)
+# ---------------------------------------------------------------------------
+
+def build_critical_difference_svg(
+ nemenyi_result: dict,
+ width: int = 780,
+ row_height: int = 22,
+) -> str:
+ """Génère le SVG du Critical Difference Diagram (Demšar 2006).
+
+ Le diagramme montre :
+ * un axe horizontal des rangs moyens (1 à k),
+ * chaque moteur positionné sur l'axe à son rang moyen,
+ * des barres horizontales épaisses reliant les moteurs statistiquement
+ indiscernables (distance ≤ CD),
+ * la longueur de CD affichée au-dessus de l'axe en référence.
+
+ Parameters
+ ----------
+ nemenyi_result:
+ Résultat de ``nemenyi_posthoc``.
+ width:
+ Largeur totale du SVG en pixels.
+ row_height:
+ Hauteur de chaque ligne d'étiquette moteur (auto-adaptatif).
+
+ Returns
+ -------
+ Chaîne contenant le SVG (balise racine ````).
+ """
+ k = nemenyi_result.get("n_engines", 0)
+ if k < 2 or nemenyi_result.get("error"):
+ return (
+ ''
+ )
+
+ engines_sorted: list[str] = list(nemenyi_result.get("engines_sorted", []))
+ mean_ranks: dict[str, float] = dict(nemenyi_result.get("mean_ranks", {}))
+ tied_groups: list[list[str]] = list(nemenyi_result.get("tied_groups", []))
+ cd: float = float(nemenyi_result.get("critical_distance", 0.0))
+
+ # Dimensions
+ left_pad, right_pad = 40, 40
+ top_pad = 50 # espace pour l'affichage CD
+ axis_y = top_pad + 10
+ bars_start_y = axis_y + 20 # première barre d'ex-aequo sous l'axe
+ # Empiler une ligne par groupe + une ligne par étiquette
+ label_rows = k # chaque moteur a sa propre ligne de label
+ bars_count = len(tied_groups)
+ total_h = bars_start_y + bars_count * 10 + label_rows * row_height + 20
+
+ axis_x0, axis_x1 = left_pad, width - right_pad
+ axis_width = axis_x1 - axis_x0
+
+ def x_for_rank(r: float) -> float:
+ # Rang 1 à gauche, rang k à droite
+ if k <= 1:
+ return axis_x0
+ return axis_x0 + (r - 1.0) / (k - 1.0) * axis_width
+
+ parts: list[str] = []
+ parts.append(
+ f'')
+ return "".join(parts)
+
+
+def _svg_escape(text: str) -> str:
+ """Échappe un texte pour inclusion sûre dans un nœud SVG/XML."""
+ return (text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ .replace("'", "'"))
+
+
+# ---------------------------------------------------------------------------
+# Frontière de Pareto (Sprint 19)
+# ---------------------------------------------------------------------------
+
+def compute_pareto_front(
+ points: list[dict],
+ objectives: tuple[str, ...] = ("cer", "cost"),
+ name_key: str = "engine",
+ minimize: Optional[tuple[bool, ...]] = None,
+) -> list[str]:
+ """Calcule la frontière de Pareto sur ``len(objectives)`` dimensions.
+
+ Un point ``p`` est Pareto-dominant si aucun autre point n'a, pour TOUS
+ les objectifs, une valeur au moins aussi bonne ET au moins une valeur
+ strictement meilleure.
+
+ Parameters
+ ----------
+ points:
+ Liste de dicts. Chaque dict doit contenir ``name_key`` et toutes les
+ clés de ``objectives``. Les points dont une valeur d'objectif est
+ ``None`` sont ignorés (pas de comparaison possible).
+ objectives:
+ Clés des objectifs à minimiser/maximiser.
+ name_key:
+ Clé identifiant le point (par défaut ``"engine"``).
+ minimize:
+ Pour chaque objectif, ``True`` = minimiser (ex. CER, coût),
+ ``False`` = maximiser (ex. ancrage). Doit avoir la même longueur
+ que ``objectives``.
+
+ Returns
+ -------
+ Liste des ``name`` des points sur le front Pareto, ordre stable depuis
+ ``points``.
+ """
+ if minimize is None:
+ minimize = tuple(True for _ in objectives)
+ if len(minimize) != len(objectives):
+ raise ValueError("`minimize` doit avoir la même longueur que `objectives`")
+
+ valid = []
+ for p in points:
+ try:
+ vals = tuple(float(p[k]) for k in objectives)
+ except (KeyError, TypeError, ValueError):
+ continue
+ valid.append((p[name_key], vals))
+
+ front: list[str] = []
+ for name_a, vals_a in valid:
+ dominated = False
+ for name_b, vals_b in valid:
+ if name_a == name_b:
+ continue
+ # B domine A si B est ≥ aussi bon partout ET strictement meilleur quelque part
+ better_or_equal_everywhere = True
+ strictly_better_somewhere = False
+ for va, vb, mini in zip(vals_a, vals_b, minimize):
+ if mini:
+ if vb > va:
+ better_or_equal_everywhere = False
+ break
+ if vb < va:
+ strictly_better_somewhere = True
+ else: # maximiser
+ if vb < va:
+ better_or_equal_everywhere = False
+ break
+ if vb > va:
+ strictly_better_somewhere = True
+ if better_or_equal_everywhere and strictly_better_somewhere:
+ dominated = True
+ break
+ if not dominated:
+ front.append(name_a)
+ return front
+
+
+# ---------------------------------------------------------------------------
+# Clustering des patterns d'erreurs
+# ---------------------------------------------------------------------------
+
+# Patterns d'erreurs fréquentes (OCR + HTR documents patrimoniaux)
+_ERROR_PATTERNS = [
+ # (pattern_re, label)
+ (r"\brn\b.*\bm\b|\bm\b.*\brn\b|rn→m|m→rn", "confusion rn/m"),
+ (r"[lI]→1|1→[lI]|l→1|1→l|I→1|1→I", "confusion l/1/I"),
+ (r"u→n|n→u|v→u|u→v", "confusion u/n/v"),
+ (r"[oO]→0|0→[oO]", "confusion O/0"),
+ (r"ſ→[fs]|[fs]→ſ", "confusion ſ/f/s"),
+ (r"é→e|è→e|ê→e|e→[éèê]", "erreur diacritique é/e"),
+ (r"œ→oe|oe→œ|æ→ae|ae→æ", "ligature œ/æ"),
+ (r"[fF]i→fi|fi→[fF]i", "ligature fi"),
+ (r"[fF]l→fl|fl→[fF]l", "ligature fl"),
+ (r"\s+→''|''→\s+", "segmentation espace"),
+]
+
+def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]:
+ """Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution."""
+ from picarones.report.diff_utils import compute_word_diff
+ ops = compute_word_diff(gt, hyp)
+ pairs = []
+ for op in ops:
+ if op["op"] == "replace":
+ pairs.append((op["old"], op["new"]))
+ elif op["op"] == "delete":
+ pairs.append((op["text"], ""))
+ elif op["op"] == "insert":
+ pairs.append(("", op["text"]))
+ return pairs
+
+
+@dataclass
+class ErrorCluster:
+ """Un cluster d'erreurs similaires."""
+ cluster_id: int
+ label: str
+ """Description humaine du pattern (ex. 'confusion rn/m')."""
+ count: int
+ examples: list[dict]
+ """Liste de {engine, gt_fragment, ocr_fragment}."""
+
+ def as_dict(self) -> dict:
+ return {
+ "cluster_id": self.cluster_id,
+ "label": self.label,
+ "count": self.count,
+ "examples": self.examples[:5], # 5 exemples max
+ }
+
+
+def cluster_errors(
+ error_data: list[dict],
+ max_clusters: int = 8,
+) -> list[ErrorCluster]:
+ """Regroupe les erreurs en clusters avec labels lisibles.
+
+ Parameters
+ ----------
+ error_data : liste de dicts {engine, gt, hypothesis}
+ max_clusters : nombre max de clusters à retourner
+
+ Returns
+ -------
+ Liste de ErrorCluster triée par count décroissant.
+ """
+ # Collecter tous les patterns d'erreur avec contexte
+ # Clé : catégorie d'erreur → liste d'exemples
+ bucket: dict[str, list[dict]] = defaultdict(list)
+ other_pairs: list[dict] = []
+
+ for item in error_data:
+ engine = item.get("engine", "")
+ gt = item.get("gt", "")
+ hyp = item.get("hypothesis", "")
+ pairs = _extract_error_pairs(gt, hyp)
+
+ for old, new in pairs:
+ if not old and not new:
+ continue
+ matched = False
+ # Essayer de matcher un pattern connu
+ probe = f"{old}→{new}"
+ for _pat, label in _ERROR_PATTERNS:
+ try:
+ if re.search(_pat, probe, re.IGNORECASE):
+ bucket[label].append({
+ "engine": engine,
+ "gt_fragment": old,
+ "ocr_fragment": new,
+ })
+ matched = True
+ break
+ except re.error:
+ pass
+
+ if not matched:
+ # Regrouper les substitutions restantes par paire de caractères
+ if len(old) <= 3 and len(new) <= 3:
+ key = f"{old}→{new}" if (old and new) else (f"—→{new}" if new else f"{old}→—")
+ bucket[key].append({
+ "engine": engine,
+ "gt_fragment": old,
+ "ocr_fragment": new,
+ })
+ else:
+ other_pairs.append({
+ "engine": engine,
+ "gt_fragment": old,
+ "ocr_fragment": new,
+ })
+
+ # Construire les clusters triés par fréquence
+ clusters: list[ErrorCluster] = []
+ cluster_id = 1
+ sorted_buckets = sorted(bucket.items(), key=lambda x: -len(x[1]))
+
+ for label, examples in sorted_buckets[:max_clusters - 1]:
+ clusters.append(ErrorCluster(
+ cluster_id=cluster_id,
+ label=label,
+ count=len(examples),
+ examples=examples,
+ ))
+ cluster_id += 1
+
+ # Cluster "autres"
+ if other_pairs:
+ clusters.append(ErrorCluster(
+ cluster_id=cluster_id,
+ label="autres substitutions",
+ count=len(other_pairs),
+ examples=other_pairs,
+ ))
+
+ # Trier par count décroissant et limiter
+ clusters.sort(key=lambda c: -c.count)
+ return clusters[:max_clusters]
+
+
+# ---------------------------------------------------------------------------
+# Matrice de corrélation entre métriques
+# ---------------------------------------------------------------------------
+
+def _pearson(x: list[float], y: list[float]) -> float:
+ """Coefficient de corrélation de Pearson."""
+ n = len(x)
+ if n < 2:
+ return 0.0
+ mx = sum(x) / n
+ my = sum(y) / n
+ num = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y))
+ den = math.sqrt(
+ sum((xi - mx) ** 2 for xi in x) * sum((yi - my) ** 2 for yi in y)
+ )
+ return num / den if den > 0 else 0.0
+
+
+def compute_correlation_matrix(
+ metrics_per_doc: list[dict],
+ metric_keys: Optional[list[str]] = None,
+) -> dict:
+ """Calcule la matrice de corrélation entre toutes les métriques numériques.
+
+ Parameters
+ ----------
+ metrics_per_doc : liste de dicts, un par document, contenant les métriques
+ metric_keys : clés à inclure (None → toutes les clés numériques)
+
+ Returns
+ -------
+ {
+ "labels": [...],
+ "matrix": [[r_ij, ...], ...] // coefficients de Pearson
+ }
+ """
+ if not metrics_per_doc:
+ return {"labels": [], "matrix": []}
+
+ if metric_keys is None:
+ # Déduire les clés numériques
+ sample = metrics_per_doc[0]
+ metric_keys = [k for k, v in sample.items() if isinstance(v, (int, float))]
+
+ # Construire les vecteurs
+ vectors: dict[str, list[float]] = {k: [] for k in metric_keys}
+ for doc in metrics_per_doc:
+ for k in metric_keys:
+ v = doc.get(k)
+ vectors[k].append(float(v) if v is not None else 0.0)
+
+ # Calculer la matrice
+ labels = metric_keys
+ n = len(labels)
+ matrix = []
+ for i in range(n):
+ row = []
+ for j in range(n):
+ r = _pearson(vectors[labels[i]], vectors[labels[j]])
+ row.append(round(r, 4))
+ matrix.append(row)
+
+ return {"labels": labels, "matrix": matrix}
+
+
+# ---------------------------------------------------------------------------
+# Courbe de fiabilité (reliability curve)
+# ---------------------------------------------------------------------------
+
+def compute_reliability_curve(
+ cer_values: list[float],
+ steps: int = 20,
+) -> list[dict]:
+ """Pour les X% documents les plus faciles, quel est le CER moyen ?
+
+ Returns
+ -------
+ Liste de {pct_docs: float, mean_cer: float}
+ """
+ if not cer_values:
+ return []
+ sorted_cer = sorted(cer_values)
+ n = len(sorted_cer)
+ points = []
+ for step in range(1, steps + 1):
+ pct = step / steps
+ cutoff = max(1, int(pct * n))
+ subset = sorted_cer[:cutoff]
+ mean_cer = sum(subset) / len(subset)
+ points.append({"pct_docs": round(pct * 100, 1), "mean_cer": round(mean_cer, 6)})
+ return points
+
+
+# ---------------------------------------------------------------------------
+# Données pour le diagramme de Venn (erreurs communes / exclusives)
+# ---------------------------------------------------------------------------
+
+def compute_venn_data(
+ engine_error_sets: dict[str, set[str]],
+) -> dict:
+ """Calcule les cardinalités pour un diagramme de Venn entre 2 ou 3 concurrents.
+
+ Parameters
+ ----------
+ engine_error_sets : {engine_name → set of doc_id:error_token_pair strings}
+
+ Returns
+ -------
+ Pour 2 concurrents :
+ {only_a, only_b, both, label_a, label_b}
+ Pour 3 concurrents :
+ {only_a, only_b, only_c, ab, ac, bc, abc, label_a, label_b, label_c}
+ """
+ names = list(engine_error_sets.keys())[:3] # max 3 pour Venn lisible
+ if len(names) < 2:
+ return {}
+
+ sets = {n: engine_error_sets[n] for n in names}
+
+ if len(names) == 2:
+ a, b = names
+ sa, sb = sets[a], sets[b]
+ return {
+ "type": "venn2",
+ "label_a": a,
+ "label_b": b,
+ "only_a": len(sa - sb),
+ "only_b": len(sb - sa),
+ "both": len(sa & sb),
+ }
+ else:
+ a, b, c = names
+ sa, sb, sc = sets[a], sets[b], sets[c]
+ return {
+ "type": "venn3",
+ "label_a": a,
+ "label_b": b,
+ "label_c": c,
+ "only_a": len(sa - sb - sc),
+ "only_b": len(sb - sa - sc),
+ "only_c": len(sc - sa - sb),
+ "ab": len((sa & sb) - sc),
+ "ac": len((sa & sc) - sb),
+ "bc": len((sb & sc) - sa),
+ "abc": len(sa & sb & sc),
+ }
diff --git a/picarones/measurements/structure.py b/picarones/measurements/structure.py
new file mode 100644
index 0000000000000000000000000000000000000000..2724f4fc76f0a86b0b7c6e3df6fb287dcf56a45b
--- /dev/null
+++ b/picarones/measurements/structure.py
@@ -0,0 +1,229 @@
+"""Analyse structurelle des résultats OCR.
+
+Mesures
+-------
+- **Taux de fusion de lignes** : l'OCR produit moins de lignes que le GT
+ (plusieurs lignes GT fusionnées en une seule).
+- **Taux de fragmentation** : l'OCR produit plus de lignes que le GT
+ (une ligne GT découpée en plusieurs).
+- **Score d'ordre de lecture** : corrélation entre l'ordre des mots GT et OCR,
+ approximé par la longueur de la sous-séquence commune la plus longue (LCS).
+- **Taux de conservation des paragraphes** : respect des sauts de paragraphe.
+
+Ces métriques sont calculées indépendamment du contenu textuel — elles mesurent
+la fidélité de la mise en page, pas la qualité des caractères.
+
+Note : sans bounding boxes disponibles, l'analyse se base uniquement sur les
+sauts de ligne présents dans les textes GT et OCR.
+"""
+
+from __future__ import annotations
+
+import difflib
+from dataclasses import dataclass
+
+
+@dataclass
+class StructureResult:
+ """Résultat de l'analyse structurelle pour un document."""
+
+ gt_line_count: int = 0
+ """Nombre de lignes dans le GT."""
+ ocr_line_count: int = 0
+ """Nombre de lignes dans l'OCR."""
+
+ line_fusion_count: int = 0
+ """Nombre de fusions de lignes (GT lignes absorbées)."""
+ line_fragmentation_count: int = 0
+ """Nombre de fragmentations (GT lignes splittées)."""
+
+ reading_order_score: float = 1.0
+ """Score d'ordre de lecture [0, 1]. 1 = ordre parfait."""
+
+ paragraph_conservation_score: float = 1.0
+ """Score de conservation des paragraphes [0, 1]."""
+
+ @property
+ def line_fusion_rate(self) -> float:
+ """Taux de fusion = fusions / lignes GT."""
+ return self.line_fusion_count / self.gt_line_count if self.gt_line_count > 0 else 0.0
+
+ @property
+ def line_fragmentation_rate(self) -> float:
+ """Taux de fragmentation = fragmentations / lignes GT."""
+ return self.line_fragmentation_count / self.gt_line_count if self.gt_line_count > 0 else 0.0
+
+ @property
+ def line_accuracy(self) -> float:
+ """Exactitude du nombre de lignes : 1 - |delta| / max(gt, ocr)."""
+ if self.gt_line_count == 0 and self.ocr_line_count == 0:
+ return 1.0
+ max_lines = max(self.gt_line_count, self.ocr_line_count)
+ delta = abs(self.gt_line_count - self.ocr_line_count)
+ return max(0.0, 1.0 - delta / max_lines)
+
+ def as_dict(self) -> dict:
+ return {
+ "gt_line_count": self.gt_line_count,
+ "ocr_line_count": self.ocr_line_count,
+ "line_fusion_count": self.line_fusion_count,
+ "line_fragmentation_count": self.line_fragmentation_count,
+ "line_fusion_rate": round(self.line_fusion_rate, 4),
+ "line_fragmentation_rate": round(self.line_fragmentation_rate, 4),
+ "line_accuracy": round(self.line_accuracy, 4),
+ "reading_order_score": round(self.reading_order_score, 4),
+ "paragraph_conservation_score": round(self.paragraph_conservation_score, 4),
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "StructureResult":
+ return cls(
+ gt_line_count=data.get("gt_line_count", 0),
+ ocr_line_count=data.get("ocr_line_count", 0),
+ line_fusion_count=data.get("line_fusion_count", 0),
+ line_fragmentation_count=data.get("line_fragmentation_count", 0),
+ reading_order_score=data.get("reading_order_score", 1.0),
+ paragraph_conservation_score=data.get("paragraph_conservation_score", 1.0),
+ )
+
+
+def analyze_structure(ground_truth: str, hypothesis: str) -> StructureResult:
+ """Analyse la structure d'un document OCR comparée au GT.
+
+ Parameters
+ ----------
+ ground_truth:
+ Texte de référence (vérité terrain), avec sauts de ligne.
+ hypothesis:
+ Texte produit par l'OCR, avec sauts de ligne.
+
+ Returns
+ -------
+ StructureResult
+ """
+ gt_lines = [ln for ln in ground_truth.splitlines() if ln.strip()]
+ ocr_lines = [ln for ln in hypothesis.splitlines() if ln.strip()]
+
+ n_gt = len(gt_lines)
+ n_ocr = len(ocr_lines)
+
+ # Fusions et fragmentations
+ fusion_count, frag_count = _count_line_changes(gt_lines, ocr_lines)
+
+ # Score d'ordre de lecture via LCS sur les mots
+ reading_order = _reading_order_score(ground_truth, hypothesis)
+
+ # Score de conservation des paragraphes (sauts de ligne vides = paragraphes)
+ para_score = _paragraph_conservation_score(ground_truth, hypothesis)
+
+ return StructureResult(
+ gt_line_count=n_gt,
+ ocr_line_count=n_ocr,
+ line_fusion_count=fusion_count,
+ line_fragmentation_count=frag_count,
+ reading_order_score=reading_order,
+ paragraph_conservation_score=para_score,
+ )
+
+
+def _count_line_changes(gt_lines: list[str], ocr_lines: list[str]) -> tuple[int, int]:
+ """Compte les fusions et fragmentations de lignes via SequenceMatcher."""
+ if not gt_lines or not ocr_lines:
+ return 0, 0
+
+ fusion_count = 0
+ frag_count = 0
+
+ # Aligner les lignes par contenu
+ matcher = difflib.SequenceMatcher(
+ None,
+ [ln.strip()[:30] for ln in gt_lines], # fingerprint court pour la comparaison
+ [ln.strip()[:30] for ln in ocr_lines],
+ autojunk=False,
+ )
+
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == "replace":
+ gt_len = i2 - i1
+ ocr_len = j2 - j1
+ if ocr_len < gt_len:
+ # Moins de lignes OCR → fusions
+ fusion_count += gt_len - ocr_len
+ elif ocr_len > gt_len:
+ # Plus de lignes OCR → fragmentations
+ frag_count += ocr_len - gt_len
+ elif tag == "delete":
+ # Lignes GT supprimées dans l'OCR → lacunes (pas fusion/frag)
+ pass
+ elif tag == "insert":
+ # Lignes insérées par l'OCR
+ frag_count += j2 - j1
+
+ return fusion_count, frag_count
+
+
+def _reading_order_score(ground_truth: str, hypothesis: str) -> float:
+ """Score d'ordre de lecture [0, 1] basé sur la LCS des mots.
+
+ On calcule la longueur de la sous-séquence commune la plus longue (LCS)
+ entre les listes de mots GT et OCR. Un score de 1 signifie que tous les
+ mots communs apparaissent dans le même ordre.
+ """
+ gt_words = ground_truth.split()
+ hyp_words = hypothesis.split()
+
+ if not gt_words or not hyp_words:
+ return 1.0
+
+ # Utiliser SequenceMatcher pour approximer la LCS
+ matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False)
+ # Ratio est 2 * nb_correspondances / (len_gt + len_ocr)
+ # C'est un proxy raisonnable de l'ordre de lecture
+ ratio = matcher.ratio()
+ return round(ratio, 4)
+
+
+def _paragraph_conservation_score(ground_truth: str, hypothesis: str) -> float:
+ """Score de conservation des paragraphes [0, 1].
+
+ Compte les sauts de paragraphe (lignes vides) dans le GT et mesure
+ le taux de conservation dans l'OCR.
+ """
+ # Un saut de paragraphe = deux sauts de ligne consécutifs
+ gt_paras = [p for p in ground_truth.split("\n\n") if p.strip()]
+ ocr_paras = [p for p in hypothesis.split("\n\n") if p.strip()]
+
+ n_gt_paras = len(gt_paras)
+ if n_gt_paras <= 1:
+ return 1.0 # pas de paragraphe distinct → score parfait
+
+ n_ocr_paras = len(ocr_paras)
+ delta = abs(n_gt_paras - n_ocr_paras)
+ score = max(0.0, 1.0 - delta / n_gt_paras)
+ return round(score, 4)
+
+
+def aggregate_structure(results: list[StructureResult]) -> dict:
+ """Agrège les résultats structurels sur un corpus."""
+ if not results:
+ return {}
+
+ import statistics
+
+ def _mean(values: list[float]) -> float:
+ return round(statistics.mean(values), 4) if values else 0.0
+
+ fusion_rates = [r.line_fusion_rate for r in results]
+ frag_rates = [r.line_fragmentation_rate for r in results]
+ reading_scores = [r.reading_order_score for r in results]
+ para_scores = [r.paragraph_conservation_score for r in results]
+ line_accuracies = [r.line_accuracy for r in results]
+
+ return {
+ "mean_line_fusion_rate": _mean(fusion_rates),
+ "mean_line_fragmentation_rate": _mean(frag_rates),
+ "mean_reading_order_score": _mean(reading_scores),
+ "mean_paragraph_conservation": _mean(para_scores),
+ "mean_line_accuracy": _mean(line_accuracies),
+ "document_count": len(results),
+ }
diff --git a/picarones/measurements/taxonomy.py b/picarones/measurements/taxonomy.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8d36076528d81c781e1ccbf4dd18c9341032237
--- /dev/null
+++ b/picarones/measurements/taxonomy.py
@@ -0,0 +1,350 @@
+"""Taxonomie des erreurs OCR — classification automatique (classes 1 à 9).
+
+Chaque erreur identifiée par l'alignement GT↔OCR est catégorisée selon
+la taxonomie Picarones :
+
+| Classe | Nom | Description |
+|--------|-------------------|----------------------------------------------------|
+| 1 | visual_confusion | Confusion morphologique (rn/m, l/1, O/0, u/n…) |
+| 2 | diacritic_error | Diacritique absent, incorrect ou ajouté |
+| 3 | case_error | Erreur de casse uniquement (A/a) |
+| 4 | ligature_error | Ligature non résolue ou mal résolue |
+| 5 | abbreviation_error| Abréviation médiévale non développée |
+| 6 | hapax | Mot introuvable dans tout lexique |
+| 7 | segmentation_error| Fusion ou fragmentation de tokens (mots/lignes) |
+| 8 | oov_character | Caractère hors-vocabulaire du moteur |
+| 9 | lacuna | Texte présent dans le GT absent de l'OCR |
+| 10 | over_normalization| Sur-normalisation LLM (voir pipelines/) |
+
+Note : la classe 10 est calculée par picarones/pipelines/over_normalization.py.
+"""
+
+from __future__ import annotations
+
+import difflib
+import unicodedata
+from dataclasses import dataclass, field
+
+
+# ---------------------------------------------------------------------------
+# Tables de référence pour la classification
+# ---------------------------------------------------------------------------
+
+#: Confusions visuelles bien connues en OCR (caractères morphologiquement proches)
+VISUAL_CONFUSIONS: dict[frozenset, str] = {}
+_VISUAL_PAIRS: list[tuple[str, str]] = [
+ # Minuscules
+ ("r", "n"), ("rn", "m"), ("l", "1"), ("l", "i"), ("l", "|"),
+ ("O", "0"), ("O", "o"), ("u", "n"), ("n", "u"), ("v", "u"),
+ ("c", "e"), ("e", "c"), ("a", "o"), ("o", "a"),
+ ("f", "ſ"), ("ſ", "f"), ("f", "t"),
+ ("h", "li"), ("h", "lı"),
+ ("m", "rn"), ("m", "in"),
+ ("d", "cl"), ("d", "a"),
+ ("q", "g"), ("p", "q"),
+ # Majuscules ↔ minuscules homographes (classe 1, pas classe 3)
+ ("I", "l"), ("I", "1"),
+ # Chiffres
+ ("1", "I"), ("1", "l"), ("0", "O"),
+ # Ponctuation
+ (".", ","), (",", "."),
+]
+for _a, _b in _VISUAL_PAIRS:
+ VISUAL_CONFUSIONS[frozenset({_a, _b})] = f"{_a}/{_b}"
+
+#: Couples de ligatures pour la détection des erreurs de ligatures
+from picarones.core.char_scores import LIGATURE_TABLE, DIACRITIC_MAP # noqa: E402
+
+# Caractères hors-ASCII présumés hors-vocabulaire (alphabet non latin de base)
+_LATIN_BASIC = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ " \t\n.,;:!?-_'\"«»()[]{}/@#%&*+=/\\|<>~^")
+
+
+# ---------------------------------------------------------------------------
+# Résultat structuré
+# ---------------------------------------------------------------------------
+
+@dataclass
+class TaxonomyResult:
+ """Résultat de la classification taxonomique des erreurs pour un document."""
+
+ counts: dict[str, int] = field(default_factory=dict)
+ """Nombre d'erreurs par classe. Clés : 'visual_confusion', 'diacritic_error'…"""
+
+ examples: dict[str, list[dict]] = field(default_factory=dict)
+ """Exemples d'erreurs par classe (max 5 par classe).
+ Format : [{'gt': 'chaîne', 'ocr': 'chaîne', 'position': int}]
+ """
+
+ total_errors: int = 0
+ """Nombre total d'erreurs classifiées."""
+
+ @property
+ def class_distribution(self) -> dict[str, float]:
+ """Distribution relative (0–1) par classe."""
+ if not self.total_errors:
+ return {}
+ return {
+ cls: round(cnt / self.total_errors, 4)
+ for cls, cnt in self.counts.items()
+ }
+
+ def as_dict(self) -> dict:
+ return {
+ "counts": self.counts,
+ "total_errors": self.total_errors,
+ "class_distribution": self.class_distribution,
+ "examples": {
+ cls: exs[:3] for cls, exs in self.examples.items()
+ },
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "TaxonomyResult":
+ return cls(
+ counts=data.get("counts", {}),
+ examples=data.get("examples", {}),
+ total_errors=data.get("total_errors", 0),
+ )
+
+
+# Noms des classes en ordre
+ERROR_CLASSES = [
+ "visual_confusion",
+ "diacritic_error",
+ "case_error",
+ "ligature_error",
+ "abbreviation_error",
+ "hapax",
+ "segmentation_error",
+ "oov_character",
+ "lacuna",
+]
+
+
+# ---------------------------------------------------------------------------
+# Classification principale
+# ---------------------------------------------------------------------------
+
+def classify_errors(
+ ground_truth: str,
+ hypothesis: str,
+ max_examples: int = 5,
+) -> TaxonomyResult:
+ """Classifie automatiquement les erreurs OCR dans une paire GT/OCR.
+
+ L'alignement utilise difflib.SequenceMatcher au niveau mot pour détecter
+ les erreurs de segmentation, puis au niveau caractère pour les autres classes.
+
+ Parameters
+ ----------
+ ground_truth:
+ Texte de référence (vérité terrain).
+ hypothesis:
+ Texte produit par l'OCR.
+ max_examples:
+ Nombre maximal d'exemples conservés par classe.
+
+ Returns
+ -------
+ TaxonomyResult
+ """
+ counts: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES}
+ examples: dict[str, list[dict]] = {cls: [] for cls in ERROR_CLASSES}
+ total = 0
+
+ if not ground_truth and not hypothesis:
+ return TaxonomyResult(counts=counts, examples=examples, total_errors=0)
+
+ # -----------------------------------------------------------------------
+ # Niveau mot : détecter segmentation (classe 7) et lacunes (classe 9)
+ # -----------------------------------------------------------------------
+ gt_words = ground_truth.split()
+ hyp_words = hypothesis.split()
+
+ word_matcher = difflib.SequenceMatcher(None, gt_words, hyp_words, autojunk=False)
+ for tag, i1, i2, j1, j2 in word_matcher.get_opcodes():
+ if tag == "delete":
+ # Mots GT absents de l'OCR → lacune (classe 9)
+ for w in gt_words[i1:i2]:
+ counts["lacuna"] += 1
+ total += 1
+ if len(examples["lacuna"]) < max_examples:
+ examples["lacuna"].append({"gt": w, "ocr": "", "position": i1})
+
+ elif tag == "insert":
+ # Mots ajoutés par l'OCR → généralement classe 8 (hors-vocab)
+ for w in hyp_words[j1:j2]:
+ if _is_oov_word(w):
+ counts["oov_character"] += 1
+ total += 1
+
+ elif tag == "replace":
+ gt_seg = gt_words[i1:i2]
+ hyp_seg = hyp_words[j1:j2]
+ # Segmentation : fusion de mots (moins de mots OCR) ou fragmentation
+ if len(hyp_seg) != len(gt_seg):
+ n_seg = abs(len(gt_seg) - len(hyp_seg))
+ counts["segmentation_error"] += n_seg
+ total += n_seg
+ if len(examples["segmentation_error"]) < max_examples:
+ examples["segmentation_error"].append({
+ "gt": " ".join(gt_seg),
+ "ocr": " ".join(hyp_seg),
+ "position": i1,
+ })
+ else:
+ # Paires mot-à-mot
+ for gt_w, hyp_w in zip(gt_seg, hyp_seg):
+ if gt_w != hyp_w:
+ _classify_word_error(
+ gt_w, hyp_w, counts, examples, max_examples
+ )
+ total += 1
+
+ return TaxonomyResult(
+ counts=counts,
+ examples=examples,
+ total_errors=total,
+ )
+
+
+def _classify_word_error(
+ gt_word: str,
+ hyp_word: str,
+ counts: dict[str, int],
+ examples: dict[str, list[dict]],
+ max_examples: int,
+) -> None:
+ """Classifie l'erreur entre deux mots non-identiques."""
+ # Classe 3 : erreur de casse seule
+ if gt_word.casefold() == hyp_word.casefold() and gt_word != hyp_word:
+ counts["case_error"] += 1
+ if len(examples["case_error"]) < max_examples:
+ examples["case_error"].append({"gt": gt_word, "ocr": hyp_word})
+ return
+
+ # Classe 4 : erreur de ligature
+ gt_norm = unicodedata.normalize("NFC", gt_word)
+ hyp_norm = unicodedata.normalize("NFC", hyp_word)
+ if _is_ligature_error(gt_norm, hyp_norm):
+ counts["ligature_error"] += 1
+ if len(examples["ligature_error"]) < max_examples:
+ examples["ligature_error"].append({"gt": gt_word, "ocr": hyp_word})
+ return
+
+ # Classe 5 : erreur d'abréviation (présence de ꝑ, ꝓ, ꝗ dans le GT)
+ if _is_abbreviation_error(gt_norm, hyp_norm):
+ counts["abbreviation_error"] += 1
+ if len(examples["abbreviation_error"]) < max_examples:
+ examples["abbreviation_error"].append({"gt": gt_word, "ocr": hyp_word})
+ return
+
+ # Classe 2 : erreur diacritique
+ if _is_diacritic_error(gt_norm, hyp_norm):
+ counts["diacritic_error"] += 1
+ if len(examples["diacritic_error"]) < max_examples:
+ examples["diacritic_error"].append({"gt": gt_word, "ocr": hyp_word})
+ return
+
+ # Classe 1 : confusion visuelle (comparaison char par char)
+ if _is_visual_confusion(gt_norm, hyp_norm):
+ counts["visual_confusion"] += 1
+ if len(examples["visual_confusion"]) < max_examples:
+ examples["visual_confusion"].append({"gt": gt_word, "ocr": hyp_word})
+ return
+
+ # Classe 8 : caractère hors-vocabulaire
+ if _is_oov_word(hyp_word):
+ counts["oov_character"] += 1
+ if len(examples["oov_character"]) < max_examples:
+ examples["oov_character"].append({"gt": gt_word, "ocr": hyp_word})
+ return
+
+ # Classe 6 : hapax (erreur résiduelle non classifiable)
+ counts["hapax"] += 1
+ if len(examples["hapax"]) < max_examples:
+ examples["hapax"].append({"gt": gt_word, "ocr": hyp_word})
+
+
+def _is_ligature_error(gt: str, hyp: str) -> bool:
+ """Vrai si la différence implique une ligature Unicode."""
+ # GT contient une ligature que l'OCR a décomposée, ou vice versa
+ for lig, seqs in LIGATURE_TABLE.items():
+ if lig in gt:
+ for seq in seqs:
+ if seq in hyp and lig not in hyp:
+ return True
+ for seq in seqs:
+ if seq in gt and lig in hyp:
+ return True
+ return False
+
+
+def _is_abbreviation_error(gt: str, hyp: str) -> bool:
+ """Vrai si le GT contient un caractère d'abréviation médiévale."""
+ abbreviation_chars = "\uA751\uA753\uA757" # ꝑ ꝓ ꝗ
+ return any(c in gt for c in abbreviation_chars)
+
+
+def _is_diacritic_error(gt: str, hyp: str) -> bool:
+ """Vrai si la différence est principalement due à des diacritiques."""
+ # Comparer les formes sans diacritiques
+ def strip_diacritics(text: str) -> str:
+ nfd = unicodedata.normalize("NFD", text)
+ return "".join(c for c in nfd if unicodedata.category(c) != "Mn")
+
+ gt_stripped = strip_diacritics(gt)
+ hyp_stripped = strip_diacritics(hyp)
+ # Si les mots sont identiques sans diacritiques → erreur diacritique
+ if gt_stripped.casefold() == hyp_stripped.casefold() and gt != hyp:
+ return True
+ # Si le GT contient des diacritiques que l'OCR a perdus et que les textes
+ # sans diacritiques sont identiques (même longueur requise)
+ gt_has_diac = any(c in DIACRITIC_MAP for c in gt)
+ return gt_has_diac and len(gt) == len(hyp) and gt_stripped.casefold() == hyp_stripped.casefold()
+
+
+def _is_visual_confusion(gt: str, hyp: str) -> bool:
+ """Vrai si la différence implique des confusions visuelles connues."""
+ if abs(len(gt) - len(hyp)) > 2:
+ return False
+ # Vérifier les paires de confusions connues
+ for pair in VISUAL_CONFUSIONS:
+ chars = list(pair)
+ if len(chars) == 2:
+ a, b = chars
+ if a in gt and b in hyp and a not in hyp:
+ return True
+ if b in gt and a in hyp and b not in hyp:
+ return True
+ return False
+
+
+def _is_oov_word(word: str) -> bool:
+ """Vrai si le mot contient des caractères hors de l'alphabet latin de base."""
+ return any(c not in _LATIN_BASIC and not c.isalpha() for c in word)
+
+
+# ---------------------------------------------------------------------------
+# Agrégation
+# ---------------------------------------------------------------------------
+
+def aggregate_taxonomy(results: list[TaxonomyResult]) -> dict:
+ """Agrège les résultats taxonomiques sur un corpus."""
+ combined: dict[str, int] = {cls: 0 for cls in ERROR_CLASSES}
+ total = 0
+ for r in results:
+ for cls, cnt in r.counts.items():
+ combined[cls] = combined.get(cls, 0) + cnt
+ total += r.total_errors
+
+ distribution = {
+ cls: round(cnt / total, 4) if total > 0 else 0.0
+ for cls, cnt in combined.items()
+ }
+ return {
+ "counts": combined,
+ "total_errors": total,
+ "class_distribution": distribution,
+ }
diff --git a/picarones/measurements/taxonomy_comparison.py b/picarones/measurements/taxonomy_comparison.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb99d5ef20d8af1985c2dd42b777499c3d1b58f3
--- /dev/null
+++ b/picarones/measurements/taxonomy_comparison.py
@@ -0,0 +1,161 @@
+"""Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3).
+
+Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4).
+
+Pourquoi ce module
+------------------
+Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale
+qu'un moteur a un profil taxonomique éloigné de ses concurrents,
+mais le rapport n'expose pas cette différence visuellement. Ce
+sprint répond à *« deux moteurs ont le même CER global, mais lequel
+fait des erreurs plus récupérables ? »*.
+
+Lecture concrète
+----------------
+- Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables
+ par un post-processing trivial (récupérables).
+- Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) →
+ irrécupérables sans relire l'image.
+
+À CER égal, A est massivement préférable pour un workflow
+d'édition critique. Cette vue rend la différence visible.
+
+Catégorisation des classes
+--------------------------
+On annote chaque classe d'erreur d'un degré de **récupérabilité**
+(critère éditorial pragmatique, pas verdict imposé) :
+
+- ``recoverable`` : récupérable par post-processing trivial
+ (case_error, ligature_error, abbreviation_error)
+- ``difficult`` : récupérable au prix d'un effort
+ (diacritic_error, visual_confusion, hapax)
+- ``irrecoverable`` : impossible à corriger sans l'image
+ (lacuna, oov_character, segmentation_error)
+
+L'utilisateur consulte ces catégories comme un guide, pas un
+verdict — c'est lui qui juge selon ses besoins éditoriaux.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+# Classification éditoriale. Documentée dans la docstring.
+RECOVERABILITY: dict[str, str] = {
+ "case_error": "recoverable",
+ "ligature_error": "recoverable",
+ "abbreviation_error": "recoverable",
+ "diacritic_error": "difficult",
+ "visual_confusion": "difficult",
+ "hapax": "difficult",
+ "lacuna": "irrecoverable",
+ "oov_character": "irrecoverable",
+ "segmentation_error": "irrecoverable",
+}
+
+
+def _normalize_counts(counts: dict[str, int]) -> dict[str, float]:
+ """Convertit un dict de comptes en proportions [0, 1]."""
+ total = sum(counts.values())
+ if total <= 0:
+ return {k: 0.0 for k in counts}
+ return {k: v / total for k, v in counts.items()}
+
+
+def compare_taxonomies(
+ engine_a_name: str,
+ engine_a_counts: dict[str, int],
+ engine_b_name: str,
+ engine_b_counts: dict[str, int],
+) -> Optional[dict]:
+ """Compare deux profils taxonomiques.
+
+ Parameters
+ ----------
+ engine_a_name, engine_b_name:
+ Noms d'identification des moteurs (utilisés dans le rendu).
+ engine_a_counts, engine_b_counts:
+ Maps ``{class_name: count}`` produites par
+ ``aggregate_taxonomy``.
+
+ Returns
+ -------
+ Optional[dict]
+ ``{
+ "engine_a": str, "engine_b": str,
+ "total_a": int, "total_b": int,
+ "classes": list[str], # classes apparaissant chez A ou B
+ "proportions_a": dict[str, float],
+ "proportions_b": dict[str, float],
+ "deltas": dict[str, float], # prop_b - prop_a (signé)
+ "recoverability": dict[str, str], # mapping class → niveau
+ "totals_by_recoverability": {
+ "recoverable": {"a": float, "b": float},
+ "difficult": {"a": float, "b": float},
+ "irrecoverable": {"a": float, "b": float},
+ },
+ }``
+ Ou ``None`` si les deux moteurs ont 0 erreur chacun.
+ """
+ if engine_a_name == engine_b_name:
+ # On accepte des comparaisons même si les noms sont
+ # identiques (cas tests), mais on émet un warning.
+ logger.warning(
+ "[taxonomy_comparison] engine_a et engine_b ont le même nom : %s",
+ engine_a_name,
+ )
+
+ total_a = sum(engine_a_counts.values()) if engine_a_counts else 0
+ total_b = sum(engine_b_counts.values()) if engine_b_counts else 0
+ if total_a == 0 and total_b == 0:
+ return None
+
+ classes = sorted(set(engine_a_counts) | set(engine_b_counts))
+ if not classes:
+ return None
+
+ prop_a = _normalize_counts(
+ {c: engine_a_counts.get(c, 0) for c in classes},
+ )
+ prop_b = _normalize_counts(
+ {c: engine_b_counts.get(c, 0) for c in classes},
+ )
+ deltas = {c: prop_b[c] - prop_a[c] for c in classes}
+
+ # Agrégat par récupérabilité (utile pour la lecture rapide)
+ totals_recov: dict[str, dict[str, float]] = {
+ "recoverable": {"a": 0.0, "b": 0.0},
+ "difficult": {"a": 0.0, "b": 0.0},
+ "irrecoverable": {"a": 0.0, "b": 0.0},
+ }
+ for cls in classes:
+ level = RECOVERABILITY.get(cls, "difficult")
+ if level not in totals_recov:
+ level = "difficult"
+ totals_recov[level]["a"] += prop_a[cls]
+ totals_recov[level]["b"] += prop_b[cls]
+
+ return {
+ "engine_a": engine_a_name,
+ "engine_b": engine_b_name,
+ "total_a": total_a,
+ "total_b": total_b,
+ "classes": classes,
+ "proportions_a": prop_a,
+ "proportions_b": prop_b,
+ "deltas": deltas,
+ "recoverability": {
+ cls: RECOVERABILITY.get(cls, "difficult") for cls in classes
+ },
+ "totals_by_recoverability": totals_recov,
+ }
+
+
+__all__ = [
+ "RECOVERABILITY",
+ "compare_taxonomies",
+]
diff --git a/picarones/measurements/throughput.py b/picarones/measurements/throughput.py
new file mode 100644
index 0000000000000000000000000000000000000000..47d0ed674492f221013aa8a53c3632db14cbe6b5
--- /dev/null
+++ b/picarones/measurements/throughput.py
@@ -0,0 +1,165 @@
+"""Throughput effectif (Sprint 91 — A.II.6).
+
+Sprint 91 — A.II.6 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Le throughput brut (pages/heure d'OCR pur) ment quand un moteur
+est rapide mais imprécis : la correction humaine *post hoc*
+absorbe le gain. La **vraie** vitesse opérationnelle inclut
+le temps de correction. Cette métrique discrimine fortement
+entre un cloud rapide à 30 % de timeouts/erreurs et un local
+lent à 100 % de fiabilité.
+
+Formule
+-------
+.. code::
+
+ pages_par_heure_utilisable =
+ pages_traitées / (durée_totale + temps_correction_humaine)
+
+Le temps de correction est estimé linéairement :
+``temps_par_erreur × nombre_d_erreurs``. Le défaut
+``time_per_error_seconds=5.0`` correspond aux études HTR-United
+(saisie manuelle d'une correction de mot par un opérateur
+formé : ≈ 5 s par erreur). L'utilisateur peut le surcharger
+pour son institution.
+
+Sortie
+------
+``compute_effective_throughput(n_pages, duration_seconds,
+n_errors, time_per_error_seconds=5.0)`` retourne ``{n_pages,
+duration_seconds, n_errors, time_per_error_seconds,
+correction_time_seconds, total_seconds, pages_per_hour_raw,
+pages_per_hour_effective, drag_ratio}``.
+
+``aggregate_effective_throughput(per_engine_data)`` agrège par
+moteur sur l'ensemble du corpus.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Iterable, Optional
+
+logger = logging.getLogger(__name__)
+
+
+_DEFAULT_TIME_PER_ERROR_SECONDS = 5.0
+
+
+def compute_effective_throughput(
+ n_pages: int,
+ duration_seconds: float,
+ n_errors: int,
+ *,
+ time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS,
+) -> Optional[dict]:
+ """Throughput effectif (pages/heure utilisables).
+
+ Parameters
+ ----------
+ n_pages:
+ Nombre de pages traitées.
+ duration_seconds:
+ Durée totale de l'OCR (somme des durées par doc).
+ n_errors:
+ Nombre d'erreurs (au niveau mot, typiquement
+ ``WER × n_words_total``).
+ time_per_error_seconds:
+ Temps moyen de correction humaine par erreur. Défaut
+ 5 s (HTR-United). Doit être ≥ 0.
+
+ Returns
+ -------
+ dict | None
+ ``None`` si ``n_pages == 0`` ou ``total_seconds == 0``
+ (pas de division par zéro).
+ """
+ if n_pages <= 0:
+ return None
+ if duration_seconds < 0 or n_errors < 0 or time_per_error_seconds < 0:
+ raise ValueError(
+ "duration_seconds, n_errors et time_per_error_seconds "
+ "doivent être ≥ 0",
+ )
+ correction_seconds = float(n_errors) * float(time_per_error_seconds)
+ total_seconds = float(duration_seconds) + correction_seconds
+ if total_seconds <= 0:
+ # Aucun temps écoulé : impossible de définir un throughput
+ return None
+ pages_per_hour_raw = (
+ n_pages / duration_seconds * 3600.0
+ if duration_seconds > 0 else None
+ )
+ pages_per_hour_effective = n_pages / total_seconds * 3600.0
+ drag_ratio = (
+ correction_seconds / total_seconds if total_seconds > 0 else 0.0
+ )
+ return {
+ "n_pages": int(n_pages),
+ "duration_seconds": float(duration_seconds),
+ "n_errors": int(n_errors),
+ "time_per_error_seconds": float(time_per_error_seconds),
+ "correction_time_seconds": correction_seconds,
+ "total_seconds": total_seconds,
+ "pages_per_hour_raw": pages_per_hour_raw,
+ "pages_per_hour_effective": pages_per_hour_effective,
+ "drag_ratio": drag_ratio,
+ }
+
+
+def aggregate_effective_throughput(
+ per_engine: Iterable[dict],
+ *,
+ time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS,
+) -> Optional[dict]:
+ """Agrège le throughput effectif par moteur.
+
+ Parameters
+ ----------
+ per_engine:
+ Itérable de dicts ``{engine_name, n_pages,
+ duration_seconds, n_errors}``.
+
+ Returns
+ -------
+ dict | None
+ ``{
+ "engines": [
+ {"engine_name", ..., compute_effective_throughput
+ fields},
+ ...
+ ],
+ "time_per_error_seconds": float,
+ }`` ou ``None`` si aucun moteur exploitable.
+ """
+ rows: list[dict] = []
+ for entry in per_engine:
+ if not isinstance(entry, dict):
+ continue
+ name = entry.get("engine_name") or entry.get("engine")
+ if not name:
+ continue
+ result = compute_effective_throughput(
+ int(entry.get("n_pages") or 0),
+ float(entry.get("duration_seconds") or 0.0),
+ int(entry.get("n_errors") or 0),
+ time_per_error_seconds=time_per_error_seconds,
+ )
+ if result is None:
+ continue
+ result["engine_name"] = str(name)
+ rows.append(result)
+ if not rows:
+ return None
+ return {
+ "engines": rows,
+ "time_per_error_seconds": float(time_per_error_seconds),
+ }
+
+
+__all__ = [
+ "compute_effective_throughput",
+ "aggregate_effective_throughput",
+]
diff --git a/picarones/measurements/worst_lines.py b/picarones/measurements/worst_lines.py
new file mode 100644
index 0000000000000000000000000000000000000000..dfece53263f29f83db9cb6dbaaf749d719b04857
--- /dev/null
+++ b/picarones/measurements/worst_lines.py
@@ -0,0 +1,199 @@
+"""Extraction transversale des « Worst lines » du corpus — Sprint 72.
+
+Sprint 72 — A.I.1 chantier 1 du plan d'évolution 2026.
+
+Pourquoi ce module
+------------------
+Le percentile p95 du CER ligne (calculé par ``line_metrics.py``,
+Sprint 10) est un nombre abstrait : *« 5 % de mes lignes ont un
+CER > 0,42 »*. Le chercheur veut **voir** ces lignes : leur
+texte, leur diff, leur document parent, pour comprendre ce qui
+casse.
+
+Ce module fournit la requête transversale qui collecte, depuis un
+``BenchmarkResult``, les **N lignes les plus mal transcrites de
+tout le corpus**, classées par CER ligne. Filtrable par moteur
+et par strate.
+
+Limite documentée
+-----------------
+``DocumentResult.line_metrics`` ne stocke que les CER par ligne,
+**pas le texte des lignes**. Pour récupérer les textes GT/hyp
+on resplitte ``ground_truth`` et ``hypothesis`` du
+``DocumentResult`` à l'index de la ligne. Cette logique
+**suppose un BenchmarkResult non-compacté** — après ``compact()``
+les textes sont tronqués à 200 caractères et les lignes au-delà
+de cette troncature ne sont plus accessibles. En pratique on
+extrait les worst lines **avant** la sérialisation/compactage.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class WorstLineEntry:
+ """Une ligne du corpus identifiée comme mal transcrite.
+
+ Champs
+ ------
+ rank:
+ Position dans le classement (1-based, 1 = pire CER).
+ cer:
+ CER de la ligne ∈ [0, 1].
+ engine_name:
+ Nom du moteur ayant produit cette hypothèse.
+ doc_id:
+ Identifiant du document parent.
+ line_index:
+ Index 0-based de la ligne dans le document GT.
+ gt_line:
+ Texte de la ligne dans la GT.
+ hyp_line:
+ Texte correspondant dans l'hypothèse (peut être ``""``
+ si l'OCR a sauté la ligne).
+ script_type:
+ Strate du document si disponible (``script_type``
+ capturé par le runner pour la stratification A.III).
+ """
+
+ rank: int
+ cer: float
+ engine_name: str
+ doc_id: str
+ line_index: int
+ gt_line: str
+ hyp_line: str
+ script_type: Optional[str] = None
+
+
+def _split_lines(text: Optional[str]) -> list[str]:
+ """Splitte un texte en lignes (cohérent avec ``line_metrics``).
+
+ Supporte les fins de ligne ``\\n``, ``\\r\\n``, ``\\r``. Les
+ lignes vides sont préservées. Retourne une liste vide si le
+ texte est None ou vide.
+ """
+ if not text:
+ return []
+ # ``splitlines`` gère \r\n et \r correctement
+ return text.splitlines()
+
+
+def _line_at(text: Optional[str], index: int) -> str:
+ """Retourne la ligne à l'index demandé, ou ``""`` si l'index
+ est hors borne (cas où l'OCR a moins de lignes que la GT)."""
+ lines = _split_lines(text)
+ if 0 <= index < len(lines):
+ return lines[index]
+ return ""
+
+
+def extract_worst_lines(
+ benchmark,
+ *,
+ top_n: int = 20,
+ engine_filter: Optional[str] = None,
+ script_type_filter: Optional[str] = None,
+) -> list[WorstLineEntry]:
+ """Extrait les ``top_n`` lignes les plus mal transcrites du
+ corpus, transversalement à tous les moteurs et documents.
+
+ Parameters
+ ----------
+ benchmark:
+ ``BenchmarkResult`` non-compacté (cf. limite ci-dessus).
+ L'objet doit exposer ``engine_reports`` (liste de
+ ``EngineReport``) et optionnellement ``doc_strata``
+ (map ``{doc_id: script_type}``, Sprint 45).
+ top_n:
+ Nombre de lignes à retourner. Défaut : 20.
+ engine_filter:
+ Si fourni, n'inclut que les lignes produites par ce moteur
+ (match exact sur ``engine_name``).
+ script_type_filter:
+ Si fourni, n'inclut que les lignes des documents de cette
+ strate (nécessite ``benchmark.doc_strata``).
+
+ Returns
+ -------
+ list[WorstLineEntry]
+ Liste triée par CER décroissant (pire en premier),
+ rang 1-based attribué après tri. Vide si aucune ligne
+ exploitable.
+ """
+ if top_n <= 0:
+ return []
+
+ doc_strata = getattr(benchmark, "doc_strata", None) or {}
+ candidates: list[tuple[float, str, str, int, str, str, Optional[str]]] = []
+
+ for engine_report in getattr(benchmark, "engine_reports", []):
+ engine_name = engine_report.engine_name
+ if engine_filter is not None and engine_name != engine_filter:
+ continue
+ for dr in engine_report.document_results:
+ line_metrics = getattr(dr, "line_metrics", None)
+ if not line_metrics:
+ continue
+ cer_per_line = line_metrics.get("cer_per_line") if isinstance(
+ line_metrics, dict,
+ ) else getattr(line_metrics, "cer_per_line", None)
+ if not cer_per_line:
+ continue
+ doc_id = dr.doc_id
+ doc_strata_value = doc_strata.get(doc_id)
+ if (
+ script_type_filter is not None
+ and doc_strata_value != script_type_filter
+ ):
+ continue
+ for idx, cer in enumerate(cer_per_line):
+ if cer <= 0.0:
+ continue
+ gt_line = _line_at(dr.ground_truth, idx)
+ hyp_line = _line_at(dr.hypothesis, idx)
+ if not gt_line and not hyp_line:
+ continue
+ candidates.append((
+ float(cer), engine_name, doc_id, idx,
+ gt_line, hyp_line, doc_strata_value,
+ ))
+
+ if not candidates:
+ return []
+
+ # Tri par CER décroissant ; en cas d'égalité, ordre stable
+ # (engine, doc_id, line_index) pour reproductibilité.
+ candidates.sort(
+ key=lambda c: (-c[0], c[1], c[2], c[3]),
+ )
+ selected = candidates[:top_n]
+
+ return [
+ WorstLineEntry(
+ rank=i + 1,
+ cer=cer,
+ engine_name=engine,
+ doc_id=doc_id,
+ line_index=line_index,
+ gt_line=gt_line,
+ hyp_line=hyp_line,
+ script_type=script_type,
+ )
+ for i, (
+ cer, engine, doc_id, line_index,
+ gt_line, hyp_line, script_type,
+ ) in enumerate(selected)
+ ]
+
+
+__all__ = [
+ "WorstLineEntry",
+ "extract_worst_lines",
+]
diff --git a/tests/test_phaseE_migration.py b/tests/test_phaseE_migration.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce908b81fd9fdbdae9a681ff430269cecb89fcb5
--- /dev/null
+++ b/tests/test_phaseE_migration.py
@@ -0,0 +1,276 @@
+"""Tests de la phase E — séparation core/ + measurements/.
+
+Couvre :
+
+- ~41 modules métriques déplacés vers ``picarones/measurements/``.
+- Sous-package ``narrative/`` (4 modules + 6 familles de détecteurs +
+ helper) déplacé vers ``picarones/measurements/narrative/``.
+- Identité préservée à travers les shims.
+- Le ``core/`` strict ne contient plus que ~13 fichiers (Cercle 1).
+- Les hooks builtin restent enregistrés.
+- Le moteur narratif fonctionne (détection + arbitre + rendu).
+- Les vues du chantier 3 fonctionnent.
+- Document ``docs/architecture-cercles.md`` mis à jour avec critère DDD.
+"""
+
+from __future__ import annotations
+
+import importlib
+import warnings
+from pathlib import Path
+
+import pytest
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 1. Imports historiques rétrocompat des mesures
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestMeasurementsRetrocompat:
+ @pytest.mark.parametrize("module_path, attribute", [
+ ("picarones.core.confusion", "build_confusion_matrix"),
+ ("picarones.core.taxonomy", "classify_errors"),
+ ("picarones.core.calibration", "compute_calibration_metrics"),
+ ("picarones.core.layout", "compute_layout_metrics"),
+ ("picarones.core.reading_order", "compute_reading_order_metrics"),
+ ("picarones.core.error_absorption", "compute_error_absorption"),
+ ("picarones.core.searchability", "compute_searchability"),
+ ("picarones.core.numerical_sequences",
+ "compute_numerical_sequence_metrics"),
+ ("picarones.core.readability", "flesch_score"),
+ ("picarones.core.specialization", "compute_specialization_score"),
+ ("picarones.core.throughput", "compute_effective_throughput"),
+ ("picarones.core.cost_projection", "project_engine"),
+ ("picarones.core.statistics", "bootstrap_ci"),
+ ("picarones.core.history", "BenchmarkHistory"),
+ ("picarones.core.builtin_hooks", "calibration_from_engine_result"),
+ ("picarones.core.line_metrics", "compute_line_metrics"),
+ ("picarones.core.hallucination", "compute_hallucination_metrics"),
+ ("picarones.core.image_quality", "analyze_image_quality"),
+ ("picarones.core.normalization", "PROFILES"),
+ ("picarones.core.rare_tokens", "extract_rare_tokens"),
+ ])
+ def test_legacy_path_works(self, module_path: str, attribute: str):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ mod = importlib.import_module(module_path)
+ assert hasattr(mod, attribute), f"{module_path}.{attribute}"
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 2. Sous-package narrative/ déplacé
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestNarrativePackageMigration:
+ def test_narrative_root_import(self):
+ from picarones.core.narrative import build_synthesis
+ assert callable(build_synthesis)
+
+ def test_narrative_facts_via_shim(self):
+ from picarones.core.narrative.facts import Fact, FactType
+ assert Fact is not None
+ assert FactType is not None
+
+ def test_narrative_registry_via_shim(self):
+ from picarones.core.narrative.registry import register_detector
+ assert callable(register_detector)
+
+ def test_narrative_detectors_via_shim(self):
+ from picarones.core.narrative.detectors import (
+ DETECTORS_BY_TYPE,
+ detect_global_leader_cer,
+ )
+ assert callable(detect_global_leader_cer)
+ assert len(DETECTORS_BY_TYPE) == 18
+
+ def test_narrative_detector_family_modules(self):
+ # Les 6 familles restent accessibles via leur ancien chemin
+ from picarones.core.narrative.detectors.ranking import (
+ detect_global_leader_cer,
+ )
+ from picarones.core.narrative.detectors.pareto import (
+ detect_pareto_alternative,
+ )
+ from picarones.core.narrative.detectors.history import (
+ detect_engine_unstable,
+ )
+ from picarones.core.narrative.detectors.quality import (
+ detect_confidence_warning,
+ )
+ from picarones.core.narrative.detectors.stratum import (
+ detect_stratum_winner,
+ )
+ from picarones.core.narrative.detectors.ensemble import (
+ detect_ensemble_opportunity,
+ )
+ assert all(callable(f) for f in [
+ detect_global_leader_cer,
+ detect_pareto_alternative,
+ detect_engine_unstable,
+ detect_confidence_warning,
+ detect_stratum_winner,
+ detect_ensemble_opportunity,
+ ])
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 3. Identité préservée
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestIdentityThroughShim:
+ def test_confusion_identity(self):
+ from picarones.core.confusion import build_confusion_matrix as via_old
+ from picarones.measurements.confusion import (
+ build_confusion_matrix as via_new,
+ )
+ assert via_old is via_new
+
+ def test_narrative_facts_identity(self):
+ from picarones.core.narrative.facts import Fact as via_old
+ from picarones.measurements.narrative.facts import Fact as via_new
+ assert via_old is via_new
+
+ def test_narrative_detector_identity(self):
+ from picarones.core.narrative.detectors.ranking import (
+ detect_speed_winner as via_old,
+ )
+ from picarones.measurements.narrative.detectors.ranking import (
+ detect_speed_winner as via_new,
+ )
+ assert via_old is via_new
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 4. core/ Cercle 1 strict — ne contient plus que ~13 modules
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestCoreIsLean:
+ """Le ``core/`` post-phase E ne contient plus que les modules
+ Cercle 1 (abstractions + orchestration). Tout le reste est shim."""
+
+ @pytest.mark.parametrize("name", [
+ "corpus", "modules", "results", "metrics",
+ "runner", "pipeline_runner", "pipeline_benchmark",
+ "pipeline_comparison", "pipeline_spec_loader",
+ "metric_registry", "metric_hooks",
+ "builtin_metrics", "alto_metrics",
+ ])
+ def test_cercle1_module_present(self, name):
+ """Les modules Cercle 1 doivent rester dans ``core/`` (pas de shim)."""
+ repo = Path(__file__).parent.parent
+ path = repo / "picarones" / "core" / f"{name}.py"
+ assert path.exists()
+ content = path.read_text(encoding="utf-8")
+ # Un module Cercle 1 a > 30 lignes (vraie logique, pas shim)
+ n_lines = len([line for line in content.splitlines() if line.strip()])
+ assert n_lines > 30, (
+ f"core/{name}.py fait {n_lines} lignes — ne devrait pas être "
+ "un shim, c'est un module Cercle 1"
+ )
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 5. Hooks builtin enregistrés (12 doc + 12 corpus)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestHooksStillRegistered:
+ def test_12_doc_hooks(self):
+ # Eager-load des hooks via builtin_hooks (qui est maintenant un shim
+ # vers measurements/builtin_hooks).
+ import picarones.core.builtin_hooks # noqa: F401
+ from picarones.core.metric_hooks import _all_document_hook_names
+
+ hooks = _all_document_hook_names()
+ # Le compte exact dépend des hooks expérimentaux qui tests
+ # pourraient ajouter, donc on vérifie >= 12 et la présence des
+ # 12 attendus.
+ expected = {
+ "confusion", "char_scores", "taxonomy", "structure",
+ "image_quality", "line_metrics", "hallucination",
+ "calibration", "philological", "searchability",
+ "numerical_sequences", "readability",
+ }
+ assert expected.issubset(set(hooks))
+
+ def test_alto_metrics_registered(self):
+ import picarones.core.pipeline_runner # eager-load
+ from picarones.core.metric_registry import select_metrics
+ from picarones.core.modules import ArtifactType
+
+ metrics = select_metrics((ArtifactType.ALTO, ArtifactType.ALTO))
+ names = {s.name for s in metrics}
+ assert "alto_text_cer" in names
+ assert "alto_text_wer" in names
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 6. build_synthesis fonctionne (intégration narrative complète)
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestNarrativeIntegration:
+ def test_build_synthesis_works(self):
+ from picarones.core.narrative import build_synthesis
+
+ synth = build_synthesis({
+ "ranking": [
+ {"engine": "tess", "mean_cer": 0.05},
+ {"engine": "pero", "mean_cer": 0.08},
+ ],
+ }, lang="fr")
+ assert "sentences" in synth
+ assert "facts" in synth
+ assert len(synth["sentences"]) >= 1
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 7. Vues du chantier 3 fonctionnent
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestChantier3ViewsAfterPhaseE:
+ def test_views_still_work(self):
+ from picarones.report.views import (
+ build_advanced_taxonomy_view_html,
+ build_diagnostics_view_html,
+ build_economics_view_html,
+ )
+ report_data = {"engines": [
+ {"name": "tess", "cer": 0.05,
+ "aggregated_taxonomy": {"class_distribution": {"x": 5}}},
+ {"name": "pero", "cer": 0.08,
+ "aggregated_taxonomy": {"class_distribution": {"x": 8}}},
+ ]}
+ # Au moins advanced_taxonomy doit produire du HTML
+ html = build_advanced_taxonomy_view_html(report_data, {})
+ assert isinstance(html, str)
+
+
+# ──────────────────────────────────────────────────────────────────────────
+# 8. Documentation cercles mise à jour
+# ──────────────────────────────────────────────────────────────────────────
+
+
+class TestArchitectureCerclesDocUpdated:
+ @pytest.fixture
+ def doc(self) -> str:
+ path = Path(__file__).parent.parent / "docs" / "architecture-cercles.md"
+ return path.read_text(encoding="utf-8")
+
+ def test_critere_corrige(self, doc):
+ """Le critère DDD remplace l'ancien critère ambigu."""
+ assert "abstractions et logique métier du domaine" in doc
+ assert "indépendantes de l'interface utilisateur" in doc
+
+ def test_mention_phase_e(self, doc):
+ """Le doc référence le sous-package measurements/."""
+ # Au moins une mention du nouveau dossier
+ # (chemin physique du Cercle 2)
+ # NB : le doc parle de ``measurements/`` mais la lettre exacte
+ # dépend de la formulation. On accepte plusieurs variantes.
+ assert "measurements" in doc.lower() or "Cercle 2" in doc