diff --git "a/docs/archive/changelog-pre-v2.md" "b/docs/archive/changelog-pre-v2.md" new file mode 100644--- /dev/null +++ "b/docs/archive/changelog-pre-v2.md" @@ -0,0 +1,3797 @@ +# Changelog pre-v2.0 — Picarones (archive historique) + +> **Archived document.** Historical reference only. +> For current changelog see [`/CHANGELOG.md`](../../CHANGELOG.md). + +Ce fichier conserve l'historique des versions **antérieures à v2.0** +(janvier 2025 → mai 2026, pré-rewrite et migration legacy). Pour +l'ère v2.0 et au-delà, voir [`/CHANGELOG.md`](../../CHANGELOG.md) à +la racine. + +Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/). +La numérotation de version suit [Semantic Versioning](https://semver.org/lang/fr/). + +--- + + +## [Unreleased] — towards 1.3.0 (release institutionnelle BnF) — 2026-05 + +> Section unique conforme à Keep-a-Changelog. Les chantiers actifs +> sont regroupés ci-dessous par thème ; chaque thème reflète un audit +> ou un fix livré sur la branche ``claude/repo-analysis-cukvm``. + +### Fix CI : Windows + cap timeout (S59) + +#### Bug Windows : `:` dans les clés du store + +Le ``FilesystemArtifactStore`` produisait des filenames de la forme +``:.json`` (séparateur ``:``). ``:`` est un +caractère réservé sur NTFS (Alternate Data Streams) — résultat : +``OSError: [WinError 87] The parameter is incorrect`` sur tout +``os.replace(tmp, dst)`` côté Windows. Le bug existait depuis le S47 +mais n'avait été révélé que par l'écriture atomique du S58 (auparavant, +``write_text`` direct laissait silencieusement un fichier orphelin). + +**Fix** : ``cache_helpers.storage_key_for_output`` utilise désormais +``__`` comme séparateur (filesystem-safe sur les trois OS). Test +architectural ``test_storage_keys_filesystem_safe.py`` couvre tous +les ``ArtifactType`` et tous les caractères Windows réservés. + +**Impact cache** : invalide les caches préexistants (qui contenaient +``:``). Le cache est régénéré au prochain run — coût ponctuel +acceptable. Aucun impact sur les artefacts persistés (l'index +``index.jsonl`` est régénéré automatiquement). + +#### CI : exclusion des tests live + timeout codecov + +Voir commit `ce30e80` : + +- Marker ``live`` ajouté à ``[tool.pytest.ini_options].markers`` et + inclus dans ``addopts`` (``-m 'not network and not live'``). + Les ``tests/integration/live/`` ne tournent plus en CI par défaut. +- ``timeout-minutes: 15`` sur le step ``Run tests`` et + ``timeout-minutes: 5`` sur ``Upload coverage to Codecov`` ; + ``fail_ci_if_error: false`` sur codecov. + +### Audit institutionnel S58-S59 (post-S57) + +#### ⚠️ BREAKING CHANGES (déprécations en cours, suppression en 2.0) + +Trois symboles supprimés au S57 sont **restaurés en S59** comme alias +dépréciés avec `DeprecationWarning` à l'accès. Ils seront supprimés +en version 2.0. Une release institutionnelle ne peut pas casser un +caller externe (espaces HuggingFace tiers, scripts BnF, notebooks de +chercheurs cités dans des articles) sans deprecation period. + +| Symbole | Statut | Cible canonique | +|---------|--------|-----------------| +| `picarones.pipeline.spec` (module) | déprécié | `picarones.domain.pipeline_spec` | +| `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT` (singulier) | déprécié | `DEFAULT_CORRECTION_PROMPTS[lang]` | +| `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT` (singulier) | déprécié | `DEFAULT_TRANSCRIPTION_PROMPTS[lang]` | + +L'argument `RateLimitMiddleware.trust_x_forwarded_for: bool` a été +**renommé en `trust_proxy_count: int`** au S58 (sémantique +sécurisée — lecture du Nème IP en partant de la fin de la chaîne XFF +au lieu du premier). Le paramètre du `create_app` correspondant +s'appelle désormais `rate_limit_trust_proxy_count`. Pas d'alias +rétrocompat — la nouvelle sémantique est incompatible avec l'ancienne. + +### REPRODUCTIBILITÉ — `RunManifest` complet (B1) + +Le `RunManifest` documente la promesse *« à code_version + corpus + +specs + dependencies_lock identiques, ré-exécuter doit donner les +mêmes résultats »*. Avant S59, deux gaps majeurs : + +1. `dependencies_lock` n'était jamais peuplé — `RunOrchestrator` + appelait `bench.run(...)` sans le passer. +2. `pipeline_names: tuple[str, ...]` ne portait que les noms ; les + `PipelineSpec` complets (steps, params, inputs_from) n'étaient + nulle part dans le manifest. Un relecteur 5 ans plus tard ne + pouvait pas reconstituer le DAG sans accès au YAML d'origine. + +S59 : + +- Nouveau module `picarones.app.services.dependencies` — + `capture_dependencies_lock()` via `importlib.metadata`. + `RunOrchestrator` capture systématiquement. +- `RunManifest.pipeline_specs: tuple[PipelineSpec, ...]` remplace + l'ancien `pipeline_names` (qui devient une property dérivée pour + rétrocompat des lecteurs). +- `RunManifest.adapter_kwargs: dict[str, dict]` capture les + constructeurs (model, temperature, etc.) — permet de reconstituer + `OpenAIAdapter(model="gpt-4o-2024-08-06", temperature=0.0)`. +- Test architectural `test_manifest_reproducibility.py` verrouille + le contrat : sérialisation déterministe, lock non vide trié, + rejet des champs extras. + +### FILTRAGE OUTPUTS DE STEP (H1) + +`PipelineExecutor` filtre désormais le dict de retour d'`execute()` +sur `step.output_types`. Sans ça, un adapter qui produit des types +non déclarés au YAML (ex. Tesseract avec `expose_confidences=True` +mais step déclarant seulement `[raw_text]`) propageait silencieusement +des artefacts en aval — bug subtil de DAG branchant. + +### RETRY EXPONENTIEL UNIFIÉ (H4) + +Nouveau module partagé `picarones.adapters._retry` avec `is_retryable` +et `call_with_retry(fn, max_retries=3, backoff_base=2.0)`. Adopté par : + +- `BaseLLMAdapter.complete` (déjà avait sa logique privée — désormais + délègue au helper unique). +- `MistralOCRAdapter._call_native_ocr_api` + `_call_chat_vision_api` +- `GoogleVisionAdapter._call_via_rest` +- `AzureDocumentIntelligenceAdapter` (POST initial) + +Politique : 3 retries, backoff 2/4/8s, sur 429 + 5xx + erreurs +réseau (TimeoutError, ConnectionError, URLError). + +### SÉCURITÉ ET TRAÇABILITÉ + +- **Path traversal (M3)** : `DocumentRef._validate_doc_id` rejette + désormais tout segment `..` dans l'`id`. Défense en profondeur + contre un caller qui construirait `DocumentRef(id="../../etc/...")` + programmatiquement. +- **Audit trail (M2)** : `POST /api/jobs` et `DELETE /api/jobs/{id}` + émettent un log INFO `[audit]` avec l'IP source pour la traçabilité + institutionnelle (création de job consomme du quota cloud, + annulation détruit des résultats partiels — actions sensibles). +- **Test XFF (H2)** : 7 tests verrouillent le parsing + `X-Forwarded-For` du `RateLimitMiddleware` (trust_proxy_count=0/1/2, + chaîne plus courte que prévu, IP spoof tentée, whitespace, no + client). +- **Lang fallback (M6)** : `BaseLLMAdapter` et `BaseVLMAdapter` + émettent un `logger.warning` quand `config["lang"]` n'est pas dans + `DEFAULT_*_PROMPTS` et fallback silencieusement à FR — un + scientifique BnF travaillant sur un corpus allemand voit le + message dans ses logs. + +### Infrastructure de test + +- `tests/api_stability/test_deprecated_aliases.py` : 4 tests sur les + alias dépréciés. +- `tests/architecture/test_manifest_reproducibility.py` : 4 tests. +- `tests/interfaces/web/test_rate_limit_xff.py` : 7 tests. + +### Rewrite A14 (S27-S46) + audit remediation (S47-S57) + +Cette section couvre la phase **rewrite ciblé** (S27-S46) puis les +**6 vagues de remédiation** des dettes identifiées en audit +*institutional readiness 2026-05* (S47-S57). Détail complet dans +`docs/migration/rewrite-status-s46.md` et +`docs/audits/remediation-plan-2026-05.md`. + +#### Phase rewrite (S27-S46) — partial rewrite + +20 sprints sur la directive *« rewrite tout, le plus solide, sans dette +technique »*. Stratégie : **rewrite parallèle**, pas full rewrite — le +nouveau monde (`picarones/{domain,formats,evaluation,pipeline,adapters, +app,reports_v2,interfaces}/`) cohabite avec le legacy +(`picarones/{cli,web,engines,llm,pipelines,report}/`) le temps que la +parité fonctionnelle soit atteinte sur le rendu rapport et que les +callers externes migrent. + +**Fondations** : `ProjectionEngine` + `EvaluationEngine` séparés, +`PipelinePlanner` + `ExecutionPlan`, `ArtifactStore` filesystem + +hash multi-paramètres. + +**Adapters natifs** (NO SHIM) : 5 OCR (Tesseract, Pero, Mistral, +Google Vision, Azure DI), 4 LLM (Anthropic, OpenAI, Mistral, Ollama), +4 VLM dérivés via MRO multiple. + +**Web app native** : skeleton FastAPI + DI, 3 routers (corpus, +benchmark, jobs), JobStore SQLite, UI Jinja2 + i18n FR/EN. + +**Reports v2** : CSV, JSON ; HTML canonique (TextView, AltoView, +SearchView). Vues thématiques legacy (Pareto, narrative, glossary, +case-studies) à porter une à une post-livraison. + +#### Phase remédiation (S47-S57) — 30 dettes adressées en 6 vagues + +| Vague | Sprint | Issues | Thème | +|-------|--------|--------|-------| +| Pré-audit | S47-S48 | #1, #2 | `ArtifactStore` wired to `PipelineExecutor` (resume by hash), `JobRunner` threading + lifespan hook | +| A | S49-S51 | #3-#7 | Web security middlewares (`SecurityHeadersMiddleware`, `BodySizeLimitMiddleware`, `RateLimitMiddleware`, `AuthenticationMiddleware`), confidences sidecar JSON, `resolve_output_path` workspace propagation | +| B | S52-S53 | #8-#11 | `AdapterStepError` hierarchy (parent commun OCR/LLM/VLM), Mistral routing strict (`.lower().startswith("mistral-ocr")`), `normalize_llm_content` sur le chemin chat | +| C | S54 | #6 | MRO guard `__init_subclass__` sur `BaseVLMAdapter` — détecte `class X(LLM, VLM)` au lieu de `class X(VLM, LLM)` à la définition | +| D | S55 | #14 | Tests d'intégration live `tests/integration/live/` avec marker `live` (pytest.importorskip pour SDK absents) | +| E | S56 | #12, #13, #17, #18, #19, #20, #22, #27, #28, #29 | `JobStore` `schema_version` table + `busy_timeout 30s`, WAL mode, `model_dump(mode="json")`, `_infer_pipeline_name` via préfixe `doc_id`, `MAX_RUNS_DISPLAYED=20`, etc. | +| F | S57 | #15, #16, #21, #23, #24, #25, #26, #30 | i18n prompts FR/EN/LA dans `BaseLLMAdapter`/`BaseVLMAdapter`, suppression du re-export orphelin `picarones.pipeline.spec`, rectifications doc CHANGELOG + audit | + +**Tous les 30 issues sont adressés au S57**. + +#### S57 — détail des rectifications + +- **#15 Lazy imports SDK tiers** : confirmé intentionnel — `mistralai`, + `anthropic`, `openai`, `ollama` sont importés à l'intérieur des + méthodes plutôt qu'au top du module. Raison : ces SDK sont des + dépendances optionnelles (extras `[mistral]`, `[anthropic]`…) — un + import top-level ferait planter `import picarones` sur un + environnement minimal. + +- **#16 i18n prompts FR/EN/LA** : `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPTS` + et `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPTS` sont désormais des + `dict[str, str]` indexés par code langue ISO 639-1 (`fr`, `en`, `la`). + Sélection : override explicite via `config["correction_prompt"]` / + `config["transcription_prompt"]` > `config["lang"]` > fallback FR. + Les anciennes constantes singulières ont été supprimées (aucun + caller ne les lisait — vérifié par grep). + +- **#21 Rectification *« rewrite fonctionnellement complet »*** : + formulation initiale trop forte. La parité fonctionnelle cible + est atteinte sur **les contrats et l'architecture**, pas sur le + **rendu rapport** (vues thématiques legacy non encore portées) ni + sur la **CLI** (commandes `history`, `compare`, `pipeline`, + `diagnose` à porter). Cf. + `docs/migration/rewrite-status-s46.md` pour le détail. + +- **#23 Qualification *« +406 tests »*** : nombre concernait + spécifiquement les **nouveaux tests écrits pour le new world** sur + S27-S45 (`tests/{adapters,pipeline,evaluation,reports_v2,app, + interfaces}/`), pas une supposée hausse de la couverture totale du + repo. Les tests legacy ont été conservés intacts — la couverture + nette du rewrite est **additive**, pas substitutive. + +- **#24 Rewrite parallèle** : documenté explicitement dans + `rewrite-status-s46.md` — `picarones/{cli,web,engines,llm, + pipelines,report}/` reste exécutable et un caller externe peut + encore importer depuis n'importe lequel. Cette coexistence est + volontaire le temps de la migration des callers, mais doit être + tenue pour ce qu'elle est : un **rewrite parallèle**, pas un *full + rewrite*. + +- **#25 File budgets** : la règle interne *« tout fichier ≥ 400 + lignes est budgété »* est un garde-fou pragmatique, pas une + doctrine ; elle force à expliciter la justification lorsqu'un + module dépasse ce seuil. Aucun fichier ne dépasse 800 lignes + après S46. + +- **#26 Suppression du re-export `picarones.pipeline.spec`** : le + module canonique est `picarones.domain.pipeline_spec` depuis le + S40. Le re-export legacy était totalement orphelin (vérifié par + grep — aucun caller interne ni legacy). Il est supprimé + directement, pas mis en deprecation soft. L'API publique du + package `picarones.pipeline` continue d'exporter `PipelineSpec`, + `PipelineStep`, `INITIAL_STEP_ID` au niveau `__init__` (raccourci + d'API standard, pas un alias de chemin). + +- **#30 Commit hygiene CER fix** : le seuil de régression CER en CI + (`perf_regression.yml`) est passé de `0.10` à `0.20` (cf. section + `[Unreleased] — fix CI perf_regression`). Justification métier : + les corpus patrimoniaux ont des CER bruts qui peuvent légitimement + varier de 5-15 points selon le tirage de validation (segmentation, + qualité d'image, présence de notes marginales). Un seuil à 10 + points faisait échouer la CI sur du bruit légitime. + +### Fix CI perf_regression + +#### ⚠️ BREAKING CHANGE — sémantique `--fail-if-cer-above` + +L'option `picarones run --fail-if-cer-above` interprétait sa valeur +comme un **pourcentage** (ex : `15.0` = 15 %). Désormais elle attend +une **fraction** ∈ [0, 1] (ex : `0.15` = 15 %), cohérent avec la +représentation interne de `BenchmarkResult.ranking()[i]["mean_cer"]`. + +**Migration** : si vous passiez `--fail-if-cer-above 15.0` (intention +« 15 % »), passez maintenant `--fail-if-cer-above 0.15`. + +**Garde-fou** : un callback Click rejette à l'analyse toute valeur +> 1.0 avec un message de migration explicite — la cassure est +**bruyante**, pas silencieuse. Il est impossible de basculer +silencieusement sur l'ancienne sémantique. + +**Pourquoi** : le job CI hebdomadaire `perf_regression.yml` passait +`0.15` en pensant fraction, mais la CLI le traitait comme 0.15 % et +échouait toujours. Le fix aligne la sémantique avec l'intention +documentée et avec la représentation interne de `mean_cer`. + +**Tests anti-régression** (10) dans +`tests/cli/test_fail_if_cer_above_semantics.py` : + +- Sémantique fraction (sous/au seuil/None/strict 1 %/lax 50 %). +- `perf_regression.yml` doit passer une valeur ∈ ]0, 1]. +- Help texte mentionne explicitement « fraction ». +- Migration guard : `15.0` → `BadParameter` avec hint « divisez par 100 ». +- `1.0` et `0.0` acceptés (bornes valides). + +--- + +## [post-Sprint 97] — chantiers de consolidation — 2026-04 → ongoing + +> 6 chantiers de consolidation **sans suppression** sur la branche +> `claude/code-quality-audit-ACnhK`, en réponse à un audit identifiant +> 16 renderers orphelins, 1500+ lignes de duplication, et 2 monolithes +> de 1200+ lignes. Stratégie : valoriser ce qui a été codé plutôt que +> supprimer ; donner une adresse à chaque module orphelin. + +### Chantier 1 — Reconstructeur ALTO + refonte engines (commit `ceb4ba7`) + +**Composants neufs** : + +- `picarones/modules/` (nouveau package) — modules `BaseModule` de + référence livrés par Picarones. +- `picarones.modules.alto_text_to_mono_region.TextToAltoMonoRegion` — + reconstructeur baseline `(IMAGE, TEXT) → ALTO 4.2 mono-région`. + Distribution spatiale proportionnelle à la longueur des mots, + déterministe, sans dépendance externe. +- `picarones.core.alto_metrics` — parser ALTO tolérant + (`extract_text_from_alto`) + 4 métriques `(ALTO, ALTO)` enregistrées + sur le registre typé Sprint 34 (`alto_text_cer/wer/mer/wil`). +- `examples/pipelines/ocr_to_alto.yaml` — pipeline déclarative + exemple `Tesseract → reconstructeur ALTO`. + +**Refactor BaseOCREngine** : 3 hooks unifiés (`_run_with_native`, +`_extract_raw_confidences`, `_normalize_token_confidences`). Les 5 +adapters OCR (Tesseract, Pero, Mistral OCR, Google Vision, Azure DI) +ne surchargent plus `run()` : 382 lignes ajoutées / 424 lignes +supprimées (-42 net), comportement et octets de sortie strictement +identiques. Le contrat `BaseModule.process()` (Sprint 33) devient +honoré, les `token_confidences` accessibles via la nouvelle propriété +`last_run_result`. + +**Verrou levé** : toute l'infrastructure des Sprints 32-34, 53-54, +63-68, 94-97 (axe B) est rétroactivement validée par un module +non-mocké. Le rapport pipeline composée a maintenant des données +réelles à montrer. + +### Chantier 2 — Profils + registre de hooks (commit `25bd1fe`) + +**Composants neufs** : + +- `picarones.core.metric_hooks` — 7 profils (`minimal`, `standard`, + `philological`, `diagnostics`, `economics`, `pipeline`, `full`) + + `DocumentMetricHook` / `CorpusMetricAggregator` + décorateurs + `@register_document_metric` / `@register_corpus_aggregator` + + `select_*` / `run_*`. +- `picarones.core.builtin_hooks` — 12 hooks document-level + 12 + agrégateurs corpus-level enregistrés sur le profil `standard`, + reproduisant exactement le comportement pré-chantier. + +**Refactor `runner.py`** : 1322 → 1019 lignes (−303). Les 11 +`try/except` codés en dur dans `_compute_document_result` sont +remplacés par un seul `run_document_hooks(profile, ...)`. Les 12 +appels d'agrégation sont remplacés par un `run_corpus_aggregators`. +Les 8 `_aggregate_X` privés deviennent des thin wrappers délégués +(rétrocompat tests Sprint 13/42). + +**CLI** : `picarones run --profile {minimal|standard|philological| +diagnostics|economics|pipeline|full}` (défaut `standard`). + +**Verrou levé** : ajouter une métrique au runner devient un travail +local — `@register_document_metric` + `@register_corpus_aggregator` +dans un fichier dédié, plus besoin de patcher `runner.py` à deux +endroits. + +### Chantier 3 — 5 vues HTML thématiques (commit `fe6661c`) + +**Nouveau package `picarones/report/views/`** (5 modules) qui adresse +les 16 renderers orphelins : + +- `economics.py` — throughput effectif (auto) + cost projection (opt-in). +- `advanced_taxonomy.py` — taxonomy_comparison (auto) + cooccurrence / + intra_doc / lexical_modernization (opt-in). +- `diagnostics.py` — leviers (auto) + image_predictive / baseline / + longitudinal / multirun_stability / worst_lines (opt-in). +- `pipeline.py` — pipeline_render + DAG + error_absorption + + incremental_comparison + module_audit (pour `picarones pipeline run`). +- `robustness.py` — robustness_projection (pour `picarones robustness`). + +**Câblage** : `report/generator.py` calcule les 3 vues automatiques +et les passe au template `view_analyses.html` qui les inclut +conditionnellement en chart-card pleine largeur. Adaptive masking +sur 2 niveaux : si une sous-section n'a pas de signal, elle est +masquée ; si la vue entière n'a aucune sous-section, elle est masquée. + +**Convention de rendu partagée** : `_render_view_shell` produit un +shell `
` collapsible (premier ouvert, autres fermés) avec +anti-injection HTML systématique. + +**Verrou levé** : plus aucun renderer n'est strictement orphelin. + +### Chantier 4 — Workflows CLI + LLM Sprint 15 + Gallica/IIIF (commit `36694e1`) + +**4.A — LLM** : `normalize_llm_content` + `log_http_error` factorisés +dans `picarones.llm.base`. Le fix Sprint 15 (normalisation +`list[ContentChunk] → str`) est désormais appliqué uniformément aux +4 adapters (Mistral, OpenAI, Anthropic, Ollama). Anthropic gagne un +log discriminant par status_code. + +**4.B — Gallica → IIIF** : nouveau module privé +`picarones/importers/_http.py` avec `validate_http_url` et +`download_url`. IIIF et Gallica y délèguent (~30 lignes de +duplication exacte éliminées). Garde-fou `file://`/`ftp://`/ +`javascript://` cohérent. + +**4.C — 3 sous-commandes CLI** : + +- `picarones diagnose` → profil `diagnostics`. +- `picarones economics` → profil `economics`. +- `picarones edition` → profil `philological`. + +Helper privé `_run_workflow(...)` factorise la logique commune des +4 commandes (run + 3 nouvelles). + +### Chantier 5 — Découpage monolithes (commit `c1ae580`) + +**5.A** — `picarones/core/narrative/detectors.py` (1229 lignes, +18 détecteurs) → package thématique avec 8 fichiers : + +- `ranking.py` (5 détecteurs), `pareto.py` (2), `stratum.py` (3), + `quality.py` (4), `history.py` (3), `ensemble.py` (1), `_helpers.py`. +- `__init__.py` réexporte les 18 détecteurs + `DETECTORS_BY_TYPE` + + `register_default_detectors`. + +**5.B** — `picarones/cli.py` (1519 lignes, 15 commandes) → package +avec 7 fichiers : + +- `__init__.py` (groupe `cli` + helpers + 5 commandes simples), + `_workflows.py` (471 L), `_pipeline.py`, `_robustness.py`, + `_history.py`, `_imports.py`, `_serve.py`. +- L'entry-point `picarones.cli:cli` (`pyproject.toml`) reste valide. + +**5.C** — `runner.py` reporté : déjà allégé de 303 lignes au +chantier 2 ; les workers picklables sont fragiles à déplacer +(casserait les fichiers `.partial.json` de reprise). + +**Verrou levé** : les deux plus gros monolithes (2748 lignes au total) +sont éclatés en 14 fichiers thématiques. Plus de conflits de merge +sur des monolithes globaux. + +### Chantier 6 — Documentation + tests features (en cours) + +- 4 nouveaux documents dans `docs/` : `architecture.md`, + `profiles.md`, `cli-workflows.md`, `views.md`. +- En-tête « Lecture rapide » ajouté à `CLAUDE.md`. +- Couche d'index thématique `tests/features/` (chantier 1 a déjà + créé `test_pipeline_ocr_to_alto.py`). + +### Bilan quantitatif + +| Indicateur | Avant chantiers | Après chantiers | +|---|---|---| +| Renderers orphelins | 16/26 | 0/26 (tous adressés) | +| `runner.py` | 1322 lignes | 1019 lignes | +| `cli.py` (monolithe) | 1519 lignes | éclaté en 7 fichiers | +| `narrative/detectors.py` | 1229 lignes | éclaté en 8 fichiers | +| `BaseModule` réel | 0 (mock-only) | `TextToAltoMonoRegion` | +| Métriques `(ALTO, ALTO)` | 0 | 4 (`alto_text_*`) | +| Profils de calcul CLI | 1 (implicite) | 7 (`--profile`) | +| Sous-commandes CLI | 12 | 15 (3 workflows dédiés) | +| Adapters LLM avec Sprint 15 | 1/4 | 4/4 | +| Adapters LLM avec log discriminant | 2/4 | 4/4 | +| Helpers HTTP factorisés | 0 (dupliqués IIIF/Gallica) | 1 module `_http.py` | +| Détecteurs par fichier | 18/1 | 18/6 (par famille) | +| Documentation thématique | 1 (CLAUDE.md monolithique) | + 4 docs ciblés | + +**Aucune ligne de code utile supprimée** — la stratégie +« valoriser plutôt que supprimer » a été tenue sur les 6 chantiers. + +--- + +## [1.2.x] — Sprints 32+ — 2026-04 → ongoing + +> Démarrage de la **Phase 0** du [plan d'évolution 2026](docs/roadmap/evolution-2026.md) : +> fondations communes pour l'enrichissement métrique (axe A) et le banc +> d'essai de pipelines composées (axe B). Les deux axes restent +> rétrocompatibles avec le mode benchmark texte historique. + +### Ajouté + +- **Sprint 97 — B.6 : politique de modules contribués + (manifest + audit + vue HTML + doc).** Avant d'ouvrir + Picarones aux contributions externes (axe B — modules tiers + que l'utilisateur amène), il faut un **cadre de qualité + explicite** : *« un module qui ne passe pas l'audit n'est + pas exécutable. »* + + Nouveau module `picarones/core/module_policy.py` : + + - Dataclass ``ModuleManifest`` avec **5 champs obligatoires** + (``name``, ``version``, ``author``, ``license``, + ``description``) + ``input_types``/``output_types`` non + vides + champs optionnels ``citation`` (BibTeX/DOI/texte + libre), ``homepage``, ``picarones_min_version``, ``extra``. + Pas de validation SPDX (l'outil documente, ne juge pas le + choix de licence). + - ``validate_manifest(manifest)`` → liste d'``AuditCheck`` + (un par champ obligatoire + 2 pour les types). + - Dataclasses ``AuditCheck(name, passed, detail)`` et + ``AuditResult(module_name, passed, checks)`` avec + ``n_passed``/``n_failed`` properties + ``as_dict()`` + sérialisable. + - ``audit_module(class_or_instance, manifest)`` ajoute + 4 checks en plus du manifest : héritage de ``BaseModule`` + (Sprint 33), correspondance ``input_types``/``output_types`` + déclarés vs manifest (case-insensitive : on accepte + ``"TEXT"`` ou ``"text"``), méthode ``process`` callable. + Retourne ``passed=True`` ssi tous les checks passent. + + Nouveau module `picarones/report/module_audit_render.py` : + ``build_module_audit_html(audits, labels)`` produit un + tableau récapitulatif des modules utilisés dans la pipeline, + chacun avec statut d'audit (✓ vert ou ✗ rouge avec compte + des checks échoués), version, auteur, licence, types + d'entrée → sortie, citation tronquée à 120 chars, page + projet tronquée à 80 chars (pas d'auto-link : anti-injection + + honnêteté, l'URL peut pointer ailleurs). Adaptive : ``""`` + si liste vide. Anti-injection systématique sur tous les + champs. + + Documentation `docs/developer/module-policy.md` (135 lignes) : + TL;DR, raison d'être, table des champs manifest, contrat + ``BaseModule`` avec exemple, audit automatique, **stratégie + d'ouverture en deux temps** (phase fermée actuelle → phase + ouverte via plugins ``picarones-module-X`` PyPI avec + ``entry_points`` une fois 5–6 modules officiels stables). + + +12 clés i18n FR/EN (`audit_*`). +23 tests dans + `test_sprint97_module_policy.py` couvrant ``ModuleManifest`` + (as_dict + champs optionnels), ``validate_manifest`` (4 cas + dont champ manquant + types vides), ``audit_module`` (6 cas + dont module valide passe, non-BaseModule échoue, I/O + mismatch échoue, **case-insensitive sur les types** prouvant + que ``"TEXT"`` côté manifest et ``ArtifactType.TEXT`` + côté module sont équivalents, accepte instance ou classe, + as_dict structuré), vue HTML 6 cas dont badge ✓/✗, + anti-injection sur ``name``, ``homepage``, ``citation``, FR + + EN, **présence de la doc** + listing des champs + obligatoires dans la doc, complétude i18n 12 clés. **Verrou + levé** : la phase fermée a maintenant son cadre formel ; la + phase ouverte (plugins PyPI) peut être déclenchée le jour + où 5–6 modules officiels stables existent, **sans refactor + de l'interface**. Tout module externe devra simplement + fournir un manifest valide et passer l'audit. + +- **Sprint 96 — B.5 : comparaison incrémentale (couche calcul + + vue HTML).** Avec 5 OCR × 3 reconstructeurs × 4 post- + correcteurs × 3 mappeurs = 180 pipelines à comparer, le + rapport noie l'information. Il faut un mécanisme de + comparaison **contrôlée** type design d'expérience. + + Nouveau module `picarones/core/incremental_comparison.py` : + + - Dataclass immuable ``PipelineRun(name, slots, score)`` + décrivant un run avec sa signature de modules + (``slots = {"ocr": "tess", "llm": "gpt-4o", ...}``) et sa + métrique numérique. + - ``compare_isolated_effect(runs, varying_slot, + higher_is_better=False)`` mesure l'effet isolé d'un slot + en fixant tous les autres : groupe les runs par + combinaison des slots fixed, calcule pour chaque valeur + du slot variant ``{n_observations, mean, stdev, min, max, + mean_rank}``, retourne ``best_value``/``worst_value`` et + le détail des groupes pour traçabilité. Les ex aequo + partagent la moyenne des rangs (convention statistique + standard). Garde-fous : ``None`` si moins de 2 runs ou + si ``varying_slot`` n'est dans aucun run ; les runs avec + schéma de slots incompatible sont ignorés (pas écrasés). + Accepte ``PipelineRun`` ou dicts compatibles. + + Nouveau module `picarones/report/incremental_comparison_render.py` + : `build_incremental_comparison_html(analysis, labels)` + produit un tableau ANOVA-like avec lignes triées par rang + moyen ascendant ; chaque ligne montre la valeur, le score + moyen coloré en gradient vert (meilleur) → rouge (pire) + normalisé sur la plage observée, l'écart-type, le rang + moyen, le nombre d'observations. ``best_value`` marquée + ★ vert, ``worst_value`` marquée ▼ rouge. Adaptive : ``""`` + si ``analysis`` est ``None`` ou ``per_value`` vide. Anti- + injection systématique sur la valeur du slot et sur le nom + du slot variant. + + **Pas de tests statistiques recalculés** : la sortie agrège + les données nécessaires pour qu'un test externe (Friedman/ + Nemenyi déjà dans `core/statistics.py` Sprint 18) puisse + les consommer. Le module ne reconstruit pas ce qui existe. + + +9 clés i18n FR/EN (`incr_*`). +20 tests dans + `test_sprint96_incremental_comparison.py` (cas standard 4×2 + → effet du LLM avec gpt rang 1.0 systématique, rang moyen + correct, best/worst identifiés, ``higher_is_better`` inverse + l'ordre, lt 2 → None, slot inconnu → None, schémas + incompatibles ignorés sans crash, acceptation de dicts, + ex aequo → rangs moyens 1.5, vue HTML adaptive + tri par + rang + marqueurs ★/▼ + anti-injection sur valeur ET sur + nom de slot + EN, **cas réaliste 5 OCR × 2 LLM** prouvant + que mistral domine systématiquement et gpt-4o aussi, + PipelineRun.as_dict + immutable, complétude i18n 9 clés). + **Verrou levé** : un benchmark d'axe B avec dizaines de + pipelines voit immédiatement *« en variant le LLM, gpt-4o + domine sur 100 % des configurations OCR (rang moyen 1.0) »* + sans avoir à parcourir les 180 lignes de comparaison brute. + +- **Sprint 95 — B.4 : visualisation DAG d'un pipeline composé + (rendu SVG server-side).** Outil d'**inspection**, pas de + construction — le YAML reste source de vérité. Permet + d'auditer rapidement la qualité d'une pipeline d'axe B + (Sprint 63+). Nouveau module + `picarones/report/pipeline_dag_render.py` : + `build_pipeline_dag_html(nodes, labels, edges=None, + thresholds=(0.05, 0.15), higher_is_better=False)` rend un + graphe orienté gauche → droite en SVG natif (pas de + bibliothèque, pas de JS). Chaque nœud est un rectangle + annoté du nom du module + types d'entrée/sortie. Chaque + arête est une flèche colorée vert/orange/rouge selon la + valeur de la métrique calculée à la jonction, avec + étiquette ``type d'artefact`` + ``métrique : valeur`` + (formatée en pourcent ou décimal). Légende intégrée avec + les seuils. Mode ``higher_is_better=True`` inverse la + sémantique pour les métriques type F1/recall. Adaptive : + ``""`` si moins d'un nœud. Auto-déduction des arêtes + séquentielles si non fournies. Anti-injection systématique + via ``html.escape`` sur le nom du nœud, le type d'artefact, + le nom de métrique et les listes input/output_types. + + **Pas de drag-and-drop, pas de notebook, pas de drill-down + par document** : le visuel sert à inspecter et déboguer, + pas à construire. Une institution sérieuse versionne ses + pipelines en YAML dans Git, pas en JSON exporté d'une UI. + Le drill-down par document reste sur le tableau de + ``error_absorption`` (Sprint 94) qui montre déjà les tokens + corrigés / introduits par jonction. + + +6 clés i18n FR/EN (`dag_*`). +18 tests dans + `test_sprint95_pipeline_dag.py` (vide → "", single node sans + flèche, 2 nœuds 1 arête avec étiquettes + valeur formatée + 4.0%, chaîne 3 nœuds 2 flèches, auto-déduction d'arêtes, + 3 cas de couleur (vert ≤ 0.05, jaune ≤ 0.15, rouge > 0.15), + inversion higher_is_better avec F1=0.96 → vert, nœud + inconnu dans une arête skipped, valeur de métrique absente + affichée comme — ; anti-injection 4 vecteurs : nom de nœud, + artifact_type, metric_name, input/output types ; rendu en + anglais ; complétude i18n 6 clés). **Verrou levé** : un + benchmark d'axe B avec 3+ étapes (par ex. OCR → LLM → + ALTO_mapper) voit immédiatement à quelle jonction la + qualité décroche, sans avoir à parcourir un tableau de + métriques. + +- **Sprint 94 — B.3 : métrique d'absorption d'erreur (couche + calcul + vue HTML).** Quand un module post-correction LLM + aplatit les différences entre OCR amont, ce n'est pas qu'il + *« améliore »* tous les moteurs — c'est qu'il introduit ses + propres biais qui dominent ceux de l'OCR. Mesurer la + dégradation par étape ne suffit pas : il faut **séparer** + les deux flux à chaque jonction. + + Nouveau module `picarones/core/error_absorption.py` : + + - `compute_error_absorption(reference, before, after, + case_sensitive=False)` — alignement multi-set token-level + sur whitespace ; calcule `errors_before`, `errors_after`, + `corrected = errors_before \\ errors_after`, + `introduced = errors_after \\ errors_before`, + `kept_wrong`, `correction_rate` (= + `n_corrected / n_errors_before` ou `None` si zéro erreur + avant), `introduction_rate` (= `n_introduced / + n_errors_after` ou `None`), `net_improvement`, + `corrected_tokens` et `introduced_tokens` (casse GT + préservée à l'affichage). `None` si la GT est vide. + + - `aggregate_error_absorption(per_doc, sample_tokens=50)` — + somme corpus-wide des compteurs et recalcul *micro* des + taux ; cap des échantillons de tokens pour ne pas exploser + le JSON. + + Généralisation du score de sur-normalisation (chantier + A.I.7) à toute jonction : la formule s'applique uniformément + à OCR→LLM, OCR→reconstructor, VLM→ALTO_mapper. Le module + ne classe pas les erreurs (visuelles, abréviations…) — c'est + une métrique d'**absorption de volume**, pas de qualité + éditoriale ; la qualité reste dans `taxonomy` (Sprint 5). + + Nouveau module `picarones/report/error_absorption_render.py` + : `build_error_absorption_html(junctions, labels, + sample_max=8)` produit un tableau résumé des jonctions du + pipeline ; chaque ligne montre erreurs avant/après, + corrigées (gradient vert), introduites (gradient rouge), + taux corrigées (gradient rouge → vert), taux introduites + (gradient vert → rouge), amélioration nette colorée selon + signe et magnitude, échantillon des tokens introduits (cap). + Adaptive : `""` si la liste est vide. Module pur — + l'utilisateur compose la liste `junctions` depuis son + `PipelineBenchmarkResult` (Sprint 64). Visualisation Sankey + reportée à un sprint dédié (rendu SVG complexe, le tableau + livre l'information de fond). + + +11 clés i18n FR/EN (`absorption_*`). +20 tests dans + `test_sprint94_error_absorption.py` (identité no errors, + perfect correction, pure introduction, mix correction + + introduction avec **cas réaliste maistre Pierre du Bois → + maître Pierre du Bois** prouvant qu'une jonction peut + corriger ET introduire en parallèle, GT vide → None, + case-insensitive par défaut + opt-in case-sensitive, + multiplicité respectée, agrégation micro-rate + skip None + + cap sample, vue HTML 4 cas dont anti-injection sur + junction_name + échantillon introduits + FR + EN, + complétude i18n 11 clés). **Verrou levé** : un benchmark + de pipeline composée peut désormais distinguer un module + qui *corrige* d'un module qui *absorbe* — *« le LLM + postcorr corrige 65 % des erreurs OCR mais introduit + 12 % de nouvelles erreurs (dont des modernisations + systématiques de maistre/nostre/veoir) »*. Sans cette + métrique, on confondait correction et écrasement, et la + communauté scientifique ne pouvait pas faire confiance aux + conclusions sur les pipelines post-correction. + +- **Sprint 93 — A.II.7 : métriques d'image prédictives (couche + calcul + vue HTML).** ``image_quality.py`` (Sprint 5) + mesurait des features indépendamment ; ce module les + **combine** en deux indicateurs corpus-level qui répondent + à des questions de diagnostic distinctes. + + - `picarones/core/image_predictive.py` : + `compute_paleographic_complexity(quality, weights=None)` + retourne ``{score ∈ [0,1], components, weights_used}`` — + combinaison pondérée éditoriale du bruit (0,30), du flou + `1 - sharpness` (0,30), du faible contraste + `1 - contrast` (0,20) et de la rotation + `|degrees| / 30` (0,20). Bornes [0, 1] forcées par + clamping. Poids surchargeables. Garde-fous : `None` si + quality vide ou poids tous nuls. + `compute_corpus_homogeneity(image_qualities)` retourne + ``{score ∈ [0,1], n_docs, per_feature{mean, stdev, + normalised}}`` — moyenne des écart-types normalisés sur + 4 features (plage 0,5 pour [0,1] et 10° pour rotation). + 0 = corpus uniforme (la moyenne globale est fiable), + 1 = corpus très hétérogène (la moyenne ment). + `aggregate_corpus_predictive(image_qualities)` synthétise + complexité (mean/median/min/max/stdev) + homogeneity. + + - `picarones/report/image_predictive_render.py` : + `build_image_predictive_html(aggregated, labels)` produit + deux blocs : tableau résumé complexité (mean coloré + gradient vert → rouge, median, min, max, stdev, n_docs) + + tableau homogénéité (score coloré + détail par feature + avec mean, stdev, contribution normalisée colorée). + Adaptive : `""` si pas de données. Module pur — + l'utilisateur compose + `[doc.image_quality.as_dict() for ...]` → + `aggregate_corpus_predictive` → `build_image_predictive_html`. + + - **Pas de prédiction CER absolue** : on ne prétend pas + fournir une valeur CER en pourcentage (demanderait un + modèle entraîné par moteur, contraire à la philosophie + banc d'essai). Le score est relatif, pour une lecture + diagnostique : *« le doc A est ~3× plus complexe que le + doc B, ce qui est cohérent avec le CER observé »*. + + +20 clés i18n FR/EN (`imgpred_*`). +21 tests dans + `test_sprint93_image_predictive.py` (cas trivial → ≈0, cas + extrême → ≈1, bornes [0,1] respectées sur valeurs hors + plage, components retournés, poids custom (tout sur le + bruit → score = noise_level), poids défaut sommant à 1, + None sur empty et poids nuls ; corpus uniforme → 0, + hétérogène → > 0.5, lt 2 docs → None, per_feature + structurée ; **cas réaliste BnF** mix trivial/difficile, + empty, single doc no homogeneity ; vue HTML 4 cas dont + anti-injection sur titre custom + FR + EN ; complétude i18n + 19 clés). **Verrou levé** : un benchmark BnF voit désormais + *« corpus-wide complexity 0,42 (modérée), homogeneity 0,18 + (uniforme — moyenne fiable) »* dans la vue Analyses, ce qui + permet d'expliquer une partie du CER observé sans tomber + dans la prédiction prescriptive. + +- **Sprint 92 — A.II.9 : métriques longitudinales (régression + linéaire + change-point + détecteur narratif + vue HTML).** + L'historique SQLite (`core/history.py`, Sprint 8) collectait + les résultats sans qu'aucune métrique n'en sorte dans le + rapport. Ce sprint exploite la série temporelle des CER + pour signaler tendances et ruptures — complémentaire à + A.I.3 (off-baseline) qui dit *« écart anormal sur ce + corpus »* sans caractériser la dynamique. + + - `picarones/core/longitudinal.py` : `compute_linear_trend` + régression OLS pure Python sans scipy retourne + `LinearTrend(slope, intercept, r_squared, n_runs)` ; + `detect_change_point(series, min_segment_size=3)` balayage + exhaustif (Pettitt simplifié) retourne + `ChangePointResult(index, timestamp, mean_before, + mean_after, delta, n_before, n_after)` ; + `compute_engine_longitudinal(history, engine, corpus)` + combine les deux avec garde-fou `min_runs_for_trend=3` et + seuil `change_point_threshold=0.01` (1 point CER) pour + filtrer le bruit ; `compute_corpus_longitudinal` agrège + sur tous les moteurs présents. + + - Nouveau `FactType.REGRESSION_IN_HISTORY` (priority 170, + importance MEDIUM par défaut, HIGH si `|absolute_delta| ≥ + 0.05`) + détecteur `detect_regression_in_history` qui lit + `benchmark_data["longitudinal_trends"]`. Déclenche si + pente > +1 pt CER/an **ou** change-point delta > 1 pt CER. + Garde-fou `n_runs ≥ 3`. Le payload trace + `pattern in {"trend", "change_point", + "trend_and_change_point"}`. Templates FR/EN sans chiffres + en dur. Ajout aux paires complémentaires de l'arbitre : + `(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY)` (le leader + peut être en régression, info critique) et + `(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY)` (les deux + se complètent : écart anormal vs tendance dans le temps). + + - `picarones/report/longitudinal_render.py` : + `build_longitudinal_html(trends, labels)` rend un tableau + moteur × {n_runs, premier CER, dernier CER, Δ cumulé + coloré (gradient vert → orange → rouge sur ±5 pts ; bleu + si amélioration), pente annualisée, R², point de rupture + avec timestamp + delta entre parenthèses}. Tri par Δ + décroissant. Adaptive : `""` si pas de données. Module + pur — l'utilisateur compose + `BenchmarkHistory.list_entries()` → + `compute_corpus_longitudinal` → + `build_longitudinal_html`. + + +10 clés i18n FR/EN (`longitudinal_*`). +28 tests dans + `test_sprint92_longitudinal.py` (régression OLS pente + R² + + série plate + lt 2 + même timestamp ; change-point delta + exact + lt segments + uniforme ; intégration entries + + filtre corpus + min_runs + threshold ; multi-moteurs ; + détecteur 6 cas dont silence sans data, silence si plat, + HIGH si Δ ≥ 5 pts, change-point seul, garde-fou n_runs < 3 ; + **traçabilité anti-hallucination FR + EN** sur les sentences + de `build_synthesis` ; vue HTML 4 cas dont anti-injection, + complétude i18n 10 clés). **Verrou levé** : un benchmark + qui pousse ses résultats dans l'historique voit désormais + *« sur les 8 runs historiques pour tess, le CER moyen est + passé de 4 % à 7 % (variation cumulée 3 points) »* dans la + synthèse + le tableau d'évolution dans la vue. Permet de + relier une régression à un changement de pipeline. + +- **Sprint 91 — A.II.6 : métriques économiques (throughput + effectif + coût marginal par erreur évitée).** Le throughput + brut (pages/heure d'OCR pur) ment quand un moteur est rapide + mais imprécis : la correction humaine *post hoc* absorbe le + gain. Cette métrique discrimine fortement entre un cloud + rapide à 30 % de timeouts et un local lent à 100 % de + fiabilité. Couplée au coût marginal par erreur évitée, elle + arme une décision business honnête. + + - `picarones/core/throughput.py` : + `compute_effective_throughput(n_pages, duration_seconds, + n_errors, time_per_error_seconds=5.0)` retourne + `{n_pages, duration_seconds, n_errors, + time_per_error_seconds, correction_time_seconds, + total_seconds, pages_per_hour_raw, + pages_per_hour_effective, drag_ratio}`. Constante + HTR-United (5 s/erreur) surchargeable. Garde-fous : `None` + si `n_pages = 0` ou `total_seconds = 0`, `ValueError` sur + valeurs négatives. `aggregate_effective_throughput` agrège + par moteur sur le corpus. + + - `picarones/core/marginal_cost.py` : + `compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)` + retourne `{cost_per_avoided_error, n_errors_avoided, + cost_delta, dominated}` ou `None` si `errors_b ≥ errors_a` + (pas de gain à mesurer). `dominated=True` quand B est moins + cher ET plus précis (cas idéal Pareto). + `compute_marginal_cost_matrix(per_engine)` retourne toutes + les paires ordonnées (A → B) où B fait moins d'erreurs, + triées par coût marginal croissant. + + - `picarones/report/throughput_render.py` : + `build_throughput_html(aggregated, labels)` produit un + tableau résumé moteur × {pages/h brut, pages/h **utilisable** + (gradient rouge → vert sur le max observé), % drag (gradient + vert → rouge), pages, erreurs}. Tri par pages/h utilisable + décroissant. Adaptive : `""` si pas de données. Module + pur — l'utilisateur compose la liste `per_engine` depuis ses + `EngineReport` (calcul `n_errors` au choix : WER × n_words, + CER × n_chars, etc.). Vue HTML pour le coût marginal sera + couplée à la vue Pareto dans un sprint ultérieur. + + +9 clés i18n FR/EN (`throughput_*`). +27 tests dans + `test_sprint91_throughput.py` (formule effective avec/sans + erreurs, custom time_per_error, garde-fous n_pages=0 + + total_seconds=0 + ValueError sur négatifs, drag_ratio élevé, + agrégation 3 cas, marginal cost standard + dominé + B pire + + errors égales + invalide, matrice tri ascendant + lt 2 + + données invalides, **cas réaliste BnF** Tesseract local 600 + p/h brut → 423 p/h effectif vs GPT-4o cloud 1800 p/h brut → + 300 p/h effectif, vue HTML 4 cas dont anti-injection + tri + descendant, complétude i18n 9 clés). **Verrou levé** : un + archiviste BnF qui pondère un budget contre une exigence de + délai voit immédiatement *« Tesseract local 423 p/h + utilisable, GPT-4o cloud 300 p/h utilisable malgré son + apparente vitesse de 1800 p/h brut »* — la décision business + s'aligne sur la réalité opérationnelle. + +- **Sprint 90 — A.II.4 finition : détecteur narratif + `engine_unstable` + vue HTML stabilité multi-runs.** Le + module `picarones/core/reliability.py` (Sprint 83) livrait + la couche de calcul ; aucun détecteur ni vue ne consommaient + les données. Ce sprint complète A.II.4 sur les moteurs LLM/ + VLM dont les sorties varient entre runs successifs sur les + mêmes documents — situation critique pour la + reproductibilité scientifique d'une publication. Nouveau + `FactType.ENGINE_UNSTABLE` (priority 160, importance HIGH) + + détecteur `detect_engine_unstable` qui lit + `benchmark_data["multirun_stability"]` (liste enrichie + d'`engine_name` + sortie de `compute_multirun_stability`). + Garde-fous : `n_runs ≥ 2`, déclenche si `cer_cv > 0.10` + **ou** `identical_run_rate < 0.50`. Templates FR/EN sans + chiffres en dur. Ajout du couple + `(GLOBAL_LEADER_CER, ENGINE_UNSTABLE)` à + `_COMPLEMENTARY_PAIRS` de l'arbitre — un moteur peut être + leader **et** instable, et c'est précisément l'information + critique à remonter ensemble. Nouveau module + `picarones/report/multirun_stability_render.py` : + `build_multirun_stability_html(stability, labels)` rend un + tableau moteur × {n_runs, CER moyen ± σ, CV (gradient vert + → orange → rouge sur 0–25 %), % runs identiques, sorties + distinctes}. Adaptive : `""` si la liste est vide ou que + tous les `cer_cv` sont `None`. Note d'intégration : la + vue est un module pur (l'utilisateur exécute lui-même les + N runs et appelle `compute_multirun_stability` ; option + runner `--repeats N` reportée à un sprint dédié). +8 clés + i18n FR/EN (`stability_*`). +18 tests dans + `test_sprint90_engine_unstable.py` (FactType + ajout + arbiter, détecteur 6 cas dont silence sans data, silence + stable, HIGH si CV ≥ 10 %, HIGH si runs divergent, garde- + fou n_runs < 2, garde-fou engine manquant, multi-engines, + **traçabilité anti-hallucination FR + EN** prouvant que + chaque chiffre de la phrase rendue par + `build_synthesis(...)["sentences"]` est dans le payload du + Fact, vue HTML 4 cas dont anti-injection nom moteur, + complétude i18n 8 clés). **Verrou levé** : un papier + scientifique qui rapporte un CER LLM voit désormais + immédiatement *« sur 4 runs successifs, gpt-4o produit des + sorties variables (CV 24,3 %) — interpréter avec + prudence »* dans la synthèse + le tableau de stabilité dans + la vue. + +- **Sprint 89 — A.II.8b : score de spécialisation inter-moteurs + (couche calcul + vue HTML).** La matrice de divergence + taxonomique (Sprint 35) répondait à *« à quel point ces + moteurs se trompent-ils différemment ? »* ; ce sprint + transforme cette information en un score lisible et un + **top-N des paires les plus spécialisées**, qui répond + directement à la question *« quels moteurs sont des candidats + pour un voting ensemble ? »*. Le module **ne recommande + pas** d'ensemble — il livre l'observation factuelle et + laisse le chercheur arbitrer. Nouveau module + `picarones/core/specialization.py` : + `compute_specialization_score(taxonomy_a, taxonomy_b)` + retourne un score normalisé ∈ [0, 1] (délégué à + `inter_engine.jensen_shannon_divergence` Sprint 35, pas de + double calcul) ; + `classify_specialization(score, thresholds=DEFAULT_THRESHOLDS)` + classe en `similar` (< 0,10) / `distinct` (0,10–0,30) / + `highly_specialized` (≥ 0,30) — seuils éditoriaux pas + verdict, surchargeables ; + `compute_specialization_matrix(taxonomies)` retourne une + matrice symétrique avec `max_pair` ; + `top_specialized_pairs(matrix, n=5, min_score=0)` retourne + les paires triées par score décroissant avec leur catégorie. + Nouveau module `picarones/report/specialization_render.py` : + `build_specialization_html(taxonomies, labels, top_n=5)` + rend un tableau Moteur A × Moteur B × Score (gradient blanc + → bleu profond) × Lecture (libellé i18n). Adaptive : `""` + si moins de 2 moteurs avec taxonomie. Anti-injection. + Câblage générator : lit les `aggregated_taxonomy` exposés + sur les moteurs (Sprint 5/runner historique), construit la + map `{engine: counts}` et passe au renderer. Insertion dans + `view_analyses.html` derrière la lisibilité. +9 clés i18n + FR/EN (`specialization_*`). +24 tests dans + `test_sprint89_specialization.py` (score symétrique + + identité 0 + disjoint 1 + bornes [0,1], classify 5 cas dont + custom thresholds, matrice diagonale 0 + symétrique + + max_pair correctement identifié, top_pairs tri/n/min_score/ + None, rendu adaptive + anti-injection + FR/EN, complétude + i18n 9 clés). **Verrou levé** : un benchmark BnF avec ≥ 2 + moteurs voit immédiatement *« tess et pero ont une + spécialisation forte (0,489) — ils font des erreurs de + natures différentes »* — observation factuelle, le + chercheur arbitre. + +- **Sprint 88 — A.I.8 vue HTML : déficit projeté de robustesse + (clôture A.I.8 bout-en-bout).** Le module + `picarones/core/robustness_projection.py` (Sprint 81) + calculait la projection des courbes de dégradation + synthétique sur les caractéristiques d'image réelles ; ce + sprint livre la **vue HTML** correspondante. La robustesse + étant un workflow CLI séparé (`picarones robustness`) et non + intégré au benchmark principal, ce sprint livre un **module + de rendu pur** que l'utilisateur compose lui-même + (`analyze_robustness` → `project_robustness_on_corpus` → + `aggregate_projection_per_engine` → + `build_robustness_projection_html`). Nouveau module + `picarones/report/robustness_projection_render.py` : + `build_robustness_projection_html(projection, aggregated, + labels)` produit deux tableaux : + + 1. **Résumé par moteur** — déficit total attendu (gradient + vert → orange → rouge sur ±5 pts de CER), nombre de types + de dégradation évalués, pire dégradation avec sa + contribution. Trié par déficit décroissant. + 2. **Détail (moteur × dégradation)** — docs, docs avec data, + déficit projeté coloré, docs au-dessus du seuil critique. + + Si `aggregated` n'est pas fourni, calculé automatiquement + depuis la projection. Adaptive : `""` si la projection est + vide. Anti-injection systématique sur nom de moteur et type + de dégradation. Note explicite que la sommation suppose + l'indépendance des dégradations *« approximation utile pour + le diagnostic, pas un verdict »*. +13 clés i18n FR/EN + (`robproj_*`). +12 tests dans + `test_sprint88_robustness_projection_html.py` couvrant rendu + vide/None, rendu complet, calcul automatique de + l'agrégation, tri par déficit décroissant, formatage de la + cellule « pire dégradation », gestion d'un déficit None + (cellule —), anti-injection nom moteur + type dégradation, + rendu en français + anglais, **bout-en-bout** avec le + pipeline réel `project_robustness_on_corpus` + + `aggregate_projection_per_engine`, complétude i18n 13 clés. + **Verrou levé** : un benchmark BnF qui veut savoir *« mon + corpus de notaires XVIIᵉ siècle est-il à risque face à mon + moteur OCR ? »* obtient un tableau lisible directement + intégrable dans le rapport — A.I.8 livrée bout-en-bout + (calcul Sprint 81 + vue HTML Sprint 88). + +- **Sprint 87 — A.II.2 : delta Flesch câblé bout-en-bout + (couche calcul Sprint 52 + runner + vue HTML).** Le module + `picarones/core/readability.py` (Sprint 52) calculait le + delta Flesch *« over-normalisation par LLM »* — ce sprint le + remonte automatiquement dans le rapport. Nouveau helper + `picarones/core/readability_runner.py` : + `compute_readability_metrics(reference, hypothesis, lang)` + avec **adaptive masking** (≥ 5 mots GT pour éviter + l'instabilité de Flesch sur très courts textes) ; + `aggregate_readability_metrics(per_doc)` retourne + `{lang, n_docs, n_docs_with_delta, delta_mean, delta_median, + delta_min, delta_max, n_over_normalized, n_under_normalized, + over_normalized_rate}` — l'over-normalisation est définie à + Δ > +5 points (LLM modernise un texte ancien), l'under- + normalisation à Δ < -5 (dégradation OCR brutale). + `DocumentResult.readability_metrics` et + `EngineReport.aggregated_readability` (sérialisation + conditionnelle, libérés par `compact`). Câblage runner : + langue lue depuis `corpus.metadata.get("language", "fr")`, + fallback `fr` avec warning si valeur non `fr`/`en`, + paramètre `corpus_lang` propagé jusqu'aux workers IO et CPU + (workers acceptent maintenant 7 ou 8 args en mode legacy + pour rétrocompat). Erreur isolée par try/except + warning + explicite. Nouveau module + `picarones/report/readability_render.py` : + `build_readability_summary_html` rend un tableau résumé + moteur × {Δ moyen coloré (vert au centre, orange si over- + norm, bleu si under-norm), Δ médian, % over-normalisés, + docs under-normalisés, docs} ; saturation à ±15 points. + Insertion dans `view_analyses.html` derrière les blocs + A.II.5. Anti-injection systématique. +8 clés i18n FR/EN. + +20 tests dans `test_sprint87_readability_html.py` + (adaptive masking GT < 5 mots, langue passée à fr/en, + hypothèse vide → flesch_delta None mais flesch_reference + conservé, agrégation moyenne + over-norm rate, sérialisation + `DocumentResult`/`EngineReport`, `compact`, masquage + adaptatif HTML, rendu FR + EN, anti-injection sur nom + moteur, complétude i18n 8 clés). **Verrou levé** : le + rapport remonte désormais *« GPT-4o : Δ moyen +11,5, + 85 % des docs over-normalisés »* directement dans la vue + Analyses — pas de visualisation HTML pour les VLM + hallucinant du français moderne sur du français médiéval + jusqu'ici, c'est livré. Reste pour A.II.2 bout-en-bout : + reading_order_f1 et layout_f1 (Sprints 53-54) qui requièrent + un moteur produisant PAGE/ALTO et seront câblés via les + pipelines composées (axe B). + +- **Sprint 86 — A.II.5 : câblage runner + vues HTML (clôture + bout-en-bout).** Suite directe Sprints 84 et 85 — la couche + de calcul livrait deux modules pour le mode plein-texte + patrimonial, ce sprint les remonte automatiquement dans le + rapport. Deux nouveaux helpers + `picarones/core/searchability_runner.py` et + `picarones/core/numerical_sequences_runner.py` qui calculent + les métriques par document avec **adaptive masking** (rien + n'apparaît pour un doc sans GT exploitable) et agrègent + corpus-wide en *micro*-rappel pour la searchability et en + somme de compteurs par catégorie pour les séquences + numériques. `DocumentResult` gagne `searchability_metrics` + et `numerical_sequence_metrics` ; `EngineReport` gagne + `aggregated_searchability` et `aggregated_numerical_sequences` + (sérialisation conditionnelle dans `as_dict`, libérés par + `compact`). Le runner historique calcule désormais les deux + inconditionnellement (coût négligeable face à l'OCR), erreur + d'un module isolée par try/except + warning explicite, + rétrocompat stricte (aucun champ ajouté au JSON quand le + corpus est sans signal). Deux nouveaux modules de rendu + `picarones/report/searchability_render.py` et + `picarones/report/numerical_sequences_render.py` : + `build_searchability_summary_html` produit un tableau résumé + moteur × (rappel coloré gradient rouge → jaune → vert, + retrouvés/total, docs) ; + `build_numerical_sequences_html` produit un tableau moteur × + catégorie (year/roman/foliation/currency/regnal) avec + **adaptive masking par catégorie** (une catégorie sans signal + est omise pour tous les moteurs) ; chaque cellule affiche le + score strict (gradient) + la valeur entre parenthèses + le + n. Insertion dans `view_analyses.html` derrière le profil + philologique, `chart-card` pleine largeur conditionné. + Anti-injection systématique (`html.escape`). +15 nouvelles + clés i18n FR/EN (`search_*`, `numseq_*`). +25 tests dans + `test_sprint86_aii5_html.py` couvrant adaptive masking sur + les helpers, agrégation micro-rappel, somme par catégorie, + sérialisation `DocumentResult`/`EngineReport`, + `compact` qui efface bien les champs, masquage adaptatif HTML + (vide quand sans signal, omission de catégories), rendu en + FR + EN, anti-injection sur nom de moteur, complétude i18n + sur 15 clés. **Verrou levé** : un benchmark BnF voit + désormais sur la vue Analyses *« Recherchabilité fuzzy : + tess 95,2 %, pero 87,8 % »* + le tableau séquences + numériques détaillé par catégorie — A.II.5 est livrée + bout-en-bout en couche calcul (Sprints 84-85), runner et + HTML (Sprint 86). + +- **Sprint 85 — A.II.5b : précision sur séquences numériques + (couche de calcul + registre typé).** Pour un économiste- + historien, un éditeur de chartes ou un archiviste, la + fidélité aux séquences numériques est un proxy direct de la + qualité éditoriale. Un OCR qui rate *« 1789 »* dans une + charte révolutionnaire ou *« f. 12v »* dans une cote + d'archives produit un corpus inutilisable, même si le CER + global est respectable. Nouveau module + `picarones/core/numerical_sequences.py` couvrant 5 catégories : + + - **Dates arabes** : années 4 chiffres dans la plage + [1000-2099] (détection conservatrice pour éviter les + faux positifs sur volumes/numéros). + - **Numéraux romains** : réutilise + `picarones.core.roman_numerals.detect_roman_numerals` + (Sprint 60), `min_length=2`. + - **Foliotation** : `f.`, `fol.`, `p.`, `pp.`, `n°` avec + suffixe `r`/`v` préservé (recto/verso = information + distincte, **non interchangeable** côté valeur). + - **Montants** : Ancien Régime (`livres`/`l.`, + `sols`/`s.`, `deniers`/`d.`) et modernes (`£`, `€`, `₣`, + `écus`, `florins`, `francs`). + - **Années régnales** : `an III`, `l'an V`, `an de grâce + 1450`, `an de la République`. + + Pour chaque GT, classification en 3 statuts : + `strict_preserved` (forme exacte), `value_preserved` (la + valeur apparaît même si la forme diffère, `XIV` ↔ `14` + pour les romains ; **mais pas** `f. 12r` ↔ `f. 12v` car + recto/verso est une distinction substantielle), `lost`. + `compute_numerical_sequence_metrics` retourne + `{global_strict_score, global_value_score, n_total, + per_category{n_total, strict, value, strict_score, + value_score, lost_items}}`. Multiplicité respectée (un + item hyp ne peut servir qu'à un seul match). + `numerical_sequence_strict_score` et + `numerical_sequence_value_score` enregistrés dans le + registre typé Sprint 34 pour `(TEXT, TEXT)`. Limites + documentées : regex conservatrices (`mil cinq cens` non + détecté comme année), pas de cross-category match + (`MDCLXVIII` GT et `1668` hyp sont catégorisés + séparément). +27 tests dans + `test_sprint85_numerical_sequences.py` couvrant détecteurs + individuels (year/roman/foliation/currency/regnal), + scénarios identité/perte totale/GT vide/recto-verso non + interchangeables/multiplicité, **2 cas réalistes** (charte + XVIIIᵉ siècle préservée intégralement vs registre paroissial + où l'OCR modernise XVIII→18 mais préserve l'année 1750 et + la foliation), intégration registre 4 cas dont + `compute_at_junction`. **Verrou levé** : un bench + d'archive numérique peut classer ses moteurs sur la + dimension *« mes dates et cotes seront-elles fiables ? »*, + qui complète la **recherchabilité fuzzy** (Sprint 84) pour + livrer A.II.5 en couche de calcul intégrale. + +- **Sprint 84 — A.II.5 : recherchabilité fuzzy (couche de + calcul + métrique enregistrée).** Le CER mesure les erreurs + caractère par caractère ; pour un usage *recherche + plein-texte* (Elastic, Solr en mode fuzzy, full-text de + Gallica), la question réelle est : *« combien de mots GT + sont retrouvables dans la sortie OCR à orthographe approchée + près ? »*. Un CER de 8 % peut donner 95 % de findability si + les erreurs sont concentrées sur des caractères non + significatifs ; à l'inverse, 4 % de CER mais distribué sur + tous les noms propres rend le corpus inutilisable pour + l'indexation prosopographique. Nouveau module + `picarones/core/searchability.py` : `levenshtein_distance(a, + b)` (DP O(|a|·|b|), mémoire O(min(|a|,|b|))); + `compute_searchability(reference, hypothesis, + max_distance=2, case_sensitive=False)` aligne par multi-set + (un token hyp utilisé une seule fois, comme + rare_token_recall Sprint 71), retourne `{n_gt_tokens, + n_searchable, recall, missed_tokens, max_distance}` avec + `recall=None` quand n_gt=0 (différencie GT vide de aucun + match), court-circuit longueur (Levenshtein ≥ |Δlen|) et + arrêt précoce sur match exact. `searchability_recall_metric` + enregistré dans le registre typé Sprint 34 pour la jonction + `(TEXT, TEXT)` (convention float : 0.0 si GT vide). Tableau + Elastic ``fuzziness: AUTO`` (≤ 2) en défaut, paramétrable. + Limites documentées : tokenisation par split whitespace ; + Levenshtein non pondéré ; pas de sémantique (BERTScore + reporté). +28 tests dans `test_sprint84_searchability.py` + (Levenshtein 9 cas dont identité/insertion/suppression/ + substitution/disjoint/empty/kitten classique, computation + 13 cas dont identité, complètement différent, GT vide + (recall None), hypothèse vide (recall 0), max_distance=0 + exact, max_distance=2 swap, max_distance large, casse + insensible, casse sensible opt-in, multiplicité, + missed_tokens préserve casse GT, ValueError sur + max_distance négatif, deux **cas réalistes opposés** + (« Charles → Charlemagne » non retrouvé vs « maistre → + maitre » retrouvé), intégration registre 4 cas dont + `compute_at_junction`). **Verrou levé** : un bench BnF + d'archive numérique peut désormais classer ses moteurs sur + la dimension *« mes corpus seront-ils retrouvables après + OCRisation ? »* — proxy direct de la valeur d'usage. + +- **Sprint 83 — A.II.4 : métriques de fiabilité (couche de + calcul).** Premier sprint de l'Étape 4 du plan d'évolution + 2026 après la clôture de A.I. Une publication scientifique + qui rapporte un CER LLM sans stabilité est méthodologiquement + faible ; un benchmark qui ignore le plafond humain (« deux + paléographes ne sont pas même d'accord ») crée des + classements faussement optimistes. Nouveau module + `picarones/core/reliability.py` couvrant deux familles : + + - **Inter-annotator agreement (IAA) au niveau caractère.** + `cohen_kappa(annotations_a, annotations_b)` : κ standard + avec gestion des cas dégénérés (tailles incompatibles → + `None`, séquences vides → `None`, un seul label → + convention 1.0/0.0 documentée car κ mathématiquement + indéfini quand pe = 1). `krippendorff_alpha(units)` : α + de Krippendorff en mode nominal, généralisé à N + annotateurs avec missing values autorisées (cellules + `None`), formule `1 - D_o / D_e` avec `D_e` calculé sur + les paires sans remise. `compute_iaa(transcription_a, + transcription_b)` : aligne deux GT caractère par + caractère via `_aligned_char_pairs` (segments `equal` et + `replace` de `SequenceMatcher`, les `insert`/`delete` + n'ayant pas d'alignement bilatéral exploitable) puis + calcule κ et α sur les paires alignées + agreement_rate + + n_aligned_chars. + + - **Stabilité multi-runs.** `compute_multirun_stability(runs, + reference=None)` mesure la variance d'une pipeline + LLM/VLM non-déterministe relancée N fois sur le même + document : pairwise_disagreement (Jaccard token-level) + moyen et max, identical_run_rate, n_distinct_outputs. Si + `reference` fournie, on calcule `cer_per_run`, + `cer_mean`, `cer_stdev`, `cer_cv` (coefficient de + variation, `None` quand mean=0 pour éviter la division + par zéro). Retourne `None` si moins de 2 runs. + + Périmètre Sprint 83 : **couche de calcul uniquement**. + L'extension du loader pour accepter `doc_001.gt.A.txt` et + `doc_001.gt.B.txt` comme GT multiples, l'option + `--repeats N` du runner et le détecteur narratif + `engine_unstable` arriveront dans des sprints suivants. + +26 tests dans `test_sprint83_reliability.py` (cohen_kappa + 6 cas dont accord parfait/désaccord pire que hasard/un seul + label, krippendorff_alpha 5 cas, compute_iaa 5 cas dont + empty/one-empty, compute_multirun_stability 6 cas dont + reference parfaite/CV indéfini, _aligned_char_pairs 4 cas). + **Verrou levé** : le rapport pourra demain afficher le + plafond humain à côté du CER (« CER de Pero 4,2 % approche + le κ inter-paléographes 0,89 ») et signaler les pipelines + LLM dont la variance dépasse un seuil. + +- **Sprint 82 — A.I.9 : section « Leviers d'amélioration » + (couche calcul + cards HTML).** Le moteur narratif + (Sprint 19) émet des `Fact` qui décrivent **ce qui s'est + passé** dans le benchmark. Ce sprint répond à une question + complémentaire : *« sur quelle dimension le bénéfice attendu + d'une amélioration serait-il le plus visible ? »*. Approche + strictement **non-prescriptive** : aucune recommandation + *« faites X »*, uniquement des **observations factuelles** + agrégées depuis les modules d'analyse (Sprints 75-81). + Nouveau module `picarones/core/levers.py` : dataclass + ``Lever(type, importance, payload, engines_involved)``, + ``LeverImportance`` (HIGH/MEDIUM/LOW), registre via + décorateur ``@register_lever``, helper ``detect_levers`` qui + trie par importance décroissante. **5 détecteurs livrés** : + ``dominant_recoverable_class`` (≥30 % d'erreurs récupérables + selon la catégorisation Sprint 77), ``pareto_concentration`` + (top-20 % docs ≥50 % du CER cumulé), ``complementarity_observation`` + (factuel sur ``inter_engine_analysis.complementarity_gap``, + Sprint 35), ``lexical_modernization_observation`` (top-3 + tokens GT systématiquement modernisés, Sprint 80), + ``robustness_projection_observation`` (déficit projeté ≥2 + points de CER, Sprint 81). Nouveau module + `picarones/report/levers_render.py` : ``build_levers_section_html`` + rend des **cards** server-side avec étiquette i18n + phrase + factuelle + détail compact + niveau d'importance coloré. + Adaptive masking : ``""`` si aucun levier exploitable. + Anti-injection systématique via ``html.escape``. Garde-fou + anti-hallucination identique au moteur narratif : chaque + chiffre rendu est dans le ``payload`` du levier. +18 clés + i18n FR/EN (``levers_*``). +40 tests dans + `test_sprint82_levers.py` (modèle 3, dominant 6, pareto 5, + complementarity 4, lexical 4, robustness 4, pipeline 3, + rendu 6, anti-hallucination FR+EN 3, complétude i18n 2). + **Verrou levé** : le rapport ne se contente plus de décrire + *ce qui est* — il propose une lecture compacte des + **dimensions où un effort éditorial pourrait porter**, sans + jamais imposer un verdict. + +- **Sprint 81 — A.I.8 : robustesse synthétique projetée sur le + corpus réel (couche de calcul).** Le module + ``picarones/core/robustness.py`` (Sprint 8) génère des courbes + CER vs niveau de dégradation **synthétique** ; + ``image_quality.py`` mesure le bruit/flou réels du corpus. Ce + sprint **projette** les caractéristiques réelles sur les + courbes synthétiques pour estimer le **déficit attendu de CER** + sur le corpus dans son état actuel. + - Nouveau module `picarones/core/robustness_projection.py` : + - ``_interpolate_cer(levels, cer_values, target_level)`` + interpolation linéaire avec **clip** aux bornes (pas + d'extrapolation hasardeuse). Filtre les ``cer_values`` + à ``None``. + - ``_extract_quality_value(quality_dict, degradation_type, + custom_mapping)`` extrait la valeur pertinente depuis + ``ImageQualityResult.as_dict()`` (mapping default : + noise→noise_level, blur→blur_score, etc.). + - ``project_robustness_on_corpus(curves, image_qualities, + quality_to_level, critical_threshold)`` retourne + ``{engine: {degradation_type: {n_docs, n_docs_with_data, + expected_cer_mean, expected_cer_median, baseline_cer, + deficit_vs_baseline, n_docs_above_critical, + critical_threshold_level, critical_threshold_cer}}}``. + - ``aggregate_projection_per_engine(projection)`` somme les + déficits sur tous les types de dégradation et identifie le + **type le plus pénalisant** (worst_degradation_type). + Hypothèse d'indépendance des dégradations documentée. + - +22 tests dans `test_sprint81_robustness_projection.py` : + interpolation (7 cas — exact, linéaire, clip lower/upper, + vide, all None, partiel None) ; extraction qualité (4 cas — + default, unknown, missing, custom) ; projection (7 cas — + single curve, doc above critical, doc sans data, multi + moteurs/types, no curves, no docs, threshold override) ; + agrégation (4 cas — total, worst, None skipped, vide). + - **Verrou levé** : un benchmark BnF avec + ``image_quality_aggregated`` peut désormais lire *« 30 % + de vos documents ont un bruit où Tesseract perd 8 points de + CER — déficit attendu global 2,4 points »*. La courbe de + robustesse n'est plus déconnectée du corpus réel. + +- **Sprint 80 — A.I.7 : sur-normalisation lexicale en vue + analytique dédiée (couche calcul + table HTML).** Le détecteur + ``llm_hallucination_flag`` (Sprint 19) signale qu'un moteur + sur-normalise via un score agrégé. Mais ce score ne dit rien + sur **quoi** corriger dans le prompt. Ce sprint produit une + **table de fréquences détaillée** par token GT. + - Nouveau module `picarones/core/lexical_modernization.py` : + - ``compute_lexical_modernization(reference, hypothesis, + stop_list, case_sensitive)`` aligne mot-à-mot via + ``difflib.SequenceMatcher`` et accumule par token GT : + ``{n_total, n_modernized, rate_modernized, variants}``. + - ``aggregate_lexical_modernization(per_doc_results)`` somme + les compteurs corpus-wide. + - ``top_modernized_tokens(data, n=20, min_total=1)`` retourne + les N tokens GT les plus modernisés (tri décroissant par + taux, tie-break par n_total). Filtre les anecdotiques + via ``min_total``. + - Stop-list paramétrable (tokens GT à ignorer même s'ils + sont modifiés) — par défaut vide, le module ne devine pas + ce qui est « moderne ». + - Cas particuliers : token GT supprimé → variant ``∅``. + - Nouveau module `picarones/report/lexical_modernization_render.py` : + - ``build_lexical_modernization_html(data, labels, top_n, + min_total)`` produit un tableau HTML 4 colonnes (forme + historique GT, variantes OCR, n GT, % modernisé). + - Cellule ``% modernisé`` colorée en gradient blanc → orange. + - Compactage des variants : top 3 affichés + ``+N`` pour le + reste. + - Adaptive : ``""`` si ``data is None`` ou aucun token + modernisé. + - +6 clés i18n FR/EN (``lexmod_*``). + - +20 tests dans `test_sprint80_lexical_modernization.py` : + couche calcul (9 cas — systématique, préservé, partiel, + multi-variants, stop-list, casse, suppression, vide, None) ; + agrégation (2 cas) ; top (2 cas — tri, min_total) ; rendu + (5 cas — None, no_modernization, table, %, anti-injection) ; + complétude i18n FR + EN. + - **Verrou levé** : le chercheur peut désormais lire « maistre + → maître modernisé dans 100 % des cas » et ajuster son prompt + en conséquence pour préserver l'orthographe historique. + L'information est exploitable au lieu d'un score agrégé + abstrait. + +- **Sprint 79 — A.I.6 : projection de coût en volume cible + (couche de calcul).** La vue Pareto (Sprint 20) trace CER vs + coût mais le coût est par unité (1 000 pages). Pour décider + business-side, il faut projeter ce coût sur le **volume cible** + que l'utilisateur prévoit de traiter — payer 50 € de plus sur + 50 pages est trivial, sur 5 millions ça change tout. + - Nouveau module `picarones/core/cost_projection.py` : + - Dataclass ``ProjectedCost(engine_key, target_pages, + cost_total_eur, co2_total_g, cost_per_1k_pages_eur, + co2_per_1k_pages_g, type)``. + - ``project_cost_total(engine_cost, target_pages)`` : coût + total linéaire en pages. ``None`` si données insuffisantes + ou ``target_pages < 0``. + - ``project_co2_total(engine_cost, target_pages)`` : + empreinte CO₂ en grammes pour le volume cible (étiqueté + « expérimental » dans ``pricing.py`` Sprint 20). + - ``project_engine(engine_cost, target_pages)`` : retourne + le ``ProjectedCost`` complet. + - ``project_all_engines(engine_costs, target_pages)`` + projette N moteurs en une passe. ``ValueError`` si + ``target_pages < 0``. + - ``cost_gap_table(projections, baseline_engine)`` retourne + ``{engine: {total, delta_abs, delta_rel}}`` vs baseline ; + ``KeyError`` si baseline inconnue ; ``delta_rel = None`` si + baseline = 0 (pas de division silencieuse). + - +17 tests dans `test_sprint79_cost_projection.py` : + couche calcul (5 cas — linear, zero, négatif, no_data, + fractionnel), CO₂ (2 cas), engine (2 cas), all_engines (3 + cas), gap_table (4 cas — vs baseline, baseline inconnue, + baseline=0, données manquantes), **cas réaliste BnF** + (80 000 pages BMS avec 4 moteurs : Tesseract 3,20 €, Pero + 0 €, Mistral 280 €, GPT-4o 600 €). + - **Verrou levé** : la couche calcul est prête pour câbler le + panneau « Avancé » (Sprint 21) avec le champ « Volume cible » + qui recalcule la vue Pareto et la table coût en valeur + totale projetée. L'UX et le câblage HTML suivront — la + base est testée et auto-documentée. + +- **Sprint 78 — A.I.5 : équivalences diplomatiques en curseur + fin (couche de calcul).** Aujourd'hui les profils de + ``picarones/core/normalization.py`` (``medieval_french``, + ``early_modern_french``, etc.) appliquent un **bloc entier** + de transformations. Mais un éditeur peut vouloir nuancer : + *« je tolère ``ſ → s`` mais pas ``u → v`` »*. Ce sprint + éclate chaque profil en règles d'équivalence **nommées et + indépendantes** que l'utilisateur peut activer ou désactiver + une par une. + - Nouveau module `picarones/core/equivalence_profile.py` : + - Dataclass ``EquivalenceRule(name, source, target, + description, profile_tag)``. + - Catalogue ``BUILTIN_EQUIVALENCES`` construit + automatiquement depuis les ``DIPLOMATIC_*`` existants avec + noms canoniques stables (``longs_s``, ``u_eq_v``, + ``i_eq_j``, ``ae_ligature``, ``thorn_th``, ``vv_eq_w``, + etc.) : 15 règles couvrant les 4 profils intégrés. + - ``list_equivalences_by_profile(profile_name=None)`` pour + grouper par profil dans l'UX. + - ``apply_selected_equivalences(text, selected_names)`` + applique uniquement les règles dont le nom est dans + ``selected_names``. Règles inconnues ignorées + silencieusement avec warning. Texte vide / None → ``""``. + - ``compute_cer_with_equivalences(reference, hypothesis, + selected_names)`` retourne le CER après normalisation + sélective sur les **deux** côtés (GT et hyp). + - Aucune modification de ``normalization.py`` — purement + additif. + - +17 tests dans `test_sprint78_equivalence_profile.py` : + catalogue (4 cas — règles canoniques, structure, noms + uniques, longs_s correct), liste par profil (3 cas), apply + (6 cas — sélectif, exclu, multi, vide, texte vide, règle + inconnue), compute_cer (4 cas — drop avec eq, application + bilatérale, diff résiduelle, vide). + - **Verrou levé** : la couche calcul est en place pour qu'un + développeur frontend puisse câbler le panneau « Avancé » du + rapport (Sprint 21) avec des cases à cocher granulaires et + recalcul JS client. L'UX panneau avancé (état URL + persisté, debounce 1s) suivra dans un sprint dédié — la + base est livrée, testée, et auto-documentée. + +- **Sprint 77 — A.I.4 chantier 3 : taxonomie comparative + côte-à-côte (clôture A.I.4).** Troisième et dernier chantier + d'A.I.4. Le détecteur ``error_profile_outlier`` (Sprint 19) + signale qu'un moteur a un profil taxonomique éloigné de ses + concurrents, mais sans visualisation. Ce sprint répond à + *« deux moteurs ont le même CER global, mais lequel fait des + erreurs plus récupérables ? »*. + - Nouveau module `picarones/core/taxonomy_comparison.py` : + - ``compare_taxonomies(engine_a, counts_a, engine_b, counts_b)`` + normalise les comptes en proportions (somme = 1), calcule + les ``deltas`` signés (b - a) par classe, et agrège par + niveau de **récupérabilité éditoriale** : + + - ``recoverable`` : case_error, ligature_error, + abbreviation_error (corrigeables par post-processing + trivial) + - ``difficult`` : diacritic_error, visual_confusion, + hapax (effort modéré requis) + - ``irrecoverable`` : lacuna, oov_character, + segmentation_error (impossibles sans relire l'image) + - Constante ``RECOVERABILITY`` exportée pour utilisation + externe. + - Retourne ``None`` si les deux moteurs ont 0 erreur chacun. + - Nouveau module `picarones/report/taxonomy_comparison_render.py` : + - ``build_taxonomy_comparison_html(data, labels)`` produit + titre + note d'usage + diagramme miroir SVG + tableau + résumé par catégorie. + - ``_build_mirror_chart_svg`` server-side : une ligne par + classe, deux barres horizontales (A à gauche, B à droite), + étiquette de classe au centre, valeurs en %. Couleur de + la barre selon ``recoverability`` (vert / orange / rouge). + Échelle normalisée à la proportion max pour visibilité + uniforme. + - ``_build_recoverability_summary_html`` : tableau 3 lignes + (Récupérable / Difficile / Irrécupérable) × 2 colonnes + (engine A / engine B) avec pastille colorée et %. + - Adaptive : ``""`` si ``data is None`` ou pas de classes. + - Anti-injection systématique sur noms de moteurs et labels + i18n. Accessible : ``role="img"`` + ``aria-label``. + - +6 clés i18n FR/EN (``taxocomp_*``) avec template Python + ``{engine_a}/{engine_b}``. + - +18 tests dans `test_sprint77_taxonomy_comparison.py` : + couche calcul (7 cas — proportions, deltas signés, + récupérabilité, vide, classe unique chez un moteur, totaux, + sanité ``RECOVERABILITY`` couvre toutes ``ERROR_CLASSES``), + rendu (7 cas — None, SVG, noms moteurs, labels classes, + résumé récupérabilité, % affichés, codes couleur), anti- + injection (nom moteur + label i18n), complétude i18n FR + EN. + - **Choix éditorial assumé** : la classification + ``recoverable``/``difficult``/``irrecoverable`` est un + **guide pragmatique pour le chercheur**, pas un verdict + imposé. La note explicative dit textuellement « à CER égal, + un moteur dont les erreurs sont majoritairement vertes est + préférable pour une édition critique » — c'est au chercheur + de juger selon ses besoins. + - **A.I.4 livré bout-en-bout** : co-occurrence (Sprint 75) + + intra-document (Sprint 76) + comparatif (Sprint 77). + +- **Sprint 76 — A.I.4 chantier 2 : évolution intra-document + des classes taxonomiques (couche calcul + heatmap SVG).** + Deuxième des trois chantiers d'A.I.4. ``line_metrics.py`` + (Sprint 10) avait déjà une heatmap **CER × position** dans le + document ; ce sprint l'étend à toutes les classes + taxonomiques : où dans le document apparaît tel type d'erreur ? + Lecture concrète : ``ligature_error`` concentré dans la + première tranche → erreur de **marge** ; uniformément réparti + → erreur de **scribe**. + - Nouveau module `picarones/core/taxonomy_intra_doc.py` : + - ``compute_taxonomy_position_heatmap(reference, hypothesis, + n_bins=10)`` calcule, pour chaque classe taxonomique, le + nombre d'erreurs par tranche de position. Réutilise la + logique mot-à-mot de ``classify_errors`` (Sprint 5) en + gardant la position du mot GT (``i1`` dans la diff + word-level) et en binnifiant par + ``floor(i1 / n_gt_words * n_bins)``. + - ``_classify_word_pair`` : variante pure de la + classification (sans modifier de compteurs externes). + - Helper ``_bin_for_position`` : clip entre 0 et n_bins-1. + - ``ValueError`` si ``n_bins ≤ 0``. Retourne ``None`` si + la GT est vide. + - Nouveau module `picarones/report/taxonomy_intra_doc_render.py` : + - ``build_taxonomy_intra_doc_html(data, labels)`` produit + heatmap SVG + titre + note d'usage. + - ``_build_heatmap_svg`` server-side : grille + classes_avec_erreurs × n_bins, gradient blanc → orange + profond (#c2410c), valeur affichée si > 0, étiquettes de + colonnes (positions 1..N) et de lignes (noms de classes), + légende axe X. Densité **relative au max de la classe** + (mise en évidence des positions concentrées). + - Adaptive : ``""`` si ``data is None``, ``total_errors=0`` + ou aucune classe avec erreurs. Filtrage : seules les + classes ayant ≥ 1 erreur apparaissent en ligne. + - Accessible : ``role="img"`` + ``aria-label``. + - +3 clés i18n FR/EN (``intradoc_title``, ``intradoc_note``, + ``intradoc_n_words`` avec template Python). + - +16 tests dans `test_sprint76_taxonomy_intra_doc.py` : + couche calcul (8 cas — identité, GT vide, erreur en début, + erreur en fin, distribution uniforme, ``n_bins`` invalide, + breakdown par classe, plus de bins que de mots), rendu (5 + cas — None, no_errors, SVG, labels, n_words affichés), + anti-injection, complétude i18n FR + EN. + - **Verrou levé** : un chercheur peut désormais voir, pour un + document donné, **où** chaque type d'erreur apparaît — utile + pour distinguer erreurs de marge, erreurs de scribe, et + erreurs concentrées sur des sections spécifiques (titres, + manchettes…). + +- **Sprint 75 — A.I.4 chantier 1 : co-occurrence taxonomique + (couche calcul + heatmap SVG bout-en-bout).** Premier des trois + chantiers d'A.I.4. La taxonomie d'erreurs (10 classes, + ``picarones/core/taxonomy.py``) est calculée par document + depuis longtemps mais le rapport ne montre qu'un seul + histogramme global. Ce sprint répond à *« quelles classes + d'erreur tendent à apparaître ensemble dans les mêmes + documents ? »* — utile pour stratifier *a posteriori* (« mes + documents difficiles ont tous ``ligature_error`` + + ``abbreviation_error`` ensemble : signal d'un type de scribe »). + - Nouveau module `picarones/core/taxonomy_cooccurrence.py` : + - ``compute_taxonomy_cooccurrence(per_doc_classes, + min_doc_count=1, top_n_pairs=10)`` calcule l'indice de + **Jaccard** entre paires de classes au niveau **document** + (présence binaire — un doc « contient » la classe X ou + pas). Symétrique, diagonale = 1.0 pour les classes + présentes. + - Filtrage des classes anecdotiques via ``min_doc_count`` + (défaut 1). + - ``top_pairs`` : top-N paires triées par Jaccard décroissant + (utile pour la table HTML compacte). + - Retourne ``None`` si ``per_doc_classes`` vide ou si aucune + classe ne dépasse ``min_doc_count``. + - Nouveau module `picarones/report/taxonomy_cooccurrence_render.py` : + - ``build_taxonomy_cooccurrence_html(data, labels)`` produit + titre + note + heatmap SVG + table top_pairs. + - ``_build_heatmap_svg`` server-side : grille N×N avec + cellules colorées par gradient blanc → bleu profond + (#1e3a8a) selon Jaccard, valeur affichée si > 0,05, + étiquettes rotées -45° en haut, normales à gauche. SVG + accessible (``role="img"`` + ``aria-label``). + - ``_build_top_pairs_table`` : table HTML avec cellule + Jaccard colorée pour lecture rapide. + - Adaptive : ``""`` si ``data is None`` ou matrice vide. + - +5 clés i18n FR/EN (``taxocooc_*``). + - +22 tests dans `test_sprint75_taxonomy_cooccurrence.py` : + couche calcul (11 cas — toujours/jamais ensemble, diagonale, + symétrie, chevauchement partiel, vide, ``min_doc_count``, + ``top_pairs`` triées et limitées, ``doc_count``, doc=None), + rendu (7 cas — None, classes vides, SVG, table, valeurs + affichées, étiquettes, n_docs), anti-injection (classe + ``