# Changelog — Picarones Tous les changements notables de ce projet sont documentés dans ce fichier. 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 ``