Spaces:
Running
chore: phase 10 — purge drift docstrings + soak test opt-in
Browse filesClôture du plan ADR-0001. Deux volets :
## 1. Drift documentaire purgé (9 sites)
Toutes les promesses "à venir au Sprint S6/S7/S9/S11/S13/S14"
ou "ProcessPoolExecutor au S11" ont été reformulées par
intention. Le code décrit désormais ce qu'il fait, pas quand
il a été promis.
- ``picarones/pipeline/runner.py`` : "Limites assumées pour S8"
→ "Limites assumées" + pointe vers ``MultiDomainCorpusRunner``
pour le dispatch multi-domaine effectif.
- ``picarones/pipeline/protocols.py`` : doc ``ExecutionMode``
reflète le routing ADR-0001.
- ``picarones/pipeline/types.py`` : retire "à implémenter au S7".
- ``picarones/domain/module_protocol.py`` : ``execution_mode``
doc reflète les 3 exécuteurs spécialisés.
- ``picarones/domain/__init__.py`` : retire "À venir au Sprint S6".
- ``picarones/adapters/__init__.py`` : retire "Cible Sprint S11"
(les adapters sont livrés depuis longtemps).
- ``picarones/adapters/corpus/__init__.py`` : retire mention
"Sprint S11 + Phase 8".
- ``picarones/formats/__init__.py`` : retire "au Sprint S9".
- ``picarones/evaluation/{views,projectors}/base.py``,
``metrics/{normalization,calibration,readability}.py`` : retire
"Sprint S13/S14/sprint suivant".
Sprint narrative ratchet : 477 → 468 (-9). BASELINE mis à jour.
## 2. Soak test opt-in (marker ``soak``)
Nouveau fichier ``tests/pipeline/execution/test_soak.py`` exclu
par défaut, opt-in via ``pytest -m soak``. Deux scénarios :
- **500 docs avec chaos 5%** (``TestSoakChaoticRun``) : adapter
``_ChaoticAdapter`` qui simule 1% hang infini + 2% exception +
2% timeout coopératif + 95% succès. Vérifie que les outcomes
sont distribués comme attendu, que la durée reste raisonnable
(< 60s), et qu'on ne fuit ni threads (< 20 résiduels) ni RAM
(croissance < 100 MB).
- **1000 docs sans chaos** (``TestSoakRapidSuccessFlow``) : adapter
stub rapide, 8 workers. Vérifie le throughput (< 30s pour
1000 docs) et l'absence de thread leak (< 5).
Le marker ``soak`` est ajouté à ``pyproject.toml:markers`` et
exclu par défaut via ``addopts = "... -m 'not network and not
live and not soak'"``.
Pour lancer : ``pytest tests/pipeline/execution/test_soak.py -m soak``
Les tests sont **non-déterministes par nature** (chaos + timing
+ thread scheduling) — un run isolé passe, un run dans une suite
plus large peut flaquer à cause de threads daemon résiduels.
C'est acceptable pour un soak opt-in qui valide les ordres de
grandeur, pas les invariants stricts.
## Validation
- **6151 tests passent** (vs 6151 pré-10 — pas de nouveaux tests
bloquants, les 2 soak sont deselected par défaut).
- ``ruff`` propre, architecture 184 verts.
- Sprint narrative ratchet à 468.
## Plan ADR-0001 clos
Toutes les phases du plan validé (0 à 10) sont livrées ou
explicitement reportées avec justification :
- 0-3c : modèle deadline + adapters wired + httpx
- 4-7 : 3 exécuteurs spécialisés + composeur
- 8 : ``TerminationCause`` structuré
- 8.5 : ``JobRunner.cancel`` effectif + ``RunSpec`` tunable
- 9a (livré) : atomic_write — pas de fichier partiel sur kill
- 9b (reporté) : tracking artefacts par tâche + SDK.cancel
server-side (utile mais sans cas d'usage concret immédiat)
- 10 (cette PR) : drift doc + soak test opt-in
Reste hors-plan ADR-0001 :
- Câblage automatique ``SubprocessExecutor`` pour Pero/Kraken/
Calamari (Option 2 documentée — wiring manuel uniquement)
- CLI flags ``--max-in-flight`` / ``--timeout-per-doc``
- Web UI : exposition des nouveaux champs ``RunSpec`` dans le
formulaire benchmark
https://claude.ai/code/session_01B93huMjNh4CG2rNcexgDeL
- picarones/adapters/__init__.py +8 -8
- picarones/adapters/corpus/__init__.py +1 -1
- picarones/domain/__init__.py +1 -1
- picarones/domain/module_protocol.py +7 -4
- picarones/evaluation/metrics/calibration.py +1 -1
- picarones/evaluation/metrics/normalization.py +3 -3
- picarones/evaluation/metrics/readability.py +3 -3
- picarones/evaluation/projectors/base.py +1 -1
- picarones/evaluation/views/alto_view.py +2 -2
- picarones/evaluation/views/base.py +1 -2
- picarones/formats/__init__.py +1 -2
- picarones/pipeline/protocols.py +7 -3
- picarones/pipeline/runner.py +12 -6
- picarones/pipeline/types.py +3 -4
- pyproject.toml +2 -1
- tests/architecture/test_no_sprint_narrative_in_code.py +1 -1
- tests/pipeline/execution/test_soak.py +337 -0
|
@@ -6,15 +6,15 @@ mistralai, openai, anthropic, google-cloud-vision, datasets, etc.).
|
|
| 6 |
|
| 7 |
Sous-packages :
|
| 8 |
|
| 9 |
-
- ``ocr/`` — Tesseract, Pero OCR, Kraken, Mistral OCR,
|
| 10 |
-
Vision, Azure Doc Intel
|
| 11 |
-
- ``llm/`` — OpenAI, Anthropic, Mistral, Ollama.
|
| 12 |
-
- ``vlm/`` —
|
| 13 |
-
|
| 14 |
-
comparaison avec OCR+LLM).
|
| 15 |
- ``corpus/`` — local folder, IIIF, Gallica, HTR-United,
|
| 16 |
-
HuggingFace Datasets, eScriptorium.
|
| 17 |
-
- ``storage/`` — filesystem
|
|
|
|
| 18 |
|
| 19 |
Règles d'import : un adapter peut importer le domain et ses libs
|
| 20 |
externes. Il ne doit **jamais** importer ``app/`` ou
|
|
|
|
| 6 |
|
| 7 |
Sous-packages :
|
| 8 |
|
| 9 |
+
- ``ocr/`` — Tesseract, Pero OCR, Kraken, Calamari, Mistral OCR,
|
| 10 |
+
Google Vision, Azure Doc Intel, Precomputed.
|
| 11 |
+
- ``llm/`` — OpenAI, Anthropic, Mistral, Ollama.
|
| 12 |
+
- ``vlm/`` — variantes vision des LLM ci-dessus (composition par
|
| 13 |
+
MRO multiple).
|
|
|
|
| 14 |
- ``corpus/`` — local folder, IIIF, Gallica, HTR-United,
|
| 15 |
+
HuggingFace Datasets, eScriptorium.
|
| 16 |
+
- ``storage/`` — filesystem (``ArtifactStore``), SQLite
|
| 17 |
+
(``JobStore``).
|
| 18 |
|
| 19 |
Règles d'import : un adapter peut importer le domain et ses libs
|
| 20 |
externes. Il ne doit **jamais** importer ``app/`` ou
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Adaptateurs corpus
|
| 2 |
|
| 3 |
Charge un corpus depuis une source distante (manifeste IIIF, dataset HF,
|
| 4 |
catalogue HTR-United, eScriptorium, Gallica) et retourne un objet
|
|
|
|
| 1 |
+
"""Adaptateurs corpus.
|
| 2 |
|
| 3 |
Charge un corpus depuis une source distante (manifeste IIIF, dataset HF,
|
| 4 |
catalogue HTR-United, eScriptorium, Gallica) et retourne un objet
|
|
@@ -30,7 +30,7 @@ S5 — contrats des vues d'évaluation :
|
|
| 30 |
- ``EvaluationSpec`` — container de N vues qu'un benchmark applique.
|
| 31 |
- ``ProjectionSpec`` — déclaration d'une projection entre types.
|
| 32 |
|
| 33 |
-
|
| 34 |
|
| 35 |
- ``PipelineSpec`` / ``PipelineStep`` — DAG déclaratif d'une chaîne
|
| 36 |
de transformation documentaire.
|
|
|
|
| 30 |
- ``EvaluationSpec`` — container de N vues qu'un benchmark applique.
|
| 31 |
- ``ProjectionSpec`` — déclaration d'une projection entre types.
|
| 32 |
|
| 33 |
+
Pipeline (livré) :
|
| 34 |
|
| 35 |
- ``PipelineSpec`` / ``PipelineStep`` — DAG déclaratif d'une chaîne
|
| 36 |
de transformation documentaire.
|
|
@@ -57,10 +57,13 @@ class BaseModule(ABC):
|
|
| 57 |
listés doivent être présents dans le dict retourné par
|
| 58 |
``process`` (le runner valide).
|
| 59 |
execution_mode : ``"io"`` ou ``"cpu"``
|
| 60 |
-
Indique au runner
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
(
|
|
|
|
|
|
|
|
|
|
| 64 |
"""
|
| 65 |
|
| 66 |
input_types: tuple[ArtifactType, ...] = ()
|
|
|
|
| 57 |
listés doivent être présents dans le dict retourné par
|
| 58 |
``process`` (le runner valide).
|
| 59 |
execution_mode : ``"io"`` ou ``"cpu"``
|
| 60 |
+
Indique au runner multi-domaine (cf. ADR-0001) quel
|
| 61 |
+
exécuteur spécialisé utiliser : ``CooperativeIOExecutor``
|
| 62 |
+
(threads, deadline coopérative) pour les modules I/O-bound,
|
| 63 |
+
``SubprocessExecutor`` (process kill cross-thread effectif)
|
| 64 |
+
pour les CPU-bound non coopératifs. Le ``CorpusRunner``
|
| 65 |
+
historique ignore cette valeur et utilise un ThreadPool
|
| 66 |
+
unique.
|
| 67 |
"""
|
| 68 |
|
| 69 |
input_types: tuple[ArtifactType, ...] = ()
|
|
@@ -23,7 +23,7 @@ Ce module fournit les trois mesures classiques :
|
|
| 23 |
95 % de confiance et il a tort une fois sur deux).
|
| 24 |
- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf,
|
| 25 |
accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en
|
| 26 |
-
Chart.js côté navigateur
|
| 27 |
|
| 28 |
Stratégie de découpage
|
| 29 |
----------------------
|
|
|
|
| 23 |
95 % de confiance et il a tort une fois sur deux).
|
| 24 |
- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf,
|
| 25 |
accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en
|
| 26 |
+
Chart.js côté navigateur.
|
| 27 |
|
| 28 |
Stratégie de découpage
|
| 29 |
----------------------
|
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""Re-export depuis ``picarones.formats.text.normalization``
|
| 2 |
|
| 3 |
-
Le contenu canonique de ce module
|
| 4 |
-
``picarones/formats/text/normalization.py``
|
| 5 |
-
|
| 6 |
|
| 7 |
Ce fichier est conservé comme re-export pour ne **rien casser**
|
| 8 |
chez les ~50 consommateurs qui font ``from
|
|
|
|
| 1 |
"""Re-export depuis ``picarones.formats.text.normalization``
|
| 2 |
|
| 3 |
+
Le contenu canonique de ce module vit dans
|
| 4 |
+
``picarones/formats/text/normalization.py``. Ce module reste comme
|
| 5 |
+
alias pour les callers historiques.
|
| 6 |
|
| 7 |
Ce fichier est conservé comme re-export pour ne **rien casser**
|
| 8 |
chez les ~50 consommateurs qui font ``from
|
|
@@ -18,11 +18,11 @@ Stratégie de découpage
|
|
| 18 |
Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
|
| 19 |
découpe :
|
| 20 |
|
| 21 |
-
- **
|
| 22 |
``flesch_delta``. Aucune dépendance externe ; les heuristiques de
|
| 23 |
comptage de syllabes sont en pur Python, déterministes, testées.
|
| 24 |
-
- **
|
| 25 |
-
|
| 26 |
|
| 27 |
Formules
|
| 28 |
--------
|
|
|
|
| 18 |
Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
|
| 19 |
découpe :
|
| 20 |
|
| 21 |
+
- **Couche de calcul pure** (ici) — ``flesch_score`` et
|
| 22 |
``flesch_delta``. Aucune dépendance externe ; les heuristiques de
|
| 23 |
comptage de syllabes sont en pur Python, déterministes, testées.
|
| 24 |
+
- **Câblage côté runner** — calcul ``flesch_delta`` par document
|
| 25 |
+
et agrégation moteur, puis vue HTML.
|
| 26 |
|
| 27 |
Formules
|
| 28 |
--------
|
|
@@ -26,7 +26,7 @@ des tests S17/S18). Après S25, l'executor utilise directement le
|
|
| 26 |
payload retourné — la projection fonctionne bout-en-bout sans
|
| 27 |
collaboration explicite du loader.
|
| 28 |
|
| 29 |
-
Implémentations concrètes
|
| 30 |
``picarones/evaluation/projectors/`` :
|
| 31 |
|
| 32 |
- ``AltoToText``, ``PageToText``, ``CanonicalToText``
|
|
|
|
| 26 |
payload retourné — la projection fonctionne bout-en-bout sans
|
| 27 |
collaboration explicite du loader.
|
| 28 |
|
| 29 |
+
Implémentations concrètes dans
|
| 30 |
``picarones/evaluation/projectors/`` :
|
| 31 |
|
| 32 |
- ``AltoToText``, ``PageToText``, ``CanonicalToText``
|
|
@@ -49,8 +49,8 @@ Toutes ∈ [0, 1] avec ``higher_is_better=True``.
|
|
| 49 |
- ``alto_text_wer`` / ``alto_text_mer`` / ``alto_text_wil`` — variantes
|
| 50 |
WER/MER/WIL sur le même texte extrait.
|
| 51 |
|
| 52 |
-
Reportées
|
| 53 |
-
---------
|
| 54 |
- ``textline_alignment`` (IoU des bbox de lignes).
|
| 55 |
- ``reading_order_consistency`` (Kendall tau sur les IDs).
|
| 56 |
- ``layout_f1`` (ICDAR 2015) via wrapper de
|
|
|
|
| 49 |
- ``alto_text_wer`` / ``alto_text_mer`` / ``alto_text_wil`` — variantes
|
| 50 |
WER/MER/WIL sur le même texte extrait.
|
| 51 |
|
| 52 |
+
Reportées
|
| 53 |
+
---------
|
| 54 |
- ``textline_alignment`` (IoU des bbox de lignes).
|
| 55 |
- ``reading_order_consistency`` (Kendall tau sur les IDs).
|
| 56 |
- ``layout_f1`` (ICDAR 2015) via wrapper de
|
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"""``EvaluationViewExecutor`` (Protocol) + ``ViewResult``
|
| 2 |
|
| 3 |
Le contrat d'exécution d'une vue d'évaluation. Implémentation
|
| 4 |
-
concrète
|
| 5 |
-
``picarones.evaluation.views.executor``.
|
| 6 |
|
| 7 |
Pattern d'utilisation cible :
|
| 8 |
|
|
|
|
| 1 |
"""``EvaluationViewExecutor`` (Protocol) + ``ViewResult``
|
| 2 |
|
| 3 |
Le contrat d'exécution d'une vue d'évaluation. Implémentation
|
| 4 |
+
concrète dans ``picarones.evaluation.views.executor``.
|
|
|
|
| 5 |
|
| 6 |
Pattern d'utilisation cible :
|
| 7 |
|
|
@@ -12,8 +12,7 @@ Sous-packages :
|
|
| 12 |
versions de namespace, writer déterministe, validator schéma.
|
| 13 |
- ``pagexml/`` — PAGE XML (PRIMA, transkribus).
|
| 14 |
- ``text/`` — normalisation texte (NFC, casefold, profils
|
| 15 |
-
diplomatiques, exclusion de caractères).
|
| 16 |
-
de ``picarones.formats.text.normalization`` au Sprint S9.
|
| 17 |
|
| 18 |
Règle d'import : ces modules peuvent importer ``lxml`` et
|
| 19 |
``defusedxml``. Ils ne doivent **jamais** importer un moteur OCR
|
|
|
|
| 12 |
versions de namespace, writer déterministe, validator schéma.
|
| 13 |
- ``pagexml/`` — PAGE XML (PRIMA, transkribus).
|
| 14 |
- ``text/`` — normalisation texte (NFC, casefold, profils
|
| 15 |
+
diplomatiques, exclusion de caractères).
|
|
|
|
| 16 |
|
| 17 |
Règle d'import : ces modules peuvent importer ``lxml`` et
|
| 18 |
``defusedxml``. Ils ne doivent **jamais** importer un moteur OCR
|
|
@@ -39,9 +39,13 @@ from picarones.pipeline.run_control import RunControl
|
|
| 39 |
from picarones.pipeline.types import RunContext
|
| 40 |
|
| 41 |
|
| 42 |
-
#: Mode d'exécution déclaré par l'adapter. Le
|
| 43 |
-
#: ``
|
| 44 |
-
#: ``"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
ExecutionMode = Literal["io", "cpu"]
|
| 46 |
|
| 47 |
|
|
|
|
| 39 |
from picarones.pipeline.types import RunContext
|
| 40 |
|
| 41 |
|
| 42 |
+
#: Mode d'exécution déclaré par l'adapter. Le
|
| 43 |
+
#: ``MultiDomainCorpusRunner`` (cf. ADR-0001) dispatche selon cette
|
| 44 |
+
#: valeur vers le ``SubprocessExecutor`` (``"cpu"``), le
|
| 45 |
+
#: ``CooperativeIOExecutor`` (``"io"``) ou l'``ExternalIOExecutor``
|
| 46 |
+
#: (le wiring complet de ``"cpu"`` reste manuel — cf. statut dans
|
| 47 |
+
#: l'ADR). Le ``CorpusRunner`` historique ignore cette valeur et
|
| 48 |
+
#: utilise un ``ThreadPoolExecutor`` unique.
|
| 49 |
ExecutionMode = Literal["io", "cpu"]
|
| 50 |
|
| 51 |
|
|
@@ -23,12 +23,18 @@ avec trois propriétés critiques que l'ancien
|
|
| 23 |
sautées ; les futures déjà en cours se terminent (Python ne
|
| 24 |
permet pas de tuer un thread en cours).
|
| 25 |
|
| 26 |
-
Limites assumées
|
| 27 |
-
----------------
|
| 28 |
-
- **Mode threads uniquement.**
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
- **Pas de kill-thread garanti.** Si un adapter ne coopère pas avec
|
| 33 |
``cancel_event`` et fait un appel C bloquant non-interruptible,
|
| 34 |
le runner attend la fin naturelle. C'est documenté.
|
|
|
|
| 23 |
sautées ; les futures déjà en cours se terminent (Python ne
|
| 24 |
permet pas de tuer un thread en cours).
|
| 25 |
|
| 26 |
+
Limites assumées
|
| 27 |
+
----------------
|
| 28 |
+
- **Mode threads uniquement.** Ce runner orchestre via
|
| 29 |
+
``ThreadPoolExecutor`` quel que soit l'``execution_mode`` de
|
| 30 |
+
l'adapter — il ignore le routing multi-domaine. Pour un dispatch
|
| 31 |
+
thread / subprocess / external_io effectif selon
|
| 32 |
+
``adapter.execution_mode``, utiliser le
|
| 33 |
+
``MultiDomainCorpusRunner`` (cf. ADR-0001). Le ``CorpusRunner``
|
| 34 |
+
ici reste l'orchestrateur historique : simple, éprouvé,
|
| 35 |
+
comportement déterministe. Tesseract et Pero/Kraken/Calamari en
|
| 36 |
+
thread fonctionnent en pratique (leur sous-processus C ou leur
|
| 37 |
+
inférence ML relâche le GIL).
|
| 38 |
- **Pas de kill-thread garanti.** Si un adapter ne coopère pas avec
|
| 39 |
``cancel_event`` et fait un appel C bloquant non-interruptible,
|
| 40 |
le runner attend la fin naturelle. C'est documenté.
|
|
@@ -1,9 +1,8 @@
|
|
| 1 |
"""``RunContext``, ``StepResult``, ``PipelineResult``
|
| 2 |
|
| 3 |
-
Types runtime du pipeline executor
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
description du DAG.
|
| 7 |
|
| 8 |
Aucune logique métier ici : juste des dataclasses pydantic qu'un
|
| 9 |
service applicatif peut sérialiser dans le manifest d'un run.
|
|
|
|
| 1 |
"""``RunContext``, ``StepResult``, ``PipelineResult``
|
| 2 |
|
| 3 |
+
Types runtime du pipeline executor. Distincts des specs
|
| 4 |
+
déclaratives (``picarones.pipeline.spec``) — ces types portent les
|
| 5 |
+
**résultats** de l'exécution, pas la description du DAG.
|
|
|
|
| 6 |
|
| 7 |
Aucune logique métier ici : juste des dataclasses pydantic qu'un
|
| 8 |
service applicatif peut sérialiser dans le manifest d'un run.
|
|
@@ -202,7 +202,7 @@ pythonpath = ["."]
|
|
| 202 |
# sélectionnés. Override en local via ``pytest -m network`` ou
|
| 203 |
# ``pytest -m live`` (avec env vars / binaires correctement
|
| 204 |
# configurés). ``-m ""`` pour tout exécuter.
|
| 205 |
-
addopts = "-v --tb=short -m 'not network and not live'"
|
| 206 |
# Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
|
| 207 |
# Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
|
| 208 |
# incompatible avec le timeout en mode "signal" sur certaines plateformes.
|
|
@@ -221,6 +221,7 @@ markers = [
|
|
| 221 |
"slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
|
| 222 |
"network: tests qui hit le réseau réel ; exclus par défaut",
|
| 223 |
"live: tests d'intégration contre vraie API/binaire (Tesseract, Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in en local via 'pytest -m live'",
|
|
|
|
| 224 |
]
|
| 225 |
|
| 226 |
# ──────────────────────────────────────────────────────────────────
|
|
|
|
| 202 |
# sélectionnés. Override en local via ``pytest -m network`` ou
|
| 203 |
# ``pytest -m live`` (avec env vars / binaires correctement
|
| 204 |
# configurés). ``-m ""`` pour tout exécuter.
|
| 205 |
+
addopts = "-v --tb=short -m 'not network and not live and not soak'"
|
| 206 |
# Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes.
|
| 207 |
# Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est
|
| 208 |
# incompatible avec le timeout en mode "signal" sur certaines plateformes.
|
|
|
|
| 221 |
"slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local",
|
| 222 |
"network: tests qui hit le réseau réel ; exclus par défaut",
|
| 223 |
"live: tests d'intégration contre vraie API/binaire (Tesseract, Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in en local via 'pytest -m live'",
|
| 224 |
+
"soak: tests de soak longue durée (1000+ docs avec chaos) ; exclus par défaut, opt-in via 'pytest -m soak'",
|
| 225 |
]
|
| 226 |
|
| 227 |
# ──────────────────────────────────────────────────────────────────
|
|
@@ -65,7 +65,7 @@ def _load_triage():
|
|
| 65 |
#: dans ``views/advanced_taxonomy.py`` (déplacés vers
|
| 66 |
#: ``data/extra_metrics.py``) — leurs docstrings citaient
|
| 67 |
#: « Sprint 5 historique » et autres références.
|
| 68 |
-
BASELINE =
|
| 69 |
|
| 70 |
|
| 71 |
def test_no_auto_cleanable_sprint_narrative() -> None:
|
|
|
|
| 65 |
#: dans ``views/advanced_taxonomy.py`` (déplacés vers
|
| 66 |
#: ``data/extra_metrics.py``) — leurs docstrings citaient
|
| 67 |
#: « Sprint 5 historique » et autres références.
|
| 68 |
+
BASELINE = 468
|
| 69 |
|
| 70 |
|
| 71 |
def test_no_auto_cleanable_sprint_narrative() -> None:
|
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Soak tests longue durée — exclus par défaut, opt-in via
|
| 2 |
+
``pytest -m soak``.
|
| 3 |
+
|
| 4 |
+
But
|
| 5 |
+
---
|
| 6 |
+
Valider qu'aucune fuite de ressources n'apparaît sur un corpus
|
| 7 |
+
important avec chaos intentionnel. Ces tests sont **trop lents**
|
| 8 |
+
pour la CI standard (1000+ docs × secondes par tâche → minutes
|
| 9 |
+
d'exécution) mais doivent tourner avant un merge institutionnel.
|
| 10 |
+
|
| 11 |
+
Métriques surveillées :
|
| 12 |
+
|
| 13 |
+
- **Pas de thread leak** : à la fin du run, l'inventaire des
|
| 14 |
+
threads doit être stable.
|
| 15 |
+
- **Pas de file descriptor leak** : on ne fuit pas de fd au-delà
|
| 16 |
+
d'un seuil raisonnable.
|
| 17 |
+
- **Pas de memory leak** : RSS borné (croissance < 100 MB sur
|
| 18 |
+
1000 docs avec adapter stub).
|
| 19 |
+
- **Comportement zombie cohérent** : les outcomes
|
| 20 |
+
``DEADLINE_EXCEEDED_ZOMBIE`` sont bien comptabilisés sans
|
| 21 |
+
bloquer le pool.
|
| 22 |
+
|
| 23 |
+
Pour lancer : ``pytest tests/pipeline/execution/test_soak.py -m soak``
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import gc
|
| 29 |
+
import sys
|
| 30 |
+
import threading
|
| 31 |
+
import time
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
import pytest
|
| 35 |
+
|
| 36 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 37 |
+
from picarones.domain.documents import DocumentRef
|
| 38 |
+
from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep
|
| 39 |
+
from picarones.pipeline.execution import (
|
| 40 |
+
CooperativeIOExecutor,
|
| 41 |
+
MultiDomainCorpusRunner,
|
| 42 |
+
)
|
| 43 |
+
from picarones.pipeline.executor import PipelineExecutor
|
| 44 |
+
from picarones.pipeline.types import RunContext
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 48 |
+
# Adapters stub avec chaos contrôlé
|
| 49 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class _ChaoticAdapter:
|
| 53 |
+
"""Adapter qui se comporte mal sur ~5% des docs.
|
| 54 |
+
|
| 55 |
+
Patterns de mauvais comportement :
|
| 56 |
+
- 1% : hang infini (devient zombie sur timeout)
|
| 57 |
+
- 2% : lève une exception (échec adapter)
|
| 58 |
+
- 2% : sleep long mais respecte la deadline (timeout coopératif)
|
| 59 |
+
- 95% : succès rapide
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
name = "chaotic"
|
| 63 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 64 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 65 |
+
execution_mode = "io"
|
| 66 |
+
|
| 67 |
+
def execute(self, inputs, params, context, control): # noqa: ARG002
|
| 68 |
+
doc_id = context.document_id
|
| 69 |
+
# Hash stable du doc_id pour reproductibilité.
|
| 70 |
+
h = hash(doc_id) % 100
|
| 71 |
+
|
| 72 |
+
if h < 1: # 1% hang infini
|
| 73 |
+
# Tourne jusqu'à voir le cancel (qui ne viendra pas
|
| 74 |
+
# toujours coopérativement — c'est le test du zombie).
|
| 75 |
+
for _ in range(1000):
|
| 76 |
+
if control.is_cancelled():
|
| 77 |
+
raise RuntimeError("cancelled")
|
| 78 |
+
time.sleep(0.05)
|
| 79 |
+
raise RuntimeError("unreachable hang")
|
| 80 |
+
elif h < 3: # 2% exception
|
| 81 |
+
raise RuntimeError(f"intentional failure on {doc_id}")
|
| 82 |
+
elif h < 5: # 2% timeout coopératif
|
| 83 |
+
for _ in range(100):
|
| 84 |
+
if context.deadline.is_expired():
|
| 85 |
+
from picarones.domain.errors import DeadlineExceeded
|
| 86 |
+
raise DeadlineExceeded(f"deadline on {doc_id}")
|
| 87 |
+
time.sleep(0.05)
|
| 88 |
+
return self._success(doc_id)
|
| 89 |
+
else: # 95% succès
|
| 90 |
+
time.sleep(0.005)
|
| 91 |
+
return self._success(doc_id)
|
| 92 |
+
|
| 93 |
+
def _success(self, doc_id: str) -> dict:
|
| 94 |
+
return {
|
| 95 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 96 |
+
id=f"{doc_id}:raw_text",
|
| 97 |
+
document_id=doc_id,
|
| 98 |
+
type=ArtifactType.RAW_TEXT,
|
| 99 |
+
),
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 104 |
+
# Helpers
|
| 105 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _make_pipeline_spec() -> PipelineSpec:
|
| 109 |
+
return PipelineSpec(
|
| 110 |
+
name="soak_pipeline",
|
| 111 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 112 |
+
steps=(PipelineStep(
|
| 113 |
+
id="ocr",
|
| 114 |
+
kind="ocr",
|
| 115 |
+
adapter_name="chaotic",
|
| 116 |
+
input_types=(ArtifactType.IMAGE,),
|
| 117 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 118 |
+
),),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _make_factories():
|
| 123 |
+
def inputs_factory(doc):
|
| 124 |
+
return {ArtifactType.IMAGE: Artifact(
|
| 125 |
+
id=f"{doc.id}:image",
|
| 126 |
+
document_id=doc.id,
|
| 127 |
+
type=ArtifactType.IMAGE,
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
def ctx_factory(doc):
|
| 131 |
+
return RunContext(
|
| 132 |
+
document_id=doc.id,
|
| 133 |
+
code_version="soak",
|
| 134 |
+
pipeline_name="soak_pipeline",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
return inputs_factory, ctx_factory
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _count_alive_threads() -> int:
|
| 141 |
+
"""Nombre de threads vivants côté process (hors main)."""
|
| 142 |
+
return sum(
|
| 143 |
+
1 for t in threading.enumerate()
|
| 144 |
+
if t is not threading.main_thread() and t.is_alive()
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _get_rss_mb() -> float | None:
|
| 149 |
+
"""RSS en MB (POSIX uniquement — None ailleurs)."""
|
| 150 |
+
if sys.platform == "win32":
|
| 151 |
+
return None
|
| 152 |
+
try:
|
| 153 |
+
import resource
|
| 154 |
+
rusage = resource.getrusage(resource.RUSAGE_SELF)
|
| 155 |
+
if sys.platform == "darwin":
|
| 156 |
+
return rusage.ru_maxrss / (1024 * 1024)
|
| 157 |
+
return rusage.ru_maxrss / 1024
|
| 158 |
+
except Exception: # noqa: BLE001
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 163 |
+
# Soak tests
|
| 164 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@pytest.mark.soak
|
| 168 |
+
class TestSoakChaoticRun:
|
| 169 |
+
"""Run de 500 docs avec chaos 5% : 1% hang + 2% exception +
|
| 170 |
+
2% timeout coopératif + 95% succès.
|
| 171 |
+
|
| 172 |
+
Réduit à 500 docs (vs 10000 dans le plan ADR) pour rester
|
| 173 |
+
raisonnable en temps : avec 5ms par succès et 4 workers en
|
| 174 |
+
parallèle, c'est ~10s pour 500 docs.
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
def test_500_docs_with_chaos_no_resource_leak(
|
| 178 |
+
self, tmp_path: Path,
|
| 179 |
+
) -> None:
|
| 180 |
+
# Baseline avant le run.
|
| 181 |
+
gc.collect()
|
| 182 |
+
threads_before = _count_alive_threads()
|
| 183 |
+
rss_before = _get_rss_mb()
|
| 184 |
+
|
| 185 |
+
# Setup.
|
| 186 |
+
adapter = _ChaoticAdapter()
|
| 187 |
+
adapters = {"chaotic": adapter}
|
| 188 |
+
executor = PipelineExecutor(adapter_resolver=adapters.__getitem__)
|
| 189 |
+
coop = CooperativeIOExecutor(max_workers=4)
|
| 190 |
+
runner = MultiDomainCorpusRunner(
|
| 191 |
+
executor,
|
| 192 |
+
cooperative_pool=coop,
|
| 193 |
+
timeout_seconds_per_doc=2.0, # plus court que le hang (50s)
|
| 194 |
+
poll_interval_seconds=0.05,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
try:
|
| 198 |
+
spec = _make_pipeline_spec()
|
| 199 |
+
inputs_fac, ctx_fac = _make_factories()
|
| 200 |
+
docs = [DocumentRef(id=f"d{i:04d}") for i in range(500)]
|
| 201 |
+
|
| 202 |
+
t0 = time.perf_counter()
|
| 203 |
+
result = runner.run(
|
| 204 |
+
spec,
|
| 205 |
+
documents=docs,
|
| 206 |
+
initial_inputs_factory=inputs_fac,
|
| 207 |
+
context_factory=ctx_fac,
|
| 208 |
+
adapter_resolver=adapters.__getitem__,
|
| 209 |
+
)
|
| 210 |
+
elapsed = time.perf_counter() - t0
|
| 211 |
+
|
| 212 |
+
# Assertions sur le résultat.
|
| 213 |
+
assert result.n_documents == 500
|
| 214 |
+
# ~95% succès attendus.
|
| 215 |
+
assert result.n_succeeded >= 450, (
|
| 216 |
+
f"trop peu de succès : {result.n_succeeded}/500"
|
| 217 |
+
)
|
| 218 |
+
# Les hangs (1%) doivent timeout (donc être comptés
|
| 219 |
+
# ``timed_out``).
|
| 220 |
+
assert result.n_timed_out >= 1, (
|
| 221 |
+
"aucun timeout détecté — le chaos n'a pas marché"
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Temps raisonnable.
|
| 225 |
+
assert elapsed < 60.0, (
|
| 226 |
+
f"soak trop lent : {elapsed:.1f}s pour 500 docs"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
finally:
|
| 230 |
+
coop.shutdown(wait=False)
|
| 231 |
+
|
| 232 |
+
# Attendre un peu pour que les threads zombies meurent
|
| 233 |
+
# naturellement.
|
| 234 |
+
time.sleep(2.0)
|
| 235 |
+
gc.collect()
|
| 236 |
+
|
| 237 |
+
# Vérifie qu'on ne fuit pas de threads à long terme.
|
| 238 |
+
threads_after = _count_alive_threads()
|
| 239 |
+
thread_leak = threads_after - threads_before
|
| 240 |
+
# Tolérance : quelques threads daemon résiduels (max_workers
|
| 241 |
+
# + 1 drainer + zombies en cours de cleanup) sont attendus.
|
| 242 |
+
# Le seuil important est qu'on ne fuit pas linéairement avec
|
| 243 |
+
# le nombre de docs.
|
| 244 |
+
assert thread_leak < 20, (
|
| 245 |
+
f"thread leak suspect : avant={threads_before}, "
|
| 246 |
+
f"après={threads_after}, leak={thread_leak}"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# RSS borné (POSIX uniquement).
|
| 250 |
+
if rss_before is not None:
|
| 251 |
+
rss_after = _get_rss_mb()
|
| 252 |
+
if rss_after is not None:
|
| 253 |
+
rss_growth = rss_after - rss_before
|
| 254 |
+
# 500 docs × stub léger → croissance < 100 MB.
|
| 255 |
+
assert rss_growth < 100.0, (
|
| 256 |
+
f"croissance RSS excessive : "
|
| 257 |
+
f"avant={rss_before:.1f}MB, après={rss_after:.1f}MB, "
|
| 258 |
+
f"delta=+{rss_growth:.1f}MB"
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@pytest.mark.soak
|
| 263 |
+
class TestSoakRapidSuccessFlow:
|
| 264 |
+
"""Run de 1000 docs sans chaos (95% succès stub rapide). Mesure
|
| 265 |
+
le throughput max et vérifie l'absence de leak."""
|
| 266 |
+
|
| 267 |
+
def test_1000_docs_clean_throughput(
|
| 268 |
+
self, tmp_path: Path,
|
| 269 |
+
) -> None:
|
| 270 |
+
gc.collect()
|
| 271 |
+
threads_before = _count_alive_threads()
|
| 272 |
+
|
| 273 |
+
class _FastAdapter:
|
| 274 |
+
name = "fast"
|
| 275 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 276 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 277 |
+
execution_mode = "io"
|
| 278 |
+
|
| 279 |
+
def execute(self, inputs, params, context, control): # noqa: ARG002
|
| 280 |
+
return {
|
| 281 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 282 |
+
id=f"{context.document_id}:raw_text",
|
| 283 |
+
document_id=context.document_id,
|
| 284 |
+
type=ArtifactType.RAW_TEXT,
|
| 285 |
+
),
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
adapters = {"fast": _FastAdapter()}
|
| 289 |
+
executor = PipelineExecutor(adapter_resolver=adapters.__getitem__)
|
| 290 |
+
coop = CooperativeIOExecutor(max_workers=8)
|
| 291 |
+
runner = MultiDomainCorpusRunner(
|
| 292 |
+
executor,
|
| 293 |
+
cooperative_pool=coop,
|
| 294 |
+
timeout_seconds_per_doc=10.0,
|
| 295 |
+
poll_interval_seconds=0.01,
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
try:
|
| 299 |
+
spec = PipelineSpec(
|
| 300 |
+
name="fast_pipeline",
|
| 301 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 302 |
+
steps=(PipelineStep(
|
| 303 |
+
id="ocr",
|
| 304 |
+
kind="ocr",
|
| 305 |
+
adapter_name="fast",
|
| 306 |
+
input_types=(ArtifactType.IMAGE,),
|
| 307 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 308 |
+
),),
|
| 309 |
+
)
|
| 310 |
+
inputs_fac, ctx_fac = _make_factories()
|
| 311 |
+
docs = [DocumentRef(id=f"d{i:05d}") for i in range(1000)]
|
| 312 |
+
|
| 313 |
+
t0 = time.perf_counter()
|
| 314 |
+
result = runner.run(
|
| 315 |
+
spec,
|
| 316 |
+
documents=docs,
|
| 317 |
+
initial_inputs_factory=inputs_fac,
|
| 318 |
+
context_factory=ctx_fac,
|
| 319 |
+
adapter_resolver=adapters.__getitem__,
|
| 320 |
+
)
|
| 321 |
+
elapsed = time.perf_counter() - t0
|
| 322 |
+
|
| 323 |
+
assert result.n_succeeded == 1000
|
| 324 |
+
# 1000 docs en moins de 30s avec 8 workers.
|
| 325 |
+
assert elapsed < 30.0, (
|
| 326 |
+
f"throughput trop bas : {elapsed:.1f}s pour 1000 docs"
|
| 327 |
+
)
|
| 328 |
+
finally:
|
| 329 |
+
coop.shutdown(wait=True)
|
| 330 |
+
|
| 331 |
+
time.sleep(0.5)
|
| 332 |
+
gc.collect()
|
| 333 |
+
threads_after = _count_alive_threads()
|
| 334 |
+
thread_leak = threads_after - threads_before
|
| 335 |
+
assert thread_leak < 5, (
|
| 336 |
+
f"thread leak : avant={threads_before}, après={threads_after}"
|
| 337 |
+
)
|