"""Tests Sprint 34 — registre typé de métriques (Phase 0.3).
Vérifie :
1. ``register_metric`` accepte les métriques typées et les expose via
``all_metrics`` / ``get_metric`` / ``select_metrics``.
2. La sélection par signature de types est exacte (pas de coercion).
3. ``compute_at_junction`` calcule toutes les métriques applicables et
tolère les erreurs d'une métrique sans casser les autres.
4. Les métriques natives (``builtin_metrics``) produisent les mêmes
valeurs que ``jiwer`` directement (parité numérique avec
``compute_metrics`` legacy).
5. Le double enregistrement avec le même nom est interdit.
6. Une signature à 1 ou 3 éléments est rejetée.
7. Le stub typé hétérogène ``(TEXT, ALTO)`` se calcule sans erreur.
"""
from __future__ import annotations
import pytest
from picarones.evaluation.metric_registry import (
MetricSpec,
all_metrics,
compute_at_junction,
get_metric,
register_metric,
select_metrics,
)
from picarones.domain.artifacts import ArtifactType
# Force l'import du module qui enregistre les métriques natives. Les
# tests s'exécutent avec ce registre peuplé ; on n'utilise pas
# ``_reset_registry_for_tests`` parce qu'on veut justement tester l'état
# par défaut visible par le runner en production.
import picarones.evaluation.metrics.builtin_metrics # noqa: F401
# ──────────────────────────────────────────────────────────────────────────
# 1 & 2. Enregistrement et sélection par signature
# ──────────────────────────────────────────────────────────────────────────
class TestRegistryBasics:
def test_builtin_metrics_loaded(self) -> None:
names = {spec.name for spec in all_metrics()}
assert {"cer", "wer", "mer", "wil"} <= names
def test_get_metric_returns_spec(self) -> None:
spec = get_metric("cer")
assert isinstance(spec, MetricSpec)
assert spec.input_types == (ArtifactType.TEXT, ArtifactType.TEXT)
assert spec.higher_is_better is False
def test_get_metric_unknown_raises(self) -> None:
with pytest.raises(KeyError):
get_metric("definitely_not_registered_42")
def test_select_text_text_includes_cer_wer(self) -> None:
selected = select_metrics((ArtifactType.TEXT, ArtifactType.TEXT))
names = {spec.name for spec in selected}
assert "cer" in names
assert "wer" in names
def test_select_alto_alto_excludes_text_metrics(self) -> None:
selected = select_metrics((ArtifactType.ALTO, ArtifactType.ALTO))
names = {spec.name for spec in selected}
assert "cer" not in names
assert "wer" not in names
def test_select_text_alto_returns_heterogeneous_metric(self) -> None:
selected = select_metrics((ArtifactType.TEXT, ArtifactType.ALTO))
names = {spec.name for spec in selected}
assert "text_preservation_after_reconstruction" in names
def test_select_returns_empty_when_no_match(self) -> None:
# ENTITIES → READING_ORDER : aucune métrique enregistrée à ce jour
assert select_metrics((ArtifactType.ENTITIES, ArtifactType.READING_ORDER)) == []
# ──────────────────────────────────────────────────────────────────────────
# 3. compute_at_junction — calcul orchestré et résilience
# ──────────────────────────────────────────────────────────────────────────
class TestComputeAtJunction:
def test_returns_all_applicable_metrics(self) -> None:
out = compute_at_junction(
"hello world",
"hello wrld",
(ArtifactType.TEXT, ArtifactType.TEXT),
)
# Au moins les 4 métriques natives doivent être présentes
for name in ("cer", "wer", "mer", "wil"):
assert name in out
assert isinstance(out[name], float)
assert 0.0 <= out[name] <= 1.0
def test_empty_dict_when_no_metric_applies(self) -> None:
# Un type d'artefact sans métrique enregistrée
out = compute_at_junction(
[], [],
(ArtifactType.ENTITIES, ArtifactType.READING_ORDER),
)
assert out == {}
def test_skip_on_error_default_true(self) -> None:
"""Une métrique qui lève est ignorée, les autres tournent."""
@register_metric(
name="_test_always_raises",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description="Test only",
)
def _broken(ref: str, hyp: str) -> float:
raise RuntimeError("intentional failure")
try:
out = compute_at_junction(
"abc", "abd",
(ArtifactType.TEXT, ArtifactType.TEXT),
)
assert "_test_always_raises" not in out
# Les natives sont toujours là
assert "cer" in out
finally:
# Nettoyage manuel — pas d'API publique, on écrit dans le dict.
from picarones.evaluation.metric_registry import _METRIC_REGISTRY
_METRIC_REGISTRY.pop("_test_always_raises", None)
def test_skip_on_error_false_propagates(self) -> None:
@register_metric(
name="_test_propagates",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
)
def _broken(ref: str, hyp: str) -> float:
raise RuntimeError("propagate me")
try:
with pytest.raises(RuntimeError, match="propagate me"):
compute_at_junction(
"x", "y",
(ArtifactType.TEXT, ArtifactType.TEXT),
skip_on_error=False,
)
finally:
from picarones.evaluation.metric_registry import _METRIC_REGISTRY
_METRIC_REGISTRY.pop("_test_propagates", None)
# ──────────────────────────────────────────────────────────────────────────
# 4. Parité numérique avec compute_metrics legacy
# ──────────────────────────────────────────────────────────────────────────
class TestParityWithLegacy:
"""Le critère « rapport identique octet par octet » du Sprint 34
se traduit en : les métriques enregistrées produisent les mêmes
chiffres que ``compute_metrics`` historique sur les mêmes paires."""
@pytest.mark.parametrize(
"ref,hyp",
[
("hello world", "hello wrld"),
("Le manuscrit médiéval", "Le manuscript medieval"),
("abcdef", "abcdef"), # cas parfait
("a", "b"),
],
)
def test_cer_matches_compute_metrics(self, ref: str, hyp: str) -> None:
from picarones.evaluation.metrics.text_metrics import compute_metrics
legacy = compute_metrics(ref, hyp)
registered = compute_at_junction(
ref, hyp,
(ArtifactType.TEXT, ArtifactType.TEXT),
)
# On compare au CER brut, pas aux variantes (NFC, caseless,
# diplomatic) qui sont des métriques distinctes non encore
# enregistrées.
assert registered["cer"] == pytest.approx(legacy.cer, abs=1e-9)
assert registered["wer"] == pytest.approx(legacy.wer, abs=1e-9)
assert registered["mer"] == pytest.approx(legacy.mer, abs=1e-9)
assert registered["wil"] == pytest.approx(legacy.wil, abs=1e-9)
# ──────────────────────────────────────────────────────────────────────────
# 5 & 6. Garde-fous d'enregistrement
# ──────────────────────────────────────────────────────────────────────────
class TestRegistrationGuards:
def test_double_register_same_name_raises(self) -> None:
@register_metric(
name="_test_duplicate",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
)
def _first(ref: str, hyp: str) -> float:
return 0.0
try:
with pytest.raises(ValueError, match="déjà enregistrée"):
@register_metric(
name="_test_duplicate",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
)
def _second(ref: str, hyp: str) -> float:
return 1.0
finally:
from picarones.evaluation.metric_registry import _METRIC_REGISTRY
_METRIC_REGISTRY.pop("_test_duplicate", None)
def test_re_register_same_function_tolerated(self) -> None:
"""Ré-importer le module ne doit pas lever (cas réel : pytest
recharge un module entre fichiers de tests)."""
def _func(ref: str, hyp: str) -> float:
return 0.0
register_metric(
name="_test_idempotent",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
)(_func)
# Second appel avec la même fonction → tolérance
register_metric(
name="_test_idempotent",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
)(_func)
from picarones.evaluation.metric_registry import _METRIC_REGISTRY
_METRIC_REGISTRY.pop("_test_idempotent", None)
def test_input_types_must_be_pair(self) -> None:
with pytest.raises(ValueError, match="couple"):
@register_metric(
name="_bad_arity_3",
input_types=( # type: ignore[arg-type]
ArtifactType.TEXT,
ArtifactType.TEXT,
ArtifactType.TEXT,
),
)
def _f(a, b, c):
return 0.0
# ──────────────────────────────────────────────────────────────────────────
# 7. Stub TEXT → ALTO opérationnel
# ──────────────────────────────────────────────────────────────────────────
class TestHeterogeneousJunction:
def test_text_preservation_runs(self) -> None:
ref = "le manuscrit médiéval"
alto = (
''
''
''
)
out = compute_at_junction(
ref, alto,
(ArtifactType.TEXT, ArtifactType.ALTO),
)
assert "text_preservation_after_reconstruction" in out
assert out["text_preservation_after_reconstruction"] == pytest.approx(1.0)
def test_text_preservation_partial(self) -> None:
ref = "alpha beta gamma"
alto = ''
score = compute_at_junction(
ref, alto,
(ArtifactType.TEXT, ArtifactType.ALTO),
)["text_preservation_after_reconstruction"]
# 1 token sur 3 préservé
assert score == pytest.approx(1 / 3, abs=1e-9)
def test_text_preservation_metric_marked_higher_is_better(self) -> None:
spec = get_metric("text_preservation_after_reconstruction")
assert spec.higher_is_better is True