- Run A : {{ diff.run_date_a or "?" }} · corpus {{ diff.corpus_a or "?" }}
- Run B : {{ diff.run_date_b or "?" }} · corpus {{ diff.corpus_b or "?" }}
- Seuil régression / amélioration : {{ "%.3f"|format(diff.threshold) }}
- ({{ "%.1f"|format(diff.threshold * 100) }} pp de CER absolu).
-
-
-
Moteurs comparés ({{ diff.deltas|length }})
-{% if not diff.deltas %}
-
Aucun moteur commun aux deux runs.
-{% else %}
-
-
-
-
Moteur
-
CER A
-
CER B
-
Δ CER
-
Docs A → B
-
État
-
-
-
- {% for d in diff.deltas %}
-
-
{{ d.engine }}
-
{{ "%.3f"|format(d.cer_a) if d.cer_a is not none else "—" }}
-
{{ "%.3f"|format(d.cer_b) if d.cer_b is not none else "—" }}
Picarones — Sprint 28 · rapport de comparaison de runs.
-
-
-"""
-
-
-def render_comparison_html(
- diff: ComparisonResult,
- output_path: str | Path,
-) -> Path:
- """Sérialise un ``ComparisonResult`` en rapport HTML auto-contenu."""
- from jinja2 import Environment, select_autoescape
-
- env = Environment(autoescape=select_autoescape(["html", "j2"]))
- template = env.from_string(_COMPARISON_TEMPLATE)
- html = template.render(diff=diff)
- out = Path(output_path)
- out.parent.mkdir(parents=True, exist_ok=True)
- out.write_text(html, encoding="utf-8")
- return out
+import warnings
+from picarones.reports_v2.html.comparison import * # noqa: F401, F403
-__all__ = [
- "EngineDelta",
- "ComparisonResult",
- "load_benchmark_json",
- "compare_benchmarks",
- "detect_regressions",
- "render_comparison_html",
-]
+warnings.warn(
+ "picarones.report.comparison is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.comparison instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/generator.py b/picarones/report/generator.py
index d45c5b77fb57310bcdb14cfd05b761101de0e9ad..db9b8b3978aa6e4b905c562085019d71750041db 100644
--- a/picarones/report/generator.py
+++ b/picarones/report/generator.py
@@ -1,466 +1,18 @@
-"""Générateur du rapport HTML interactif auto-contenu.
+"""``picarones.report.generator`` — shim re-export (déprécié, suppression 2.0).
-Le rapport produit est un fichier HTML unique embarquant :
-- Toutes les données (JSON inline)
-- Chart.js et diff2html (depuis cdnjs)
-- CSS et JavaScript de l'application
-
-Vues disponibles
-----------------
-1. Classement — tableau triable par colonne (CER, WER, MER, WIL)
-2. Galerie — grille d'images avec badge CER coloré
-3. Document — image zoomable + diff coloré GT / OCR par moteur
-4. Analyses — histogramme CER + graphique radar
-
-Architecture
-------------
-Ce module est l'**orchestrateur**. Les responsabilités lourdes sont
-découpées en sous-modules :
-
-- :mod:`picarones.report.assets` — chargement vendor.js, encodage
- base64 d'images, externalisation lazy.
-- :mod:`picarones.report.report_data` — construction du dict JSON
- passé au template (engines, documents, statistiques, Pareto, etc.).
-- :mod:`picarones.report.render_helpers` — couleurs / SVG mutualisés.
-
-Rétrocompat
------------
-Deux noms historiques sont **encore importés par des tests** sous
-leur préfixe ``_`` et doivent être préservés :
-
-- ``_build_report_data`` (importé par 14 fichiers de tests).
-- ``_cer_color`` (importé par ``tests/report/test_report.py``).
-
-Les autres noms ``_pct``, ``_safe``, ``_cer_bg``, ``_encode_image_b64``,
-``_encode_images_b64_from_result``, ``_externalize_images_to_dir``,
-``_load_vendor_js`` sont soit utilisés en interne (les 3 derniers,
-voir :meth:`ReportGenerator.generate`), soit accessibles via leur
-nom canonique dans :mod:`picarones.report.assets` ou
-:mod:`picarones.report.render_helpers`.
+Canonique : :mod:`picarones.reports_v2.html.generator`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-import json
-import logging
-from pathlib import Path
-from typing import Any, Optional
-
-from picarones.evaluation.benchmark_result import BenchmarkResult
-from picarones.measurements.statistics import build_critical_difference_svg
-from picarones.reports_v2._helpers.assets import (
- encode_images_b64_from_result as _encode_images_b64_from_result,
- externalize_images_to_dir as _externalize_images_to_dir,
- load_vendor_js as _load_vendor_js,
-)
-
-# Ré-exports rétrocompat consommés par les tests externes (cf. docstring
-# de module). La directive de fin de ligne documente l'intention de
-# ré-export et empêche ruff de marquer l'import comme inutilisé.
-from picarones.reports_v2._helpers.render_helpers import cer_step_color as _cer_color # noqa: F401
-from picarones.report.report_data import build_report_data as _build_report_data # noqa: F401
-
-logger = logging.getLogger(__name__)
-
-
-# ---------------------------------------------------------------------------
-# Rendu Jinja2
-# ---------------------------------------------------------------------------
-
-# Depuis le Sprint 16, le template monolithique ~3100 lignes a été découpé en
-# fichiers externes dans ``picarones/report/templates/`` (CSS, JS, vues HTML).
-# ``base.html.j2`` assemble le tout via ``{% include %}``.
-
-_TEMPLATES_DIR = Path(__file__).parent / "templates"
-
-
-def _build_jinja_env():
- """Construit l'Environment Jinja2 pour le rapport.
-
- Autoescape désactivé : le comportement est équivalent à celui du
- ``_HTML_TEMPLATE.format()`` historique. Les variables injectées
- (JSON embarqué, SVG généré, synthèse narrative issue de templates
- internes) sont toutes produites par le code Picarones et ne
- nécessitent pas d'échappement HTML.
- """
- from jinja2 import Environment, FileSystemLoader
- env = Environment(
- loader=FileSystemLoader(str(_TEMPLATES_DIR)),
- autoescape=False,
- keep_trailing_newline=True,
- )
- return env
-
-
-# ---------------------------------------------------------------------------
-# Classe principale
-# ---------------------------------------------------------------------------
-
-class ReportGenerator:
- """Génère un rapport HTML interactif depuis un BenchmarkResult.
-
- Usage
- -----
- >>> from picarones.report import ReportGenerator
- >>> gen = ReportGenerator(benchmark_result)
- >>> path = gen.generate("rapport.html")
- >>> # Rapport en anglais :
- >>> gen_en = ReportGenerator(benchmark_result, lang="en")
- >>> path_en = gen_en.generate("report.html")
- """
-
- def __init__(
- self,
- benchmark: BenchmarkResult,
- images_b64: Optional[dict[str, str]] = None,
- lang: str = "fr",
- normalization_profile: Any = None,
- lazy_images: bool = False,
- ) -> None:
- """
- Parameters
- ----------
- benchmark:
- Résultat de benchmark à visualiser.
- images_b64:
- Dictionnaire {doc_id: data-URI base64 OU url relative} des images.
- Si None, le générateur cherche dans ``benchmark.metadata["_images_b64"]``.
- Si ``lazy_images=True``, la valeur attendue est une URL relative
- comme ``"report-assets/.png"``.
- lang:
- Code langue du rapport : ``"fr"`` (défaut) ou ``"en"``.
- normalization_profile:
- Profil de normalisation effectivement utilisé (Sprint 27 — pour
- le snapshot de reproductibilité). ``None`` retombe sur le
- profil mentionné dans ``benchmark.metadata["normalization_profile"]``
- s'il est présent, sinon snapshot indisponible.
- lazy_images:
- Sprint A5 (M-16) — si ``True``, les images sont écrites en
- fichiers PNG/JPEG dans ``/report-assets/`` à côté
- du HTML, et référencées via ````.
- Le rapport reste auto-portant si on copie aussi le dossier
- d'assets. Utile pour les corpus > 50 documents (un rapport
- base64 monolithique de 1 000 docs dépasse 200 MB et fait
- ramer le navigateur). En mode mono-doc ou démo : laisser
- ``False`` pour un fichier HTML unique transportable.
- """
- self.benchmark = benchmark
- self.images_b64: dict[str, str] = images_b64 or {}
- self.lang = lang
- self.normalization_profile = normalization_profile
- self.lazy_images = lazy_images
-
- # Récupérer les images embarquées dans les metadata (fixtures)
- if not self.images_b64:
- self.images_b64 = benchmark.metadata.get("_images_b64", {}) # type: ignore[assignment]
-
- # Sprint 27 — fallback : profil de normalisation depuis les metadata
- if self.normalization_profile is None:
- self.normalization_profile = benchmark.metadata.get("normalization_profile")
-
- def generate(self, output_path: str | Path) -> Path:
- """Génère le fichier HTML et le sauvegarde sur disque.
+import warnings
- Parameters
- ----------
- output_path:
- Chemin du fichier HTML à écrire.
+from picarones.reports_v2.html.generator import * # noqa: F401, F403
- Returns
- -------
- Path
- Chemin absolu du fichier généré.
- """
- from picarones.i18n import get_labels
-
- output_path = Path(output_path)
- output_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Sprint A5 (M-16) — externalisation des images si lazy_images=True
- # ou auto-encodage base64 sinon. Les deux modes alimentent la même
- # variable ``images_b64`` (le nom est conservé pour rétrocompat ;
- # en mode lazy la valeur est une URL relative au lieu d'un data-URI).
- # En mode lazy, on **force** l'externalisation même si self.images_b64
- # est pré-rempli (par les fixtures, par metadata, etc.) — sinon le
- # rapport contiendrait quand même des data-URI géants.
- if self.lazy_images:
- images_b64 = _externalize_images_to_dir(
- self.benchmark, output_path.parent,
- )
- else:
- images_b64 = self.images_b64
- if not images_b64:
- images_b64 = _encode_images_b64_from_result(self.benchmark)
-
- labels = get_labels(self.lang)
- report_data = _build_report_data(self.benchmark, images_b64)
-
- # Sprint 27 — snapshots de reproductibilité (pricing, glossaire,
- # profil de normalisation, environnement). Embarqués dans le JSON
- # du rapport pour qu'un lecteur puisse régénérer la synthèse, le
- # Pareto et le glossaire sans accès au code source.
- from picarones.report.snapshot import snapshot_all
- report_data["snapshots"] = snapshot_all(
- lang=self.lang,
- normalization_profile=self.normalization_profile,
- )
-
- report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
- i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
- chartjs_js = _load_vendor_js("chart.umd.min.js")
-
- # Sprint 17 — rendu SVG du CDD côté serveur (statique, pas de JS)
- cdd_svg = build_critical_difference_svg(
- report_data.get("statistics", {}).get("nemenyi", {}),
- )
-
- # Sprint 18 — synthèse factuelle narrative (déterministe, sans LLM)
- from picarones.measurements.narrative import build_synthesis
- synthesis = build_synthesis(report_data, lang=self.lang)
-
- # Sprint 20 — glossaire contextuel chargé depuis YAML
- from picarones.reports_v2.glossary import load_glossary
- glossary = load_glossary(self.lang)
- glossary_json = json.dumps(glossary, ensure_ascii=False, separators=(",", ":"))
-
- section_html = self._build_section_html(report_data, labels)
-
- env = _build_jinja_env()
- template = env.get_template("base.html.j2")
- html = template.render(
- corpus_name=self.benchmark.corpus_name,
- picarones_version=self.benchmark.picarones_version,
- report_data_json=report_json,
- i18n_json=i18n_json,
- html_lang=labels.get("html_lang", "fr"),
- chartjs_inline=chartjs_js,
- critical_difference_svg=cdd_svg,
- friedman=report_data.get("statistics", {}).get("friedman", {}),
- synthesis=synthesis,
- glossary_json=glossary_json,
- **section_html,
- )
-
- output_path.write_text(html, encoding="utf-8")
- return output_path.resolve()
-
- def _build_section_html(
- self, report_data: dict, labels: dict[str, str],
- ) -> dict[str, str]:
- """Construit toutes les sections HTML conditionnelles du rapport.
-
- Chaque renderer (NER, calibration, philologie, etc.) est appelé
- de manière indépendante. Une section retourne ``""`` si aucun
- moteur n'a de signal pour elle — le template gère l'affichage
- conditionnel.
-
- Returns
- -------
- dict[str, str]
- Map ``{nom_de_section: html}`` à splatter dans
- ``template.render(**section_html)``.
- """
- engines = report_data.get("engines", [])
-
- # Sprint 37 — section inter-moteurs (matrice de divergence + oracle).
- from picarones.reports_v2.html.renderers.inter_engine import (
- build_divergence_matrix_html,
- build_oracle_gap_html,
- )
- # Sprint 41 — section NER (résumé F1 par moteur + heatmap par catégorie).
- from picarones.reports_v2.html.renderers.ner import (
- build_ner_per_category_html,
- build_ner_summary_html,
- )
- # Sprint 43 — section calibration (tableau ECE/MCE + grille de
- # reliability diagrams par moteur).
- from picarones.reports_v2.html.renderers.calibration import (
- build_calibration_summary_html,
- build_reliability_diagrams_grid_html,
- )
- # Sprint 46 — section stratifiée (tableau par strate).
- from picarones.reports_v2.html.renderers.stratification import (
- build_stratified_ranking_html,
- )
- # Sprint 62 — profil philologique (6 sections adaptive).
- from picarones.reports_v2.html.renderers.philological import (
- build_philological_profile_html,
- )
- # Sprint 86 — A.II.5 : recherchabilité fuzzy + séquences numériques.
- from picarones.reports_v2.html.renderers.searchability import (
- build_searchability_summary_html,
- )
- from picarones.reports_v2.html.renderers.numerical_sequences import (
- build_numerical_sequences_html,
- )
- # Sprint 87 — A.II.2 : lisibilité (delta Flesch).
- from picarones.reports_v2.html.renderers.readability import (
- build_readability_summary_html,
- )
- # Sprint 89 — A.II.8b : spécialisation inter-moteurs.
- from picarones.reports_v2.html.renderers.specialization import (
- build_specialization_html,
- )
- # Chantier 3 (post-Sprint 97) — 3 vues thématiques composées.
- from picarones.reports_v2.html.views import (
- build_advanced_taxonomy_view_html,
- build_diagnostics_view_html,
- build_economics_view_html,
- )
- # Sprint « câblage des modules test-only » (mai 2026) — sections
- # qui consomment les nouvelles métriques calculées dans
- # ``report_data.extra_metrics``.
- from picarones.reports_v2.html.renderers.marginal_cost import (
- build_marginal_cost_html,
- )
- from picarones.reports_v2.html.renderers.rare_token_recall import (
- build_rare_token_recall_html,
- )
- from picarones.reports_v2.html.renderers.taxonomy_cooccurrence import (
- build_taxonomy_cooccurrence_html,
- )
- from picarones.reports_v2.html.renderers.taxonomy_intra_doc import (
- build_taxonomy_intra_doc_html,
- )
-
- # Spécialisation : construit une map {engine: counts} depuis les
- # ``aggregated_taxonomy`` ; un moteur sans taxonomie est exclu.
- taxos: dict = {}
- for eng in engines:
- tax = eng.get("aggregated_taxonomy")
- if isinstance(tax, dict):
- counts = tax.get("counts") if "counts" in tax else tax
- if isinstance(counts, dict) and counts:
- taxos[eng.get("name", "?")] = {
- k: float(v) for k, v in counts.items()
- if isinstance(v, (int, float))
- }
-
- return {
- # Sprint 37
- "divergence_matrix_html": build_divergence_matrix_html(
- report_data.get("inter_engine_analysis"), labels=labels,
- ),
- "oracle_gap_html": build_oracle_gap_html(
- report_data.get("inter_engine_analysis"), labels=labels,
- ),
- # Sprint 41
- "ner_summary_html": build_ner_summary_html(engines, labels=labels),
- "ner_per_category_html": build_ner_per_category_html(engines, labels=labels),
- # Sprint 43
- "calibration_summary_html": build_calibration_summary_html(
- engines, labels=labels,
- ),
- "reliability_diagrams_html": build_reliability_diagrams_grid_html(
- engines, labels=labels,
- ),
- # Sprint 46
- "stratified_ranking_html": build_stratified_ranking_html(
- report_data.get("stratified_ranking"),
- report_data.get("available_strata"),
- report_data.get("corpus_homogeneity"),
- labels=labels,
- ),
- # Sprint 62
- "philological_profile_html": build_philological_profile_html(
- engines, labels=labels,
- ),
- # Sprint 86
- "searchability_html": build_searchability_summary_html(
- engines, labels=labels,
- ),
- "numerical_sequences_html": build_numerical_sequences_html(
- engines, labels=labels,
- ),
- # Sprint 87
- "readability_html": build_readability_summary_html(
- engines, labels=labels,
- ),
- # Sprint 89
- "specialization_html": build_specialization_html(taxos, labels=labels),
- # Chantier 3 — vues thématiques composées
- "economics_view_html": build_economics_view_html(
- report_data, labels=labels,
- engine_reports=self.benchmark.engine_reports,
- ),
- "advanced_taxonomy_view_html": build_advanced_taxonomy_view_html(
- report_data, labels=labels,
- ),
- "diagnostics_view_html": build_diagnostics_view_html(
- report_data, labels=labels,
- ),
- # Sprint « câblage des modules test-only » (mai 2026) :
- # 4 nouvelles sections pour les modules câblés en
- # ``report_data.extra_metrics``. Adaptive : "" si pas de signal.
- "taxonomy_cooccurrence_html": build_taxonomy_cooccurrence_html(
- report_data.get("taxonomy_cooccurrence"), labels=labels,
- ),
- "taxonomy_intra_doc_html": build_taxonomy_intra_doc_html(
- report_data.get("taxonomy_intra_doc"), labels=labels,
- ),
- "rare_token_recall_html": build_rare_token_recall_html(
- report_data.get("rare_token_recall"), labels=labels,
- ),
- "marginal_cost_html": build_marginal_cost_html(
- report_data.get("marginal_cost"), labels=labels,
- ),
- }
-
- @classmethod
- def from_json(cls, json_path: str | Path, **kwargs) -> "ReportGenerator":
- """Crée un générateur depuis un fichier JSON de résultats.
-
- Compatible avec les fichiers produits par ``BenchmarkResult.to_json()``.
- Les images base64 doivent être passées via ``kwargs["images_b64"]``
- si elles ne sont pas dans le JSON.
- """
- import json as _json
-
- data = _json.loads(Path(json_path).read_text(encoding="utf-8"))
-
- # Reconstruction minimale d'un BenchmarkResult depuis le dict
- from picarones.measurements.metrics import MetricsResult
- from picarones.evaluation.benchmark_result import DocumentResult, EngineReport
-
- engine_reports = []
- for er_data in data.get("engine_reports", []):
- doc_results = []
- for dr_data in er_data.get("document_results", []):
- m = dr_data["metrics"]
- metrics = MetricsResult(
- cer=m["cer"], cer_nfc=m["cer_nfc"], cer_caseless=m["cer_caseless"],
- wer=m["wer"], wer_normalized=m["wer_normalized"],
- mer=m["mer"], wil=m["wil"],
- reference_length=m["reference_length"],
- hypothesis_length=m["hypothesis_length"],
- error=m.get("error"),
- )
- doc_results.append(DocumentResult(
- doc_id=dr_data["doc_id"],
- image_path=dr_data["image_path"],
- ground_truth=dr_data["ground_truth"],
- hypothesis=dr_data["hypothesis"],
- metrics=metrics,
- duration_seconds=dr_data.get("duration_seconds", 0.0),
- engine_error=dr_data.get("engine_error"),
- ))
- engine_reports.append(EngineReport(
- engine_name=er_data["engine_name"],
- engine_version=er_data.get("engine_version", "unknown"),
- engine_config=er_data.get("engine_config", {}),
- document_results=doc_results,
- ))
-
- corpus_info = data.get("corpus", {})
- bm = BenchmarkResult(
- corpus_name=corpus_info.get("name", "Corpus"),
- corpus_source=corpus_info.get("source"),
- document_count=corpus_info.get("document_count", 0),
- engine_reports=engine_reports,
- run_date=data.get("run_date", ""),
- picarones_version=data.get("picarones_version", ""),
- metadata=data.get("metadata", {}),
- )
-
- images_b64 = kwargs.pop("images_b64", {})
- return cls(bm, images_b64=images_b64, **kwargs)
+warnings.warn(
+ "picarones.report.generator is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.generator instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/__init__.py b/picarones/report/report_data/__init__.py
index ddab80b98bfa9ccadd008337a486d9a550712a6a..97baad6fc1e9efc2b70d4a2aa7d95f8523dcf94f 100644
--- a/picarones/report/report_data/__init__.py
+++ b/picarones/report/report_data/__init__.py
@@ -1,132 +1,21 @@
-"""Construction du dict de données consommé par le template Jinja.
+"""``picarones.report.report_data`` — shim re-export (déprécié, suppression 2.0).
-Avant le découpage, ``picarones.report.generator._build_report_data``
-faisait 463 lignes pour transformer un :class:`BenchmarkResult` en
-dict prêt pour Jinja. Cette fonction empilait par sprint des blocs
-indépendants — engines, documents, statistiques, scatter plots,
-front Pareto, etc.
-
-Ce sous-package éclate la construction en modules thématiques :
-
-- :mod:`engines` — résumé par moteur (``engines_summary``).
-- :mod:`documents` — vue galerie + détail + difficulté Sprint 7.
-- :mod:`statistics` — Wilcoxon, Friedman, Nemenyi, bootstrap CIs,
- reliability curves, Venn, error clusters, corrélations.
-- :mod:`scatter` — Sprint 10 : Gini vs CER, ratio vs anchor.
-- :mod:`pareto` — Sprint 19 : 3 fronts Pareto + métadonnées pricing.
- Expose deux fonctions séparées : :func:`attach_engine_costs`
- (mute) et :func:`build_pareto_section` (pure).
-
-L'API publique :func:`build_report_data` orchestre ces modules dans
-le bon ordre. La séquence Pareto en deux temps
-(``attach_engine_costs`` → ``build_pareto_section``) rend la
-mutation explicite — les fonctions ``build_*`` du sous-package
-sont pures sauf ``attach_engine_costs`` dont le nom le dit.
+Canonique : :mod:`picarones.reports_v2.html.data`. Phase 5.E du
+retrait du legacy.
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- from picarones.evaluation.benchmark_result import BenchmarkResult
+import warnings
-from picarones.report.report_data.documents import (
- annotate_documents_with_difficulty,
- build_documents,
-)
-from picarones.report.report_data.engines import build_engines_summary
-from picarones.report.report_data.extra_metrics import (
- compute_marginal_cost_section,
- compute_rare_token_recall_per_engine,
- compute_taxonomy_cooccurrence_section,
- compute_taxonomy_intra_doc_section,
-)
-from picarones.report.report_data.pareto import (
- attach_engine_costs,
- build_pareto_section,
-)
-from picarones.report.report_data.scatter import (
- build_gini_vs_cer,
- build_ratio_vs_anchor,
-)
-from picarones.report.report_data.statistics import (
- build_bootstrap_cis,
- build_correlation_per_engine,
- build_error_clusters,
- build_friedman_and_nemenyi,
- build_pairwise_wilcoxon,
- build_reliability_curves,
- build_venn_data,
+from picarones.reports_v2.html.data import * # noqa: F401, F403
+from picarones.reports_v2.html.data import ( # noqa: F401
+ build_report_data,
)
-
-def build_report_data(
- benchmark: "BenchmarkResult", images_b64: dict[str, str],
-) -> dict:
- """Transforme un :class:`BenchmarkResult` en dict pour le rapport HTML.
-
- Ordre critique :
-
- 1. Construire ``engines_summary`` (pur).
- 2. Construire ``documents`` puis annoter avec la difficulté (mute
- ``documents``).
- 3. **Attacher** les coûts à ``engines_summary`` (mute, nom
- explicite).
- 4. **Construire** le bloc Pareto (pure, lit les coûts attachés).
- """
- engines_summary = build_engines_summary(benchmark)
- documents = build_documents(benchmark, images_b64)
- annotate_documents_with_difficulty(benchmark, documents)
-
- attach_engine_costs(engines_summary, benchmark)
- pareto_data = build_pareto_section(engines_summary)
-
- return {
- "meta": {
- "corpus_name": benchmark.corpus_name,
- "corpus_source": benchmark.corpus_source,
- "document_count": benchmark.document_count,
- "run_date": benchmark.run_date,
- "picarones_version": benchmark.picarones_version,
- "metadata": benchmark.metadata,
- },
- "ranking": benchmark.ranking(),
- "engines": engines_summary,
- "documents": documents,
- # Sprint 7
- "statistics": {
- "pairwise_wilcoxon": build_pairwise_wilcoxon(benchmark),
- "bootstrap_cis": build_bootstrap_cis(benchmark),
- **build_friedman_and_nemenyi(benchmark),
- },
- "reliability_curves": build_reliability_curves(benchmark),
- "venn_data": build_venn_data(benchmark),
- "error_clusters": build_error_clusters(benchmark),
- "correlation_per_engine": build_correlation_per_engine(benchmark),
- # Sprint 10
- "gini_vs_cer": build_gini_vs_cer(benchmark),
- "ratio_vs_anchor": build_ratio_vs_anchor(benchmark),
- # Sprint 19 — vue Pareto coût/qualité avec variantes d'axe
- "pareto": pareto_data,
- # Sprint 36 — analyse inter-moteurs (divergence taxonomique +
- # complémentarité / oracle). ``None`` si moins de 2 moteurs.
- "inter_engine_analysis": benchmark.inter_engine_analysis,
- # Sprint 45-46 — stratification par script_type
- "available_strata": benchmark.available_strata(),
- "stratified_ranking": benchmark.stratified_ranking() or None,
- "corpus_homogeneity": benchmark.corpus_homogeneity(),
- # Sprint « câblage des modules test-only » (mai 2026) — métriques
- # corpus-wide qui jusque-là n'étaient pas remontées dans le rapport.
- # Sprint 71 (A.I.1) : recall sur tokens rares (hapax + dis legomena).
- "rare_token_recall": compute_rare_token_recall_per_engine(benchmark),
- # Sprint 75 (A.I.4) : co-occurrence taxonomique inter-classes.
- "taxonomy_cooccurrence": compute_taxonomy_cooccurrence_section(benchmark),
- # Sprint 76 (A.I.4) : heatmap class × position (intra-document).
- "taxonomy_intra_doc": compute_taxonomy_intra_doc_section(benchmark),
- # Sprint 91 (A.II.6) : matrice de coût marginal entre paires de moteurs.
- "marginal_cost": compute_marginal_cost_section(engines_summary),
- }
-
-
-__all__ = ["build_report_data"]
+warnings.warn(
+ "picarones.report.report_data is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/_helpers.py b/picarones/report/report_data/_helpers.py
index de8fdee0516ca33fbe73a1eda9ed6095478e73b8..340ece9795de13c06cb6d50688576c61f600525f 100644
--- a/picarones/report/report_data/_helpers.py
+++ b/picarones/report/report_data/_helpers.py
@@ -1,30 +1,18 @@
-"""Helpers numériques internes au sous-package report_data.
+"""``picarones.report.report_data._helpers`` — shim re-export (déprécié, suppression 2.0).
-Petites fonctions utilitaires partagées par tous les builders de
-sections (engines, documents, statistics, scatter, pareto). Ne pas
-importer depuis l'extérieur du sous-package — ces helpers sont
-spécifiques aux conventions du dict JSON consommé par le template.
+Canonique : :mod:`picarones.reports_v2.html.data._helpers`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-from typing import Optional
+import warnings
+from picarones.reports_v2.html.data._helpers import * # noqa: F401, F403
-def safe_round(v: Optional[float], decimals: int = 4) -> float:
- """Arrondit un float optionnel ; ``None`` devient ``0.0``."""
- return round(v or 0.0, decimals)
-
-
-def percent_string(v: Optional[float], decimals: int = 2) -> str:
- """Formate un ratio ∈ [0, 1] en chaîne pourcentage : ``0.4723 → "47.23 %"``.
-
- ``None`` → ``"—"``. Conservé pour rétrocompat avec d'éventuels
- callers externes (Sprint 7 historique).
- """
- if v is None:
- return "—"
- return f"{v * 100:.{decimals}f} %"
-
-
-__all__ = ["safe_round", "percent_string"]
+warnings.warn(
+ "picarones.report.report_data._helpers is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data._helpers instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/documents.py b/picarones/report/report_data/documents.py
index 63a0f147bf0fa2ed86d9691e27dcd17c62b67b38..ecee3ba89650df3b01bc9e96bef0bc2637729503 100644
--- a/picarones/report/report_data/documents.py
+++ b/picarones/report/report_data/documents.py
@@ -1,167 +1,18 @@
-"""Construction de la liste ``documents`` (vue galerie + vue détail).
+"""``picarones.report.report_data.documents`` — shim re-export (déprécié, suppression 2.0).
-Pour chaque document du corpus, agrège les hypothèses de tous les
-moteurs avec leurs métriques, le diff caractère par caractère, et
-les champs spécifiques aux pipelines OCR+LLM (intermédiaire, mode,
-sur-normalisation).
-
-:func:`annotate_documents_with_difficulty` enrichit ensuite chaque
-document avec son score de difficulté intrinsèque (Sprint 7).
+Canonique : :mod:`picarones.reports_v2.html.data.documents`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
-
-from picarones.core.diff_utils import compute_char_diff, compute_word_diff
-from picarones.measurements.difficulty import (
- compute_all_difficulties,
- difficulty_label,
-)
-from picarones.report.report_data._helpers import safe_round
-
-if TYPE_CHECKING:
- from picarones.evaluation.benchmark_result import BenchmarkResult
-
-
-def build_documents(
- benchmark: "BenchmarkResult", images_b64: dict[str, str],
-) -> list[dict]:
- """Retourne la liste ordonnée des documents prêts pour le template.
-
- L'ordre des documents préserve l'ordre d'apparition (premier moteur
- d'abord, puis compléments depuis les moteurs suivants si certains
- documents ne sont pas couverts par tous les moteurs).
- """
- seen_doc_ids: set[str] = set()
- doc_ids_ordered: list[str] = []
- for report in benchmark.engine_reports:
- for dr in report.document_results:
- if dr.doc_id not in seen_doc_ids:
- seen_doc_ids.add(dr.doc_id)
- doc_ids_ordered.append(dr.doc_id)
-
- # Index croisé : doc_id → {engine_name → DocumentResult}
- doc_engine_map: dict[str, dict] = {did: {} for did in doc_ids_ordered}
- for report in benchmark.engine_reports:
- for dr in report.document_results:
- doc_engine_map.setdefault(dr.doc_id, {})[report.engine_name] = dr
-
- documents: list[dict] = []
- engine_names = [r.engine_name for r in benchmark.engine_reports]
- for doc_id in doc_ids_ordered:
- engine_results: list[dict] = []
- gt = ""
- image_path = ""
- for engine_name in engine_names:
- dr = doc_engine_map[doc_id].get(engine_name)
- if dr is None:
- continue
- gt = dr.ground_truth
- image_path = dr.image_path
- er_entry = _build_engine_result_entry(engine_name, dr)
- engine_results.append(er_entry)
+import warnings
- # CER moyen sur ce document (pour le badge galerie)
- cer_values = [er["cer"] for er in engine_results if er["error"] is None]
- mean_cer = sum(cer_values) / len(cer_values) if cer_values else 1.0
- best_engine = min(engine_results, key=lambda x: x["cer"], default=None)
+from picarones.reports_v2.html.data.documents import * # noqa: F401, F403
- # Script type (depuis metadata par document si disponible)
- script_type = ""
- first_engine = engine_names[0] if engine_names else None
- first_dr = doc_engine_map[doc_id].get(first_engine)
- if first_dr and first_dr.image_quality:
- script_type = first_dr.image_quality.get("script_type", "")
-
- documents.append({
- "doc_id": doc_id,
- "image_path": image_path,
- "image_b64": images_b64.get(doc_id, ""),
- "ground_truth": gt,
- "mean_cer": safe_round(mean_cer),
- "best_engine": best_engine["engine"] if best_engine else "",
- "engine_results": engine_results,
- "script_type": script_type,
- })
- return documents
-
-
-def _build_engine_result_entry(engine_name: str, dr) -> dict:
- """Construit une entrée moteur pour un document donné (extrait pour lisibilité)."""
- diff_ops = compute_char_diff(dr.ground_truth, dr.hypothesis)
- er_entry: dict = {
- "engine": engine_name,
- "hypothesis": dr.hypothesis,
- "cer": safe_round(dr.metrics.cer),
- "cer_diplomatic": safe_round(dr.metrics.cer_diplomatic) if dr.metrics.cer_diplomatic is not None else None,
- "wer": safe_round(dr.metrics.wer),
- "mer": safe_round(dr.metrics.mer),
- "wil": safe_round(dr.metrics.wil),
- "duration": dr.duration_seconds,
- "error": dr.engine_error,
- "diff": diff_ops,
- }
- # Champs spécifiques aux pipelines OCR+LLM
- if dr.ocr_intermediate is not None:
- er_entry["ocr_intermediate"] = dr.ocr_intermediate
- er_entry["ocr_diff"] = compute_word_diff(dr.ground_truth, dr.ocr_intermediate)
- er_entry["llm_correction_diff"] = compute_word_diff(dr.ocr_intermediate, dr.hypothesis)
- if dr.pipeline_metadata:
- on = dr.pipeline_metadata.get("over_normalization")
- if on is not None:
- er_entry["over_normalization"] = on
- er_entry["pipeline_mode"] = dr.pipeline_metadata.get("pipeline_mode")
- # Sprint 5 — métriques avancées par document
- if dr.char_scores is not None:
- er_entry["ligature_score"] = safe_round(dr.char_scores.get("ligature", {}).get("score"))
- er_entry["diacritic_score"] = safe_round(dr.char_scores.get("diacritic", {}).get("score"))
- if dr.taxonomy is not None:
- er_entry["taxonomy"] = dr.taxonomy
- if dr.structure is not None:
- er_entry["structure"] = dr.structure
- if dr.image_quality is not None:
- er_entry["image_quality"] = dr.image_quality
- # Sprint 10
- if dr.line_metrics is not None:
- er_entry["line_metrics"] = dr.line_metrics
- if dr.hallucination_metrics is not None:
- er_entry["hallucination_metrics"] = dr.hallucination_metrics
- return er_entry
-
-
-def annotate_documents_with_difficulty(
- benchmark: "BenchmarkResult", documents: list[dict],
-) -> None:
- """Annote chaque document du dict avec son score de difficulté (Sprint 7).
-
- Modifie ``documents`` en place. Les valeurs par défaut ``0.5`` /
- ``"Modéré"`` sont retournées si la difficulté n'a pas pu être
- calculée (par exemple corpus dégénéré).
- """
- doc_ids_ordered = [d["doc_id"] for d in documents]
- gt_map = {d["doc_id"]: d["ground_truth"] for d in documents}
- cer_map: dict[str, dict[str, float]] = {d["doc_id"]: {} for d in documents}
- iq_map: dict[str, float] = {}
- for report in benchmark.engine_reports:
- for dr in report.document_results:
- cer_map.setdefault(dr.doc_id, {})[report.engine_name] = safe_round(dr.metrics.cer)
- if dr.image_quality and "quality_score" in dr.image_quality:
- iq_map[dr.doc_id] = dr.image_quality["quality_score"]
- difficulty_scores = compute_all_difficulties(
- doc_ids=doc_ids_ordered,
- ground_truths=gt_map,
- cer_map=cer_map,
- image_quality_map=iq_map or None,
- )
- for doc in documents:
- ds = difficulty_scores.get(doc["doc_id"])
- if ds:
- doc["difficulty_score"] = safe_round(ds.score)
- doc["difficulty_label"] = difficulty_label(ds.score)
- else:
- doc["difficulty_score"] = 0.5
- doc["difficulty_label"] = "Modéré"
-
-
-__all__ = ["build_documents", "annotate_documents_with_difficulty"]
+warnings.warn(
+ "picarones.report.report_data.documents is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data.documents instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/engines.py b/picarones/report/report_data/engines.py
index b2279edf69924f76f9cad3108e67ccec1651c82e..15ac61140e047e921619a365e4e46b538f817dae 100644
--- a/picarones/report/report_data/engines.py
+++ b/picarones/report/report_data/engines.py
@@ -1,103 +1,18 @@
-"""Construction du résumé par moteur (``engines_summary``).
+"""``picarones.report.report_data.engines`` — shim re-export (déprécié, suppression 2.0).
-Pour chaque ``EngineReport``, accumule métriques agrégées (CER, WER,
-MER, WIL), distribution CER pour l'histogramme, métriques avancées
-patrimoniales (Sprint 5), distribution d'erreurs (Sprint 10), NER
-(Sprint 41), calibration (Sprint 43), profil philologique (Sprint
-62), recherchabilité + séquences numériques (Sprint 86), lisibilité
-(Sprint 87) et indicateurs pipeline OCR+LLM.
-
-Les coûts (durée moyenne, prix par 1k pages, CO₂) sont ajoutés
-ultérieurement par :mod:`picarones.report.report_data.pareto` qui
-en a besoin pour calculer les fronts.
+Canonique : :mod:`picarones.reports_v2.html.data.engines`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
-
-from picarones.report.report_data._helpers import safe_round
-
-if TYPE_CHECKING:
- from picarones.evaluation.benchmark_result import BenchmarkResult
-
-
-def build_engines_summary(benchmark: "BenchmarkResult") -> list[dict]:
- """Retourne la liste des dicts moteur, une entrée par ``EngineReport``."""
- engines_summary: list[dict] = []
- for report in benchmark.engine_reports:
- agg = report.aggregated_metrics
- diplo_agg = agg.get("cer_diplomatic", {})
-
- line_metrics = report.aggregated_line_metrics
- halluc = report.aggregated_hallucination
-
- entry: dict = {
- "name": report.engine_name,
- "version": report.engine_version,
- "cer": safe_round(agg.get("cer", {}).get("mean")),
- "wer": safe_round(agg.get("wer", {}).get("mean")),
- "mer": safe_round(agg.get("mer", {}).get("mean")),
- "wil": safe_round(agg.get("wil", {}).get("mean")),
- "cer_median": safe_round(agg.get("cer", {}).get("median")),
- "cer_min": safe_round(agg.get("cer", {}).get("min")),
- "cer_max": safe_round(agg.get("cer", {}).get("max")),
- "doc_count": agg.get("document_count", 0),
- "failed": agg.get("failed_count", 0),
- # CER diplomatique (après normalisation historique : ſ=s, u=v, i=j…)
- "cer_diplomatic": safe_round(diplo_agg.get("mean")) if diplo_agg else None,
- "cer_diplomatic_profile": diplo_agg.get("profile"),
- # Distribution pour l'histogramme : liste des CER individuels
- "cer_values": [
- safe_round(dr.metrics.cer)
- for dr in report.document_results
- if dr.metrics.error is None
- ],
- "cer_diplomatic_values": [
- safe_round(dr.metrics.cer_diplomatic)
- for dr in report.document_results
- if dr.metrics.error is None and dr.metrics.cer_diplomatic is not None
- ],
- # Champs pipeline OCR+LLM (vides pour les moteurs OCR seuls)
- "is_pipeline": report.is_pipeline,
- "pipeline_info": report.pipeline_info,
- # Sprint 5 — métriques avancées patrimoniales
- "ligature_score": safe_round(report.ligature_score) if report.ligature_score is not None else None,
- "diacritic_score": safe_round(report.diacritic_score) if report.diacritic_score is not None else None,
- "aggregated_confusion": report.aggregated_confusion,
- "aggregated_taxonomy": report.aggregated_taxonomy,
- "aggregated_structure": report.aggregated_structure,
- "aggregated_image_quality": report.aggregated_image_quality,
- # Sprint 10 — distribution des erreurs + hallucinations VLM
- "gini": safe_round(line_metrics.get("gini_mean")) if line_metrics else None,
- "cer_p90": safe_round(line_metrics.get("percentiles", {}).get("p90")) if line_metrics else None,
- "cer_p99": safe_round(line_metrics.get("percentiles", {}).get("p99")) if line_metrics else None,
- "catastrophic_rate_30": safe_round(line_metrics.get("catastrophic_rate", {}).get("0.3")) if line_metrics else None,
- "aggregated_line_metrics": line_metrics,
- "anchor_score": safe_round(halluc.get("anchor_score_mean")) if halluc else None,
- "length_ratio": safe_round(halluc.get("length_ratio_mean")) if halluc else None,
- "hallucinating_doc_rate": safe_round(halluc.get("hallucinating_doc_rate")) if halluc else None,
- "aggregated_hallucination": halluc,
- # Sprint 41 — NER agrégé (None si aucun calcul effectué)
- "aggregated_ner": report.aggregated_ner,
- # Sprint 43 — calibration agrégée (None si aucune confidence
- # n'a été exposée par le moteur sur ce corpus)
- "aggregated_calibration": report.aggregated_calibration,
- # Sprint 62 — profil philologique agrégé (None si aucun
- # signal philologique sur le corpus pour ce moteur)
- "aggregated_philological": report.aggregated_philological,
- # Sprint 86 — A.II.5 (recherchabilité fuzzy + séquences
- # numériques). None si aucun document n'a de signal.
- "aggregated_searchability": report.aggregated_searchability,
- "aggregated_numerical_sequences": (
- report.aggregated_numerical_sequences
- ),
- # Sprint 87 — A.II.2 (delta Flesch agrégé)
- "aggregated_readability": report.aggregated_readability,
- "is_vlm": report.pipeline_info.get("is_vlm", False) if report.pipeline_info else False,
- }
- engines_summary.append(entry)
- return engines_summary
+import warnings
+from picarones.reports_v2.html.data.engines import * # noqa: F401, F403
-__all__ = ["build_engines_summary"]
+warnings.warn(
+ "picarones.report.report_data.engines is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data.engines instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/extra_metrics.py b/picarones/report/report_data/extra_metrics.py
index 5c598e69df6e5dcf068ddd74797480222402aa0f..57b69ee7b8788f64647d9b410181bdec9f18aa04 100644
--- a/picarones/report/report_data/extra_metrics.py
+++ b/picarones/report/report_data/extra_metrics.py
@@ -1,272 +1,18 @@
-"""Métriques additionnelles consommées par le rapport HTML.
+"""``picarones.report.report_data.extra_metrics`` — shim re-export (déprécié, suppression 2.0).
-Sprint « câblage des modules test-only » (mai 2026) : intègre dans le
-flux de génération du rapport des modules de mesure qui jusque-là
-n'étaient appelés par aucun consommateur en production. Concrètement :
-
-- :func:`compute_rare_token_recall_per_engine` — Sprint 71 (A.I.1) :
- recall sur tokens rares (hapax + dis legomena) corpus-wide. Discrimine
- un OCR qui rate les noms propres rares (critique pour l'indexation
- prosopographique).
-- :func:`compute_taxonomy_cooccurrence_section` — Sprint 75 (A.I.4
- chantier 1) : indice de Jaccard inter-classes au niveau document.
-- :func:`compute_taxonomy_intra_doc_section` — Sprint 76 (A.I.4
- chantier 2) : heatmap class × position pour repérer les zones
- concentrées d'erreur.
-- :func:`compute_marginal_cost_section` — Sprint 91 (A.II.6) : coût
- marginal d'un moteur B vs A par erreur évitée.
-
-Toutes les fonctions sont **pures** (pas de mutation in-place) et
-retournent ``None`` ou un dict vide quand les pré-requis ne sont pas
-réunis (corpus vide, taxonomy absente, etc.) — pattern adaptive masking.
+Canonique : :mod:`picarones.reports_v2.html.data.extra_metrics`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-from typing import TYPE_CHECKING, Optional
-
-from picarones.measurements.marginal_cost import compute_marginal_cost_matrix
-from picarones.measurements.rare_tokens import (
- compute_rare_token_recall,
- extract_rare_tokens,
-)
-from picarones.measurements.taxonomy_cooccurrence import (
- compute_taxonomy_cooccurrence,
-)
-from picarones.measurements.taxonomy_intra_doc import (
- compute_taxonomy_position_heatmap,
-)
-
-if TYPE_CHECKING:
- from picarones.evaluation.benchmark_result import BenchmarkResult
-
-
-# ──────────────────────────────────────────────────────────────────
-# Rare-token recall (Sprint 71)
-# ──────────────────────────────────────────────────────────────────
-
-
-def compute_rare_token_recall_per_engine(
- benchmark: "BenchmarkResult",
- max_freq: int = 2,
-) -> dict[str, dict]:
- """Recall corpus-wide sur les tokens rares pour chaque moteur.
-
- Étapes :
- 1. Extraire les tokens rares du corpus (apparaissent ≤ ``max_freq``
- fois dans toutes les GT).
- 2. Pour chaque moteur, calculer le recall moyen pondéré par doc.
-
- Retour : ``{engine_name: {n_rare_tokens, n_recalled, recall, n_docs}}``,
- vide si aucun moteur ou aucun token rare détecté.
- """
- if not benchmark.engine_reports:
- return {}
- # Liste des GT du corpus (premier moteur fait foi).
- gts = [
- dr.ground_truth
- for dr in benchmark.engine_reports[0].document_results
- if dr.ground_truth
- ]
- if not gts:
- return {}
- rare_tokens = extract_rare_tokens(gts, max_freq=max_freq)
- if not rare_tokens:
- return {}
-
- out: dict[str, dict] = {}
- for report in benchmark.engine_reports:
- n_total_rare = 0
- n_total_recalled = 0
- n_docs = 0
- for dr in report.document_results:
- if dr.metrics.error is not None:
- continue
- metrics = compute_rare_token_recall(
- dr.ground_truth, dr.hypothesis, rare_tokens,
- )
- n_total_rare += metrics["n_rare_tokens_in_reference"]
- n_total_recalled += metrics["n_rare_tokens_recalled"]
- n_docs += 1
- recall = (
- n_total_recalled / n_total_rare if n_total_rare > 0 else None
- )
- out[report.engine_name] = {
- "n_rare_tokens": n_total_rare,
- "n_recalled": n_total_recalled,
- "recall": recall,
- "n_docs": n_docs,
- "max_freq": max_freq,
- }
- return out
-
-
-# ──────────────────────────────────────────────────────────────────
-# Co-occurrence taxonomique (Sprint 75)
-# ──────────────────────────────────────────────────────────────────
-
-
-def compute_taxonomy_cooccurrence_section(
- benchmark: "BenchmarkResult",
-) -> Optional[dict]:
- """Calcule la matrice de co-occurrence taxonomique corpus-wide.
-
- Pour chaque document, on collecte l'union des classes d'erreur
- apparues sur ce document tous moteurs confondus, puis on calcule
- l'indice de Jaccard entre paires de classes au niveau corpus.
+import warnings
- Retour : sortie de
- :func:`picarones.measurements.taxonomy_cooccurrence.compute_taxonomy_cooccurrence`,
- ou ``None`` si aucune classification taxonomique n'est disponible.
- """
- # Map doc_id → index dans per_doc_classes pour merger correctement
- # les classes des moteurs additionnels qui évaluent le même doc.
- # **Bug évité** : ne PAS utiliser un set pour retrouver l'index — un
- # set n'a pas d'ordre garanti, ``list(set).index(x)`` retourne un
- # index qui ne correspond pas à la position dans la liste parallèle.
- doc_id_to_idx: dict[str, int] = {}
- per_doc_classes: list[set[str]] = []
+from picarones.reports_v2.html.data.extra_metrics import * # noqa: F401, F403
- for report in benchmark.engine_reports:
- for dr in report.document_results:
- if dr.taxonomy is None:
- continue
- classes = {
- cls
- for cls, count in (dr.taxonomy.get("counts") or {}).items()
- if count > 0
- }
- if not classes:
- continue
- idx = doc_id_to_idx.get(dr.doc_id)
- if idx is None:
- doc_id_to_idx[dr.doc_id] = len(per_doc_classes)
- per_doc_classes.append(classes)
- else:
- # Doc déjà vu (autre moteur) : merger les classes.
- per_doc_classes[idx] |= classes
-
- if not per_doc_classes:
- return None
- return compute_taxonomy_cooccurrence(per_doc_classes)
-
-
-# ──────────────────────────────────────────────────────────────────
-# Heatmap intra-document class × position (Sprint 76)
-# ──────────────────────────────────────────────────────────────────
-
-
-def compute_taxonomy_intra_doc_section(
- benchmark: "BenchmarkResult",
- n_bins: int = 10,
-) -> Optional[dict]:
- """Heatmap agrégée class × position binnée sur l'ensemble du corpus.
-
- Pour chaque doc unique on garde le heatmap calculé par le **premier**
- moteur (déduplication : un même doc évalué par N moteurs ne compte
- qu'une fois). Puis on somme par classe et bin de position.
-
- Retourne un dict compatible avec
- :func:`picarones.report.taxonomy_intra_doc_render.build_taxonomy_intra_doc_html`
- (clés ``n_bins``, ``per_class``, ``total_errors``, ``n_words_gt``).
- Retourne ``None`` si aucun document n'a de signal exploitable.
- """
- aggregated: dict[str, list[int]] = {}
- seen_doc_ids: set[str] = set()
- total_errors = 0
- n_words_gt = 0
-
- for report in benchmark.engine_reports:
- for dr in report.document_results:
- if dr.doc_id in seen_doc_ids:
- continue # déduplication : ne pas compter un doc 2 fois
- if dr.metrics.error is not None or not dr.ground_truth:
- continue
- heatmap = compute_taxonomy_position_heatmap(
- dr.ground_truth, dr.hypothesis, n_bins=n_bins,
- )
- if heatmap is None:
- continue
- seen_doc_ids.add(dr.doc_id)
- n_words_gt += len(dr.ground_truth.split())
- per_class = heatmap.get("per_class", {})
- for cls, counts in per_class.items():
- cls_total = sum(counts)
- if cls_total == 0:
- continue
- total_errors += cls_total
- if cls not in aggregated:
- aggregated[cls] = [0] * n_bins
- for i in range(n_bins):
- aggregated[cls][i] += counts[i] if i < len(counts) else 0
-
- if not aggregated:
- return None
- return {
- "n_bins": n_bins,
- "n_docs_with_data": len(seen_doc_ids),
- "total_errors": total_errors,
- "n_words_gt": n_words_gt,
- "per_class": aggregated,
- }
-
-
-# ──────────────────────────────────────────────────────────────────
-# Coût marginal inter-moteurs (Sprint 91)
-# ──────────────────────────────────────────────────────────────────
-
-
-def compute_marginal_cost_section(
- engines_summary: list[dict],
-) -> Optional[list[dict]]:
- """Matrice de coût marginal entre paires de moteurs.
-
- Lit ``cost`` (attaché par :func:`attach_engine_costs`) et estime
- le nombre d'erreurs. Pour chaque paire ``A → B``, calcule le coût
- additionnel par erreur évitée.
-
- **Note d'estimation** : le nombre d'erreurs est dérivé de
- ``cer × n_caractères_corpus`` quand la longueur moyenne de doc
- est disponible, sinon repli sur ``cer × 1000`` (proxy pour
- 1000 caractères standardisés). Les coûts marginaux affichés sont
- des estimations pessimistes — pour un benchmark de corpus
- homogène, l'ordonnancement est fiable ; pour un mix de
- types de documents, à interpréter avec prudence.
-
- Retour : liste de dicts (sortie ``["pairs"]`` de
- :func:`compute_marginal_cost_matrix`) triée par coût marginal
- croissant, ou ``None`` si moins de 2 moteurs ont des données
- coût + erreur exploitables.
- """
- per_engine: dict[str, dict] = {}
- for entry in engines_summary:
- cost = entry.get("cost") or {}
- cost_per_1k = cost.get("cost_per_1k_pages_eur")
- cer = entry.get("cer")
- doc_count = entry.get("doc_count") or 0
- if cost_per_1k is None or cer is None or doc_count == 0:
- continue
- # Proxy : cer × 1000 caractères / page (échelle stable cohérente
- # avec ``cost_per_1k_pages_eur``).
- estimated_errors = cer * 1000.0
- per_engine[entry["name"]] = {
- "cost": cost_per_1k,
- "errors": estimated_errors,
- }
- if len(per_engine) < 2:
- return None
- result = compute_marginal_cost_matrix(per_engine)
- if not result:
- return None
- # ``compute_marginal_cost_matrix`` retourne ``{"pairs": [...]}``.
- # On expose la liste ``pairs`` pour que le renderer reçoive un
- # itérable de dicts (pas un wrapper).
- return result.get("pairs") or None
-
-
-__all__ = [
- "compute_rare_token_recall_per_engine",
- "compute_taxonomy_cooccurrence_section",
- "compute_taxonomy_intra_doc_section",
- "compute_marginal_cost_section",
-]
+warnings.warn(
+ "picarones.report.report_data.extra_metrics is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data.extra_metrics instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/pareto.py b/picarones/report/report_data/pareto.py
index cabd714145a6e1971f2dc87cf53d1a092e884d07..74a785d81ae180b7eda1ce402c3e517691a9cc40 100644
--- a/picarones/report/report_data/pareto.py
+++ b/picarones/report/report_data/pareto.py
@@ -1,159 +1,18 @@
-"""Front Pareto coût/qualité (Sprint 19).
+"""``picarones.report.report_data.pareto`` — shim re-export (déprécié, suppression 2.0).
-Construit trois fronts Pareto avec des axes alternatifs :
-
-- ``cost`` — CER vs coût € / 1000 pages.
-- ``speed`` — CER vs durée moyenne par page.
-- ``co2`` — CER vs empreinte carbone (g CO₂ / 1000 pages, expérimental).
-
-API
----
-Deux fonctions séparées pour rendre le contrat explicite :
-
-1. :func:`attach_engine_costs` — **mute en place** ``engines_summary``
- en y ajoutant ``mean_duration_seconds`` et ``cost`` (extraits du
- benchmark et de la table de pricing). Le nom dit clairement qu'il
- y a mutation.
-2. :func:`build_pareto_section` — **fonction pure**, lit les coûts
- déjà attachés à ``engines_summary``. Retourne le dict ``pareto``
- prêt pour le template.
-
-L'orchestrateur (``__init__.py``) appelle les deux dans l'ordre.
-Cette séparation rend possible :
-
-- Tester :func:`build_pareto_section` indépendamment avec un
- ``engines_summary`` pré-fabriqué.
-- Réutiliser les coûts attachés sans recalculer Pareto.
+Canonique : :mod:`picarones.reports_v2.html.data.pareto`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
-
-from picarones.measurements.pricing import (
- build_costs_for_benchmark,
- load_pricing_database,
-)
-from picarones.measurements.statistics import compute_pareto_front
-
-if TYPE_CHECKING:
- from picarones.evaluation.benchmark_result import BenchmarkResult
-
-
-def attach_engine_costs(
- engines_summary: list[dict], benchmark: "BenchmarkResult",
-) -> None:
- """Annote chaque entrée de ``engines_summary`` avec son coût.
-
- **Mute en place** : ajoute deux champs à chaque dict moteur :
-
- - ``mean_duration_seconds`` (float ou ``None`` si pas de durée).
- - ``cost`` : dict de la forme ``{cost_per_1k_pages_eur: ...,
- co2_per_1k_pages_g: ..., ...}`` ou ``None`` si pricing
- indisponible.
+import warnings
- Doit être appelée AVANT :func:`build_pareto_section`, qui lit
- ces deux champs.
- """
- durations_by_engine: dict[str, float] = {}
- for report in benchmark.engine_reports:
- durs = [
- dr.duration_seconds
- for dr in report.document_results
- if dr.duration_seconds is not None
- ]
- if durs:
- durations_by_engine[report.engine_name] = sum(durs) / len(durs)
+from picarones.reports_v2.html.data.pareto import * # noqa: F401, F403
- costs_by_engine = build_costs_for_benchmark(
- engines_summary, durations_by_engine,
- )
- for entry in engines_summary:
- name = entry["name"]
- entry["mean_duration_seconds"] = (
- round(durations_by_engine.get(name, 0.0), 4)
- if name in durations_by_engine else None
- )
- entry["cost"] = costs_by_engine.get(name)
-
-
-def build_pareto_section(engines_summary: list[dict]) -> dict:
- """Construit le bloc ``pareto`` du dict de rapport.
-
- **Fonction pure** : ne mute rien. Lit ``mean_duration_seconds``
- et ``cost`` qui doivent avoir été attachés en amont par
- :func:`attach_engine_costs`. Si ces champs sont absents, le
- moteur est silencieusement omis du front (cohérent avec un
- moteur qui n'a pas de prix connu).
-
- Retour
- ------
- dict
- Trois fronts Pareto (``cost``, ``speed``, ``co2``) plus
- ``pricing_meta`` (table de pricing utilisée).
- """
- pricing_defaults, _ = load_pricing_database()
-
- pareto_points = []
- for entry in engines_summary:
- cer = entry.get("cer")
- cost = (entry.get("cost") or {}).get("cost_per_1k_pages_eur")
- if cer is None or cost is None:
- continue
- pareto_points.append({"engine": entry["name"], "cer": cer, "cost": cost})
- pareto_front_engines = compute_pareto_front(
- pareto_points, objectives=("cer", "cost"),
- )
-
- pareto_speed_points = []
- for entry in engines_summary:
- cer = entry.get("cer")
- dur = entry.get("mean_duration_seconds")
- if cer is None or dur is None:
- continue
- pareto_speed_points.append({"engine": entry["name"], "cer": cer, "dur": dur})
- pareto_front_speed = compute_pareto_front(
- pareto_speed_points, objectives=("cer", "dur"),
- )
-
- pareto_co2_points = []
- for entry in engines_summary:
- cer = entry.get("cer")
- co2 = (entry.get("cost") or {}).get("co2_per_1k_pages_g")
- if cer is None or co2 is None:
- continue
- pareto_co2_points.append({"engine": entry["name"], "cer": cer, "co2": co2})
- pareto_front_co2 = compute_pareto_front(
- pareto_co2_points, objectives=("cer", "co2"),
- )
-
- return {
- "cost": {
- "points": pareto_points,
- "front": pareto_front_engines,
- "axis_label": "Coût (€ / 1000 pages)",
- },
- "speed": {
- "points": pareto_speed_points,
- "front": pareto_front_speed,
- "axis_label": "Temps moyen (s / page)",
- },
- "co2": {
- "points": pareto_co2_points,
- "front": pareto_front_co2,
- "axis_label": (
- "Empreinte carbone (g CO₂ / 1000 pages, expérimental)"
- ),
- },
- "pricing_meta": {
- "last_updated": pricing_defaults.last_updated,
- "currency": pricing_defaults.currency,
- "hourly_rate_local_cpu_eur": pricing_defaults.hourly_rate_local_cpu_eur,
- "hourly_rate_local_gpu_eur": pricing_defaults.hourly_rate_local_gpu_eur,
- "grid_intensity_local": pricing_defaults.grid_intensity_local,
- "grid_intensity_cloud": pricing_defaults.grid_intensity_cloud,
- },
- }
-
-
-__all__ = ["attach_engine_costs", "build_pareto_section"]
+warnings.warn(
+ "picarones.report.report_data.pareto is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data.pareto instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/scatter.py b/picarones/report/report_data/scatter.py
index 045b02621c9f74c485e84299e13106a1b57e5afc..bb02fcd23b3f809b7c99f6b4ee1e8ac9942e600b 100644
--- a/picarones/report/report_data/scatter.py
+++ b/picarones/report/report_data/scatter.py
@@ -1,56 +1,18 @@
-"""Scatter plots du rapport (Sprint 10).
+"""``picarones.report.report_data.scatter`` — shim re-export (déprécié, suppression 2.0).
-- ``gini_vs_cer`` — corrélation Gini (concentration des erreurs)
- vs CER moyen, par moteur.
-- ``ratio_vs_anchor`` — ratio de longueur OCR/GT vs score d'ancrage,
- par moteur (révèle les hallucinations VLM).
+Canonique : :mod:`picarones.reports_v2.html.data.scatter`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-from typing import TYPE_CHECKING
+import warnings
-from picarones.report.report_data._helpers import safe_round
+from picarones.reports_v2.html.data.scatter import * # noqa: F401, F403
-if TYPE_CHECKING:
- from picarones.evaluation.benchmark_result import BenchmarkResult
-
-
-def build_gini_vs_cer(benchmark: "BenchmarkResult") -> list[dict]:
- """Scatter Gini de la distribution d'erreurs vs CER moyen."""
- gini_vs_cer: list[dict] = []
- for report in benchmark.engine_reports:
- line_metrics = report.aggregated_line_metrics
- gini_val = line_metrics.get("gini_mean") if line_metrics else None
- cer_val = report.mean_cer
- if gini_val is not None and cer_val is not None:
- gini_vs_cer.append({
- "engine": report.engine_name,
- "cer": safe_round(cer_val),
- "gini": safe_round(gini_val),
- "is_pipeline": report.is_pipeline,
- })
- return gini_vs_cer
-
-
-def build_ratio_vs_anchor(benchmark: "BenchmarkResult") -> list[dict]:
- """Scatter ratio de longueur vs score d'ancrage (détection VLM)."""
- ratio_vs_anchor: list[dict] = []
- for report in benchmark.engine_reports:
- halluc = report.aggregated_hallucination
- if not halluc:
- continue
- ratio_vs_anchor.append({
- "engine": report.engine_name,
- "length_ratio": safe_round(halluc.get("length_ratio_mean", 1.0)),
- "anchor_score": safe_round(halluc.get("anchor_score_mean", 1.0)),
- "hallucinating_rate": safe_round(halluc.get("hallucinating_doc_rate", 0.0)),
- "is_vlm": (
- report.pipeline_info.get("is_vlm", False)
- if report.pipeline_info else False
- ),
- })
- return ratio_vs_anchor
-
-
-__all__ = ["build_gini_vs_cer", "build_ratio_vs_anchor"]
+warnings.warn(
+ "picarones.report.report_data.scatter is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data.scatter instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/report_data/statistics.py b/picarones/report/report_data/statistics.py
index 340c44f3384a271fe25e6a74be4bcb6a81c954d0..46fe4dc2989a0a5659dc8e59c85f6261938310b6 100644
--- a/picarones/report/report_data/statistics.py
+++ b/picarones/report/report_data/statistics.py
@@ -1,216 +1,18 @@
-"""Sections statistiques du rapport (Sprint 7 + Sprint 17).
+"""``picarones.report.report_data.statistics`` — shim re-export (déprécié, suppression 2.0).
-Construit les blocs :
-
-- ``pairwise_wilcoxon`` — tests de Wilcoxon par paire de moteurs.
-- ``bootstrap_cis`` — intervalles de confiance bootstrap par moteur.
-- ``friedman`` + ``nemenyi`` — Sprint 17, multi-moteurs.
-- ``reliability_curves`` — courbes de fiabilité par moteur.
-- ``venn_data`` — diagramme de Venn des erreurs communes/exclusives.
-- ``error_clusters`` — clustering des patterns d'erreurs.
-- ``correlation_per_engine`` — matrice de corrélation par moteur.
+Canonique : :mod:`picarones.reports_v2.html.data.statistics`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-from typing import TYPE_CHECKING, Optional
-
-from picarones.core.diff_utils import compute_word_diff
-from picarones.measurements.statistics import (
- bootstrap_ci,
- cluster_errors,
- compute_correlation_matrix,
- compute_pairwise_stats,
- compute_reliability_curve,
- compute_venn_data,
- friedman_test,
- nemenyi_posthoc,
-)
-from picarones.report.report_data._helpers import safe_round
-
-if TYPE_CHECKING:
- from picarones.evaluation.benchmark_result import BenchmarkResult
-
-
-def _engine_cer_values(benchmark: "BenchmarkResult") -> dict[str, list[float]]:
- """Map ``engine_name → [cer_individuels valides]``."""
- out: dict[str, list[float]] = {}
- for report in benchmark.engine_reports:
- vals = [
- safe_round(dr.metrics.cer)
- for dr in report.document_results
- if dr.metrics.error is None
- ]
- if vals:
- out[report.engine_name] = vals
- return out
-
-
-def build_pairwise_wilcoxon(benchmark: "BenchmarkResult") -> list[dict]:
- """Tests de Wilcoxon par paire de moteurs (Sprint 7)."""
- return compute_pairwise_stats(_engine_cer_values(benchmark))
-
-
-def build_bootstrap_cis(benchmark: "BenchmarkResult") -> list[dict]:
- """Intervalles de confiance bootstrap par moteur (Sprint 7)."""
- bootstrap_cis: list[dict] = []
- for engine_name, vals in _engine_cer_values(benchmark).items():
- lo, hi = bootstrap_ci(vals)
- mean_v = sum(vals) / len(vals) if vals else 0.0
- bootstrap_cis.append({
- "engine": engine_name,
- "mean": safe_round(mean_v),
- "ci_lower": safe_round(lo),
- "ci_upper": safe_round(hi),
- })
- return bootstrap_cis
-
-
-def build_friedman_and_nemenyi(benchmark: "BenchmarkResult") -> dict:
- """Test de Friedman + post-hoc Nemenyi (Sprint 17, multi-moteurs).
-
- Alignement strict sur le même ordre de documents : on reconstruit
- la map à partir des documents communs à tous les moteurs, sinon
- Friedman n'est pas applicable.
-
- Returns
- -------
- dict
- ``{"friedman": {...}, "nemenyi": {...}}`` à fusionner dans
- la section ``statistics`` du rapport.
- """
- # Liste ordonnée des doc_ids selon l'ordre d'apparition.
- seen: set[str] = set()
- doc_ids_ordered: list[str] = []
- for report in benchmark.engine_reports:
- for dr in report.document_results:
- if dr.doc_id not in seen:
- seen.add(dr.doc_id)
- doc_ids_ordered.append(dr.doc_id)
+import warnings
- common_doc_ids: Optional[set[str]] = None
- for report in benchmark.engine_reports:
- doc_ids = {dr.doc_id for dr in report.document_results if dr.metrics.error is None}
- common_doc_ids = doc_ids if common_doc_ids is None else common_doc_ids & doc_ids
+from picarones.reports_v2.html.data.statistics import * # noqa: F401, F403
- engine_cer_aligned: dict[str, list[float]] = {}
- if common_doc_ids:
- ordered_common = [d for d in doc_ids_ordered if d in common_doc_ids]
- for report in benchmark.engine_reports:
- dr_by_id = {dr.doc_id: dr for dr in report.document_results}
- engine_cer_aligned[report.engine_name] = [
- safe_round(dr_by_id[d].metrics.cer) for d in ordered_common
- ]
-
- if engine_cer_aligned:
- friedman = friedman_test(engine_cer_aligned)
- nemenyi = nemenyi_posthoc(engine_cer_aligned)
- else:
- friedman = {
- "statistic": 0.0, "p_value": 1.0, "significant": False,
- "df": 0, "n_blocks": 0, "n_engines": 0, "mean_ranks": {},
- "interpretation": "Test de Friedman non calculé — aucun document commun.",
- "error": "no_common_documents",
- }
- nemenyi = {
- "alpha": 0.05, "critical_distance": 0.0, "q_alpha": 0.0,
- "n_blocks": 0, "n_engines": 0, "mean_ranks": {},
- "engines_sorted": [], "significant_matrix": [], "tied_groups": [],
- "error": "no_common_documents",
- }
- return {"friedman": friedman, "nemenyi": nemenyi}
-
-
-def build_reliability_curves(benchmark: "BenchmarkResult") -> list[dict]:
- """Courbes de fiabilité par moteur (Sprint 7)."""
- reliability_curves: list[dict] = []
- for report in benchmark.engine_reports:
- vals = [
- safe_round(dr.metrics.cer)
- for dr in report.document_results
- if dr.metrics.error is None
- ]
- curve = compute_reliability_curve(vals)
- reliability_curves.append({
- "engine": report.engine_name,
- "points": curve,
- })
- return reliability_curves
-
-
-def build_venn_data(benchmark: "BenchmarkResult") -> dict:
- """Venn des erreurs communes / exclusives (Sprint 7).
-
- Construit les ensembles d'erreurs par moteur :
- ``{engine → set("doc_id:gt_tok:hyp_tok")}``.
- """
- venn_error_sets: dict[str, set[str]] = {}
- for report in benchmark.engine_reports:
- error_set: set[str] = set()
- for dr in report.document_results:
- ops = compute_word_diff(dr.ground_truth, dr.hypothesis)
- for op in ops:
- if op["op"] in ("replace", "delete", "insert"):
- key = (
- f"{dr.doc_id}:"
- f"{op.get('old', op.get('text', ''))}:"
- f"{op.get('new', op.get('text', ''))}"
- )
- error_set.add(key)
- venn_error_sets[report.engine_name] = error_set
- return compute_venn_data(venn_error_sets)
-
-
-def build_error_clusters(benchmark: "BenchmarkResult") -> list[dict]:
- """Clustering des patterns d'erreurs (Sprint 7)."""
- error_data_all: list[dict] = []
- for report in benchmark.engine_reports:
- for dr in report.document_results:
- error_data_all.append({
- "engine": report.engine_name,
- "gt": dr.ground_truth,
- "hypothesis": dr.hypothesis,
- })
- error_clusters_raw = cluster_errors(error_data_all, max_clusters=8)
- return [c.as_dict() for c in error_clusters_raw]
-
-
-def build_correlation_per_engine(benchmark: "BenchmarkResult") -> list[dict]:
- """Matrice de corrélation par moteur entre métriques métiers (Sprint 7)."""
- correlation_per_engine: list[dict] = []
- for report in benchmark.engine_reports:
- metrics_list: list[dict[str, float]] = []
- for dr in report.document_results:
- if dr.metrics.error is not None:
- continue
- entry: dict[str, float] = {
- "cer": safe_round(dr.metrics.cer),
- "wer": safe_round(dr.metrics.wer),
- "mer": safe_round(dr.metrics.mer),
- "wil": safe_round(dr.metrics.wil),
- }
- if dr.image_quality:
- entry["quality_score"] = safe_round(dr.image_quality.get("quality_score", 0.5))
- entry["sharpness"] = safe_round(dr.image_quality.get("sharpness_score", 0.5))
- if dr.char_scores:
- entry["ligature"] = safe_round(dr.char_scores.get("ligature", {}).get("score", 0.5))
- entry["diacritic"] = safe_round(dr.char_scores.get("diacritic", {}).get("score", 0.5))
- metrics_list.append(entry)
- if metrics_list:
- corr = compute_correlation_matrix(metrics_list)
- correlation_per_engine.append({
- "engine": report.engine_name,
- **corr,
- })
- return correlation_per_engine
-
-
-__all__ = [
- "build_pairwise_wilcoxon",
- "build_bootstrap_cis",
- "build_friedman_and_nemenyi",
- "build_reliability_curves",
- "build_venn_data",
- "build_error_clusters",
- "build_correlation_per_engine",
-]
+warnings.warn(
+ "picarones.report.report_data.statistics is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.data.statistics instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/snapshot.py b/picarones/report/snapshot.py
index 6042a2134894116ad2918c0756c935a657eedcfc..38438a0334dd1ef12e943986f454280b126034d9 100644
--- a/picarones/report/snapshot.py
+++ b/picarones/report/snapshot.py
@@ -1,266 +1,18 @@
-"""Snapshots de reproductibilité pour le rapport HTML (Sprint 27).
+"""``picarones.report.snapshot`` — shim re-export (déprécié, suppression 2.0).
-Le rapport HTML auto-contenu doit pouvoir être *rejoué* sans avoir
-accès au code source du moment où il a été généré : un lecteur en
-2026 doit pouvoir comprendre exactement quelle table de prix, quelle
-définition de métrique, quel profil de normalisation, et quelle
-version de Picarones ont produit les chiffres affichés.
-
-Avant le Sprint 27, le rapport intégrait uniquement
-``pareto.pricing_meta.last_updated`` — une simple date de mise à jour
-qui ne disait rien sur le contenu de la table. Si quelqu'un modifiait
-``picarones/data/pricing.yaml`` après génération, il était impossible
-de reconstituer ce qu'avait vu le lecteur du rapport.
-
-Quatre snapshots sont produits par ce module et embarqués dans
-``report_data.snapshots`` :
-
-- ``pricing`` — YAML brut intégral de la table de prix.
-- ``glossary`` — entrées du glossaire pour la langue du rapport.
-- ``normalization`` — profil de normalisation effectivement appliqué.
-- ``environment`` — version Picarones, Python, plateforme, commit git
- si dispo, liste figée des dépendances installées.
-
-Garanties
----------
-- **Déterminisme** : sur entrées identiques, ``snapshot_all()`` produit
- un dict bit-à-bit identique. Les listes sont triées, les timestamps
- sont absents.
-- **Pas d'effet de bord** : le module ne modifie aucun état global ;
- les chemins YAML sont uniquement lus, jamais écrits.
-- **Dégradé non bloquant** : si pyyaml est absent, si ``pricing.yaml``
- n'existe pas, si git n'est pas installé, le snapshot retourne un
- dict ``{"available": False, "reason": "..."}`` plutôt que de lever.
+Canonique : :mod:`picarones.reports_v2.html.snapshot`. Phase 5.E
+du retrait du legacy.
"""
from __future__ import annotations
-import logging
-import platform
-import subprocess
-import sys
-from importlib.metadata import distributions
-from pathlib import Path
-from typing import Any, Optional
-
-from picarones import __version__
-
-logger = logging.getLogger(__name__)
-
-
-# ---------------------------------------------------------------------------
-# Pricing snapshot
-# ---------------------------------------------------------------------------
-
-def pricing_snapshot(pricing_path: Optional[Path] = None) -> dict[str, Any]:
- """Retourne le YAML brut + dict parsé de la table de prix utilisée.
-
- Si ``pricing_path`` n'est pas fourni, utilise le chemin par défaut
- de ``picarones.measurements.pricing._DEFAULT_PRICING_PATH``.
- """
- if pricing_path is None:
- try:
- from picarones.measurements.pricing import _DEFAULT_PRICING_PATH
- pricing_path = _DEFAULT_PRICING_PATH
- except ImportError:
- return {"available": False, "reason": "module pricing introuvable"}
-
- pricing_path = Path(pricing_path)
- if not pricing_path.exists():
- return {
- "available": False,
- "reason": f"pricing.yaml introuvable : {pricing_path}",
- "expected_path": str(pricing_path),
- }
-
- try:
- raw = pricing_path.read_text(encoding="utf-8")
- except OSError as exc:
- return {
- "available": False,
- "reason": f"lecture impossible : {exc}",
- "expected_path": str(pricing_path),
- }
-
- try:
- import yaml
- data = yaml.safe_load(raw) or {}
- except (ImportError, Exception) as exc:
- # Pas de yaml ou parsing en échec — on garde le brut quand même.
- logger.warning("[snapshot] parsing pricing.yaml échoué : %s", exc)
- data = {}
-
- return {
- "available": True,
- "source_path": str(pricing_path),
- "filename": pricing_path.name,
- "size_bytes": len(raw.encode("utf-8")),
- "raw_yaml": raw,
- "data": data,
- }
-
-
-# ---------------------------------------------------------------------------
-# Glossary snapshot
-# ---------------------------------------------------------------------------
-
-def glossary_snapshot(
- lang: str = "fr",
- used_keys: Optional[list[str] | set[str]] = None,
-) -> dict[str, Any]:
- """Retourne les entrées du glossaire qui figurent dans le rapport.
-
- ``used_keys`` permet de ne snapshotter que les termes effectivement
- référencés (réduit la taille). ``None`` → toutes les entrées de la
- langue (mode conservateur).
- """
- try:
- from picarones.reports_v2.glossary import load_glossary, SUPPORTED_LANGS
- except ImportError:
- return {"available": False, "reason": "module glossary introuvable"}
-
- full = load_glossary(lang) or {}
- if not full:
- return {
- "available": False,
- "reason": f"aucune entrée pour lang={lang!r}",
- "supported_langs": SUPPORTED_LANGS,
- }
-
- if used_keys is not None:
- keys = set(used_keys)
- entries = {k: v for k, v in full.items() if k in keys}
- else:
- entries = dict(full)
-
- # Tri pour reproductibilité bit-à-bit.
- entries_sorted = {k: entries[k] for k in sorted(entries)}
-
- return {
- "available": True,
- "lang": lang,
- "entry_count": len(entries_sorted),
- "entries": entries_sorted,
- }
-
-
-# ---------------------------------------------------------------------------
-# Normalization profile snapshot
-# ---------------------------------------------------------------------------
-
-def normalization_snapshot(profile: Any) -> dict[str, Any]:
- """Sérialise un ``NormalizationProfile``.
-
- Couvre les profils built-in (``medieval_french``, ``nfc``, …) et les
- profils custom YAML chargés au runtime — l'objectif est qu'un
- lecteur du rapport puisse régénérer exactement la même
- normalisation à partir de ce snapshot.
- """
- if profile is None:
- return {"available": False, "reason": "aucun profil fourni"}
-
- # NormalizationProfile est un dataclass — on accède aux champs par
- # nom plutôt que via ``asdict`` pour bien contrôler le format.
- try:
- return {
- "available": True,
- "name": getattr(profile, "name", "unknown"),
- "nfc": bool(getattr(profile, "nfc", True)),
- "caseless": bool(getattr(profile, "caseless", False)),
- "diplomatic_table": dict(getattr(profile, "diplomatic_table", {}) or {}),
- "exclude_chars": sorted(getattr(profile, "exclude_chars", set()) or set()),
- "description": getattr(profile, "description", ""),
- }
- except Exception as exc:
- return {"available": False, "reason": f"sérialisation échouée : {exc}"}
-
-
-# ---------------------------------------------------------------------------
-# Environment snapshot
-# ---------------------------------------------------------------------------
-
-def _git_commit(repo_path: Optional[Path] = None) -> Optional[str]:
- """Retourne le commit git court (12 chars) si on est dans un repo, sinon None."""
- cwd = repo_path or Path(__file__).resolve().parents[2]
- try:
- out = subprocess.check_output(
- ["git", "rev-parse", "HEAD"],
- cwd=str(cwd),
- stderr=subprocess.DEVNULL,
- text=True,
- timeout=2,
- ).strip()
- return out[:12] if out else None
- except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
- return None
-
-
-def _installed_packages(limit: int = 200) -> list[str]:
- """Liste figée des paquets installés au format ``name==version``.
-
- Triée par nom (case-insensitive) pour reproductibilité. Cappée à
- ``limit`` paquets pour ne pas exploser le poids du rapport.
- """
- try:
- pkgs: list[str] = []
- seen: set[str] = set()
- for d in distributions():
- try:
- name = (d.metadata.get("Name") or "").strip()
- version = (d.version or "").strip()
- except Exception:
- continue
- if not name or name.lower() in seen:
- continue
- seen.add(name.lower())
- pkgs.append(f"{name}=={version}")
- pkgs.sort(key=str.lower)
- return pkgs[:limit]
- except Exception as exc: # pragma: no cover — défense en profondeur
- logger.warning("[snapshot] enum dépendances échoué : %s", exc)
- return []
-
-
-def environment_snapshot(repo_path: Optional[Path] = None) -> dict[str, Any]:
- """Retourne version Picarones, Python, plateforme, commit, deps figées."""
- return {
- "available": True,
- "picarones_version": __version__,
- "python_version": platform.python_version(),
- "python_implementation": platform.python_implementation(),
- "platform": platform.platform(),
- "executable": sys.executable,
- "git_commit": _git_commit(repo_path),
- "installed_packages": _installed_packages(),
- }
-
-
-# ---------------------------------------------------------------------------
-# API agrégée
-# ---------------------------------------------------------------------------
-
-def snapshot_all(
- *,
- lang: str = "fr",
- glossary_used_keys: Optional[list[str] | set[str]] = None,
- pricing_path: Optional[Path] = None,
- normalization_profile: Any = None,
- repo_path: Optional[Path] = None,
-) -> dict[str, Any]:
- """Construit le bloc ``snapshots`` à embarquer dans ``report_data``."""
- return {
- "pricing": pricing_snapshot(pricing_path=pricing_path),
- "glossary": glossary_snapshot(lang=lang, used_keys=glossary_used_keys),
- "normalization": normalization_snapshot(normalization_profile),
- "environment": environment_snapshot(repo_path=repo_path),
- "schema_version": 1,
- }
+import warnings
+from picarones.reports_v2.html.snapshot import * # noqa: F401, F403
-__all__ = [
- "pricing_snapshot",
- "glossary_snapshot",
- "normalization_snapshot",
- "environment_snapshot",
- "snapshot_all",
-]
+warnings.warn(
+ "picarones.report.snapshot is deprecated and will be removed in 2.0. "
+ "Import from picarones.reports_v2.html.snapshot instead.",
+ DeprecationWarning,
+ stacklevel=2,
+)
diff --git a/picarones/report/templates/_critical_difference.html b/picarones/report/templates/_critical_difference.html
deleted file mode 100644
index 5b501b9a01d20f94a6b5993011df6e6cc7deec46..0000000000000000000000000000000000000000
--- a/picarones/report/templates/_critical_difference.html
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
Test multi-moteurs — Friedman & Nemenyi
-
-
-
- {% if friedman.error %}
-
{{ friedman.interpretation }}
- {% else %}
-
- Friedman :
- Q = {{ "%.3f"|format(friedman.statistic) }},
- df = {{ friedman.df }},
- n = {{ friedman.n_blocks }} documents,
- k = {{ friedman.n_engines }} moteurs,
- p = {{ "%.4f"|format(friedman.p_value) }}
- {% if friedman.significant %}
- p < 0,05
- {% else %}
- p ≥ 0,05
- {% endif %}
-
-
- {{ critical_difference_svg | safe }}
-
- {% endif %}
-
-
-
Comment lire ce diagramme ?
-
-
L'axe horizontal montre le rang moyen de chaque moteur (1 = meilleur, k = pire).
-
Les barres horizontales épaisses relient les moteurs statistiquement indiscernables au seuil α = 0,05 (test post-hoc de Nemenyi).
-
La barre rouge CD en haut à gauche donne la distance critique de référence : deux moteurs dont les rangs moyens diffèrent de moins que CD ne peuvent pas être distingués.
-
Référence : Demšar (2006), Statistical Comparisons of Classifiers over Multiple Data Sets, JMLR 7:1-30.
- Axe radar : CER, WER, MER, WIL — valeurs inversées (plus c'est haut, meilleur est le moteur).
-
-
-
-
-
CER par document (tous moteurs)
-
-
-
-
-
-
-
Temps d'exécution moyen (secondes/document)
-
-
-
-
-
-
-
Qualité image ↔ CER (scatter plot)
-
-
-
-
- Chaque point = un document. Axe X = score qualité image [0–1]. Axe Y = CER. Corrélation négative attendue.
-
-
-
-
-
Taxonomie des erreurs par moteur
-
-
-
-
- Distribution des classes d'erreurs (classes 1–9 de la taxonomie Picarones).
-
-
-
-
-
-
Courbes de fiabilité
-
-
-
-
- Pour les X% documents les plus faciles (triés par CER croissant), quel est le CER moyen cumulé ?
- Une courbe basse = moteur performant même sur les documents faciles.
-
-
-
-
-
-
Intervalles de confiance à 95 % (bootstrap)
-
-
-
-
- IC à 95% sur le CER moyen par moteur (1000 itérations bootstrap).
-
-
-
-
-
-
Erreurs communes / exclusives (Venn)
-
-
- Intersection des ensembles d'erreurs entre les 2 ou 3 premiers concurrents.
- Erreurs communes = segments partagés.
-
-
-
-
-
-
Tests de Wilcoxon — comparaisons par paires
-
-
- Test signé-rangé de Wilcoxon (non-paramétrique). Seuil α = 0.05.
-
-
-
-
-
-
Clustering des patterns d'erreurs
-
-
-
-
-
-
Gini vs CER moyen — idéal : bas-gauche
-
-
-
-
- Axe X = CER moyen, Axe Y = coefficient de Gini. Un moteur idéal a CER bas ET Gini bas (erreurs rares et uniformes).
-
-
-
-
-
-
Ratio longueur vs ancrage — hallucinations VLM
-
-
-
-
- Axe X = score d'ancrage trigrammes [0–1]. Axe Y = ratio longueur sortie/GT.
- Zone ⚠️ : ancrage < 0.5 ou ratio > 1.2 → hallucinations probables.
-
-
-
-
-
-
Compromis qualité / coût
-
-
-
-
-
-
-
- Les moteurs sur la frontière de Pareto (en évidence) sont ceux pour
- lesquels aucun autre moteur n'offre simultanément un meilleur CER ET
- un meilleur coût. Prix indicatifs (table interne, datée). Le mode
- carbone est expérimental.
-
-
- Hypothèses détaillées par moteur
-
-
-
-
-
- {% if calibration_summary_html or reliability_diagrams_html %}
-
-
Calibration des moteurs
-
- {% if calibration_summary_html %}
-
{{ calibration_summary_html }}
- {% endif %}
- {% if reliability_diagrams_html %}
-
{{ reliability_diagrams_html }}
- {% endif %}
-
-
- ECE (Expected Calibration Error) : moyenne pondérée des écarts
- |confiance − précision| par bin. Plus l'ECE est bas, plus le
- moteur est honnête sur sa fiabilité — la diagonale du diagramme
- représente la calibration parfaite. Un ECE élevé signale qu'on
- ne peut pas se fier au score de confiance pour cibler la
- relecture humaine.
-
-
- {% endif %}
-
-
- {% if ner_summary_html or ner_per_category_html %}
-
-
Précision sur entités nommées
-
- {% if ner_summary_html %}
-
{{ ner_summary_html }}
- {% endif %}
- {% if ner_per_category_html %}
-
{{ ner_per_category_html }}
- {% endif %}
-
-
- F1 calculé par alignement IoU ≥ 0,5 sur les spans (labels
- case-insensitive). Plus le F1 est haut, plus le moteur restitue
- fidèlement les entités nommées (personnes, lieux, dates) — ce
- qui prédit l'utilité aval pour l'indexation prosopographique.
- Cette métrique mesure conjointement OCR + extracteur NER ; le
- modèle d'extraction lui-même peut halluciner.
-
- {% endif %}
-
-
- {% if divergence_matrix_html or oracle_gap_html %}
-
-
Analyse inter-moteurs
-
- {% if divergence_matrix_html %}
-
{{ divergence_matrix_html }}
- {% endif %}
- {% if oracle_gap_html %}
-
{{ oracle_gap_html }}
- {% endif %}
-
-
- Plus la divergence est élevée, plus deux moteurs se trompent sur des
- classes d'erreurs différentes — ils sont alors candidats à un voting
- ensemble. L'oracle est la borne supérieure du recall token-level
- atteignable par ce voting (proxy bag-of-words).
-
- Recalcule CER, WER, MER, WIL, Gini et ancrage en excluant les documents détectés comme hallucinés ou problématiques.
- Cochez/décochez des documents dans la Galerie pour les exclure manuellement.
-
-
-
-
-
-
-
-
-
-
-
diff --git a/picarones/reports_v2/html/__init__.py b/picarones/reports_v2/html/__init__.py
index 32cf1d15f16c9d9a418a003fee5fe550b0f43dd1..4bfeaa45ccb44c8b33e0c786f15828b7393287bb 100644
--- a/picarones/reports_v2/html/__init__.py
+++ b/picarones/reports_v2/html/__init__.py
@@ -21,6 +21,7 @@ Usage
from __future__ import annotations
+from picarones.reports_v2.html.generator import ReportGenerator
from picarones.reports_v2.html.render import HtmlReportRenderer
-__all__ = ["HtmlReportRenderer"]
+__all__ = ["HtmlReportRenderer", "ReportGenerator"]
diff --git a/picarones/reports_v2/html/comparison.py b/picarones/reports_v2/html/comparison.py
new file mode 100644
index 0000000000000000000000000000000000000000..ae03a544a3730a8df3bbe9b1323ff9a14ee02fb2
--- /dev/null
+++ b/picarones/reports_v2/html/comparison.py
@@ -0,0 +1,414 @@
+"""Comparaison de deux runs de benchmark (Sprint 28).
+
+Phase 5.E — module relocalisé depuis ``picarones.report.comparison``
+vers ``picarones.reports_v2.html.comparison``. Le chemin legacy
+reste disponible via un shim avec ``DeprecationWarning`` ;
+suppression prévue en 2.0.
+
+Le Sprint 8 a livré la persistance longitudinale via SQLite
+(``picarones.measurements.history``) et un détecteur de régression CLI. Mais
+aucun outil n'exposait la **comparaison** de deux runs côté rapport :
+un chercheur qui itère sur 8 prompts ne pouvait pas voir d'un coup
+*« Tesseract → GPT-4o version V2 a régressé de 0,8 pp en CER moyen
+sur la strate paroissiaux par rapport à V1 »*.
+
+Ce module fournit :
+
+- ``load_benchmark_json(path)`` — charge le JSON produit par
+ ``BenchmarkResult.as_dict()`` ou ``picarones run -o results.json``.
+- ``compare_benchmarks(a, b)`` — calcule les deltas par moteur
+ (CER mean, WER mean, comptes de documents traités/échoués) et
+ par strate quand la métadonnée est présente.
+- ``detect_regressions(diff, threshold)`` — liste les moteurs en
+ régression (delta CER > threshold) et en amélioration
+ (delta CER < -threshold).
+- ``render_comparison_html(diff, output_path)`` — rendu HTML
+ auto-contenu minimal via Jinja2 pour partage.
+
+Conventions
+-----------
+- Les deltas sont calculés ``b - a`` (donc positif = ``b`` est pire).
+- Un moteur présent dans un seul run apparaît dans ``only_in_a`` /
+ ``only_in_b``, jamais dans ``deltas``.
+- Un moteur dont le ``mean_cer`` est ``None`` (échec total) est
+ signalé mais ne génère pas de delta numérique.
+- ``threshold`` est en absolu (CER en fraction, pas en %). Défaut
+ 0.005 = 0,5 pp.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Modèles
+# ---------------------------------------------------------------------------
+
+@dataclass
+class EngineDelta:
+ """Différence ``b - a`` pour un moteur donné."""
+ engine: str
+ cer_a: Optional[float]
+ cer_b: Optional[float]
+ delta_cer: Optional[float]
+ wer_a: Optional[float]
+ wer_b: Optional[float]
+ delta_wer: Optional[float]
+ docs_a: int
+ docs_b: int
+ failed_a: int
+ failed_b: int
+ is_regression: bool = False
+ is_improvement: bool = False
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "engine": self.engine,
+ "cer_a": self.cer_a,
+ "cer_b": self.cer_b,
+ "delta_cer": self.delta_cer,
+ "wer_a": self.wer_a,
+ "wer_b": self.wer_b,
+ "delta_wer": self.delta_wer,
+ "docs_a": self.docs_a,
+ "docs_b": self.docs_b,
+ "failed_a": self.failed_a,
+ "failed_b": self.failed_b,
+ "is_regression": self.is_regression,
+ "is_improvement": self.is_improvement,
+ }
+
+
+@dataclass
+class ComparisonResult:
+ """Résultat d'une comparaison ``b - a`` entre deux runs."""
+ label_a: str
+ label_b: str
+ run_date_a: Optional[str]
+ run_date_b: Optional[str]
+ corpus_a: Optional[str]
+ corpus_b: Optional[str]
+ deltas: list[EngineDelta] = field(default_factory=list)
+ only_in_a: list[str] = field(default_factory=list)
+ only_in_b: list[str] = field(default_factory=list)
+ threshold: float = 0.005
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "label_a": self.label_a,
+ "label_b": self.label_b,
+ "run_date_a": self.run_date_a,
+ "run_date_b": self.run_date_b,
+ "corpus_a": self.corpus_a,
+ "corpus_b": self.corpus_b,
+ "threshold": self.threshold,
+ "deltas": [d.as_dict() for d in self.deltas],
+ "only_in_a": list(self.only_in_a),
+ "only_in_b": list(self.only_in_b),
+ "regressions": [d.as_dict() for d in self.deltas if d.is_regression],
+ "improvements": [d.as_dict() for d in self.deltas if d.is_improvement],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Chargement
+# ---------------------------------------------------------------------------
+
+def load_benchmark_json(path: str | Path) -> dict[str, Any]:
+ """Charge un JSON de benchmark depuis disque.
+
+ Accepte :
+ - le format ``BenchmarkResult.as_dict()`` (clé ``ranking``,
+ ``engine_reports`` ou ``engines``) ;
+ - un dict déjà parsé ; dans ce cas, ``path`` peut être un dict.
+ """
+ if isinstance(path, dict):
+ return path
+ p = Path(path)
+ if not p.exists():
+ raise FileNotFoundError(f"Fichier benchmark introuvable : {p}")
+ with p.open(encoding="utf-8") as fh:
+ data = json.load(fh)
+ if not isinstance(data, dict):
+ raise ValueError(f"Le JSON {p} doit être un dict.")
+ return data
+
+
+# ---------------------------------------------------------------------------
+# Comparaison
+# ---------------------------------------------------------------------------
+
+def _ranking_index(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ """Indexe ``ranking`` par nom de moteur — robuste aux deux formats.
+
+ Un ``BenchmarkResult.as_dict()`` expose ``ranking`` directement
+ (clés ``engine``, ``mean_cer``, …). Le format alternatif ``engines``
+ expose le même contenu sous des clés légèrement différentes —
+ on normalise vers le format ``ranking``.
+ """
+ ranking = data.get("ranking")
+ if isinstance(ranking, list) and ranking:
+ return {
+ r["engine"]: {
+ "engine": r["engine"],
+ "mean_cer": r.get("mean_cer"),
+ "mean_wer": r.get("mean_wer"),
+ "documents": int(r.get("documents") or 0),
+ "failed": int(r.get("failed") or 0),
+ }
+ for r in ranking
+ if isinstance(r, dict) and r.get("engine")
+ }
+ # Fallback : ``engines`` (format report_data)
+ engines = data.get("engines") or []
+ out: dict[str, dict[str, Any]] = {}
+ if isinstance(engines, list):
+ for e in engines:
+ if not isinstance(e, dict):
+ continue
+ name = e.get("name") or e.get("engine")
+ if not name:
+ continue
+ out[name] = {
+ "engine": name,
+ "mean_cer": e.get("cer"),
+ "mean_wer": e.get("wer"),
+ "documents": int(e.get("documents") or 0),
+ "failed": int(e.get("failed") or 0),
+ }
+ return out
+
+
+def _label_of(data: dict[str, Any], default: str) -> str:
+ meta = data.get("meta") or {}
+ return (
+ meta.get("corpus_name")
+ or (data.get("corpus") or {}).get("name")
+ or default
+ )
+
+
+def _run_date_of(data: dict[str, Any]) -> Optional[str]:
+ return (
+ data.get("run_date")
+ or (data.get("meta") or {}).get("run_date")
+ )
+
+
+def _corpus_of(data: dict[str, Any]) -> Optional[str]:
+ meta = data.get("meta") or {}
+ return (
+ meta.get("corpus_source")
+ or (data.get("corpus") or {}).get("source")
+ or meta.get("corpus_name")
+ )
+
+
+def _safe_delta(a: Optional[float], b: Optional[float]) -> Optional[float]:
+ if a is None or b is None:
+ return None
+ return float(b) - float(a)
+
+
+def compare_benchmarks(
+ a: str | Path | dict[str, Any],
+ b: str | Path | dict[str, Any],
+ *,
+ threshold: float = 0.005,
+ label_a: str = "A",
+ label_b: str = "B",
+) -> ComparisonResult:
+ """Compare deux runs et retourne les deltas par moteur.
+
+ Convention : un delta CER positif signifie que ``b`` est *moins bon*
+ que ``a`` (régression). Un seuil ``threshold`` strictement positif
+ (en fraction, ex. 0,005 = 0,5 pp) discrimine régression / bruit.
+ """
+ da = load_benchmark_json(a) if not isinstance(a, dict) else a
+ db = load_benchmark_json(b) if not isinstance(b, dict) else b
+
+ idx_a = _ranking_index(da)
+ idx_b = _ranking_index(db)
+
+ common = sorted(set(idx_a) & set(idx_b))
+ only_a = sorted(set(idx_a) - set(idx_b))
+ only_b = sorted(set(idx_b) - set(idx_a))
+
+ deltas: list[EngineDelta] = []
+ for name in common:
+ ea = idx_a[name]
+ eb = idx_b[name]
+ delta_cer = _safe_delta(ea["mean_cer"], eb["mean_cer"])
+ delta_wer = _safe_delta(ea["mean_wer"], eb["mean_wer"])
+ regression = bool(delta_cer is not None and delta_cer > threshold)
+ improvement = bool(delta_cer is not None and delta_cer < -threshold)
+ deltas.append(
+ EngineDelta(
+ engine=name,
+ cer_a=ea["mean_cer"],
+ cer_b=eb["mean_cer"],
+ delta_cer=delta_cer,
+ wer_a=ea["mean_wer"],
+ wer_b=eb["mean_wer"],
+ delta_wer=delta_wer,
+ docs_a=int(ea["documents"]),
+ docs_b=int(eb["documents"]),
+ failed_a=int(ea["failed"]),
+ failed_b=int(eb["failed"]),
+ is_regression=regression,
+ is_improvement=improvement,
+ )
+ )
+
+ # Tri : régressions (delta décroissant) puis améliorations (delta croissant).
+ deltas.sort(key=lambda d: (
+ not d.is_regression,
+ -(d.delta_cer if d.delta_cer is not None else 0.0),
+ ))
+
+ return ComparisonResult(
+ label_a=label_a,
+ label_b=label_b,
+ run_date_a=_run_date_of(da),
+ run_date_b=_run_date_of(db),
+ corpus_a=_corpus_of(da),
+ corpus_b=_corpus_of(db),
+ deltas=deltas,
+ only_in_a=only_a,
+ only_in_b=only_b,
+ threshold=float(threshold),
+ )
+
+
+def detect_regressions(
+ diff: ComparisonResult,
+) -> list[EngineDelta]:
+ """Retourne uniquement les moteurs en régression dans ``diff``."""
+ return [d for d in diff.deltas if d.is_regression]
+
+
+# ---------------------------------------------------------------------------
+# Rendu HTML
+# ---------------------------------------------------------------------------
+
+_COMPARISON_TEMPLATE = """
+
+
+
+Picarones — Comparaison de runs
+
+
+
+
+ Run A : {{ diff.run_date_a or "?" }} · corpus {{ diff.corpus_a or "?" }}
+ Run B : {{ diff.run_date_b or "?" }} · corpus {{ diff.corpus_b or "?" }}
+ Seuil régression / amélioration : {{ "%.3f"|format(diff.threshold) }}
+ ({{ "%.1f"|format(diff.threshold * 100) }} pp de CER absolu).
+
+
+
Moteurs comparés ({{ diff.deltas|length }})
+{% if not diff.deltas %}
+
Aucun moteur commun aux deux runs.
+{% else %}
+
+
+
+
Moteur
+
CER A
+
CER B
+
Δ CER
+
Docs A → B
+
État
+
+
+
+ {% for d in diff.deltas %}
+
+
{{ d.engine }}
+
{{ "%.3f"|format(d.cer_a) if d.cer_a is not none else "—" }}
+
{{ "%.3f"|format(d.cer_b) if d.cer_b is not none else "—" }}