Claude commited on
Commit
fda1a60
·
unverified ·
1 Parent(s): 3bffe86

refactor(arch): Sprint A3 — refactor cercles + importers (B-1, B-2, B-3, m-17)

Browse files

Phase 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 CHANGED
@@ -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__",
picarones/core/diff_utils.py ADDED
@@ -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
picarones/core/facts.py CHANGED
@@ -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."""
picarones/core/pipeline.py CHANGED
@@ -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
- # Eager-load des modules qui enregistrent des métriques dans le
61
- # registre typé (Sprint 34) sans ces imports, ``compute_at_junction``
62
- # trouverait un registre vide et ne calculerait rien aux jonctions.
63
- # Sprint 34 : cer / wer / mer / wil + stub TEXT→ALTO
64
- import picarones.measurements.builtin_metrics # noqa: F401
65
- # Sprints 55-60 : métriques philologiques.
66
- import picarones.measurements.unicode_blocks # noqa: F401
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
 
picarones/extras/importers/__init__.py CHANGED
@@ -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
  ]
picarones/extras/importers/_fallback_log.py ADDED
@@ -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()
picarones/extras/importers/htr_united.py CHANGED
@@ -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
- pass
 
 
 
 
 
 
 
 
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
 
picarones/extras/importers/huggingface.py CHANGED
@@ -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
- pass
 
 
 
 
 
 
 
 
 
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
- pass
 
 
 
 
 
 
 
 
 
 
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")
picarones/measurements/__init__.py CHANGED
@@ -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
picarones/measurements/difficulty.py CHANGED
@@ -190,13 +190,6 @@ def difficulty_label(score: float) -> str:
190
  return "Très difficile"
191
 
192
 
193
- def difficulty_color(score: float) -> str:
194
- """Retourne une couleur CSS pour un score de difficulté."""
195
- from picarones.report.colors import COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_RED
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.
 
 
 
 
 
 
 
picarones/measurements/narrative/arbiter.py CHANGED
@@ -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
 
picarones/measurements/narrative/detectors/__init__.py CHANGED
@@ -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",
picarones/measurements/narrative/detectors/history.py CHANGED
@@ -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
picarones/measurements/narrative/templates/en.yaml CHANGED
@@ -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.
picarones/measurements/narrative/templates/fr.yaml CHANGED
@@ -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.
picarones/measurements/statistics.py CHANGED
@@ -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
- from picarones.report.diff_utils import compute_word_diff
 
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:
picarones/report/diff_utils.py CHANGED
@@ -1,89 +1,26 @@
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
 
1
+ """-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
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/report/difficulty_render.py ADDED
@@ -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
picarones/report/generator.py CHANGED
@@ -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.report.diff_utils import compute_char_diff, compute_word_diff
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,
picarones/report/worst_lines_render.py CHANGED
@@ -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.report.diff_utils import compute_char_diff
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:
tests/core/test_circle_dependencies.py ADDED
@@ -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
tests/{report → core}/test_diff_utils.py RENAMED
@@ -1,6 +1,6 @@
1
- """Tests pour picarones.report.diff_utils."""
2
 
3
- from picarones.report.diff_utils import compute_word_diff, compute_char_diff, diff_stats
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:
tests/integration/test_chantier5.py CHANGED
@@ -48,9 +48,11 @@ class TestDetectorsPackage:
48
  "detect_engine_unstable",
49
  "detect_regression_in_history",
50
  "detect_ensemble_opportunity",
 
 
51
  ])
52
- def test_all_18_detectors_importable_from_root(self, name):
53
- """Rétrocompat : les 18 détecteurs s'importent depuis le package
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
- assert len(DETECTORS_BY_TYPE) == 18, (
63
- f"DETECTORS_BY_TYPE doit contenir 18 entrées, en a {len(DETECTORS_BY_TYPE)}"
 
 
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
- ("history", 3),
 
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):
tests/{measurements → integration}/test_sprint11_i18n_english.py RENAMED
File without changes
tests/{measurements → integration}/test_sprint94_error_absorption.py RENAMED
File without changes