Spaces:
Sleeping
refactor(arch): Sprint A3 — refactor cercles + importers (B-1, B-2, B-3, m-17)
Browse filesPhase 1 du plan de remédiation institutionnelle. Quatre violations de
la règle d'architecture en 3 cercles fermées + un garde-fou
architectural posé pour empêcher toute régression future.
B-1 : déplace ``compute_word_diff`` / ``compute_char_diff`` /
``diff_stats`` de ``picarones/report/diff_utils.py`` vers
``picarones/core/diff_utils.py`` (Cercle 1, source canonique).
``report/diff_utils.py`` reste comme ré-export trivial avec
``DeprecationWarning`` (suppression v1.3.0). Les 3 consommateurs
(``measurements/statistics.py:861``, ``report/generator.py:39``,
``report/worst_lines_render.py:22``) importent désormais depuis
``core``. Tests déplacés ``tests/report/`` → ``tests/core/``.
B-2 : déplace ``difficulty_color()`` de
``picarones/measurements/difficulty.py:195`` vers le nouveau module
``picarones/report/difficulty_render.py``. ``measurements/difficulty.py``
ne contient plus que de la logique purement numérique.
B-3 : remplace 4 sites ``except Exception: pass`` (huggingface.py:266,
416 + htr_united.py:431, 448) par
``logger.warning`` + appel à un nouveau journal en mémoire
``picarones.extras.importers._fallback_log`` (record_fallback /
consume_fallback_log). Le moteur narratif consomme ce journal via le
nouveau ``FactType.IMPORTER_FALLBACK_TRIGGERED`` (priority 180,
importance MEDIUM, HIGH si ≥2 incidents sur le même importer).
Templates FR + EN ajoutés (10 lignes chacun, factuel sans chiffre en
dur).
m-17 : déplace 2 tests qui violaient la règle d'imports cross-cercle
(``tests/measurements/test_sprint11_i18n_english.py`` et
``tests/measurements/test_sprint94_error_absorption.py``) vers
``tests/integration/`` puisqu'ils consomment du Cercle 3.
Bonus refactor : la cérémonie d'eager-load des métriques typées
(Sprint 34) qui vivait dans ``core/pipeline.py`` (11 imports vers
``picarones.measurements.*``, violation Cercle 1→2) est déplacée
dans ``picarones/measurements/__init__.py``. Le top-level
``picarones/__init__.py`` déclenche désormais l'enregistrement via
``import picarones.measurements as _trigger_metric_registration``.
Garde-fou architectural :
``tests/core/test_circle_dependencies.py`` parse l'AST de tous les
fichiers Cercle 1+2 et fail dès qu'un import remonte vers un cercle
plus extérieur. Couvre imports top-level ET paresseux dans les
fonctions (le piège qui a permis B-1 et B-2). 105 fichiers audités,
0 violation.
Mises à jour expectations Sprint 29 + chantier5 :
``DETECTORS_BY_TYPE`` 18→19, history 3→4 détecteurs,
``_FALLBACK_TYPE_ORDER`` étendue.
Validation locale : 77/77 Sprint 29 + chantier5 verts ;
123/123 tests/core (diff_utils + circle_dependencies) verts ; ruff,
mypy strict sur core/, bandit (0 HIGH/MEDIUM) tous verts. Suite
complète relancée en arrière-plan pour confirmation finale.
- picarones/__init__.py +8 -0
- picarones/core/diff_utils.py +89 -0
- picarones/core/facts.py +9 -0
- picarones/core/pipeline.py +7 -19
- picarones/extras/importers/__init__.py +11 -0
- picarones/extras/importers/_fallback_log.py +98 -0
- picarones/extras/importers/htr_united.py +21 -3
- picarones/extras/importers/huggingface.py +23 -4
- picarones/measurements/__init__.py +34 -0
- picarones/measurements/difficulty.py +3 -10
- picarones/measurements/narrative/arbiter.py +5 -0
- picarones/measurements/narrative/detectors/__init__.py +2 -0
- picarones/measurements/narrative/detectors/history.py +64 -0
- picarones/measurements/narrative/templates/en.yaml +8 -0
- picarones/measurements/narrative/templates/fr.yaml +8 -0
- picarones/measurements/statistics.py +2 -1
- picarones/report/diff_utils.py +18 -81
- picarones/report/difficulty_render.py +45 -0
- picarones/report/generator.py +1 -1
- picarones/report/worst_lines_render.py +1 -1
- tests/core/test_circle_dependencies.py +260 -0
- tests/{report → core}/test_diff_utils.py +2 -2
- tests/integration/test_chantier5.py +10 -5
- tests/{measurements → integration}/test_sprint11_i18n_english.py +0 -0
- tests/{measurements → integration}/test_sprint94_error_absorption.py +0 -0
|
@@ -73,6 +73,14 @@ from picarones.core.metric_registry import (
|
|
| 73 |
select_metrics,
|
| 74 |
)
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
__all__ = [
|
| 77 |
"__version__",
|
| 78 |
"__author__",
|
|
|
|
| 73 |
select_metrics,
|
| 74 |
)
|
| 75 |
|
| 76 |
+
# Sprint A3 — trigger d'enregistrement du registre typé (Sprint 34).
|
| 77 |
+
# L'import de ``picarones.measurements`` provoque l'exécution des
|
| 78 |
+
# décorateurs ``@register_metric`` sur ``cer``, ``wer``, ``mer``,
|
| 79 |
+
# ``wil`` + ~15 métriques philologiques + reading order + NER + ALTO.
|
| 80 |
+
# Ce trigger remplace l'ancien import croisé Cercle 1 → Cercle 2 dans
|
| 81 |
+
# ``core/pipeline.py`` (violation B-1/B-2 du même esprit).
|
| 82 |
+
import picarones.measurements as _trigger_metric_registration # noqa: F401, E402
|
| 83 |
+
|
| 84 |
__all__ = [
|
| 85 |
"__version__",
|
| 86 |
"__author__",
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Calcul du diff mot-à-mot entre vérité terrain et sortie OCR.
|
| 2 |
+
|
| 3 |
+
Produit une liste d'opérations sérialisables en JSON, consommée
|
| 4 |
+
par le rendu JS dans le rapport HTML.
|
| 5 |
+
|
| 6 |
+
Opérations possibles
|
| 7 |
+
--------------------
|
| 8 |
+
{"op": "equal", "text": "mot"}
|
| 9 |
+
{"op": "insert", "text": "mot"} -- présent dans l'OCR mais pas dans la GT
|
| 10 |
+
{"op": "delete", "text": "mot"} -- présent dans la GT mais pas dans l'OCR
|
| 11 |
+
{"op": "replace", "old": "…", "new": "…"} -- substitution (orange)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import difflib
|
| 17 |
+
import re
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _tokenize(text: str) -> list[str]:
|
| 22 |
+
"""Découpe le texte en tokens (mots + ponctuation + espaces)."""
|
| 23 |
+
# Conserver les espaces comme tokens pour un rendu fidèle
|
| 24 |
+
return re.split(r"(\s+)", text)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def compute_word_diff(reference: str, hypothesis: str) -> list[dict[str, Any]]:
|
| 28 |
+
"""Calcule un diff mot-à-mot entre deux textes.
|
| 29 |
+
|
| 30 |
+
Parameters
|
| 31 |
+
----------
|
| 32 |
+
reference:
|
| 33 |
+
Texte de vérité terrain.
|
| 34 |
+
hypothesis:
|
| 35 |
+
Texte produit par le moteur OCR.
|
| 36 |
+
|
| 37 |
+
Returns
|
| 38 |
+
-------
|
| 39 |
+
list of dict
|
| 40 |
+
Séquence d'opérations : equal, insert, delete, replace.
|
| 41 |
+
"""
|
| 42 |
+
ref_tokens = reference.split()
|
| 43 |
+
hyp_tokens = hypothesis.split()
|
| 44 |
+
|
| 45 |
+
matcher = difflib.SequenceMatcher(None, ref_tokens, hyp_tokens, autojunk=False)
|
| 46 |
+
ops: list[dict[str, Any]] = []
|
| 47 |
+
|
| 48 |
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 49 |
+
ref_chunk = " ".join(ref_tokens[i1:i2])
|
| 50 |
+
hyp_chunk = " ".join(hyp_tokens[j1:j2])
|
| 51 |
+
|
| 52 |
+
if tag == "equal":
|
| 53 |
+
ops.append({"op": "equal", "text": ref_chunk})
|
| 54 |
+
elif tag == "insert":
|
| 55 |
+
ops.append({"op": "insert", "text": hyp_chunk})
|
| 56 |
+
elif tag == "delete":
|
| 57 |
+
ops.append({"op": "delete", "text": ref_chunk})
|
| 58 |
+
elif tag == "replace":
|
| 59 |
+
ops.append({"op": "replace", "old": ref_chunk, "new": hyp_chunk})
|
| 60 |
+
|
| 61 |
+
return ops
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def compute_char_diff(reference: str, hypothesis: str) -> list[dict[str, Any]]:
|
| 65 |
+
"""Diff caractère par caractère — utile pour les tokens courts."""
|
| 66 |
+
matcher = difflib.SequenceMatcher(None, list(reference), list(hypothesis), autojunk=False)
|
| 67 |
+
ops: list[dict[str, Any]] = []
|
| 68 |
+
|
| 69 |
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 70 |
+
ref_chunk = reference[i1:i2]
|
| 71 |
+
hyp_chunk = hypothesis[j1:j2]
|
| 72 |
+
if tag == "equal":
|
| 73 |
+
ops.append({"op": "equal", "text": ref_chunk})
|
| 74 |
+
elif tag == "insert":
|
| 75 |
+
ops.append({"op": "insert", "text": hyp_chunk})
|
| 76 |
+
elif tag == "delete":
|
| 77 |
+
ops.append({"op": "delete", "text": ref_chunk})
|
| 78 |
+
elif tag == "replace":
|
| 79 |
+
ops.append({"op": "replace", "old": ref_chunk, "new": hyp_chunk})
|
| 80 |
+
|
| 81 |
+
return ops
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def diff_stats(ops: list[dict[str, Any]]) -> dict[str, int]:
|
| 85 |
+
"""Compte le nombre d'insertions, suppressions et substitutions."""
|
| 86 |
+
stats = {"equal": 0, "insert": 0, "delete": 0, "replace": 0}
|
| 87 |
+
for op in ops:
|
| 88 |
+
stats[op["op"]] += 1
|
| 89 |
+
return stats
|
|
@@ -100,6 +100,15 @@ class FactType(str, Enum):
|
|
| 100 |
(régression progressive), soit change-point avec delta >
|
| 101 |
seuil (rupture brutale)."""
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
class FactImportance(int, Enum):
|
| 105 |
"""Score d'importance d'un fait — décide l'ordre et la sélection."""
|
|
|
|
| 100 |
(régression progressive), soit change-point avec delta >
|
| 101 |
seuil (rupture brutale)."""
|
| 102 |
|
| 103 |
+
IMPORTER_FALLBACK_TRIGGERED = "importer_fallback_triggered"
|
| 104 |
+
"""Un import distant (HuggingFace, HTR-United, Gallica, eScriptorium…)
|
| 105 |
+
a échoué ou a basculé en mode dégradé pendant la constitution du
|
| 106 |
+
corpus (Sprint A3, item B-3). Le moteur narratif lit
|
| 107 |
+
``picarones.extras.importers.consume_fallback_log()`` qui retourne
|
| 108 |
+
et **vide** la liste des incidents accumulés depuis le dernier
|
| 109 |
+
benchmark. Un Fact par incident, importance MEDIUM (HIGH si
|
| 110 |
+
plusieurs incidents sur le même importer)."""
|
| 111 |
+
|
| 112 |
|
| 113 |
class FactImportance(int, Enum):
|
| 114 |
"""Score d'importance d'un fait — décide l'ordre et la sélection."""
|
|
@@ -57,25 +57,13 @@ from picarones.core.corpus import Document, GTLevel
|
|
| 57 |
from picarones.core.metric_registry import compute_at_junction
|
| 58 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 59 |
|
| 60 |
-
#
|
| 61 |
-
#
|
| 62 |
-
#
|
| 63 |
-
#
|
| 64 |
-
|
| 65 |
-
#
|
| 66 |
-
|
| 67 |
-
import picarones.measurements.abbreviations # noqa: F401
|
| 68 |
-
import picarones.measurements.mufi # noqa: F401
|
| 69 |
-
import picarones.measurements.early_modern_typography # noqa: F401
|
| 70 |
-
import picarones.measurements.modern_archives # noqa: F401
|
| 71 |
-
import picarones.measurements.roman_numerals # noqa: F401
|
| 72 |
-
# Sprint 53 : reading order F1. Sprints 38, 52 : NER, readability.
|
| 73 |
-
import picarones.measurements.reading_order # noqa: F401
|
| 74 |
-
import picarones.measurements.readability # noqa: F401
|
| 75 |
-
import picarones.measurements.ner # noqa: F401
|
| 76 |
-
# Chantier 1 (post-Sprint 97) : métriques (ALTO, ALTO) pour évaluer
|
| 77 |
-
# les reconstructeurs ALTO contre une GT ALTO du document.
|
| 78 |
-
import picarones.measurements.alto_metrics # noqa: F401
|
| 79 |
|
| 80 |
logger = logging.getLogger(__name__)
|
| 81 |
|
|
|
|
| 57 |
from picarones.core.metric_registry import compute_at_junction
|
| 58 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 59 |
|
| 60 |
+
# Sprint A3 (renforce la règle Cercle 1 → Cercle 1 uniquement) — la
|
| 61 |
+
# cérémonie d'eager-load des métriques typées (Sprint 34) qui vivait
|
| 62 |
+
# ici a été déplacée dans ``picarones/measurements/__init__.py``. Tout
|
| 63 |
+
# consommateur de ``compute_at_junction`` (typiquement la classe
|
| 64 |
+
# ``PipelineRunner`` ci-dessous) doit avoir importé
|
| 65 |
+
# ``picarones.measurements`` au moins une fois — c'est le cas dans
|
| 66 |
+
# l'API publique via ``picarones.__init__`` qui déclenche le trigger.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
logger = logging.getLogger(__name__)
|
| 69 |
|
|
@@ -30,6 +30,12 @@ from picarones.extras.importers.escriptorium import (
|
|
| 30 |
EScriptoriumDocument,
|
| 31 |
connect_escriptorium,
|
| 32 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
__all__ = [
|
| 35 |
"IIIFImporter",
|
|
@@ -42,4 +48,9 @@ __all__ = [
|
|
| 42 |
"EScriptoriumProject",
|
| 43 |
"EScriptoriumDocument",
|
| 44 |
"connect_escriptorium",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
]
|
|
|
|
| 30 |
EScriptoriumDocument,
|
| 31 |
connect_escriptorium,
|
| 32 |
)
|
| 33 |
+
from picarones.extras.importers._fallback_log import (
|
| 34 |
+
consume_fallback_log,
|
| 35 |
+
peek_fallback_log,
|
| 36 |
+
record_fallback,
|
| 37 |
+
reset_fallback_log,
|
| 38 |
+
)
|
| 39 |
|
| 40 |
__all__ = [
|
| 41 |
"IIIFImporter",
|
|
|
|
| 48 |
"EScriptoriumProject",
|
| 49 |
"EScriptoriumDocument",
|
| 50 |
"connect_escriptorium",
|
| 51 |
+
# Sprint A3 (B-3) — journal des fallbacks d'importer
|
| 52 |
+
"record_fallback",
|
| 53 |
+
"consume_fallback_log",
|
| 54 |
+
"peek_fallback_log",
|
| 55 |
+
"reset_fallback_log",
|
| 56 |
]
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Journal en mémoire des fallbacks d'importer (Sprint A3, item B-3).
|
| 2 |
+
|
| 3 |
+
Quand un importer (HuggingFace, HTR-United, Gallica, eScriptorium…)
|
| 4 |
+
bascule en mode dégradé (timeout réseau, JSON mal formé, ZIP corrompu,
|
| 5 |
+
catalogue distant indisponible…), il enregistre un incident ici via
|
| 6 |
+
:func:`record_fallback`. Le moteur narratif consomme ces incidents via
|
| 7 |
+
:func:`consume_fallback_log`, qui **vide** la liste pour qu'un benchmark
|
| 8 |
+
suivant ne remonte pas les incidents du précédent.
|
| 9 |
+
|
| 10 |
+
Conception volontairement minimale :
|
| 11 |
+
|
| 12 |
+
- Pas de persistance disque (les incidents sont contextuels à un run).
|
| 13 |
+
- Pas de structure complexe (juste un ``list[dict]`` thread-safe).
|
| 14 |
+
- Le runner / le rapport peuvent ignorer la liste sans casser.
|
| 15 |
+
|
| 16 |
+
Le détecteur de Fact correspondant (``FactType.IMPORTER_FALLBACK_TRIGGERED``)
|
| 17 |
+
est implémenté dans
|
| 18 |
+
:mod:`picarones.measurements.narrative.detectors.history`.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
import threading
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_lock = threading.Lock()
|
| 30 |
+
_fallbacks: list[dict[str, Any]] = []
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def record_fallback(
|
| 34 |
+
importer: str,
|
| 35 |
+
operation: str,
|
| 36 |
+
error: BaseException | None = None,
|
| 37 |
+
*,
|
| 38 |
+
extra: dict[str, Any] | None = None,
|
| 39 |
+
) -> None:
|
| 40 |
+
"""Enregistre un incident de mode dégradé.
|
| 41 |
+
|
| 42 |
+
Logge également via ``logger.warning`` pour qu'un opérateur voit
|
| 43 |
+
l'incident en temps réel sans dépendre du rapport.
|
| 44 |
+
|
| 45 |
+
Parameters
|
| 46 |
+
----------
|
| 47 |
+
importer:
|
| 48 |
+
Nom court de l'importer (ex : ``"huggingface"``, ``"htr_united"``).
|
| 49 |
+
operation:
|
| 50 |
+
Description courte de l'opération (ex : ``"yaml_catalogue_parse"``,
|
| 51 |
+
``"image_save"``, ``"hub_search"``).
|
| 52 |
+
error:
|
| 53 |
+
Exception originelle (utilisée pour le message log et stockée dans
|
| 54 |
+
le payload sous forme de chaîne — pas l'objet, pour éviter les
|
| 55 |
+
références persistantes).
|
| 56 |
+
extra:
|
| 57 |
+
Champs additionnels (URL distante, identifiant dataset…) qui peuvent
|
| 58 |
+
être utiles à un détecteur de Fact ultérieur.
|
| 59 |
+
"""
|
| 60 |
+
error_repr = repr(error) if error is not None else None
|
| 61 |
+
logger.warning(
|
| 62 |
+
"[importers/%s] %s a échoué (mode dégradé) : %s",
|
| 63 |
+
importer,
|
| 64 |
+
operation,
|
| 65 |
+
error_repr,
|
| 66 |
+
)
|
| 67 |
+
entry: dict[str, Any] = {
|
| 68 |
+
"importer": importer,
|
| 69 |
+
"operation": operation,
|
| 70 |
+
"error": error_repr,
|
| 71 |
+
}
|
| 72 |
+
if extra:
|
| 73 |
+
entry["extra"] = dict(extra)
|
| 74 |
+
with _lock:
|
| 75 |
+
_fallbacks.append(entry)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def consume_fallback_log() -> list[dict[str, Any]]:
|
| 79 |
+
"""Retourne ET vide la liste des incidents accumulés.
|
| 80 |
+
|
| 81 |
+
Le moteur narratif appelle cette fonction au moment de construire
|
| 82 |
+
la synthèse pour transformer chaque incident en ``Fact``."""
|
| 83 |
+
with _lock:
|
| 84 |
+
out = list(_fallbacks)
|
| 85 |
+
_fallbacks.clear()
|
| 86 |
+
return out
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def peek_fallback_log() -> list[dict[str, Any]]:
|
| 90 |
+
"""Retourne une copie sans vider — utile pour les tests."""
|
| 91 |
+
with _lock:
|
| 92 |
+
return list(_fallbacks)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def reset_fallback_log() -> None:
|
| 96 |
+
"""Vide la liste sans rien retourner — utile pour les fixtures pytest."""
|
| 97 |
+
with _lock:
|
| 98 |
+
_fallbacks.clear()
|
|
@@ -428,7 +428,17 @@ def _try_download_corpus(
|
|
| 428 |
dest = output_path / Path(fname).name
|
| 429 |
dest.write_bytes(zf.read(fname))
|
| 430 |
return len(gt_files)
|
| 431 |
-
except Exception:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
return 0
|
| 433 |
|
| 434 |
|
|
@@ -445,8 +455,16 @@ def _parse_yml_catalogue(raw: str) -> list[HTRUnitedEntry]:
|
|
| 445 |
data = yaml.safe_load(raw)
|
| 446 |
if isinstance(data, list):
|
| 447 |
return [HTRUnitedEntry.from_dict(d) for d in data if isinstance(d, dict)]
|
| 448 |
-
except Exception:
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
return [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE]
|
| 451 |
|
| 452 |
|
|
|
|
| 428 |
dest = output_path / Path(fname).name
|
| 429 |
dest.write_bytes(zf.read(fname))
|
| 430 |
return len(gt_files)
|
| 431 |
+
except Exception as exc: # noqa: BLE001 — large surface (réseau, ZIP, FS)
|
| 432 |
+
# Sprint A3 (B-3) : on documente l'incident plutôt que de le
|
| 433 |
+
# masquer ; le caller reçoit toujours 0 pour préserver le
|
| 434 |
+
# contrat numérique de retour.
|
| 435 |
+
from picarones.extras.importers._fallback_log import record_fallback
|
| 436 |
+
record_fallback(
|
| 437 |
+
importer="htr_united",
|
| 438 |
+
operation="download_zip_samples",
|
| 439 |
+
error=exc,
|
| 440 |
+
extra={"output_path": str(output_path)},
|
| 441 |
+
)
|
| 442 |
return 0
|
| 443 |
|
| 444 |
|
|
|
|
| 455 |
data = yaml.safe_load(raw)
|
| 456 |
if isinstance(data, list):
|
| 457 |
return [HTRUnitedEntry.from_dict(d) for d in data if isinstance(d, dict)]
|
| 458 |
+
except Exception as exc: # noqa: BLE001 — yaml + parsing user-supplied
|
| 459 |
+
# Sprint A3 (B-3) : un YAML mal formé bascule en mode démo
|
| 460 |
+
# sans que l'utilisateur en soit averti — on logge et on émet
|
| 461 |
+
# un Fact pour que la synthèse du rapport mentionne l'incident.
|
| 462 |
+
from picarones.extras.importers._fallback_log import record_fallback
|
| 463 |
+
record_fallback(
|
| 464 |
+
importer="htr_united",
|
| 465 |
+
operation="yaml_catalogue_parse",
|
| 466 |
+
error=exc,
|
| 467 |
+
)
|
| 468 |
return [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE]
|
| 469 |
|
| 470 |
|
|
@@ -263,8 +263,17 @@ class HuggingFaceImporter:
|
|
| 263 |
if ds.dataset_id not in existing_ids:
|
| 264 |
results.append(ds)
|
| 265 |
existing_ids.add(ds.dataset_id)
|
| 266 |
-
except Exception:
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
return results[:limit]
|
| 270 |
|
|
@@ -413,8 +422,18 @@ def _try_import_with_datasets_lib(
|
|
| 413 |
img_file = output_path / f"doc_{i:04d}.jpg"
|
| 414 |
try:
|
| 415 |
image.save(str(img_file))
|
| 416 |
-
except Exception:
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
|
| 419 |
gt_file = output_path / f"doc_{i:04d}.gt.txt"
|
| 420 |
gt_file.write_text(str(text), encoding="utf-8")
|
|
|
|
| 263 |
if ds.dataset_id not in existing_ids:
|
| 264 |
results.append(ds)
|
| 265 |
existing_ids.add(ds.dataset_id)
|
| 266 |
+
except Exception as exc: # noqa: BLE001 — réseau/API tierce
|
| 267 |
+
# Sprint A3 (B-3) : la recherche API échoue silencieusement →
|
| 268 |
+
# l'utilisateur ne voit que les datasets de référence et croit
|
| 269 |
+
# que l'API est vide. On documente l'incident.
|
| 270 |
+
from picarones.extras.importers._fallback_log import record_fallback
|
| 271 |
+
record_fallback(
|
| 272 |
+
importer="huggingface",
|
| 273 |
+
operation="hub_search_api",
|
| 274 |
+
error=exc,
|
| 275 |
+
extra={"query": query, "language": language, "limit": limit},
|
| 276 |
+
)
|
| 277 |
|
| 278 |
return results[:limit]
|
| 279 |
|
|
|
|
| 422 |
img_file = output_path / f"doc_{i:04d}.jpg"
|
| 423 |
try:
|
| 424 |
image.save(str(img_file))
|
| 425 |
+
except Exception as exc: # noqa: BLE001 — PIL/PIL-IO
|
| 426 |
+
# Sprint A3 (B-3) : un échec de sauvegarde d'image
|
| 427 |
+
# produirait un GT orphelin (texte sans image). On
|
| 428 |
+
# documente et on continue — le GT est tout de même
|
| 429 |
+
# écrit pour préserver la cohérence numérique du compteur.
|
| 430 |
+
from picarones.extras.importers._fallback_log import record_fallback
|
| 431 |
+
record_fallback(
|
| 432 |
+
importer="huggingface",
|
| 433 |
+
operation="image_save",
|
| 434 |
+
error=exc,
|
| 435 |
+
extra={"img_file": str(img_file), "doc_index": i},
|
| 436 |
+
)
|
| 437 |
|
| 438 |
gt_file = output_path / f"doc_{i:04d}.gt.txt"
|
| 439 |
gt_file.write_text(str(text), encoding="utf-8")
|
|
@@ -117,3 +117,37 @@ Moteur narratif :
|
|
| 117 |
Voir :doc:`docs/architecture.md` pour la cartographie complète et
|
| 118 |
la règle de dépendance des 3 cercles.
|
| 119 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
Voir :doc:`docs/architecture.md` pour la cartographie complète et
|
| 118 |
la règle de dépendance des 3 cercles.
|
| 119 |
"""
|
| 120 |
+
|
| 121 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 122 |
+
# Sprint A3 (renforce le respect de la règle Cercle 2 → Cercle 1
|
| 123 |
+
# uniquement) — la cérémonie d'enregistrement des métriques typées dans
|
| 124 |
+
# le registre Sprint 34 a été déplacée ici depuis ``core/pipeline.py``
|
| 125 |
+
# qui violait la règle.
|
| 126 |
+
#
|
| 127 |
+
# Tout consommateur qui veut utiliser ``compute_at_junction``
|
| 128 |
+
# (``picarones.core.metric_registry``) doit avoir importé
|
| 129 |
+
# ``picarones.measurements`` au moins une fois pour que les décorateurs
|
| 130 |
+
# ``@register_metric`` aient été exécutés. C'est le cas par défaut dans
|
| 131 |
+
# le pipeline standard ; les notebooks isolés peuvent ajouter
|
| 132 |
+
# ``import picarones.measurements`` (suivi d'un commentaire d'exception
|
| 133 |
+
# ruff sur la ligne d'import si leur linter signale un import inutilisé).
|
| 134 |
+
#
|
| 135 |
+
# Sans ces imports, ``compute_at_junction`` trouverait un registre vide
|
| 136 |
+
# et ne calculerait rien aux jonctions.
|
| 137 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 138 |
+
# Sprint 34 : cer / wer / mer / wil + stub TEXT→ALTO
|
| 139 |
+
from picarones.measurements import builtin_metrics # noqa: F401
|
| 140 |
+
# Sprints 55-60 : métriques philologiques.
|
| 141 |
+
from picarones.measurements import abbreviations # noqa: F401
|
| 142 |
+
from picarones.measurements import early_modern_typography # noqa: F401
|
| 143 |
+
from picarones.measurements import modern_archives # noqa: F401
|
| 144 |
+
from picarones.measurements import mufi # noqa: F401
|
| 145 |
+
from picarones.measurements import roman_numerals # noqa: F401
|
| 146 |
+
from picarones.measurements import unicode_blocks # noqa: F401
|
| 147 |
+
# Sprint 53 : reading order F1. Sprints 38, 52 : NER, readability.
|
| 148 |
+
from picarones.measurements import ner # noqa: F401
|
| 149 |
+
from picarones.measurements import readability # noqa: F401
|
| 150 |
+
from picarones.measurements import reading_order # noqa: F401
|
| 151 |
+
# Chantier 1 (post-Sprint 97) : métriques (ALTO, ALTO) pour évaluer
|
| 152 |
+
# les reconstructeurs ALTO contre une GT ALTO du document.
|
| 153 |
+
from picarones.measurements import alto_metrics # noqa: F401
|
|
@@ -190,13 +190,6 @@ def difficulty_label(score: float) -> str:
|
|
| 190 |
return "Très difficile"
|
| 191 |
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
if score < 0.25:
|
| 197 |
-
return COLOR_GREEN
|
| 198 |
-
if score < 0.50:
|
| 199 |
-
return COLOR_YELLOW
|
| 200 |
-
if score < 0.75:
|
| 201 |
-
return COLOR_ORANGE
|
| 202 |
-
return COLOR_RED
|
|
|
|
| 190 |
return "Très difficile"
|
| 191 |
|
| 192 |
|
| 193 |
+
# Sprint A3 (B-2) : ``difficulty_color`` a été déplacée dans
|
| 194 |
+
# :mod:`picarones.report.difficulty_render` pour respecter la règle
|
| 195 |
+
# Cercle 2 → Cercle 1 uniquement. Ce module reste purement numérique.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -83,6 +83,11 @@ _FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
|
|
| 83 |
# caractérisant la tendance : l'écart courant est-il une
|
| 84 |
# dégradation graduelle, une rupture brutale, ou un bruit ?
|
| 85 |
FactType.REGRESSION_IN_HISTORY,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
)
|
| 87 |
|
| 88 |
|
|
|
|
| 83 |
# caractérisant la tendance : l'écart courant est-il une
|
| 84 |
# dégradation graduelle, une rupture brutale, ou un bruit ?
|
| 85 |
FactType.REGRESSION_IN_HISTORY,
|
| 86 |
+
# Sprint A3 — priority 180, en queue. Les incidents d'importer
|
| 87 |
+
# sont contextuels à l'acquisition de données (non au ranking) ;
|
| 88 |
+
# ils viennent en toute fin de synthèse comme avertissement sur
|
| 89 |
+
# la qualité du corpus.
|
| 90 |
+
FactType.IMPORTER_FALLBACK_TRIGGERED,
|
| 91 |
)
|
| 92 |
|
| 93 |
|
|
@@ -61,6 +61,7 @@ from picarones.measurements.narrative.detectors.quality import (
|
|
| 61 |
from picarones.measurements.narrative.detectors.history import (
|
| 62 |
detect_engine_off_baseline,
|
| 63 |
detect_engine_unstable,
|
|
|
|
| 64 |
detect_regression_in_history,
|
| 65 |
)
|
| 66 |
from picarones.measurements.narrative.detectors.ensemble import (
|
|
@@ -120,6 +121,7 @@ __all__ = [
|
|
| 120 |
# history
|
| 121 |
"detect_engine_off_baseline",
|
| 122 |
"detect_engine_unstable",
|
|
|
|
| 123 |
"detect_regression_in_history",
|
| 124 |
# ensemble
|
| 125 |
"detect_ensemble_opportunity",
|
|
|
|
| 61 |
from picarones.measurements.narrative.detectors.history import (
|
| 62 |
detect_engine_off_baseline,
|
| 63 |
detect_engine_unstable,
|
| 64 |
+
detect_importer_fallback,
|
| 65 |
detect_regression_in_history,
|
| 66 |
)
|
| 67 |
from picarones.measurements.narrative.detectors.ensemble import (
|
|
|
|
| 121 |
# history
|
| 122 |
"detect_engine_off_baseline",
|
| 123 |
"detect_engine_unstable",
|
| 124 |
+
"detect_importer_fallback",
|
| 125 |
"detect_regression_in_history",
|
| 126 |
# ensemble
|
| 127 |
"detect_ensemble_opportunity",
|
|
@@ -271,3 +271,67 @@ def detect_regression_in_history(benchmark_data: dict) -> list[Fact]:
|
|
| 271 |
engines_involved=(engine,),
|
| 272 |
))
|
| 273 |
return facts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
engines_involved=(engine,),
|
| 272 |
))
|
| 273 |
return facts
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ---------------------------------------------------------------------------
|
| 277 |
+
# Sprint A3 (item B-3) — détecteur IMPORTER_FALLBACK_TRIGGERED
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@register_detector(
|
| 282 |
+
FactType.IMPORTER_FALLBACK_TRIGGERED,
|
| 283 |
+
# Priorité 180 — en queue, après les détecteurs de tendance historique.
|
| 284 |
+
# L'incident d'importer est *informationnel sur l'acquisition*, pas
|
| 285 |
+
# sur le ranking ou la performance d'un moteur — il vient logiquement
|
| 286 |
+
# après tout le reste de la synthèse.
|
| 287 |
+
priority=180,
|
| 288 |
+
importance=FactImportance.MEDIUM,
|
| 289 |
+
)
|
| 290 |
+
def detect_importer_fallback(benchmark_data: dict) -> list[Fact]:
|
| 291 |
+
"""Émet un Fact par incident d'importer en mode dégradé.
|
| 292 |
+
|
| 293 |
+
Lit ``benchmark_data["importer_fallbacks"]`` (liste de dicts
|
| 294 |
+
produite par ``picarones.extras.importers.consume_fallback_log()``).
|
| 295 |
+
Si la clé est absente ou vide, le détecteur reste silencieux —
|
| 296 |
+
typiquement le cas pour un benchmark qui n'utilise pas d'importer
|
| 297 |
+
distant (corpus local).
|
| 298 |
+
|
| 299 |
+
Importance HIGH si **plusieurs incidents** sur le même importer
|
| 300 |
+
(signal d'une indisponibilité prolongée plutôt qu'un échec
|
| 301 |
+
isolé) ; MEDIUM sinon.
|
| 302 |
+
"""
|
| 303 |
+
fallbacks = benchmark_data.get("importer_fallbacks") or []
|
| 304 |
+
if not fallbacks:
|
| 305 |
+
return []
|
| 306 |
+
|
| 307 |
+
# Compte par importer pour détecter les incidents répétés.
|
| 308 |
+
counts: dict[str, int] = {}
|
| 309 |
+
for entry in fallbacks:
|
| 310 |
+
if isinstance(entry, dict):
|
| 311 |
+
counts[str(entry.get("importer", "unknown"))] = (
|
| 312 |
+
counts.get(str(entry.get("importer", "unknown")), 0) + 1
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
facts: list[Fact] = []
|
| 316 |
+
for entry in fallbacks:
|
| 317 |
+
if not isinstance(entry, dict):
|
| 318 |
+
continue
|
| 319 |
+
importer = str(entry.get("importer", "unknown"))
|
| 320 |
+
operation = str(entry.get("operation", "unknown"))
|
| 321 |
+
importance = (
|
| 322 |
+
FactImportance.HIGH if counts.get(importer, 0) >= 2 else FactImportance.MEDIUM
|
| 323 |
+
)
|
| 324 |
+
payload: dict = {
|
| 325 |
+
"importer": importer,
|
| 326 |
+
"operation": operation,
|
| 327 |
+
"incidents_for_importer": counts.get(importer, 1),
|
| 328 |
+
}
|
| 329 |
+
if entry.get("error"):
|
| 330 |
+
payload["error_repr"] = str(entry["error"])
|
| 331 |
+
facts.append(Fact(
|
| 332 |
+
type=FactType.IMPORTER_FALLBACK_TRIGGERED,
|
| 333 |
+
importance=importance,
|
| 334 |
+
payload=payload,
|
| 335 |
+
engines_involved=(),
|
| 336 |
+
))
|
| 337 |
+
return facts
|
|
@@ -94,3 +94,11 @@ regression_in_history: >-
|
|
| 94 |
moved from {first_cer_pct} % to {last_cer_pct} %
|
| 95 |
(cumulative change {absolute_delta_pct} points). Investigate what
|
| 96 |
changed in the pipeline or the models.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
moved from {first_cer_pct} % to {last_cer_pct} %
|
| 95 |
(cumulative change {absolute_delta_pct} points). Investigate what
|
| 96 |
changed in the pipeline or the models.
|
| 97 |
+
|
| 98 |
+
# Sprint A3 (item B-3) — importer fallback incidents.
|
| 99 |
+
# The payload contains `importer`, `operation` and `incidents_for_importer`.
|
| 100 |
+
importer_fallback_triggered: >-
|
| 101 |
+
The "{importer}" importer fell back to degraded mode during the
|
| 102 |
+
"{operation}" operation ({incidents_for_importer} incident(s) this
|
| 103 |
+
run). Imported data may be incomplete or from a fallback — check
|
| 104 |
+
the logs for details.
|
|
@@ -99,3 +99,11 @@ regression_in_history: >-
|
|
| 99 |
est passé de {first_cer_pct} % à {last_cer_pct} %
|
| 100 |
(variation cumulée {absolute_delta_pct} points). Vérifier ce qui
|
| 101 |
a changé dans le pipeline ou les modèles.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
est passé de {first_cer_pct} % à {last_cer_pct} %
|
| 100 |
(variation cumulée {absolute_delta_pct} points). Vérifier ce qui
|
| 101 |
a changé dans le pipeline ou les modèles.
|
| 102 |
+
|
| 103 |
+
# Sprint A3 (item B-3) — incidents d'importer en mode dégradé.
|
| 104 |
+
# Le payload contient `importer`, `operation` et `incidents_for_importer`.
|
| 105 |
+
importer_fallback_triggered: >-
|
| 106 |
+
L'importer « {importer} » a basculé en mode dégradé pendant l'opération
|
| 107 |
+
« {operation} » ({incidents_for_importer} incident·s sur ce run). Les
|
| 108 |
+
données importées peuvent être incomplètes ou issues d'un fallback —
|
| 109 |
+
consulter les logs pour le détail.
|
|
@@ -858,7 +858,8 @@ _ERROR_PATTERNS = [
|
|
| 858 |
|
| 859 |
def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]:
|
| 860 |
"""Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution."""
|
| 861 |
-
|
|
|
|
| 862 |
ops = compute_word_diff(gt, hyp)
|
| 863 |
pairs = []
|
| 864 |
for op in ops:
|
|
|
|
| 858 |
|
| 859 |
def _extract_error_pairs(gt: str, hyp: str) -> list[tuple[str, str]]:
|
| 860 |
"""Extrait les paires (gt_char_seq, hyp_char_seq) d'erreurs de substitution."""
|
| 861 |
+
# Sprint A3 (B-1) : import depuis Cercle 1, plus de violation Cercle 2→3.
|
| 862 |
+
from picarones.core.diff_utils import compute_word_diff
|
| 863 |
ops = compute_word_diff(gt, hyp)
|
| 864 |
pairs = []
|
| 865 |
for op in ops:
|
|
@@ -1,89 +1,26 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
-
|
| 8 |
-
{"op": "equal", "text": "mot"}
|
| 9 |
-
{"op": "insert", "text": "mot"} -- présent dans l'OCR mais pas dans la GT
|
| 10 |
-
{"op": "delete", "text": "mot"} -- présent dans la GT mais pas dans l'OCR
|
| 11 |
-
{"op": "replace", "old": "…", "new": "…"} -- substitution (orange)
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
-
import
|
| 17 |
-
import re
|
| 18 |
-
from typing import Any
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def compute_word_diff(reference: str, hypothesis: str) -> list[dict[str, Any]]:
|
| 28 |
-
"""Calcule un diff mot-à-mot entre deux textes.
|
| 29 |
-
|
| 30 |
-
Parameters
|
| 31 |
-
----------
|
| 32 |
-
reference:
|
| 33 |
-
Texte de vérité terrain.
|
| 34 |
-
hypothesis:
|
| 35 |
-
Texte produit par le moteur OCR.
|
| 36 |
-
|
| 37 |
-
Returns
|
| 38 |
-
-------
|
| 39 |
-
list of dict
|
| 40 |
-
Séquence d'opérations : equal, insert, delete, replace.
|
| 41 |
-
"""
|
| 42 |
-
ref_tokens = reference.split()
|
| 43 |
-
hyp_tokens = hypothesis.split()
|
| 44 |
-
|
| 45 |
-
matcher = difflib.SequenceMatcher(None, ref_tokens, hyp_tokens, autojunk=False)
|
| 46 |
-
ops: list[dict[str, Any]] = []
|
| 47 |
-
|
| 48 |
-
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 49 |
-
ref_chunk = " ".join(ref_tokens[i1:i2])
|
| 50 |
-
hyp_chunk = " ".join(hyp_tokens[j1:j2])
|
| 51 |
-
|
| 52 |
-
if tag == "equal":
|
| 53 |
-
ops.append({"op": "equal", "text": ref_chunk})
|
| 54 |
-
elif tag == "insert":
|
| 55 |
-
ops.append({"op": "insert", "text": hyp_chunk})
|
| 56 |
-
elif tag == "delete":
|
| 57 |
-
ops.append({"op": "delete", "text": ref_chunk})
|
| 58 |
-
elif tag == "replace":
|
| 59 |
-
ops.append({"op": "replace", "old": ref_chunk, "new": hyp_chunk})
|
| 60 |
-
|
| 61 |
-
return ops
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def compute_char_diff(reference: str, hypothesis: str) -> list[dict[str, Any]]:
|
| 65 |
-
"""Diff caractère par caractère — utile pour les tokens courts."""
|
| 66 |
-
matcher = difflib.SequenceMatcher(None, list(reference), list(hypothesis), autojunk=False)
|
| 67 |
-
ops: list[dict[str, Any]] = []
|
| 68 |
-
|
| 69 |
-
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 70 |
-
ref_chunk = reference[i1:i2]
|
| 71 |
-
hyp_chunk = hypothesis[j1:j2]
|
| 72 |
-
if tag == "equal":
|
| 73 |
-
ops.append({"op": "equal", "text": ref_chunk})
|
| 74 |
-
elif tag == "insert":
|
| 75 |
-
ops.append({"op": "insert", "text": hyp_chunk})
|
| 76 |
-
elif tag == "delete":
|
| 77 |
-
ops.append({"op": "delete", "text": ref_chunk})
|
| 78 |
-
elif tag == "replace":
|
| 79 |
-
ops.append({"op": "replace", "old": ref_chunk, "new": hyp_chunk})
|
| 80 |
-
|
| 81 |
-
return ops
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def diff_stats(ops: list[dict[str, Any]]) -> dict[str, int]:
|
| 85 |
-
"""Compte le nombre d'insertions, suppressions et substitutions."""
|
| 86 |
-
stats = {"equal": 0, "insert": 0, "delete": 0, "replace": 0}
|
| 87 |
-
for op in ops:
|
| 88 |
-
stats[op["op"]] += 1
|
| 89 |
-
return stats
|
|
|
|
| 1 |
+
"""Ré-export rétrocompat — la canonique est :mod:`picarones.core.diff_utils`.
|
| 2 |
|
| 3 |
+
Sprint A3 (item B-1 de l'audit institutional-readiness-2026-05) :
|
| 4 |
+
``compute_word_diff`` et consorts ont été déplacés dans Cercle 1 pour
|
| 5 |
+
respecter la règle de dépendance (Cercle 2 → Cercle 1 uniquement).
|
| 6 |
|
| 7 |
+
Ce module reste pour les consommateurs externes existants (scripts,
|
| 8 |
+
notebooks, plug-ins). Suppression planifiée v1.3.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
+
import warnings as _warnings
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
from picarones.core.diff_utils import ( # noqa: F401
|
| 16 |
+
compute_char_diff,
|
| 17 |
+
compute_word_diff,
|
| 18 |
+
diff_stats,
|
| 19 |
+
)
|
| 20 |
|
| 21 |
+
_warnings.warn(
|
| 22 |
+
"picarones.report.diff_utils est déprécié — utiliser "
|
| 23 |
+
"picarones.core.diff_utils. Ce ré-export sera retiré en v1.3.0.",
|
| 24 |
+
DeprecationWarning,
|
| 25 |
+
stacklevel=2,
|
| 26 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers de rendu pour le score de difficulté intrinsèque.
|
| 2 |
+
|
| 3 |
+
Sprint A3 (item B-2 de l'audit institutional-readiness-2026-05) :
|
| 4 |
+
``difficulty_color`` vivait précédemment dans
|
| 5 |
+
``picarones/measurements/difficulty.py`` et y violait la règle
|
| 6 |
+
Cercle 2 → Cercle 3 par un import paresseux de
|
| 7 |
+
``picarones.report.colors``. La fonction est désormais placée à sa
|
| 8 |
+
juste place — Cercle 3, à côté de la palette qu'elle consomme — et
|
| 9 |
+
``measurements/difficulty.py`` ne contient plus que de la logique
|
| 10 |
+
purement numérique.
|
| 11 |
+
|
| 12 |
+
Le module pur ``picarones.measurements.difficulty`` reste utilisable
|
| 13 |
+
sans dépendance vers ``picarones.report``.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from picarones.report.colors import (
|
| 19 |
+
COLOR_GREEN,
|
| 20 |
+
COLOR_ORANGE,
|
| 21 |
+
COLOR_RED,
|
| 22 |
+
COLOR_YELLOW,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def difficulty_color(score: float) -> str:
|
| 27 |
+
"""Retourne une couleur CSS pour un score de difficulté ∈ [0, 1].
|
| 28 |
+
|
| 29 |
+
Convention :
|
| 30 |
+
|
| 31 |
+
- score < 0.25 → vert (« facile »)
|
| 32 |
+
- score < 0.50 → jaune (« modéré »)
|
| 33 |
+
- score < 0.75 → orange (« difficile »)
|
| 34 |
+
- score ≥ 0.75 → rouge (« très difficile »)
|
| 35 |
+
|
| 36 |
+
Le label texte correspondant est produit par
|
| 37 |
+
:func:`picarones.measurements.difficulty.difficulty_label`.
|
| 38 |
+
"""
|
| 39 |
+
if score < 0.25:
|
| 40 |
+
return COLOR_GREEN
|
| 41 |
+
if score < 0.50:
|
| 42 |
+
return COLOR_YELLOW
|
| 43 |
+
if score < 0.75:
|
| 44 |
+
return COLOR_ORANGE
|
| 45 |
+
return COLOR_RED
|
|
@@ -36,7 +36,7 @@ def _load_vendor_js(name: str) -> str:
|
|
| 36 |
return f"/* vendor/{name} non trouvé */"
|
| 37 |
|
| 38 |
from picarones.core.results import BenchmarkResult
|
| 39 |
-
from picarones.
|
| 40 |
from picarones.measurements.statistics import (
|
| 41 |
compute_pairwise_stats,
|
| 42 |
compute_reliability_curve,
|
|
|
|
| 36 |
return f"/* vendor/{name} non trouvé */"
|
| 37 |
|
| 38 |
from picarones.core.results import BenchmarkResult
|
| 39 |
+
from picarones.core.diff_utils import compute_char_diff, compute_word_diff
|
| 40 |
from picarones.measurements.statistics import (
|
| 41 |
compute_pairwise_stats,
|
| 42 |
compute_reliability_curve,
|
|
@@ -19,7 +19,7 @@ from html import escape as _e
|
|
| 19 |
from typing import Optional
|
| 20 |
|
| 21 |
from picarones.measurements.worst_lines import WorstLineEntry
|
| 22 |
-
from picarones.
|
| 23 |
|
| 24 |
|
| 25 |
def _color_for_cer(cer: float) -> str:
|
|
|
|
| 19 |
from typing import Optional
|
| 20 |
|
| 21 |
from picarones.measurements.worst_lines import WorstLineEntry
|
| 22 |
+
from picarones.core.diff_utils import compute_char_diff
|
| 23 |
|
| 24 |
|
| 25 |
def _color_for_cer(cer: float) -> str:
|
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Garde-fou architectural — direction des dépendances entre cercles.
|
| 2 |
+
|
| 3 |
+
Sprint A3 du plan de remédiation institutionnelle (renforce B-1, B-2,
|
| 4 |
+
B-3 contre toute régression future).
|
| 5 |
+
|
| 6 |
+
L'architecture en 3 cercles documentée dans
|
| 7 |
+
:doc:`docs/architecture.md` impose que les imports aillent **uniquement**
|
| 8 |
+
de l'extérieur vers l'intérieur :
|
| 9 |
+
|
| 10 |
+
::
|
| 11 |
+
|
| 12 |
+
Cercle 3 (extras, report, cli, web)
|
| 13 |
+
│
|
| 14 |
+
▼
|
| 15 |
+
Cercle 2 (measurements, engines, llm, pipelines, modules)
|
| 16 |
+
│
|
| 17 |
+
▼
|
| 18 |
+
Cercle 1 (core)
|
| 19 |
+
|
| 20 |
+
Ce module parse l'AST de tous les fichiers ``.py`` dans Cercles 1 et 2
|
| 21 |
+
et **échoue** dès qu'un import remontant vers un cercle plus extérieur
|
| 22 |
+
est détecté. Le test couvre :
|
| 23 |
+
|
| 24 |
+
- Imports top-level (``from picarones.report import …``).
|
| 25 |
+
- Imports paresseux à l'intérieur des fonctions (le piège classique
|
| 26 |
+
qui a permis la naissance de B-1 et B-2).
|
| 27 |
+
- ``import picarones.report.X`` au format module (en plus de
|
| 28 |
+
``from picarones.report.X import ...``).
|
| 29 |
+
|
| 30 |
+
Mécanismes d'exception : aucun. Toute violation doit être corrigée en
|
| 31 |
+
remontant le code à un cercle approprié, **pas** ajoutée à une
|
| 32 |
+
liste d'exceptions.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import ast
|
| 38 |
+
from collections.abc import Iterator
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
import pytest
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 45 |
+
PICARONES_ROOT = REPO_ROOT / "picarones"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Cartographie des cercles
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
#: Modules de Cercle 1 (abstractions pures).
|
| 53 |
+
CIRCLE_1_PREFIXES: frozenset[str] = frozenset({"picarones.core"})
|
| 54 |
+
|
| 55 |
+
#: Modules de Cercle 2 (logique métier).
|
| 56 |
+
CIRCLE_2_PREFIXES: frozenset[str] = frozenset(
|
| 57 |
+
{
|
| 58 |
+
"picarones.measurements",
|
| 59 |
+
"picarones.engines",
|
| 60 |
+
"picarones.llm",
|
| 61 |
+
"picarones.pipelines",
|
| 62 |
+
"picarones.modules",
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
#: Modules de Cercle 3 (entrées, plugins, rendu).
|
| 67 |
+
CIRCLE_3_PREFIXES: frozenset[str] = frozenset(
|
| 68 |
+
{
|
| 69 |
+
"picarones.report",
|
| 70 |
+
"picarones.cli",
|
| 71 |
+
"picarones.web",
|
| 72 |
+
"picarones.extras",
|
| 73 |
+
}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _circle_of(module_dotted: str) -> int:
|
| 78 |
+
"""Retourne 1, 2, 3 ou 0 (hors-package) pour un nom de module."""
|
| 79 |
+
if not module_dotted.startswith("picarones"):
|
| 80 |
+
return 0
|
| 81 |
+
if any(module_dotted == p or module_dotted.startswith(p + ".") for p in CIRCLE_1_PREFIXES):
|
| 82 |
+
return 1
|
| 83 |
+
if any(module_dotted == p or module_dotted.startswith(p + ".") for p in CIRCLE_2_PREFIXES):
|
| 84 |
+
return 2
|
| 85 |
+
if any(module_dotted == p or module_dotted.startswith(p + ".") for p in CIRCLE_3_PREFIXES):
|
| 86 |
+
return 3
|
| 87 |
+
return 0
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _file_to_module(path: Path) -> str:
|
| 91 |
+
"""Convertit ``picarones/measurements/runner.py`` en
|
| 92 |
+
``picarones.measurements.runner``."""
|
| 93 |
+
rel = path.relative_to(REPO_ROOT)
|
| 94 |
+
parts = rel.with_suffix("").parts
|
| 95 |
+
# Supprime ``__init__`` final
|
| 96 |
+
if parts and parts[-1] == "__init__":
|
| 97 |
+
parts = parts[:-1]
|
| 98 |
+
return ".".join(parts)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
# Extraction des imports via AST
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _walk_imports(source: str) -> Iterator[tuple[str, int]]:
|
| 107 |
+
"""Yield ``(module_dotted, lineno)`` pour chaque import du fichier,
|
| 108 |
+
qu'il soit top-level ou paresseux dans une fonction.
|
| 109 |
+
|
| 110 |
+
Capture :
|
| 111 |
+
|
| 112 |
+
- ``import picarones.report.X`` → ``picarones.report.X``
|
| 113 |
+
- ``from picarones.report.X import Y`` → ``picarones.report.X``
|
| 114 |
+
- ``from picarones.report import X`` → ``picarones.report.X`` (Y ignoré
|
| 115 |
+
pour la classification de cercle, mais le préfixe importe).
|
| 116 |
+
"""
|
| 117 |
+
tree = ast.parse(source)
|
| 118 |
+
for node in ast.walk(tree):
|
| 119 |
+
if isinstance(node, ast.Import):
|
| 120 |
+
for alias in node.names:
|
| 121 |
+
yield alias.name, node.lineno
|
| 122 |
+
elif isinstance(node, ast.ImportFrom):
|
| 123 |
+
if node.level != 0:
|
| 124 |
+
# Imports relatifs ne franchissent jamais de cercle.
|
| 125 |
+
continue
|
| 126 |
+
if node.module is None:
|
| 127 |
+
continue
|
| 128 |
+
yield node.module, node.lineno
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
# Collecte des fichiers à auditer
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _python_files_in(*subpaths: str) -> list[Path]:
|
| 137 |
+
out: list[Path] = []
|
| 138 |
+
for sub in subpaths:
|
| 139 |
+
d = PICARONES_ROOT / sub
|
| 140 |
+
if not d.exists():
|
| 141 |
+
continue
|
| 142 |
+
out.extend(p for p in d.rglob("*.py") if "__pycache__" not in p.parts)
|
| 143 |
+
return sorted(out)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
CIRCLE_1_FILES = _python_files_in("core")
|
| 147 |
+
CIRCLE_2_FILES = _python_files_in(
|
| 148 |
+
"measurements", "engines", "llm", "pipelines", "modules"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ---------------------------------------------------------------------------
|
| 153 |
+
# Tests
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@pytest.mark.parametrize("path", CIRCLE_1_FILES, ids=lambda p: str(p.relative_to(REPO_ROOT)))
|
| 158 |
+
def test_circle_1_no_outer_import(path: Path) -> None:
|
| 159 |
+
"""Aucun fichier de Cercle 1 ne doit importer Cercle 2 ou 3."""
|
| 160 |
+
source = path.read_text(encoding="utf-8")
|
| 161 |
+
own_module = _file_to_module(path)
|
| 162 |
+
violations: list[tuple[str, int]] = []
|
| 163 |
+
for imported, lineno in _walk_imports(source):
|
| 164 |
+
# Ignorer les imports vers le module lui-même
|
| 165 |
+
if imported == own_module:
|
| 166 |
+
continue
|
| 167 |
+
circle = _circle_of(imported)
|
| 168 |
+
if circle in (2, 3):
|
| 169 |
+
violations.append((imported, lineno))
|
| 170 |
+
assert not violations, (
|
| 171 |
+
f"{path.relative_to(REPO_ROOT)} (Cercle 1) importe vers un cercle "
|
| 172 |
+
f"plus extérieur — violation de la règle d'architecture :\n"
|
| 173 |
+
+ "\n".join(f" ligne {ln}: import {mod}" for mod, ln in violations)
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@pytest.mark.parametrize("path", CIRCLE_2_FILES, ids=lambda p: str(p.relative_to(REPO_ROOT)))
|
| 178 |
+
def test_circle_2_no_outer_import(path: Path) -> None:
|
| 179 |
+
"""Aucun fichier de Cercle 2 ne doit importer Cercle 3.
|
| 180 |
+
|
| 181 |
+
Cercle 2 → Cercle 1 reste autorisé (et même attendu pour les
|
| 182 |
+
abstractions partagées). Cercle 2 → Cercle 2 (entre sous-packages
|
| 183 |
+
measurements/engines/llm/…) est aussi autorisé."""
|
| 184 |
+
source = path.read_text(encoding="utf-8")
|
| 185 |
+
own_module = _file_to_module(path)
|
| 186 |
+
violations: list[tuple[str, int]] = []
|
| 187 |
+
for imported, lineno in _walk_imports(source):
|
| 188 |
+
if imported == own_module:
|
| 189 |
+
continue
|
| 190 |
+
circle = _circle_of(imported)
|
| 191 |
+
if circle == 3:
|
| 192 |
+
violations.append((imported, lineno))
|
| 193 |
+
assert not violations, (
|
| 194 |
+
f"{path.relative_to(REPO_ROOT)} (Cercle 2) importe vers Cercle 3 — "
|
| 195 |
+
f"violation de la règle d'architecture :\n"
|
| 196 |
+
+ "\n".join(f" ligne {ln}: import {mod}" for mod, ln in violations)
|
| 197 |
+
+ "\n\nFix: déplacer la logique réutilisable dans Cercle 1, "
|
| 198 |
+
"ou refactorer pour que la dépendance s'inverse."
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def test_no_circle_1_file_imports_circle_3() -> None:
|
| 203 |
+
"""Méta-test : énumère explicitement les violations Cercle 1 → 3.
|
| 204 |
+
|
| 205 |
+
Permet d'avoir un seul échec global lisible si la regex de
|
| 206 |
+
parametrize masque le compte total."""
|
| 207 |
+
total_violations: list[str] = []
|
| 208 |
+
for path in CIRCLE_1_FILES:
|
| 209 |
+
source = path.read_text(encoding="utf-8")
|
| 210 |
+
for imported, lineno in _walk_imports(source):
|
| 211 |
+
if _circle_of(imported) in (2, 3):
|
| 212 |
+
total_violations.append(
|
| 213 |
+
f"{path.relative_to(REPO_ROOT)}:{lineno} → {imported}"
|
| 214 |
+
)
|
| 215 |
+
assert not total_violations, (
|
| 216 |
+
f"{len(total_violations)} violation(s) totales Cercle 1 → extérieur :\n"
|
| 217 |
+
+ "\n".join(total_violations)
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def test_no_circle_2_file_imports_circle_3() -> None:
|
| 222 |
+
"""Méta-test : énumère explicitement les violations Cercle 2 → 3."""
|
| 223 |
+
total_violations: list[str] = []
|
| 224 |
+
for path in CIRCLE_2_FILES:
|
| 225 |
+
source = path.read_text(encoding="utf-8")
|
| 226 |
+
for imported, lineno in _walk_imports(source):
|
| 227 |
+
if _circle_of(imported) == 3:
|
| 228 |
+
total_violations.append(
|
| 229 |
+
f"{path.relative_to(REPO_ROOT)}:{lineno} → {imported}"
|
| 230 |
+
)
|
| 231 |
+
assert not total_violations, (
|
| 232 |
+
f"{len(total_violations)} violation(s) totales Cercle 2 → 3 :\n"
|
| 233 |
+
+ "\n".join(total_violations)
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ---------------------------------------------------------------------------
|
| 238 |
+
# Sanité
|
| 239 |
+
# ---------------------------------------------------------------------------
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_circles_are_not_empty() -> None:
|
| 243 |
+
"""Pré-requis : les listes de fichiers ne doivent pas être vides
|
| 244 |
+
(sinon les paramétrisations ne couvrent rien)."""
|
| 245 |
+
assert CIRCLE_1_FILES, "Cercle 1 vide — chemin core/ introuvable."
|
| 246 |
+
assert CIRCLE_2_FILES, "Cercle 2 vide — au moins un sous-package attendu."
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def test_circle_classification_examples() -> None:
|
| 250 |
+
"""Tests d'auto-validation de ``_circle_of``."""
|
| 251 |
+
assert _circle_of("picarones.core.corpus") == 1
|
| 252 |
+
assert _circle_of("picarones.core.diff_utils") == 1
|
| 253 |
+
assert _circle_of("picarones.measurements.runner") == 2
|
| 254 |
+
assert _circle_of("picarones.engines.tesseract") == 2
|
| 255 |
+
assert _circle_of("picarones.report.generator") == 3
|
| 256 |
+
assert _circle_of("picarones.cli") == 3
|
| 257 |
+
assert _circle_of("picarones.web.app") == 3
|
| 258 |
+
assert _circle_of("picarones.extras.importers.huggingface") == 3
|
| 259 |
+
assert _circle_of("numpy") == 0
|
| 260 |
+
assert _circle_of("picarones") == 0 # le package racine lui-même
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
"""Tests pour picarones.
|
| 2 |
|
| 3 |
-
from picarones.
|
| 4 |
|
| 5 |
|
| 6 |
class TestComputeWordDiff:
|
|
|
|
| 1 |
+
"""Tests pour picarones.core.diff_utils (déplacé depuis report/ en Sprint A3, B-1)."""
|
| 2 |
|
| 3 |
+
from picarones.core.diff_utils import compute_word_diff, compute_char_diff, diff_stats
|
| 4 |
|
| 5 |
|
| 6 |
class TestComputeWordDiff:
|
|
@@ -48,9 +48,11 @@ class TestDetectorsPackage:
|
|
| 48 |
"detect_engine_unstable",
|
| 49 |
"detect_regression_in_history",
|
| 50 |
"detect_ensemble_opportunity",
|
|
|
|
|
|
|
| 51 |
])
|
| 52 |
-
def
|
| 53 |
-
"""Rétrocompat : les
|
| 54 |
comme avant le chantier 5 (tests Sprints 20, 23, 29, 36, 44, 46, 73)."""
|
| 55 |
from picarones.measurements.narrative import detectors
|
| 56 |
assert hasattr(detectors, name), f"{name} disparu après chantier 5"
|
|
@@ -59,8 +61,10 @@ class TestDetectorsPackage:
|
|
| 59 |
def test_DETECTORS_BY_TYPE_still_exposed(self):
|
| 60 |
from picarones.measurements.narrative.detectors import DETECTORS_BY_TYPE
|
| 61 |
assert isinstance(DETECTORS_BY_TYPE, dict)
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
)
|
| 65 |
|
| 66 |
def test_register_default_detectors_still_callable(self):
|
|
@@ -72,7 +76,8 @@ class TestDetectorsPackage:
|
|
| 72 |
("pareto", 2),
|
| 73 |
("stratum", 3),
|
| 74 |
("quality", 4),
|
| 75 |
-
|
|
|
|
| 76 |
("ensemble", 1),
|
| 77 |
])
|
| 78 |
def test_submodules_have_expected_detector_count(self, submodule, detector_count):
|
|
|
|
| 48 |
"detect_engine_unstable",
|
| 49 |
"detect_regression_in_history",
|
| 50 |
"detect_ensemble_opportunity",
|
| 51 |
+
# Sprint A3 — détecteur d'incidents d'importer en mode dégradé.
|
| 52 |
+
"detect_importer_fallback",
|
| 53 |
])
|
| 54 |
+
def test_all_19_detectors_importable_from_root(self, name):
|
| 55 |
+
"""Rétrocompat : les 19 détecteurs (18 historiques + Sprint A3) s'importent depuis le package
|
| 56 |
comme avant le chantier 5 (tests Sprints 20, 23, 29, 36, 44, 46, 73)."""
|
| 57 |
from picarones.measurements.narrative import detectors
|
| 58 |
assert hasattr(detectors, name), f"{name} disparu après chantier 5"
|
|
|
|
| 61 |
def test_DETECTORS_BY_TYPE_still_exposed(self):
|
| 62 |
from picarones.measurements.narrative.detectors import DETECTORS_BY_TYPE
|
| 63 |
assert isinstance(DETECTORS_BY_TYPE, dict)
|
| 64 |
+
# Sprint A3 — passage de 18 à 19 détecteurs (ajout
|
| 65 |
+
# IMPORTER_FALLBACK_TRIGGERED).
|
| 66 |
+
assert len(DETECTORS_BY_TYPE) == 19, (
|
| 67 |
+
f"DETECTORS_BY_TYPE doit contenir 19 entrées, en a {len(DETECTORS_BY_TYPE)}"
|
| 68 |
)
|
| 69 |
|
| 70 |
def test_register_default_detectors_still_callable(self):
|
|
|
|
| 76 |
("pareto", 2),
|
| 77 |
("stratum", 3),
|
| 78 |
("quality", 4),
|
| 79 |
+
# Sprint A3 — history passe de 3 à 4 (ajout detect_importer_fallback).
|
| 80 |
+
("history", 4),
|
| 81 |
("ensemble", 1),
|
| 82 |
])
|
| 83 |
def test_submodules_have_expected_detector_count(self, submodule, detector_count):
|
|
File without changes
|
|
File without changes
|