Claude commited on
Commit
323f92d
·
unverified ·
1 Parent(s): b80ac6e

feat(migration): Phase 4-bis — diagnostic + aliases ArtifactType préparatoires

Browse files

Tentative 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).

docs/migration/legacy-retirement-plan.md CHANGED
@@ -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 | À démarrer (migration ArtifactType + débloqueur des bloqués) |
469
  | 5-11 | ⚪ À démarrer |
470
 
471
- **Dernière mise à jour** : 2026-05 (Phase 4 partielle livrée).
 
 
 
 
 
 
 
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.
picarones/domain/artifacts.py CHANGED
@@ -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.
picarones/domain/module_protocol.py ADDED
@@ -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"]