Spaces:
Sleeping
feat(migration): Phase 4-bis — diagnostic + aliases ArtifactType préparatoires
Browse filesTentative de migration coordonnée de l'ArtifactType (debloqueur
des 13 mesures legacy + 6 modules core/) — diagnostic posé,
exécution reportée à un sprint dédié.
Stratégie testée
================
Exploiter le mécanisme natif d'aliases d'Enum Python : un membre
avec la même valeur qu'un autre devient un alias. Ajout au
domain.artifacts.ArtifactType :
- TEXT = "raw_text" (alias de RAW_TEXT)
- ALTO = "alto_xml" (alias de ALTO_XML)
- PAGE = "page_xml" (alias de PAGE_XML)
Plus un hook _missing_ qui accepte les valeurs string legacy
(ArtifactType("text") retourne RAW_TEXT, etc.).
Création de domain/module_protocol.py avec BaseModule + ExecutionMode
canoniques (le rewrite a aussi ses Protocols spécialisés
BaseOCRAdapter/BaseLLMAdapter/BaseVLMAdapter dans adapters/ ;
BaseModule reste le contrat générique pour modules tiers).
Tentative reportée
==================
Transformer core/modules.py en shim qui ré-exporte ArtifactType +
BaseModule depuis le canonique a cassé 27 tests legacy (Sprint 63
pipeline runner, Sprint 65 pipeline comparison, Sprint 68 HTML).
Diagnostic
==========
Le legacy core.results.BenchmarkResult.junction_metrics est un
dict[str, dict] indexé par ArtifactType.value. Quand
core.modules.ArtifactType est passé au superset canonique,
ArtifactType.TEXT.value passe silencieusement de "text" à
"raw_text". Les dicts produits par le runner sont alors indexés
par "raw_text", mais les tests cherchent encore la clé "text".
Conséquence : la migration de core.modules.ArtifactType n'est PAS
un simple rename. Elle implique une coordination avec :
- core/results.py (BenchmarkResult — 30 champs agrégés indexés)
- core/pipeline.py (junction_metrics)
- measurements/runner/* (orchestrateur legacy)
- measurements/pipeline_benchmark.py + pipeline_comparison.py
Ces modules indexent des dicts par valeur string ArtifactType, un
couplage implicite invisible à l'audit initial.
Conservé en place
=================
- domain/artifacts.py : aliases TEXT/ALTO/PAGE + _missing_.
Inoffensif — aucun caller legacy ne les voit. Préparation pour
la session future qui complétera Phase 4-bis.
- domain/module_protocol.py : BaseModule canonique défini. Pas
encore consommé par les sous-classes legacy (qui continuent à
hériter de core.modules.BaseModule original).
Tracker mis à jour
==================
docs/migration/legacy-retirement-plan.md documente :
- la tentative et son revert,
- le diagnostic du couplage dicts-string,
- le plan rectifié pour Phase 4-bis (25-30 j vs 18-22 estimés —
le couplage n'avait pas été vu à l'audit).
Validation
==========
- pytest tests/ : 5019 passed (état pré-tentative restauré).
- pytest -m regression : 16 passed.
- Test architectural test_no_legacy_imports_in_rewrite : 3 passed.
- ruff check : clean.
Phase 5 (22 renderers HTML) reste possible en parallèle, indépendante
de Phase 4-bis. Ou poursuivre les phases plus petites
(Phase 6/7/8).
|
@@ -278,6 +278,59 @@ legacy reste vert. Les 13 modules réels + 6 modules `core/`
|
|
| 278 |
restants sont documentés comme dépendant d'une migration
|
| 279 |
ArtifactType.
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
### Phase 5 — Reports HTML (`report/`)
|
| 282 |
|
| 283 |
**Modules** :
|
|
@@ -465,7 +518,13 @@ mais le CER a glissé de 0,002 par doc »*.
|
|
| 465 |
| 2 | ✅ Terminée (8/8 modules statistics migrés) |
|
| 466 |
| 3 | ✅ Terminée (11 modules narrative + 2 templates + 18 détecteurs migrés) |
|
| 467 |
| 4 | ✅ Partielle (9 modules autonomes/cascade ; 13 modules + 6 modules `core/` + 1 sous-package → Phase 4-bis) |
|
| 468 |
-
| 4-bis |
|
| 469 |
| 5-11 | ⚪ À démarrer |
|
| 470 |
|
| 471 |
-
**Dernière mise à jour** : 2026-05 (Phase 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
restants sont documentés comme dépendant d'une migration
|
| 279 |
ArtifactType.
|
| 280 |
|
| 281 |
+
#### Tentative Phase 4-bis (avortée — diagnostic posé)
|
| 282 |
+
|
| 283 |
+
Une tentative de migration coordonnée de l'``ArtifactType`` a été
|
| 284 |
+
explorée puis revertée :
|
| 285 |
+
|
| 286 |
+
**Stratégie testée** : exploiter le mécanisme natif d'aliases
|
| 287 |
+
d'``Enum`` Python (un membre avec la même valeur qu'un autre devient
|
| 288 |
+
un alias). Ajout de ``TEXT = "raw_text"``, ``ALTO = "alto_xml"``,
|
| 289 |
+
``PAGE = "page_xml"`` à ``domain.artifacts.ArtifactType`` + hook
|
| 290 |
+
``_missing_`` pour accepter les valeurs string legacy. Puis
|
| 291 |
+
transformation de ``core/modules.py`` en shim qui ré-exporte
|
| 292 |
+
``ArtifactType`` et ``BaseModule`` depuis le canonique.
|
| 293 |
+
|
| 294 |
+
**Conservé en place** : les aliases + ``_missing_`` dans
|
| 295 |
+
``domain.artifacts.ArtifactType``. Inoffensif — aucun code legacy
|
| 296 |
+
ne les voit puisqu'aucun module legacy n'importe encore depuis le
|
| 297 |
+
canonique.
|
| 298 |
+
|
| 299 |
+
**Reverté** : le shim ``core/modules.py``. Cause : passer le
|
| 300 |
+
``core.modules.ArtifactType`` du legacy enum 6 valeurs au superset
|
| 301 |
+
canonique change silencieusement ``ArtifactType.TEXT.value`` de
|
| 302 |
+
``"text"`` à ``"raw_text"``. Or 27 tests legacy
|
| 303 |
+
(``test_sprint63_pipeline_runner``, ``test_sprint65_pipeline_comparison``,
|
| 304 |
+
``test_sprint68_pipeline_comparison_html`` etc.) reposent sur le
|
| 305 |
+
fait que les clés des dicts ``junction_metrics`` produites par le
|
| 306 |
+
runner legacy sont les valeurs string legacy. Quand le runner
|
| 307 |
+
utilise ``at.value`` pour stocker, il stocke maintenant ``"raw_text"``,
|
| 308 |
+
et les tests qui cherchaient ``junction_metrics["text"]`` cassent.
|
| 309 |
+
|
| 310 |
+
Le diagnostic est plus profond qu'un simple rename : le legacy
|
| 311 |
+
``BenchmarkResult.junction_metrics`` est un ``dict[str, dict]``
|
| 312 |
+
indexé par valeur string ; sa stabilité de format est implicitement
|
| 313 |
+
testée. Migrer ``core.modules.ArtifactType`` exige un travail
|
| 314 |
+
**par module** d'identification des dicts indexés par valeur
|
| 315 |
+
string, et soit (a) double-clé pour rétrocompat, (b) migration
|
| 316 |
+
ordonnée tests-en-même-temps.
|
| 317 |
+
|
| 318 |
+
**Plan rectifié pour Phase 4-bis** :
|
| 319 |
+
|
| 320 |
+
1. Lister exhaustivement les dicts indexés par ``ArtifactType.value``
|
| 321 |
+
dans le legacy (``core/results.py``, ``core/pipeline.py``,
|
| 322 |
+
``measurements/runner/``, ``measurements/pipeline_*``).
|
| 323 |
+
2. Décider la stratégie par module : double-clé pendant la
|
| 324 |
+
migration vs migration coordonnée tests + code.
|
| 325 |
+
3. Migrer un cluster à la fois en validant la suite après chaque.
|
| 326 |
+
|
| 327 |
+
**Effort rectifié** : 25-30 jours (vs 18-22 estimés initialement —
|
| 328 |
+
le couplage implicite des dicts indexés par valeur string n'avait
|
| 329 |
+
pas été vu à l'audit).
|
| 330 |
+
|
| 331 |
+
**Statut Phase 4-bis** : analyse posée, exécution reportée à un
|
| 332 |
+
sprint dédié de plusieurs sessions.
|
| 333 |
+
|
| 334 |
### Phase 5 — Reports HTML (`report/`)
|
| 335 |
|
| 336 |
**Modules** :
|
|
|
|
| 518 |
| 2 | ✅ Terminée (8/8 modules statistics migrés) |
|
| 519 |
| 3 | ✅ Terminée (11 modules narrative + 2 templates + 18 détecteurs migrés) |
|
| 520 |
| 4 | ✅ Partielle (9 modules autonomes/cascade ; 13 modules + 6 modules `core/` + 1 sous-package → Phase 4-bis) |
|
| 521 |
+
| 4-bis | 🟡 Diagnostic posé, exécution reportée (couplage dicts-string plus complexe que prévu — voir détail Phase 4) |
|
| 522 |
| 5-11 | ⚪ À démarrer |
|
| 523 |
|
| 524 |
+
**Dernière mise à jour** : 2026-05 (Phase 4-bis tentative + revert + diagnostic).
|
| 525 |
+
|
| 526 |
+
**Reste en place suite à la tentative Phase 4-bis** : aliases
|
| 527 |
+
``TEXT``/``ALTO``/``PAGE`` dans ``domain.artifacts.ArtifactType``
|
| 528 |
+
(inoffensif) + hook ``_missing_`` pour accepter les valeurs string
|
| 529 |
+
legacy. Préparation pour la session future qui complétera Phase
|
| 530 |
+
4-bis.
|
|
@@ -103,6 +103,36 @@ class ArtifactType(str, Enum):
|
|
| 103 |
#: reliability diagram).
|
| 104 |
CONFIDENCES = "confidences"
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
def compute_content_hash(payload: bytes) -> str:
|
| 108 |
"""SHA-256 hex (64 chars) d'un payload binaire.
|
|
|
|
| 103 |
#: reliability diagram).
|
| 104 |
CONFIDENCES = "confidences"
|
| 105 |
|
| 106 |
+
#: Aliases legacy pour rétrocompat avec ``picarones.core.modules``
|
| 107 |
+
#: (Phase 4-bis du retrait du legacy). Le mécanisme natif d'Enum
|
| 108 |
+
#: Python rend ces noms équivalents aux canoniques :
|
| 109 |
+
#:
|
| 110 |
+
#: >>> ArtifactType.TEXT is ArtifactType.RAW_TEXT
|
| 111 |
+
#: True
|
| 112 |
+
#:
|
| 113 |
+
#: Le mapping sémantique TEXT → RAW_TEXT est documenté dans
|
| 114 |
+
#: ``docs/migration/regression-tolerances.md``. À supprimer en 2.0
|
| 115 |
+
#: une fois tous les callers legacy retirés.
|
| 116 |
+
TEXT = "raw_text"
|
| 117 |
+
ALTO = "alto_xml"
|
| 118 |
+
PAGE = "page_xml"
|
| 119 |
+
|
| 120 |
+
@classmethod
|
| 121 |
+
def _missing_(cls, value):
|
| 122 |
+
"""Accepte les valeurs string legacy (``"text"``, ``"alto"``,
|
| 123 |
+
``"page"``) en plus des valeurs canoniques.
|
| 124 |
+
|
| 125 |
+
Ce hook est invoqué par ``ArtifactType("text")`` (lecture YAML
|
| 126 |
+
legacy par exemple) — sans lui, ``ValueError``. À supprimer
|
| 127 |
+
en 2.0 avec les aliases legacy ci-dessus.
|
| 128 |
+
"""
|
| 129 |
+
legacy_map = {
|
| 130 |
+
"text": cls.RAW_TEXT,
|
| 131 |
+
"alto": cls.ALTO_XML,
|
| 132 |
+
"page": cls.PAGE_XML,
|
| 133 |
+
}
|
| 134 |
+
return legacy_map.get(value)
|
| 135 |
+
|
| 136 |
|
| 137 |
def compute_content_hash(payload: bytes) -> str:
|
| 138 |
"""SHA-256 hex (64 chars) d'un payload binaire.
|
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``BaseModule`` — interface générique d'un module exécutable.
|
| 2 |
+
|
| 3 |
+
Un module est une **fonction typée** d'artefacts vers artefacts. Il
|
| 4 |
+
déclare ce qu'il consomme (``input_types``) et ce qu'il produit
|
| 5 |
+
(``output_types``), et expose une méthode ``process`` qui prend un
|
| 6 |
+
dictionnaire d'entrées et retourne un dictionnaire de sorties.
|
| 7 |
+
|
| 8 |
+
Usage minimal ::
|
| 9 |
+
|
| 10 |
+
class UpperCaseModule(BaseModule):
|
| 11 |
+
input_types = (ArtifactType.RAW_TEXT,)
|
| 12 |
+
output_types = (ArtifactType.RAW_TEXT,)
|
| 13 |
+
execution_mode = "cpu"
|
| 14 |
+
|
| 15 |
+
@property
|
| 16 |
+
def name(self) -> str:
|
| 17 |
+
return "uppercase"
|
| 18 |
+
|
| 19 |
+
def process(self, inputs):
|
| 20 |
+
txt = inputs[ArtifactType.RAW_TEXT]
|
| 21 |
+
return {ArtifactType.RAW_TEXT: txt.upper()}
|
| 22 |
+
|
| 23 |
+
Ce module canonique (Phase 4-bis du retrait du legacy) est le
|
| 24 |
+
remplacement de ``picarones.core.modules.BaseModule``. Le shim
|
| 25 |
+
legacy ``core/modules.py`` le ré-exporte pour la rétrocompat des
|
| 26 |
+
~25 callers (engines, measurements, modules officiels, cli, web,
|
| 27 |
+
report) qui le consomment.
|
| 28 |
+
|
| 29 |
+
Le rewrite a aussi des protocols spécialisés
|
| 30 |
+
(``BaseOCRAdapter``, ``BaseLLMAdapter``, ``BaseVLMAdapter`` dans
|
| 31 |
+
``picarones.adapters``) qui sont des cas particuliers de
|
| 32 |
+
``BaseModule`` typés pour leur domaine. ``BaseModule`` reste le
|
| 33 |
+
contrat **générique** pour les modules contribués par des tiers.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
from abc import ABC, abstractmethod
|
| 39 |
+
from typing import Any, Literal
|
| 40 |
+
|
| 41 |
+
from picarones.domain.artifacts import ArtifactType
|
| 42 |
+
|
| 43 |
+
ExecutionMode = Literal["io", "cpu"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class BaseModule(ABC):
|
| 47 |
+
"""Interface générique pour tout module exécutable par le runner.
|
| 48 |
+
|
| 49 |
+
Un module est une fonction typée d'artefacts vers artefacts. Il
|
| 50 |
+
déclare ce qu'il consomme et ce qu'il produit, et expose une
|
| 51 |
+
méthode ``process`` qui prend un dictionnaire d'entrées et
|
| 52 |
+
retourne un dictionnaire de sorties.
|
| 53 |
+
|
| 54 |
+
Attributs de classe (à surcharger en sous-classe)
|
| 55 |
+
-------------------------------------------------
|
| 56 |
+
input_types : tuple[ArtifactType, ...]
|
| 57 |
+
Types d'artefacts consommés par ``process``. L'ordre n'a
|
| 58 |
+
pas de signification ; le runner passe un dictionnaire.
|
| 59 |
+
output_types : tuple[ArtifactType, ...]
|
| 60 |
+
Types d'artefacts produits par ``process``. Tous les types
|
| 61 |
+
listés doivent être présents dans le dict retourné par
|
| 62 |
+
``process`` (le runner valide).
|
| 63 |
+
execution_mode : ``"io"`` ou ``"cpu"``
|
| 64 |
+
Indique au runner quel exécuteur utiliser :
|
| 65 |
+
``ThreadPoolExecutor`` pour les modules I/O-bound (API,
|
| 66 |
+
réseau), ``ProcessPoolExecutor`` pour les CPU-bound
|
| 67 |
+
(Tesseract, Pero).
|
| 68 |
+
"""
|
| 69 |
+
|
| 70 |
+
input_types: tuple[ArtifactType, ...] = ()
|
| 71 |
+
output_types: tuple[ArtifactType, ...] = ()
|
| 72 |
+
execution_mode: ExecutionMode = "io"
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
@abstractmethod
|
| 76 |
+
def name(self) -> str:
|
| 77 |
+
"""Identifiant unique et stable du module."""
|
| 78 |
+
|
| 79 |
+
@abstractmethod
|
| 80 |
+
def process(
|
| 81 |
+
self,
|
| 82 |
+
inputs: dict[ArtifactType, Any],
|
| 83 |
+
) -> dict[ArtifactType, Any]:
|
| 84 |
+
"""Exécute le module sur les artefacts d'entrée.
|
| 85 |
+
|
| 86 |
+
Parameters
|
| 87 |
+
----------
|
| 88 |
+
inputs:
|
| 89 |
+
Dictionnaire ``{ArtifactType: payload}``. Tous les types
|
| 90 |
+
déclarés dans ``input_types`` doivent être présents
|
| 91 |
+
(``validate_inputs`` peut être utilisé pour valider).
|
| 92 |
+
|
| 93 |
+
Returns
|
| 94 |
+
-------
|
| 95 |
+
dict[ArtifactType, Any]
|
| 96 |
+
Dictionnaire des sorties produites. Tous les types
|
| 97 |
+
déclarés dans ``output_types`` doivent être présents.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
def metadata(self) -> dict:
|
| 101 |
+
"""Métadonnées libres exposées par le module.
|
| 102 |
+
|
| 103 |
+
Sous-classes peuvent surcharger pour exposer la version, la
|
| 104 |
+
license, la citation académique, etc. Le runner inclut ce
|
| 105 |
+
dict dans le résultat afin que le rapport puisse l'afficher.
|
| 106 |
+
"""
|
| 107 |
+
return {}
|
| 108 |
+
|
| 109 |
+
# ──────────────────────────────────────────────────────────────────
|
| 110 |
+
# Helpers de validation utilisés par le runner et les tests
|
| 111 |
+
# ──────────────────────────────────────────────────────────────────
|
| 112 |
+
|
| 113 |
+
def validate_inputs(self, inputs: dict[ArtifactType, Any]) -> None:
|
| 114 |
+
"""Lève ``ValueError`` si un type d'entrée déclaré est manquant."""
|
| 115 |
+
missing = [t for t in self.input_types if t not in inputs]
|
| 116 |
+
if missing:
|
| 117 |
+
raise ValueError(
|
| 118 |
+
f"Module {self.name!r} : entrées manquantes "
|
| 119 |
+
f"{[t.value for t in missing]} (attendues : "
|
| 120 |
+
f"{[t.value for t in self.input_types]})",
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
def validate_outputs(self, outputs: dict[ArtifactType, Any]) -> None:
|
| 124 |
+
"""Lève ``ValueError`` si un type de sortie déclaré est manquant."""
|
| 125 |
+
missing = [t for t in self.output_types if t not in outputs]
|
| 126 |
+
if missing:
|
| 127 |
+
raise ValueError(
|
| 128 |
+
f"Module {self.name!r} : sorties manquantes "
|
| 129 |
+
f"{[t.value for t in missing]} (déclarées : "
|
| 130 |
+
f"{[t.value for t in self.output_types]})",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
def __repr__(self) -> str:
|
| 134 |
+
ins = ",".join(t.value for t in self.input_types) or "·"
|
| 135 |
+
outs = ",".join(t.value for t in self.output_types) or "·"
|
| 136 |
+
return f"{self.__class__.__name__}(name={self.name!r}, {ins}→{outs})"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
__all__ = ["ArtifactType", "BaseModule", "ExecutionMode"]
|