# 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/). --- ## [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 ``