Picarones / picarones /core /metric_hooks.py
Claude
chantier2: système de profils + registre de hooks dans le runner
25bd1fe unverified
Raw
History Blame
16.1 kB
"""Registre typΓ© des hooks de mΓ©triques document-level et corpus-level.
Chantier 2 du plan d'Γ©volution post-Sprint 97.
Pourquoi ce module
------------------
Avant ce chantier, ``picarones.core.runner._compute_document_result``
contenait **11 imports tardifs codΓ©s en dur** vers
``picarones.core.confusion``, ``char_scores``, ``taxonomy``, ``structure``,
``image_quality``, ``line_metrics``, ``hallucination``,
``philological_runner``, ``searchability_runner``,
``numerical_sequences_runner``, ``readability_runner`` β€” chacun enrobΓ©
dans un ``try/except Exception`` qui logue un warning. SymΓ©triquement,
la phase d'agrΓ©gation contenait 11 fonctions ``_aggregate_*`` ou
``aggregate_*``. Ajouter une nouvelle mΓ©trique exigeait de patcher
``runner.py`` Γ  deux endroits, ce qui rendait le fichier monolithique
(1322 lignes) et fragile.
Ce module centralise le mΓ©canisme :
- **Profils** (``minimal`` / ``standard`` / ``philological`` /
``diagnostics`` / ``pipeline`` / ``full``) β€” l'utilisateur choisit
quel sous-ensemble de mΓ©triques calculer selon son use case.
- **Hooks document-level** (:class:`DocumentMetricHook`) enregistrΓ©s via
:func:`register_document_metric` β€” fonctions appelΓ©es pour chaque
document, leur retour remplit un attribut nommΓ© du ``DocumentResult``.
- **AgrΓ©gateurs corpus-level** (:class:`CorpusMetricAggregator`)
enregistrΓ©s via :func:`register_corpus_aggregator` β€” fonctions
appelΓ©es une fois par moteur pour synthΓ©tiser les
``DocumentResult`` en attributs ``aggregated_*`` du ``EngineReport``.
RΓ©trocompat stricte
-------------------
Le profil ``standard`` (dΓ©faut) active exactement les 11 hooks et 11
agrΓ©gateurs historiques. Comportement, ordre d'exΓ©cution, gestion
d'erreurs et octets de sortie : strictement identiques Γ  avant le
chantier 2. La preuve est dans ``tests/test_metric_hooks.py``
(cas-tests qui comparent profil ``standard`` vs comportement legacy
sur fixtures).
Comment ajouter un hook
-----------------------
Pour ajouter une mΓ©trique document-level :
>>> from picarones.core.metric_hooks import (
... register_document_metric, PROFILE_STANDARD, PROFILE_FULL,
... )
>>>
>>> @register_document_metric(
... name="my_metric",
... attribute="my_metric", # nom du champ dans DocumentResult
... profiles=(PROFILE_STANDARD, PROFILE_FULL),
... requires_success=True,
... )
... def my_hook(*, ground_truth, hypothesis, image_path, corpus_lang,
... ocr_result):
... # Imports tardifs OK ici β€” le coΓ»t n'est payΓ© que si le hook
... # est dans le profil actif.
... from my_pkg import compute_my_metric
... return compute_my_metric(ground_truth, hypothesis)
Pour un nouveau profil, l'ajouter Γ  :data:`KNOWN_PROFILES` (et
rΓ©fΓ©rencer dans la doc utilisateur ``docs/profiles/``).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Optional
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────
# Profils
# ──────────────────────────────────────────────────────────────────────────
PROFILE_MINIMAL = "minimal"
"""Profil le plus lΓ©ger : juste CER/WER de ``compute_metrics``. Aucun
hook document-level ni agrΓ©gateur ne s'exΓ©cute. Cible : tests rapides
ou bench massif oΓΉ seul le CER global compte."""
PROFILE_STANDARD = "standard"
"""Profil par dΓ©faut. Active tous les hooks et agrΓ©gateurs historiques
de Picarones (Sprints 5 + 10 + 39+42 + 55-60 + 84-87). Comportement
strictement identique au runner prΓ©-chantier-2."""
PROFILE_PHILOLOGICAL = "philological"
"""Profil orientΓ© Γ©dition critique : standard + emphase philologique.
Aujourd'hui Γ©quivalent Γ  standard ; rΓ©servΓ© pour des hooks futurs
spΓ©cifiques aux corpus mΓ©diΓ©vaux et imprimΓ©s anciens."""
PROFILE_DIAGNOSTICS = "diagnostics"
"""Profil orientΓ© diagnostic : standard + leviers d'amΓ©lioration,
prΓ©diction de complexitΓ©, baseline historique. RΓ©servΓ© pour des
hooks futurs (chantiers 3-4)."""
PROFILE_ECONOMICS = "economics"
"""Profil orientΓ© dΓ©cision budget : minimal + mΓ©triques Γ©conomiques
(throughput effectif, coΓ»t marginal). RΓ©servΓ© pour des hooks futurs."""
PROFILE_PIPELINE = "pipeline"
"""Profil pour les benchmarks de pipelines composΓ©es (axe B). Active
les hooks pertinents aux jonctions du DAG. RΓ©servΓ© pour des hooks
futurs spΓ©cifiques aux pipelines."""
PROFILE_FULL = "full"
"""Profil exhaustif : tous les hooks de tous les profils. CoΓ»t
maximal mais reproductibilitΓ© scientifique maximale."""
KNOWN_PROFILES: frozenset[str] = frozenset({
PROFILE_MINIMAL,
PROFILE_STANDARD,
PROFILE_PHILOLOGICAL,
PROFILE_DIAGNOSTICS,
PROFILE_ECONOMICS,
PROFILE_PIPELINE,
PROFILE_FULL,
})
def validate_profile(profile: str) -> None:
"""Lève ``ValueError`` si ``profile`` n'est pas connu.
Le runner appelle cette fonction au dΓ©marrage pour rejeter
rapidement une faute de frappe utilisateur (``--profile philolagic``).
"""
if profile not in KNOWN_PROFILES:
raise ValueError(
f"profil inconnu : {profile!r}. "
f"Profils valides : {sorted(KNOWN_PROFILES)}"
)
# ──────────────────────────────────────────────────────────────────────────
# Modèles de hook
# ──────────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class DocumentMetricHook:
"""Hook calculΓ© pour chaque document.
Attributs
---------
name:
Identifiant lisible utilisΓ© dans les logs et les warnings.
attribute:
Nom du champ du :class:`DocumentResult` Γ  remplir (par
exemple ``"confusion_matrix"`` ou ``"taxonomy"``). Doit
correspondre exactement Γ  un attribut existant β€” le runner
passe le rΓ©sultat via ``setattr``.
profiles:
Ensemble des profils dans lesquels ce hook s'active.
func:
Fonction calculant la mΓ©trique. Signature attendue :
``func(*, ground_truth, hypothesis, image_path, corpus_lang,
ocr_result) -> Any``. Tous les arguments sont passΓ©s en
keyword pour que les hooks puissent ignorer ceux qu'ils
n'utilisent pas avec ``**_``.
requires_success:
Si ``True``, le hook n'est appelΓ© que quand
``ocr_result.success`` (texte hyp non-vide). Γ‰vite de gaspiller
du temps sur des documents en erreur OCR.
requires_token_confidences:
Si ``True``, le hook n'est appelΓ© que quand
``ocr_result.token_confidences`` est non-vide. RΓ©servΓ© Γ  la
calibration (Sprint 42).
"""
name: str
attribute: str
profiles: frozenset[str]
func: Callable[..., Any]
requires_success: bool = False
requires_token_confidences: bool = False
@dataclass(frozen=True)
class CorpusMetricAggregator:
"""AgrΓ©gateur calculΓ© une fois par moteur sur tous les documents.
Attributs
---------
name:
Identifiant lisible.
attribute:
Nom du champ du :class:`EngineReport` Γ  remplir (par
exemple ``"aggregated_confusion"``).
profiles:
Profils dans lesquels l'agrΓ©gateur s'active.
func:
``func(document_results: list[DocumentResult]) -> Any``.
"""
name: str
attribute: str
profiles: frozenset[str]
func: Callable[..., Any]
# ──────────────────────────────────────────────────────────────────────────
# Registres globaux
# ──────────────────────────────────────────────────────────────────────────
_DOCUMENT_HOOKS: list[DocumentMetricHook] = []
_CORPUS_AGGREGATORS: list[CorpusMetricAggregator] = []
def _check_profiles(profiles: Iterable[str]) -> frozenset[str]:
frozen = frozenset(profiles)
unknown = frozen - KNOWN_PROFILES
if unknown:
raise ValueError(
f"profils inconnus : {sorted(unknown)}. "
f"Profils valides : {sorted(KNOWN_PROFILES)}"
)
return frozen
def register_document_metric(
*,
name: str,
attribute: str,
profiles: Iterable[str],
requires_success: bool = False,
requires_token_confidences: bool = False,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""DΓ©corateur d'enregistrement d'un hook document-level."""
profiles_set = _check_profiles(profiles)
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
# Idempotence : si le mΓͺme func est rΓ©-enregistrΓ© (rΓ©-import
# du module en test), on ignore silencieusement. Si un autre
# func tente le mΓͺme ``name``, on lΓ¨ve.
for existing in _DOCUMENT_HOOKS:
if existing.name == name:
if existing.func is func:
return func
raise ValueError(
f"hook document '{name}' dΓ©jΓ  enregistrΓ© par "
f"{existing.func.__module__}.{existing.func.__qualname__}"
)
_DOCUMENT_HOOKS.append(DocumentMetricHook(
name=name,
attribute=attribute,
profiles=profiles_set,
func=func,
requires_success=requires_success,
requires_token_confidences=requires_token_confidences,
))
return func
return decorator
def register_corpus_aggregator(
*,
name: str,
attribute: str,
profiles: Iterable[str],
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""DΓ©corateur d'enregistrement d'un agrΓ©gateur corpus-level."""
profiles_set = _check_profiles(profiles)
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
for existing in _CORPUS_AGGREGATORS:
if existing.name == name:
if existing.func is func:
return func
raise ValueError(
f"agrΓ©gateur corpus '{name}' dΓ©jΓ  enregistrΓ© par "
f"{existing.func.__module__}.{existing.func.__qualname__}"
)
_CORPUS_AGGREGATORS.append(CorpusMetricAggregator(
name=name,
attribute=attribute,
profiles=profiles_set,
func=func,
))
return func
return decorator
# ──────────────────────────────────────────────────────────────────────────
# SΓ©lection + exΓ©cution selon profil
# ──────────────────────────────────────────────────────────────────────────
def select_document_hooks(profile: str) -> list[DocumentMetricHook]:
"""Retourne les hooks document-level actifs pour ``profile``.
L'ordre d'enregistrement est prΓ©servΓ© pour garantir que les
warnings et logs apparaissent dans le mΓͺme ordre qu'avant le
chantier 2 (cf. test de rΓ©trocompat).
"""
validate_profile(profile)
return [h for h in _DOCUMENT_HOOKS if profile in h.profiles]
def select_corpus_aggregators(profile: str) -> list[CorpusMetricAggregator]:
"""Retourne les agrΓ©gateurs corpus-level actifs pour ``profile``."""
validate_profile(profile)
return [a for a in _CORPUS_AGGREGATORS if profile in a.profiles]
def run_document_hooks(
profile: str,
*,
ground_truth: str,
hypothesis: str,
image_path: str,
corpus_lang: str,
ocr_result: Any,
) -> dict[str, Any]:
"""ExΓ©cute tous les hooks document-level actifs pour ``profile``.
Retourne un dict ``{attribute_name: value}`` que le runner peut
appliquer au ``DocumentResult`` via ``setattr`` ou ``**kwargs``.
PrΓ©-conditions :
- les hooks Γ  ``requires_success=True`` ne tournent que si
``ocr_result.success`` ;
- les hooks Γ  ``requires_token_confidences=True`` ne tournent
que si ``ocr_result.token_confidences`` est non vide.
Toute exception levΓ©e par un hook est loggΓ©e en warning et
le hook est sautΓ© (``attribute`` absent du dict retournΓ©). Aucun
hook ne fait jamais Γ©chouer le calcul des autres β€” discipline
historique prΓ©servΓ©e.
"""
out: dict[str, Any] = {}
for hook in select_document_hooks(profile):
if hook.requires_success and not getattr(ocr_result, "success", False):
continue
if hook.requires_token_confidences and not getattr(
ocr_result, "token_confidences", None,
):
continue
try:
value = hook.func(
ground_truth=ground_truth,
hypothesis=hypothesis,
image_path=image_path,
corpus_lang=corpus_lang,
ocr_result=ocr_result,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"[%s] fonctionnalitΓ© dΓ©gradΓ©e : %s", hook.name, exc,
)
continue
if value is not None:
out[hook.attribute] = value
return out
def run_corpus_aggregators(
profile: str,
document_results: list,
) -> dict[str, Any]:
"""ExΓ©cute tous les agrΓ©gateurs corpus-level pour ``profile``.
Retourne un dict ``{attribute_name: value}`` Γ  appliquer au
``EngineReport``. Comme pour les hooks doc-level, une exception
dans un agrΓ©gateur est loggΓ©e et l'agrΓ©gateur sautΓ©.
"""
out: dict[str, Any] = {}
for agg in select_corpus_aggregators(profile):
try:
value = agg.func(document_results)
except Exception as exc: # noqa: BLE001
logger.warning(
"[aggregate_%s] fonctionnalitΓ© dΓ©gradΓ©e : %s", agg.name, exc,
)
continue
if value is not None:
out[agg.attribute] = value
return out
# ──────────────────────────────────────────────────────────────────────────
# Helpers test-only
# ──────────────────────────────────────────────────────────────────────────
def _reset_for_tests() -> None:
"""Vide les registres globaux. **RΓ©servΓ© aux tests** β€” dΓ©sactive
toutes les mΓ©triques en production."""
_DOCUMENT_HOOKS.clear()
_CORPUS_AGGREGATORS.clear()
def _all_document_hook_names() -> list[str]:
return [h.name for h in _DOCUMENT_HOOKS]
def _all_corpus_aggregator_names() -> list[str]:
return [a.name for a in _CORPUS_AGGREGATORS]
__all__ = [
"PROFILE_MINIMAL",
"PROFILE_STANDARD",
"PROFILE_PHILOLOGICAL",
"PROFILE_DIAGNOSTICS",
"PROFILE_ECONOMICS",
"PROFILE_PIPELINE",
"PROFILE_FULL",
"KNOWN_PROFILES",
"validate_profile",
"DocumentMetricHook",
"CorpusMetricAggregator",
"register_document_metric",
"register_corpus_aggregator",
"select_document_hooks",
"select_corpus_aggregators",
"run_document_hooks",
"run_corpus_aggregators",
]