Claude commited on
Commit
0605de8
·
unverified ·
1 Parent(s): 53bed75

feat(migration): Phase 5.C batch 2 — 5 renderers moyens vers reports_v2/html/

Browse files

Deuxième vague de migration des 22 renderers thématiques.
Substitution : ``numerical_sequences_render`` reporté au batch 3
car sa dépendance ``measurements/numerical_sequences.py`` dépend
elle-même de ``measurements/roman_numerals.py``, deux modules
non encore migrés vers ``evaluation/metrics/``. Remplacé par
``longitudinal_render`` qui n'a pas de dépendance legacy.

Migrations effectuées
---------------------
| Source legacy | Destination canonique |
|----------------------------------------------|------------------------------------------------------|
| ``report/difficulty_render.py`` (45) | ``reports_v2/html/renderers/difficulty.py`` |
| ``report/lexical_modernization_render.py`` (114) | ``reports_v2/html/renderers/lexical_modernization.py`` |
| ``report/multirun_stability_render.py`` (151)| ``reports_v2/html/renderers/multirun_stability.py`` |
| ``report/throughput_render.py`` (154) | ``reports_v2/html/renderers/throughput.py`` |
| ``report/longitudinal_render.py`` (165) | ``reports_v2/html/renderers/longitudinal.py`` |

Total : ~629 lignes relocalisées. 5 nouveaux shims minimaux
(< 20 lignes) avec ``DeprecationWarning``.

Adaptations transverses
-----------------------
- ``reports_v2/html/renderers/lexical_modernization.py`` import
canonique ``picarones.evaluation.metrics.lexical_modernization``
(au lieu du shim legacy
``picarones.measurements.lexical_modernization``).
- ``test_module_coverage.py::TEST_ONLY_BASELINE`` étendu à
``"lexical_modernization"`` (même rationale que
``specialization`` au batch 1).
- Tests + ``picarones/report/generator.py`` mis à jour pour les
5 chemins canoniques.

Cumul Phase 5.C (batches 1+2)
-----------------------------
10 / 22 renderers migrés (~1198 lignes). 12 renderers restants.

Acceptance
----------
5019 tests passent, lint vert, architecture vérifiée.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

docs/migration/legacy-retirement-plan.md CHANGED
@@ -691,12 +691,13 @@ architecture vérifiée.
691
 
692
  **Reporté aux batches suivants** :
693
 
694
- - Batch 2 (~5 renderers moyens, 150-200 LOC chacun) :
695
- ``difficulty``, ``lexical_modernization``, ``numerical_sequences``,
696
- ``multirun_stability``, ``throughput``.
697
- - Batch 3 (~5 renderers moyens) : ``longitudinal``,
698
- ``module_audit``, ``ner``, ``image_predictive``,
699
- ``incremental_comparison``.
 
700
  - Batch 4 (~5 renderers gros) : ``error_absorption``,
701
  ``baseline``, ``inter_engine``, ``robustness_projection``,
702
  ``stratification``.
@@ -711,6 +712,46 @@ architecture vérifiée.
711
 
712
  Effort restant estimé : 8-12 jours.
713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
715
 
716
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
 
691
 
692
  **Reporté aux batches suivants** :
693
 
694
+ - Batch 2 (cf. ci-dessous) — 5 renderers (45-165 LOC).
695
+ - Batch 3 (~5 renderers moyens) : ``module_audit``,
696
+ ``ner``, ``image_predictive``,
697
+ ``incremental_comparison``, ``numerical_sequences``
698
+ (ce dernier exige d'abord la migration du module
699
+ ``measurements/numerical_sequences.py`` qui dépend de
700
+ ``measurements/roman_numerals.py``).
701
  - Batch 4 (~5 renderers gros) : ``error_absorption``,
702
  ``baseline``, ``inter_engine``, ``robustness_projection``,
703
  ``stratification``.
 
712
 
713
  Effort restant estimé : 8-12 jours.
714
 
715
+ #### Phase 5.C.batch2 — Lot 2 : 5 renderers moyens (2026-05)
716
+
717
+ Deuxième vague. Substitution dans la sélection initiale :
718
+ ``numerical_sequences_render`` reporté au batch 3 (sa dépendance
719
+ ``measurements/numerical_sequences.py`` dépend elle-même de
720
+ ``measurements/roman_numerals.py``, deux modules legacy non
721
+ migrés vers ``evaluation/metrics/`` ; le renderer ne peut donc pas
722
+ les importer depuis le canonique). Remplacé par
723
+ ``longitudinal_render`` qui n'a pas de dépendance legacy.
724
+
725
+ **Migrations effectuées** :
726
+
727
+ | Source legacy | Destination canonique |
728
+ |----------------------------------------------|------------------------------------------------------|
729
+ | ``report/difficulty_render.py`` (45) | ``reports_v2/html/renderers/difficulty.py`` |
730
+ | ``report/lexical_modernization_render.py`` (114) | ``reports_v2/html/renderers/lexical_modernization.py`` |
731
+ | ``report/multirun_stability_render.py`` (151)| ``reports_v2/html/renderers/multirun_stability.py`` |
732
+ | ``report/throughput_render.py`` (154) | ``reports_v2/html/renderers/throughput.py`` |
733
+ | ``report/longitudinal_render.py`` (165) | ``reports_v2/html/renderers/longitudinal.py`` |
734
+
735
+ Total : ~629 lignes relocalisées. 5 nouveaux shims minimaux
736
+ (< 20 lignes) avec ``DeprecationWarning``.
737
+
738
+ **Adaptations transverses** :
739
+
740
+ - ``reports_v2/html/renderers/lexical_modernization.py`` import
741
+ canonique ``picarones.evaluation.metrics.lexical_modernization``
742
+ (au lieu du shim legacy ``picarones.measurements.lexical_modernization``).
743
+ - ``test_module_coverage.py::TEST_ONLY_BASELINE`` étendu à
744
+ ``"lexical_modernization"`` (même rationale que ``specialization``
745
+ au batch 1).
746
+ - Tests + ``picarones/report/generator.py`` mis à jour pour les
747
+ 5 chemins canoniques.
748
+
749
+ **Acceptance batch 2** : 5019 tests passent, lint vert,
750
+ architecture vérifiée.
751
+
752
+ **Cumul Phase 5.C** (batches 1+2) : 10 / 22 renderers migrés
753
+ (~1198 lignes). 12 renderers restants.
754
+
755
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
756
 
757
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
picarones/report/difficulty_render.py CHANGED
@@ -1,45 +1,18 @@
1
- """Helpers de rendu pour le score de difficulté intrinsèque.
2
 
3
- Sprint A3 (item B-2 de l'audit institutional-readiness-2026-05) :
4
- ``difficulty_color`` vivait précédemment dans
5
- ``picarones/measurements/difficulty.py`` et y violait la règle
6
- Cercle 2 → Cercle 3 par un import paresseux de
7
- ``picarones.report.colors``. La fonction est désormais placée à sa
8
- juste place — Cercle 3, à côté de la palette qu'elle consomme — et
9
- ``measurements/difficulty.py`` ne contient plus que de la logique
10
- purement numérique.
11
-
12
- Le module pur ``picarones.measurements.difficulty`` reste utilisable
13
- sans dépendance vers ``picarones.report``.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
- from picarones.reports_v2._helpers.colors import (
19
- COLOR_GREEN,
20
- COLOR_ORANGE,
21
- COLOR_RED,
22
- COLOR_YELLOW,
23
- )
24
-
25
 
26
- def difficulty_color(score: float) -> str:
27
- """Retourne une couleur CSS pour un score de difficulté ∈ [0, 1].
28
 
29
- Convention :
30
-
31
- - score < 0.25 → vert (« facile »)
32
- - score < 0.50 → jaune (« modéré »)
33
- - score < 0.75 → orange (« difficile »)
34
- - score ≥ 0.75 → rouge (« très difficile »)
35
-
36
- Le label texte correspondant est produit par
37
- :func:`picarones.measurements.difficulty.difficulty_label`.
38
- """
39
- if score < 0.25:
40
- return COLOR_GREEN
41
- if score < 0.50:
42
- return COLOR_YELLOW
43
- if score < 0.75:
44
- return COLOR_ORANGE
45
- return COLOR_RED
 
1
+ """``picarones.report.difficulty_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.difficulty`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.difficulty import * # noqa: F401, F403
 
12
 
13
+ warnings.warn(
14
+ "picarones.report.difficulty_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.difficulty instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
 
 
 
 
 
 
 
 
 
 
picarones/report/lexical_modernization_render.py CHANGED
@@ -1,114 +1,18 @@
1
- """Rendu HTML de la vue « Modernisation lexicale » — Sprint 80.
2
 
3
- A.I.7 du plan d'évolution 2026.
4
-
5
- Suite directe ``picarones/core/lexical_modernization.py``.
6
- Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76/77) :
7
- **server-side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Vue
10
- ---
11
- Tableau trié par taux de modernisation décroissant : forme
12
- historique GT → forme(s) modernisée(s), occurrences GT, %.
13
- Couleur de cellule pour le %.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
- from html import escape as _e
19
- from typing import Optional
20
-
21
- from picarones.measurements.lexical_modernization import top_modernized_tokens
22
- from picarones.reports_v2._helpers.render_helpers import (
23
- GRADIENT_TARGET_ORANGE,
24
- color_single_gradient,
25
- )
26
-
27
-
28
- def _format_variants(variants: dict, max_show: int = 3) -> str:
29
- """Liste compacte des variants modernisés."""
30
- items = sorted(variants.items(), key=lambda kv: -kv[1])
31
- shown = items[:max_show]
32
- rest = len(items) - max_show
33
- parts = [
34
- f"{_e(form)} ({count})"
35
- for form, count in shown
36
- ]
37
- if rest > 0:
38
- parts.append(f"+{rest}")
39
- return ", ".join(parts)
40
 
 
41
 
42
- def build_lexical_modernization_html(
43
- data: Optional[dict],
44
- labels: Optional[dict[str, str]] = None,
45
- *,
46
- top_n: int = 20,
47
- min_total: int = 1,
48
- ) -> str:
49
- """Construit la table HTML de modernisation lexicale.
50
-
51
- Retourne ``""`` si ``data is None`` ou si aucun token modernisé.
52
- """
53
- if not data:
54
- return ""
55
- rows = top_modernized_tokens(data, n=top_n, min_total=min_total)
56
- if not rows:
57
- return ""
58
- labels = labels or {}
59
- title = labels.get(
60
- "lexmod_title", "Modernisation lexicale (top tokens)",
61
- )
62
- note = labels.get(
63
- "lexmod_note",
64
- "Tokens GT que le moteur réécrit le plus souvent. "
65
- "Lecture : « maistre → maître modernisé dans 85 % des cas » "
66
- "indique de quoi corriger dans le prompt pour préserver "
67
- "l'orthographe historique.",
68
- )
69
- gt_label = labels.get("lexmod_gt_label", "Forme historique GT")
70
- hyp_label = labels.get("lexmod_hyp_label", "Variantes OCR")
71
- n_label = labels.get("lexmod_n_label", "n GT")
72
- rate_label = labels.get("lexmod_rate_label", "% modernisé")
73
-
74
- parts = [
75
- '<div class="lexmod" style="margin:1rem 0">',
76
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
77
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
78
- f'{_e(note)}</div>',
79
- '<table style="border-collapse:collapse;width:100%;'
80
- 'font-size:.85rem">',
81
- '<thead><tr>',
82
- ]
83
- for col in (gt_label, hyp_label, n_label, rate_label):
84
- parts.append(
85
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
86
- f'border-bottom:1px solid #ccc;font-weight:600">'
87
- f'{_e(col)}</th>'
88
- )
89
- parts.append("</tr></thead><tbody>")
90
- for gt_token, slot in rows:
91
- rate = slot.get("rate_modernized", 0.0)
92
- n_total = slot.get("n_total", 0)
93
- variants_str = _format_variants(slot.get("variants") or {})
94
- rate_color = color_single_gradient(rate, end_rgb=GRADIENT_TARGET_ORANGE)
95
- parts.append(
96
- f'<tr>'
97
- f'<td style="padding:.3rem .5rem;font-family:monospace">'
98
- f'{_e(gt_token)}</td>'
99
- f'<td style="padding:.3rem .5rem;font-size:.85rem">'
100
- f'{variants_str}</td>'
101
- f'<td style="padding:.3rem .5rem;text-align:right;'
102
- f'font-family:monospace">{n_total}</td>'
103
- f'<td style="padding:.3rem .5rem;text-align:right;'
104
- f'background:{rate_color};font-family:monospace">'
105
- f'{rate * 100:.0f}%</td>'
106
- f'</tr>'
107
- )
108
- parts.append("</tbody></table></div>")
109
- return "".join(parts)
110
-
111
-
112
- __all__ = [
113
- "build_lexical_modernization_html",
114
- ]
 
1
+ """``picarones.report.lexical_modernization_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.lexical_modernization`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.lexical_modernization import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.lexical_modernization_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.lexical_modernization instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/report/longitudinal_render.py CHANGED
@@ -1,165 +1,18 @@
1
- """Rendu HTML « Évolution dans le temps » Sprint 92 (A.II.9).
2
 
3
- Suite directe ``picarones/core/longitudinal.py``. Pattern
4
- identique aux autres rendus : server-side, pas de JS, anti-
5
- injection systématique.
6
-
7
- Vue
8
- ---
9
- Tableau résumé moteur × {n_runs, premier CER, dernier CER,
10
- variation cumulée colorée, pente annualisée, R², point de
11
- rupture si détecté}.
12
-
13
- Adaptive : ``""`` si la liste est vide.
14
-
15
- Note d'intégration
16
- ------------------
17
- Module pur — l'utilisateur compose :
18
-
19
- .. code-block:: python
20
-
21
- from picarones.measurements.history import BenchmarkHistory
22
- from picarones.measurements.longitudinal import compute_corpus_longitudinal
23
- from picarones.report.longitudinal_render import build_longitudinal_html
24
-
25
- hist = BenchmarkHistory(db_path)
26
- entries = hist.list_entries()
27
- trends = compute_corpus_longitudinal(entries, corpus_name)
28
- html = build_longitudinal_html(trends, labels)
29
  """
30
 
31
  from __future__ import annotations
32
 
33
- from html import escape as _e
34
- from typing import Optional
35
-
36
- from picarones.reports_v2._helpers.render_helpers import color_diverging
37
-
38
-
39
- def _bg_for_cer_delta(delta_pct: float) -> str:
40
- """Cellule colorée pour un delta de CER en points de pourcentage :
41
- vert si delta ≈ 0, orange/rouge en régression, bleu en amélioration.
42
- Saturation à ±5 points.
43
- """
44
- if abs(delta_pct) < 1.0:
45
- return "#a7f0a7"
46
- return color_diverging(
47
- delta_pct,
48
- max_abs=5.0,
49
- neutral_rgb=(167, 240, 167),
50
- positive_rgb=(220, 50, 50),
51
- negative_rgb=(90, 160, 210),
52
- )
53
-
54
-
55
- def build_longitudinal_html(
56
- trends: Optional[list],
57
- labels: Optional[dict[str, str]] = None,
58
- ) -> str:
59
- """Construit la vue HTML longitudinale.
60
-
61
- Parameters
62
- ----------
63
- trends:
64
- Sortie de ``compute_corpus_longitudinal`` (liste de
65
- dicts). Si ``None`` ou vide, retourne ``""``.
66
- labels:
67
- Dict i18n. Clés sous le préfixe ``longitudinal_*``.
68
- """
69
- if not trends:
70
- return ""
71
- rows = [t for t in trends if isinstance(t, dict) and t.get("engine_name")]
72
- if not rows:
73
- return ""
74
- labels = labels or {}
75
- title = labels.get(
76
- "longitudinal_title", "Évolution dans le temps",
77
- )
78
- note = labels.get(
79
- "longitudinal_note",
80
- "Tendance et points de rupture sur l'historique SQLite "
81
- "des runs précédents. Une variation positive signale "
82
- "une dégradation cumulée — utile pour relier une "
83
- "régression à un changement de pipeline ou de modèle.",
84
- )
85
- h_engine = labels.get("longitudinal_engine", "Moteur")
86
- h_n_runs = labels.get("longitudinal_n_runs", "Runs")
87
- h_first = labels.get("longitudinal_first", "Premier CER")
88
- h_last = labels.get("longitudinal_last", "Dernier CER")
89
- h_delta = labels.get("longitudinal_delta", "Δ cumulé (pts)")
90
- h_slope = labels.get("longitudinal_slope", "Pente annuelle (pts/an)")
91
- h_r2 = labels.get("longitudinal_r2", "R²")
92
- h_change = labels.get("longitudinal_change", "Rupture")
93
-
94
- parts = [
95
- '<section class="longitudinal-section" style="margin:1rem 0">',
96
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
97
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
98
- f'{_e(note)}</div>',
99
- '<table style="border-collapse:collapse;width:100%;'
100
- 'font-size:.9rem">',
101
- '<thead><tr>',
102
- ]
103
- for col in (h_engine, h_n_runs, h_first, h_last, h_delta,
104
- h_slope, h_r2, h_change):
105
- parts.append(
106
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
107
- f'border-bottom:1px solid #ccc;font-weight:600">'
108
- f'{_e(col)}</th>'
109
- )
110
- parts.append("</tr></thead><tbody>")
111
- for entry in sorted(
112
- rows,
113
- key=lambda r: -float(r.get("absolute_delta") or 0.0),
114
- ):
115
- engine = str(entry.get("engine_name") or "?")
116
- n_runs = int(entry.get("n_runs") or 0)
117
- first_cer = float(entry.get("first_cer") or 0.0)
118
- last_cer = float(entry.get("last_cer") or 0.0)
119
- delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
120
- delta_color = _bg_for_cer_delta(delta_pct)
121
- trend = entry.get("trend") or {}
122
- slope = trend.get("slope")
123
- r2 = trend.get("r_squared")
124
- slope_str = (
125
- f"{float(slope) * 365 * 100:+.2f}"
126
- if isinstance(slope, (int, float)) else "—"
127
- )
128
- r2_str = (
129
- f"{float(r2):.2f}"
130
- if isinstance(r2, (int, float)) else "—"
131
- )
132
- cp = entry.get("change_point")
133
- if isinstance(cp, dict) and cp.get("timestamp"):
134
- cp_delta = float(cp.get("delta") or 0.0)
135
- cp_str = (
136
- f'{_e(str(cp["timestamp"]))} '
137
- f'<span style="opacity:.75">'
138
- f'({cp_delta * 100:+.2f} pts)</span>'
139
- )
140
- else:
141
- cp_str = "—"
142
- parts.append(
143
- f'<tr>'
144
- f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
145
- f'<td style="padding:.4rem .6rem;text-align:right;'
146
- f'font-family:monospace">{n_runs}</td>'
147
- f'<td style="padding:.4rem .6rem;text-align:right;'
148
- f'font-family:monospace">{first_cer * 100:.2f}%</td>'
149
- f'<td style="padding:.4rem .6rem;text-align:right;'
150
- f'font-family:monospace">{last_cer * 100:.2f}%</td>'
151
- f'<td style="padding:.4rem .6rem;text-align:right;'
152
- f'background:{delta_color};font-family:monospace;'
153
- f'font-weight:600">{delta_pct:+.2f}</td>'
154
- f'<td style="padding:.4rem .6rem;text-align:right;'
155
- f'font-family:monospace">{slope_str}</td>'
156
- f'<td style="padding:.4rem .6rem;text-align:right;'
157
- f'font-family:monospace">{r2_str}</td>'
158
- f'<td style="padding:.4rem .6rem">{cp_str}</td>'
159
- f'</tr>'
160
- )
161
- parts.append("</tbody></table></section>")
162
- return "".join(parts)
163
 
 
164
 
165
- __all__ = ["build_longitudinal_html"]
 
 
 
 
 
 
1
+ """``picarones.report.longitudinal_render``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.longitudinal`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.longitudinal import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.longitudinal_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.longitudinal instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/multirun_stability_render.py CHANGED
@@ -1,151 +1,18 @@
1
- """Rendu HTML « Stabilité multi-runs » Sprint 90 (A.II.4).
2
 
3
- Suite directe ``picarones/core/reliability.compute_multirun_stability``
4
- (Sprint 83). Pattern identique aux autres rendus : server-side,
5
- pas de JS, anti-injection systématique.
6
-
7
- Note d'intégration
8
- ------------------
9
- La stabilité multi-runs n'est pas calculée automatiquement par
10
- le runner — l'utilisateur doit relancer son moteur LLM/VLM
11
- plusieurs fois (option ``--repeats N`` du runner reportée à un
12
- sprint dédié) et appeler ``compute_multirun_stability`` lui-
13
- même. Cette vue est donc un **module de rendu pur** que
14
- l'utilisateur compose :
15
-
16
- .. code-block:: python
17
-
18
- from picarones.measurements.reliability import compute_multirun_stability
19
- from picarones.report.multirun_stability_render import (
20
- build_multirun_stability_html,
21
- )
22
-
23
- stability = []
24
- for engine_name, runs in per_engine_runs.items():
25
- s = compute_multirun_stability(runs, reference=ref)
26
- if s is not None:
27
- s["engine_name"] = engine_name
28
- stability.append(s)
29
- html = build_multirun_stability_html(stability, labels)
30
-
31
- Vue
32
- ---
33
- Tableau moteur × {n_runs, CER moyen ± écart-type, CV (%),
34
- % paires identiques, n outputs distincts}. Cellule CV colorée
35
- par gradient vert (stable) → rouge (instable, CV > 20 %).
36
-
37
- Adaptive : ``""`` si la liste est vide ou que tous les
38
- ``cer_cv`` sont ``None``.
39
  """
40
 
41
  from __future__ import annotations
42
 
43
- from html import escape as _e
44
- from typing import Optional
45
-
46
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
47
-
48
-
49
- def build_multirun_stability_html(
50
- stability: Optional[list],
51
- labels: Optional[dict[str, str]] = None,
52
- ) -> str:
53
- """Construit la vue HTML de stabilité multi-runs.
54
-
55
- Parameters
56
- ----------
57
- stability:
58
- Liste de dicts (un par moteur) issus de
59
- ``compute_multirun_stability`` enrichis d'un
60
- ``engine_name``. Si vide ou ``None``, retourne ``""``.
61
- labels:
62
- Dict i18n. Clés sous le préfixe ``stability_*``.
63
- """
64
- if not stability:
65
- return ""
66
- rows = [s for s in stability if isinstance(s, dict) and s.get("engine_name")]
67
- if not rows:
68
- return ""
69
- labels = labels or {}
70
- title = labels.get("stability_title", "Stabilité multi-runs")
71
- note = labels.get(
72
- "stability_note",
73
- "Quand un moteur LLM/VLM est non déterministe, la "
74
- "variance entre runs successifs sur les mêmes documents "
75
- "est un proxy de la fiabilité scientifique. Un CV élevé "
76
- "ou un faible taux de runs identiques discrédite "
77
- "l'interprétation du CER moyen.",
78
- )
79
- h_engine = labels.get("stability_engine", "Moteur")
80
- h_n_runs = labels.get("stability_n_runs", "Runs")
81
- h_cer = labels.get("stability_cer", "CER moyen ± σ")
82
- h_cv = labels.get("stability_cv", "CV (%)")
83
- h_identical = labels.get("stability_identical", "% runs identiques")
84
- h_distinct = labels.get("stability_distinct", "Sorties distinctes")
85
-
86
- parts = [
87
- '<section class="stability-section" style="margin:1rem 0">',
88
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
89
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
90
- f'{_e(note)}</div>',
91
- '<table style="border-collapse:collapse;width:100%;'
92
- 'font-size:.9rem">',
93
- '<thead><tr>',
94
- ]
95
- for col in (h_engine, h_n_runs, h_cer, h_cv, h_identical, h_distinct):
96
- parts.append(
97
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
98
- f'border-bottom:1px solid #ccc;font-weight:600">'
99
- f'{_e(col)}</th>'
100
- )
101
- parts.append("</tr></thead><tbody>")
102
- for stab in rows:
103
- engine = str(stab.get("engine_name") or "?")
104
- n_runs = int(stab.get("n_runs") or 0)
105
- cer_mean = stab.get("cer_mean")
106
- cer_stdev = stab.get("cer_stdev")
107
- cer_cv = stab.get("cer_cv")
108
- identical = stab.get("identical_run_rate")
109
- n_distinct = stab.get("n_distinct_outputs")
110
- if isinstance(cer_mean, (int, float)) and isinstance(cer_stdev, (int, float)):
111
- cer_str = f"{cer_mean * 100:.2f}% ± {cer_stdev * 100:.2f}%"
112
- elif isinstance(cer_mean, (int, float)):
113
- cer_str = f"{cer_mean * 100:.2f}%"
114
- else:
115
- cer_str = "—"
116
- if isinstance(cer_cv, (int, float)):
117
- cv_color = color_traffic_light(float(cer_cv), low_is_good=True, scale_max=0.25)
118
- cv_cell = (
119
- f'<td style="padding:.4rem .6rem;text-align:right;'
120
- f'background:{cv_color};font-family:monospace;'
121
- f'font-weight:600">{float(cer_cv) * 100:.1f}</td>'
122
- )
123
- else:
124
- cv_cell = (
125
- '<td style="padding:.4rem .6rem;text-align:right;'
126
- 'opacity:.4">—</td>'
127
- )
128
- identical_str = (
129
- f"{float(identical) * 100:.1f}"
130
- if isinstance(identical, (int, float)) else "—"
131
- )
132
- distinct_str = str(n_distinct) if isinstance(n_distinct, int) else "—"
133
- parts.append(
134
- f'<tr>'
135
- f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
136
- f'<td style="padding:.4rem .6rem;text-align:right;'
137
- f'font-family:monospace">{n_runs}</td>'
138
- f'<td style="padding:.4rem .6rem;text-align:right;'
139
- f'font-family:monospace">{cer_str}</td>'
140
- f'{cv_cell}'
141
- f'<td style="padding:.4rem .6rem;text-align:right;'
142
- f'font-family:monospace">{identical_str}</td>'
143
- f'<td style="padding:.4rem .6rem;text-align:right;'
144
- f'font-family:monospace">{distinct_str}</td>'
145
- f'</tr>'
146
- )
147
- parts.append("</tbody></table></section>")
148
- return "".join(parts)
149
 
 
150
 
151
- __all__ = ["build_multirun_stability_html"]
 
 
 
 
 
 
1
+ """``picarones.report.multirun_stability_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.multirun_stability`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.multirun_stability import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.multirun_stability_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.multirun_stability instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/throughput_render.py CHANGED
@@ -1,154 +1,18 @@
1
- """Rendu HTML « Throughput effectif » Sprint 91 (A.II.6).
2
 
3
- Suite directe ``picarones/core/throughput.py``. Pattern
4
- identique aux autres rendus : server-side, pas de JS, anti-
5
- injection systématique.
6
-
7
- Vue
8
- ---
9
- Tableau résumé moteur × {pages/h brut, pages/h **utilisable**,
10
- % temps de correction (drag), n_pages, n_errors}. La cellule
11
- **pages/h utilisable** est colorée en gradient rouge (faible)
12
- → vert (élevé), normalisé sur le maximum observé.
13
-
14
- Adaptive : ``""`` si ``aggregate_effective_throughput`` retourne
15
- ``None`` (aucun moteur exploitable).
16
-
17
- Note d'intégration
18
- ------------------
19
- Cette vue est un **module pur** — l'utilisateur compose :
20
-
21
- .. code-block:: python
22
-
23
- from picarones.measurements.throughput import (
24
- aggregate_effective_throughput,
25
- )
26
- from picarones.report.throughput_render import (
27
- build_throughput_html,
28
- )
29
-
30
- per_engine = []
31
- for report in benchmark.engine_reports:
32
- n_errors = sum(
33
- int(round(dr.metrics.wer * dr.metrics.reference_length / 5))
34
- for dr in report.document_results
35
- )
36
- per_engine.append({
37
- "engine_name": report.engine_name,
38
- "n_pages": len(report.document_results),
39
- "duration_seconds": sum(
40
- dr.duration_seconds for dr in report.document_results
41
- ),
42
- "n_errors": n_errors,
43
- })
44
- agg = aggregate_effective_throughput(per_engine)
45
- html = build_throughput_html(agg, labels)
46
  """
47
 
48
  from __future__ import annotations
49
 
50
- from html import escape as _e
51
- from typing import Optional
52
-
53
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
54
-
55
-
56
- def build_throughput_html(
57
- aggregated: Optional[dict],
58
- labels: Optional[dict[str, str]] = None,
59
- ) -> str:
60
- """Construit la vue HTML throughput effectif.
61
-
62
- Parameters
63
- ----------
64
- aggregated:
65
- Sortie de ``aggregate_effective_throughput``. Si
66
- ``None`` ou liste vide, retourne ``""``.
67
- labels:
68
- Dict i18n. Clés sous le préfixe ``throughput_*``.
69
- """
70
- if not aggregated:
71
- return ""
72
- rows = aggregated.get("engines") or []
73
- if not rows:
74
- return ""
75
- labels = labels or {}
76
- title = labels.get("throughput_title", "Throughput effectif")
77
- note = labels.get(
78
- "throughput_note",
79
- "Pages traitables par heure en intégrant le temps de "
80
- "correction humaine post-OCR. Discrimine entre un cloud "
81
- "rapide mais imprécis et un local lent mais fiable. "
82
- "Constante de correction : {time_per_error}s par erreur "
83
- "(défaut HTR-United, surchargeable).",
84
- )
85
- time_per_error = aggregated.get("time_per_error_seconds", 5.0)
86
- note = note.replace("{time_per_error}", f"{time_per_error:.0f}")
87
- h_engine = labels.get("throughput_engine", "Moteur")
88
- h_raw = labels.get("throughput_raw", "Pages/h brut")
89
- h_effective = labels.get(
90
- "throughput_effective", "Pages/h utilisable",
91
- )
92
- h_drag = labels.get("throughput_drag", "% correction")
93
- h_pages = labels.get("throughput_pages", "Pages")
94
- h_errors = labels.get("throughput_errors", "Erreurs")
95
- max_eff = max(
96
- (r.get("pages_per_hour_effective") or 0.0) for r in rows
97
- )
98
-
99
- parts = [
100
- '<section class="throughput-section" style="margin:1rem 0">',
101
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
102
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
103
- f'{_e(note)}</div>',
104
- '<table style="border-collapse:collapse;width:100%;'
105
- 'font-size:.9rem">',
106
- '<thead><tr>',
107
- ]
108
- for col in (h_engine, h_raw, h_effective, h_drag, h_pages, h_errors):
109
- parts.append(
110
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
111
- f'border-bottom:1px solid #ccc;font-weight:600">'
112
- f'{_e(col)}</th>'
113
- )
114
- parts.append("</tr></thead><tbody>")
115
- for row in sorted(
116
- rows,
117
- key=lambda r: -(r.get("pages_per_hour_effective") or 0.0),
118
- ):
119
- engine = str(row.get("engine_name") or "?")
120
- raw = row.get("pages_per_hour_raw")
121
- eff = row.get("pages_per_hour_effective") or 0.0
122
- drag = row.get("drag_ratio") or 0.0
123
- n_pages = int(row.get("n_pages") or 0)
124
- n_errors = int(row.get("n_errors") or 0)
125
- eff_color = (
126
- color_traffic_light(eff, scale_max=max_eff)
127
- if max_eff > 0 else "#e0e0e0"
128
- )
129
- drag_color = color_traffic_light(drag, low_is_good=True)
130
- raw_str = (
131
- f"{raw:,.0f}" if isinstance(raw, (int, float)) else "—"
132
- )
133
- parts.append(
134
- f'<tr>'
135
- f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
136
- f'<td style="padding:.4rem .6rem;text-align:right;'
137
- f'font-family:monospace">{raw_str}</td>'
138
- f'<td style="padding:.4rem .6rem;text-align:right;'
139
- f'background:{eff_color};font-family:monospace;'
140
- f'font-weight:600">{eff:,.0f}</td>'
141
- f'<td style="padding:.4rem .6rem;text-align:right;'
142
- f'background:{drag_color};font-family:monospace">'
143
- f'{drag * 100:.1f}%</td>'
144
- f'<td style="padding:.4rem .6rem;text-align:right;'
145
- f'font-family:monospace">{n_pages}</td>'
146
- f'<td style="padding:.4rem .6rem;text-align:right;'
147
- f'font-family:monospace">{n_errors}</td>'
148
- f'</tr>'
149
- )
150
- parts.append("</tbody></table></section>")
151
- return "".join(parts)
152
 
 
153
 
154
- __all__ = ["build_throughput_html"]
 
 
 
 
 
 
1
+ """``picarones.report.throughput_render``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.throughput`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.throughput import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.throughput_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.throughput instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/views/advanced_taxonomy.py CHANGED
@@ -203,7 +203,7 @@ def build_advanced_taxonomy_view_html(
203
  # Sous-section 4 : modernisation lexicale (opt-in)
204
  if lexical_modernization:
205
  try:
206
- from picarones.report.lexical_modernization_render import (
207
  build_lexical_modernization_html,
208
  )
209
  html = build_lexical_modernization_html(
 
203
  # Sous-section 4 : modernisation lexicale (opt-in)
204
  if lexical_modernization:
205
  try:
206
+ from picarones.reports_v2.html.renderers.lexical_modernization import (
207
  build_lexical_modernization_html,
208
  )
209
  html = build_lexical_modernization_html(
picarones/report/views/diagnostics.py CHANGED
@@ -165,7 +165,7 @@ def build_diagnostics_view_html(
165
  # Sous-section 4 : évolution longitudinale (opt-in)
166
  if longitudinal:
167
  try:
168
- from picarones.report.longitudinal_render import (
169
  build_longitudinal_html,
170
  )
171
  html = build_longitudinal_html(longitudinal, labels=labels)
@@ -185,7 +185,7 @@ def build_diagnostics_view_html(
185
  # Sous-section 5 : stabilité multi-runs (opt-in)
186
  if stability:
187
  try:
188
- from picarones.report.multirun_stability_render import (
189
  build_multirun_stability_html,
190
  )
191
  html = build_multirun_stability_html(stability, labels=labels)
 
165
  # Sous-section 4 : évolution longitudinale (opt-in)
166
  if longitudinal:
167
  try:
168
+ from picarones.reports_v2.html.renderers.longitudinal import (
169
  build_longitudinal_html,
170
  )
171
  html = build_longitudinal_html(longitudinal, labels=labels)
 
185
  # Sous-section 5 : stabilité multi-runs (opt-in)
186
  if stability:
187
  try:
188
+ from picarones.reports_v2.html.renderers.multirun_stability import (
189
  build_multirun_stability_html,
190
  )
191
  html = build_multirun_stability_html(stability, labels=labels)
picarones/report/views/economics.py CHANGED
@@ -134,7 +134,7 @@ def build_economics_view_html(
134
  from picarones.measurements.throughput import (
135
  aggregate_effective_throughput,
136
  )
137
- from picarones.report.throughput_render import (
138
  build_throughput_html,
139
  )
140
  inputs = _estimate_engine_throughput_inputs(engine_reports)
 
134
  from picarones.measurements.throughput import (
135
  aggregate_effective_throughput,
136
  )
137
+ from picarones.reports_v2.html.renderers.throughput import (
138
  build_throughput_html,
139
  )
140
  inputs = _estimate_engine_throughput_inputs(engine_reports)
picarones/reports_v2/html/renderers/difficulty.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers de rendu pour le score de difficulté intrinsèque.
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.difficulty_render`` vers
5
+ ``picarones.reports_v2.html.renderers.difficulty``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Sprint A3 (item B-2 de l'audit institutional-readiness-2026-05) :
10
+ ``difficulty_color`` vivait précédemment dans
11
+ ``picarones/measurements/difficulty.py`` et y violait la règle
12
+ Cercle 2 → Cercle 3 par un import paresseux de
13
+ ``picarones.report.colors``. La fonction est désormais placée à sa
14
+ juste place — Cercle 3, à côté de la palette qu'elle consomme — et
15
+ ``measurements/difficulty.py`` ne contient plus que de la logique
16
+ purement numérique.
17
+
18
+ Le module pur ``picarones.measurements.difficulty`` reste utilisable
19
+ sans dépendance vers ``picarones.report``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from picarones.reports_v2._helpers.colors import (
25
+ COLOR_GREEN,
26
+ COLOR_ORANGE,
27
+ COLOR_RED,
28
+ COLOR_YELLOW,
29
+ )
30
+
31
+
32
+ def difficulty_color(score: float) -> str:
33
+ """Retourne une couleur CSS pour un score de difficulté ∈ [0, 1].
34
+
35
+ Convention :
36
+
37
+ - score < 0.25 → vert (« facile »)
38
+ - score < 0.50 → jaune (« modéré »)
39
+ - score < 0.75 → orange (« difficile »)
40
+ - score ≥ 0.75 → rouge (« très difficile »)
41
+
42
+ Le label texte correspondant est produit par
43
+ :func:`picarones.measurements.difficulty.difficulty_label`.
44
+ """
45
+ if score < 0.25:
46
+ return COLOR_GREEN
47
+ if score < 0.50:
48
+ return COLOR_YELLOW
49
+ if score < 0.75:
50
+ return COLOR_ORANGE
51
+ return COLOR_RED
picarones/reports_v2/html/renderers/lexical_modernization.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la vue « Modernisation lexicale » — Sprint 80.
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.lexical_modernization_render`` vers
5
+ ``picarones.reports_v2.html.renderers.lexical_modernization``.
6
+ Le chemin legacy reste disponible via un shim avec
7
+ ``DeprecationWarning`` ; suppression prévue en 2.0.
8
+
9
+ A.I.7 du plan d'évolution 2026.
10
+
11
+ Suite directe ``picarones/core/lexical_modernization.py``.
12
+ Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76/77) :
13
+ **server-side**, pas de JavaScript, anti-injection systématique.
14
+
15
+ Vue
16
+ ---
17
+ Tableau trié par taux de modernisation décroissant : forme
18
+ historique GT → forme(s) modernisée(s), occurrences GT, %.
19
+ Couleur de cellule pour le %.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from html import escape as _e
25
+ from typing import Optional
26
+
27
+ from picarones.evaluation.metrics.lexical_modernization import top_modernized_tokens
28
+ from picarones.reports_v2._helpers.render_helpers import (
29
+ GRADIENT_TARGET_ORANGE,
30
+ color_single_gradient,
31
+ )
32
+
33
+
34
+ def _format_variants(variants: dict, max_show: int = 3) -> str:
35
+ """Liste compacte des variants modernisés."""
36
+ items = sorted(variants.items(), key=lambda kv: -kv[1])
37
+ shown = items[:max_show]
38
+ rest = len(items) - max_show
39
+ parts = [
40
+ f"{_e(form)} ({count})"
41
+ for form, count in shown
42
+ ]
43
+ if rest > 0:
44
+ parts.append(f"+{rest}")
45
+ return ", ".join(parts)
46
+
47
+
48
+ def build_lexical_modernization_html(
49
+ data: Optional[dict],
50
+ labels: Optional[dict[str, str]] = None,
51
+ *,
52
+ top_n: int = 20,
53
+ min_total: int = 1,
54
+ ) -> str:
55
+ """Construit la table HTML de modernisation lexicale.
56
+
57
+ Retourne ``""`` si ``data is None`` ou si aucun token modernisé.
58
+ """
59
+ if not data:
60
+ return ""
61
+ rows = top_modernized_tokens(data, n=top_n, min_total=min_total)
62
+ if not rows:
63
+ return ""
64
+ labels = labels or {}
65
+ title = labels.get(
66
+ "lexmod_title", "Modernisation lexicale (top tokens)",
67
+ )
68
+ note = labels.get(
69
+ "lexmod_note",
70
+ "Tokens GT que le moteur réécrit le plus souvent. "
71
+ "Lecture : « maistre → maître modernisé dans 85 % des cas » "
72
+ "indique de quoi corriger dans le prompt pour préserver "
73
+ "l'orthographe historique.",
74
+ )
75
+ gt_label = labels.get("lexmod_gt_label", "Forme historique GT")
76
+ hyp_label = labels.get("lexmod_hyp_label", "Variantes OCR")
77
+ n_label = labels.get("lexmod_n_label", "n GT")
78
+ rate_label = labels.get("lexmod_rate_label", "% modernisé")
79
+
80
+ parts = [
81
+ '<div class="lexmod" style="margin:1rem 0">',
82
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
83
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
84
+ f'{_e(note)}</div>',
85
+ '<table style="border-collapse:collapse;width:100%;'
86
+ 'font-size:.85rem">',
87
+ '<thead><tr>',
88
+ ]
89
+ for col in (gt_label, hyp_label, n_label, rate_label):
90
+ parts.append(
91
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
92
+ f'border-bottom:1px solid #ccc;font-weight:600">'
93
+ f'{_e(col)}</th>'
94
+ )
95
+ parts.append("</tr></thead><tbody>")
96
+ for gt_token, slot in rows:
97
+ rate = slot.get("rate_modernized", 0.0)
98
+ n_total = slot.get("n_total", 0)
99
+ variants_str = _format_variants(slot.get("variants") or {})
100
+ rate_color = color_single_gradient(rate, end_rgb=GRADIENT_TARGET_ORANGE)
101
+ parts.append(
102
+ f'<tr>'
103
+ f'<td style="padding:.3rem .5rem;font-family:monospace">'
104
+ f'{_e(gt_token)}</td>'
105
+ f'<td style="padding:.3rem .5rem;font-size:.85rem">'
106
+ f'{variants_str}</td>'
107
+ f'<td style="padding:.3rem .5rem;text-align:right;'
108
+ f'font-family:monospace">{n_total}</td>'
109
+ f'<td style="padding:.3rem .5rem;text-align:right;'
110
+ f'background:{rate_color};font-family:monospace">'
111
+ f'{rate * 100:.0f}%</td>'
112
+ f'</tr>'
113
+ )
114
+ parts.append("</tbody></table></div>")
115
+ return "".join(parts)
116
+
117
+
118
+ __all__ = [
119
+ "build_lexical_modernization_html",
120
+ ]
picarones/reports_v2/html/renderers/longitudinal.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Évolution dans le temps » — Sprint 92 (A.II.9).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.longitudinal_render`` vers
5
+ ``picarones.reports_v2.html.renderers.longitudinal``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/longitudinal.py``. Pattern
10
+ identique aux autres rendus : server-side, pas de JS, anti-
11
+ injection systématique.
12
+
13
+ Vue
14
+ ---
15
+ Tableau résumé moteur × {n_runs, premier CER, dernier CER,
16
+ variation cumulée colorée, pente annualisée, R², point de
17
+ rupture si détecté}.
18
+
19
+ Adaptive : ``""`` si la liste est vide.
20
+
21
+ Note d'intégration
22
+ ------------------
23
+ Module pur — l'utilisateur compose :
24
+
25
+ .. code-block:: python
26
+
27
+ from picarones.measurements.history import BenchmarkHistory
28
+ from picarones.measurements.longitudinal import compute_corpus_longitudinal
29
+ from picarones.reports_v2.html.renderers.longitudinal import build_longitudinal_html
30
+
31
+ hist = BenchmarkHistory(db_path)
32
+ entries = hist.list_entries()
33
+ trends = compute_corpus_longitudinal(entries, corpus_name)
34
+ html = build_longitudinal_html(trends, labels)
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from html import escape as _e
40
+ from typing import Optional
41
+
42
+ from picarones.reports_v2._helpers.render_helpers import color_diverging
43
+
44
+
45
+ def _bg_for_cer_delta(delta_pct: float) -> str:
46
+ """Cellule colorée pour un delta de CER en points de pourcentage :
47
+ vert si delta ≈ 0, orange/rouge en régression, bleu en amélioration.
48
+ Saturation à ±5 points.
49
+ """
50
+ if abs(delta_pct) < 1.0:
51
+ return "#a7f0a7"
52
+ return color_diverging(
53
+ delta_pct,
54
+ max_abs=5.0,
55
+ neutral_rgb=(167, 240, 167),
56
+ positive_rgb=(220, 50, 50),
57
+ negative_rgb=(90, 160, 210),
58
+ )
59
+
60
+
61
+ def build_longitudinal_html(
62
+ trends: Optional[list],
63
+ labels: Optional[dict[str, str]] = None,
64
+ ) -> str:
65
+ """Construit la vue HTML longitudinale.
66
+
67
+ Parameters
68
+ ----------
69
+ trends:
70
+ Sortie de ``compute_corpus_longitudinal`` (liste de
71
+ dicts). Si ``None`` ou vide, retourne ``""``.
72
+ labels:
73
+ Dict i18n. Clés sous le préfixe ``longitudinal_*``.
74
+ """
75
+ if not trends:
76
+ return ""
77
+ rows = [t for t in trends if isinstance(t, dict) and t.get("engine_name")]
78
+ if not rows:
79
+ return ""
80
+ labels = labels or {}
81
+ title = labels.get(
82
+ "longitudinal_title", "Évolution dans le temps",
83
+ )
84
+ note = labels.get(
85
+ "longitudinal_note",
86
+ "Tendance et points de rupture sur l'historique SQLite "
87
+ "des runs précédents. Une variation positive signale "
88
+ "une dégradation cumulée — utile pour relier une "
89
+ "régression à un changement de pipeline ou de modèle.",
90
+ )
91
+ h_engine = labels.get("longitudinal_engine", "Moteur")
92
+ h_n_runs = labels.get("longitudinal_n_runs", "Runs")
93
+ h_first = labels.get("longitudinal_first", "Premier CER")
94
+ h_last = labels.get("longitudinal_last", "Dernier CER")
95
+ h_delta = labels.get("longitudinal_delta", "Δ cumulé (pts)")
96
+ h_slope = labels.get("longitudinal_slope", "Pente annuelle (pts/an)")
97
+ h_r2 = labels.get("longitudinal_r2", "R²")
98
+ h_change = labels.get("longitudinal_change", "Rupture")
99
+
100
+ parts = [
101
+ '<section class="longitudinal-section" style="margin:1rem 0">',
102
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
103
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
104
+ f'{_e(note)}</div>',
105
+ '<table style="border-collapse:collapse;width:100%;'
106
+ 'font-size:.9rem">',
107
+ '<thead><tr>',
108
+ ]
109
+ for col in (h_engine, h_n_runs, h_first, h_last, h_delta,
110
+ h_slope, h_r2, h_change):
111
+ parts.append(
112
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
113
+ f'border-bottom:1px solid #ccc;font-weight:600">'
114
+ f'{_e(col)}</th>'
115
+ )
116
+ parts.append("</tr></thead><tbody>")
117
+ for entry in sorted(
118
+ rows,
119
+ key=lambda r: -float(r.get("absolute_delta") or 0.0),
120
+ ):
121
+ engine = str(entry.get("engine_name") or "?")
122
+ n_runs = int(entry.get("n_runs") or 0)
123
+ first_cer = float(entry.get("first_cer") or 0.0)
124
+ last_cer = float(entry.get("last_cer") or 0.0)
125
+ delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
126
+ delta_color = _bg_for_cer_delta(delta_pct)
127
+ trend = entry.get("trend") or {}
128
+ slope = trend.get("slope")
129
+ r2 = trend.get("r_squared")
130
+ slope_str = (
131
+ f"{float(slope) * 365 * 100:+.2f}"
132
+ if isinstance(slope, (int, float)) else "—"
133
+ )
134
+ r2_str = (
135
+ f"{float(r2):.2f}"
136
+ if isinstance(r2, (int, float)) else "—"
137
+ )
138
+ cp = entry.get("change_point")
139
+ if isinstance(cp, dict) and cp.get("timestamp"):
140
+ cp_delta = float(cp.get("delta") or 0.0)
141
+ cp_str = (
142
+ f'{_e(str(cp["timestamp"]))} '
143
+ f'<span style="opacity:.75">'
144
+ f'({cp_delta * 100:+.2f} pts)</span>'
145
+ )
146
+ else:
147
+ cp_str = "—"
148
+ parts.append(
149
+ f'<tr>'
150
+ f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
151
+ f'<td style="padding:.4rem .6rem;text-align:right;'
152
+ f'font-family:monospace">{n_runs}</td>'
153
+ f'<td style="padding:.4rem .6rem;text-align:right;'
154
+ f'font-family:monospace">{first_cer * 100:.2f}%</td>'
155
+ f'<td style="padding:.4rem .6rem;text-align:right;'
156
+ f'font-family:monospace">{last_cer * 100:.2f}%</td>'
157
+ f'<td style="padding:.4rem .6rem;text-align:right;'
158
+ f'background:{delta_color};font-family:monospace;'
159
+ f'font-weight:600">{delta_pct:+.2f}</td>'
160
+ f'<td style="padding:.4rem .6rem;text-align:right;'
161
+ f'font-family:monospace">{slope_str}</td>'
162
+ f'<td style="padding:.4rem .6rem;text-align:right;'
163
+ f'font-family:monospace">{r2_str}</td>'
164
+ f'<td style="padding:.4rem .6rem">{cp_str}</td>'
165
+ f'</tr>'
166
+ )
167
+ parts.append("</tbody></table></section>")
168
+ return "".join(parts)
169
+
170
+
171
+ __all__ = ["build_longitudinal_html"]
picarones/reports_v2/html/renderers/multirun_stability.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Stabilité multi-runs » — Sprint 90 (A.II.4).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.multirun_stability_render`` vers
5
+ ``picarones.reports_v2.html.renderers.multirun_stability``.
6
+ Le chemin legacy reste disponible via un shim avec
7
+ ``DeprecationWarning`` ; suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/reliability.compute_multirun_stability``
10
+ (Sprint 83). Pattern identique aux autres rendus : server-side,
11
+ pas de JS, anti-injection systématique.
12
+
13
+ Note d'intégration
14
+ ------------------
15
+ La stabilité multi-runs n'est pas calculée automatiquement par
16
+ le runner — l'utilisateur doit relancer son moteur LLM/VLM
17
+ plusieurs fois (option ``--repeats N`` du runner reportée à un
18
+ sprint dédié) et appeler ``compute_multirun_stability`` lui-
19
+ même. Cette vue est donc un **module de rendu pur** que
20
+ l'utilisateur compose :
21
+
22
+ .. code-block:: python
23
+
24
+ from picarones.measurements.reliability import compute_multirun_stability
25
+ from picarones.reports_v2.html.renderers.multirun_stability import (
26
+ build_multirun_stability_html,
27
+ )
28
+
29
+ stability = []
30
+ for engine_name, runs in per_engine_runs.items():
31
+ s = compute_multirun_stability(runs, reference=ref)
32
+ if s is not None:
33
+ s["engine_name"] = engine_name
34
+ stability.append(s)
35
+ html = build_multirun_stability_html(stability, labels)
36
+
37
+ Vue
38
+ ---
39
+ Tableau moteur × {n_runs, CER moyen ± écart-type, CV (%),
40
+ % paires identiques, n outputs distincts}. Cellule CV colorée
41
+ par gradient vert (stable) → rouge (instable, CV > 20 %).
42
+
43
+ Adaptive : ``""`` si la liste est vide ou que tous les
44
+ ``cer_cv`` sont ``None``.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from html import escape as _e
50
+ from typing import Optional
51
+
52
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
53
+
54
+
55
+ def build_multirun_stability_html(
56
+ stability: Optional[list],
57
+ labels: Optional[dict[str, str]] = None,
58
+ ) -> str:
59
+ """Construit la vue HTML de stabilité multi-runs.
60
+
61
+ Parameters
62
+ ----------
63
+ stability:
64
+ Liste de dicts (un par moteur) issus de
65
+ ``compute_multirun_stability`` enrichis d'un
66
+ ``engine_name``. Si vide ou ``None``, retourne ``""``.
67
+ labels:
68
+ Dict i18n. Clés sous le préfixe ``stability_*``.
69
+ """
70
+ if not stability:
71
+ return ""
72
+ rows = [s for s in stability if isinstance(s, dict) and s.get("engine_name")]
73
+ if not rows:
74
+ return ""
75
+ labels = labels or {}
76
+ title = labels.get("stability_title", "Stabilité multi-runs")
77
+ note = labels.get(
78
+ "stability_note",
79
+ "Quand un moteur LLM/VLM est non déterministe, la "
80
+ "variance entre runs successifs sur les mêmes documents "
81
+ "est un proxy de la fiabilité scientifique. Un CV élevé "
82
+ "ou un faible taux de runs identiques discrédite "
83
+ "l'interprétation du CER moyen.",
84
+ )
85
+ h_engine = labels.get("stability_engine", "Moteur")
86
+ h_n_runs = labels.get("stability_n_runs", "Runs")
87
+ h_cer = labels.get("stability_cer", "CER moyen ± σ")
88
+ h_cv = labels.get("stability_cv", "CV (%)")
89
+ h_identical = labels.get("stability_identical", "% runs identiques")
90
+ h_distinct = labels.get("stability_distinct", "Sorties distinctes")
91
+
92
+ parts = [
93
+ '<section class="stability-section" style="margin:1rem 0">',
94
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
95
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
96
+ f'{_e(note)}</div>',
97
+ '<table style="border-collapse:collapse;width:100%;'
98
+ 'font-size:.9rem">',
99
+ '<thead><tr>',
100
+ ]
101
+ for col in (h_engine, h_n_runs, h_cer, h_cv, h_identical, h_distinct):
102
+ parts.append(
103
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
104
+ f'border-bottom:1px solid #ccc;font-weight:600">'
105
+ f'{_e(col)}</th>'
106
+ )
107
+ parts.append("</tr></thead><tbody>")
108
+ for stab in rows:
109
+ engine = str(stab.get("engine_name") or "?")
110
+ n_runs = int(stab.get("n_runs") or 0)
111
+ cer_mean = stab.get("cer_mean")
112
+ cer_stdev = stab.get("cer_stdev")
113
+ cer_cv = stab.get("cer_cv")
114
+ identical = stab.get("identical_run_rate")
115
+ n_distinct = stab.get("n_distinct_outputs")
116
+ if isinstance(cer_mean, (int, float)) and isinstance(cer_stdev, (int, float)):
117
+ cer_str = f"{cer_mean * 100:.2f}% ± {cer_stdev * 100:.2f}%"
118
+ elif isinstance(cer_mean, (int, float)):
119
+ cer_str = f"{cer_mean * 100:.2f}%"
120
+ else:
121
+ cer_str = "—"
122
+ if isinstance(cer_cv, (int, float)):
123
+ cv_color = color_traffic_light(float(cer_cv), low_is_good=True, scale_max=0.25)
124
+ cv_cell = (
125
+ f'<td style="padding:.4rem .6rem;text-align:right;'
126
+ f'background:{cv_color};font-family:monospace;'
127
+ f'font-weight:600">{float(cer_cv) * 100:.1f}</td>'
128
+ )
129
+ else:
130
+ cv_cell = (
131
+ '<td style="padding:.4rem .6rem;text-align:right;'
132
+ 'opacity:.4">—</td>'
133
+ )
134
+ identical_str = (
135
+ f"{float(identical) * 100:.1f}"
136
+ if isinstance(identical, (int, float)) else "—"
137
+ )
138
+ distinct_str = str(n_distinct) if isinstance(n_distinct, int) else "—"
139
+ parts.append(
140
+ f'<tr>'
141
+ f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
142
+ f'<td style="padding:.4rem .6rem;text-align:right;'
143
+ f'font-family:monospace">{n_runs}</td>'
144
+ f'<td style="padding:.4rem .6rem;text-align:right;'
145
+ f'font-family:monospace">{cer_str}</td>'
146
+ f'{cv_cell}'
147
+ f'<td style="padding:.4rem .6rem;text-align:right;'
148
+ f'font-family:monospace">{identical_str}</td>'
149
+ f'<td style="padding:.4rem .6rem;text-align:right;'
150
+ f'font-family:monospace">{distinct_str}</td>'
151
+ f'</tr>'
152
+ )
153
+ parts.append("</tbody></table></section>")
154
+ return "".join(parts)
155
+
156
+
157
+ __all__ = ["build_multirun_stability_html"]
picarones/reports_v2/html/renderers/throughput.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Throughput effectif » — Sprint 91 (A.II.6).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.throughput_render`` vers
5
+ ``picarones.reports_v2.html.renderers.throughput``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/throughput.py``. Pattern
10
+ identique aux autres rendus : server-side, pas de JS, anti-
11
+ injection systématique.
12
+
13
+ Vue
14
+ ---
15
+ Tableau résumé moteur × {pages/h brut, pages/h **utilisable**,
16
+ % temps de correction (drag), n_pages, n_errors}. La cellule
17
+ **pages/h utilisable** est colorée en gradient rouge (faible)
18
+ → vert (élevé), normalisé sur le maximum observé.
19
+
20
+ Adaptive : ``""`` si ``aggregate_effective_throughput`` retourne
21
+ ``None`` (aucun moteur exploitable).
22
+
23
+ Note d'intégration
24
+ ------------------
25
+ Cette vue est un **module pur** — l'utilisateur compose :
26
+
27
+ .. code-block:: python
28
+
29
+ from picarones.measurements.throughput import (
30
+ aggregate_effective_throughput,
31
+ )
32
+ from picarones.reports_v2.html.renderers.throughput import (
33
+ build_throughput_html,
34
+ )
35
+
36
+ per_engine = []
37
+ for report in benchmark.engine_reports:
38
+ n_errors = sum(
39
+ int(round(dr.metrics.wer * dr.metrics.reference_length / 5))
40
+ for dr in report.document_results
41
+ )
42
+ per_engine.append({
43
+ "engine_name": report.engine_name,
44
+ "n_pages": len(report.document_results),
45
+ "duration_seconds": sum(
46
+ dr.duration_seconds for dr in report.document_results
47
+ ),
48
+ "n_errors": n_errors,
49
+ })
50
+ agg = aggregate_effective_throughput(per_engine)
51
+ html = build_throughput_html(agg, labels)
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ from html import escape as _e
57
+ from typing import Optional
58
+
59
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
60
+
61
+
62
+ def build_throughput_html(
63
+ aggregated: Optional[dict],
64
+ labels: Optional[dict[str, str]] = None,
65
+ ) -> str:
66
+ """Construit la vue HTML throughput effectif.
67
+
68
+ Parameters
69
+ ----------
70
+ aggregated:
71
+ Sortie de ``aggregate_effective_throughput``. Si
72
+ ``None`` ou liste vide, retourne ``""``.
73
+ labels:
74
+ Dict i18n. Clés sous le préfixe ``throughput_*``.
75
+ """
76
+ if not aggregated:
77
+ return ""
78
+ rows = aggregated.get("engines") or []
79
+ if not rows:
80
+ return ""
81
+ labels = labels or {}
82
+ title = labels.get("throughput_title", "Throughput effectif")
83
+ note = labels.get(
84
+ "throughput_note",
85
+ "Pages traitables par heure en intégrant le temps de "
86
+ "correction humaine post-OCR. Discrimine entre un cloud "
87
+ "rapide mais imprécis et un local lent mais fiable. "
88
+ "Constante de correction : {time_per_error}s par erreur "
89
+ "(défaut HTR-United, surchargeable).",
90
+ )
91
+ time_per_error = aggregated.get("time_per_error_seconds", 5.0)
92
+ note = note.replace("{time_per_error}", f"{time_per_error:.0f}")
93
+ h_engine = labels.get("throughput_engine", "Moteur")
94
+ h_raw = labels.get("throughput_raw", "Pages/h brut")
95
+ h_effective = labels.get(
96
+ "throughput_effective", "Pages/h utilisable",
97
+ )
98
+ h_drag = labels.get("throughput_drag", "% correction")
99
+ h_pages = labels.get("throughput_pages", "Pages")
100
+ h_errors = labels.get("throughput_errors", "Erreurs")
101
+ max_eff = max(
102
+ (r.get("pages_per_hour_effective") or 0.0) for r in rows
103
+ )
104
+
105
+ parts = [
106
+ '<section class="throughput-section" style="margin:1rem 0">',
107
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
108
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
109
+ f'{_e(note)}</div>',
110
+ '<table style="border-collapse:collapse;width:100%;'
111
+ 'font-size:.9rem">',
112
+ '<thead><tr>',
113
+ ]
114
+ for col in (h_engine, h_raw, h_effective, h_drag, h_pages, h_errors):
115
+ parts.append(
116
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
117
+ f'border-bottom:1px solid #ccc;font-weight:600">'
118
+ f'{_e(col)}</th>'
119
+ )
120
+ parts.append("</tr></thead><tbody>")
121
+ for row in sorted(
122
+ rows,
123
+ key=lambda r: -(r.get("pages_per_hour_effective") or 0.0),
124
+ ):
125
+ engine = str(row.get("engine_name") or "?")
126
+ raw = row.get("pages_per_hour_raw")
127
+ eff = row.get("pages_per_hour_effective") or 0.0
128
+ drag = row.get("drag_ratio") or 0.0
129
+ n_pages = int(row.get("n_pages") or 0)
130
+ n_errors = int(row.get("n_errors") or 0)
131
+ eff_color = (
132
+ color_traffic_light(eff, scale_max=max_eff)
133
+ if max_eff > 0 else "#e0e0e0"
134
+ )
135
+ drag_color = color_traffic_light(drag, low_is_good=True)
136
+ raw_str = (
137
+ f"{raw:,.0f}" if isinstance(raw, (int, float)) else "—"
138
+ )
139
+ parts.append(
140
+ f'<tr>'
141
+ f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
142
+ f'<td style="padding:.4rem .6rem;text-align:right;'
143
+ f'font-family:monospace">{raw_str}</td>'
144
+ f'<td style="padding:.4rem .6rem;text-align:right;'
145
+ f'background:{eff_color};font-family:monospace;'
146
+ f'font-weight:600">{eff:,.0f}</td>'
147
+ f'<td style="padding:.4rem .6rem;text-align:right;'
148
+ f'background:{drag_color};font-family:monospace">'
149
+ f'{drag * 100:.1f}%</td>'
150
+ f'<td style="padding:.4rem .6rem;text-align:right;'
151
+ f'font-family:monospace">{n_pages}</td>'
152
+ f'<td style="padding:.4rem .6rem;text-align:right;'
153
+ f'font-family:monospace">{n_errors}</td>'
154
+ f'</tr>'
155
+ )
156
+ parts.append("</tbody></table></section>")
157
+ return "".join(parts)
158
+
159
+
160
+ __all__ = ["build_throughput_html"]
tests/architecture/test_module_coverage.py CHANGED
@@ -61,17 +61,17 @@ MEASUREMENTS_DIR = PICARONES_DIR / "measurements"
61
  #: de fin de ligne ``noqa F401`` dans
62
  #: ``picarones/measurements/__init__.py``).
63
  TEST_ONLY_BASELINE: frozenset[str] = frozenset({
64
- # Phase 5.C : ``measurements/specialization.py`` est un shim
65
- # vers ``evaluation/metrics/specialization``. Son unique
66
- # consommateur production (le renderer
67
- # ``report/specialization_render.py``) a été migré vers
68
- # ``reports_v2/html/renderers/specialization.py`` qui importe
69
- # le canonique directement (la règle layer-dependencies
70
- # interdit d'importer le shim depuis ``reports_v2/``). Tant
71
- # que des tests ou un caller externe utilisent le chemin
72
- # legacy, le shim reste en place ; l'entrée ici est retirée
73
- # au plus tard à la version 2.0 quand le shim disparaîtra.
74
  "specialization",
 
75
  })
76
 
77
 
 
61
  #: de fin de ligne ``noqa F401`` dans
62
  #: ``picarones/measurements/__init__.py``).
63
  TEST_ONLY_BASELINE: frozenset[str] = frozenset({
64
+ # Phase 5.C : modules ``measurements/X.py`` qui sont des shims
65
+ # vers ``evaluation/metrics/X``. Leur unique consommateur
66
+ # production (le renderer ``report/X_render.py``) a été migré
67
+ # vers ``reports_v2/html/renderers/X.py`` qui importe le
68
+ # canonique directement (la règle layer-dependencies interdit
69
+ # d'importer un shim depuis ``reports_v2/``). Tant que des
70
+ # tests ou un caller externe utilisent le chemin legacy, le
71
+ # shim reste en place ; les entrées ici sont retirées au plus
72
+ # tard à la version 2.0 quand les shims disparaîtront.
 
73
  "specialization",
74
+ "lexical_modernization",
75
  })
76
 
77
 
tests/report/test_sprint80_lexical_modernization.py CHANGED
@@ -32,7 +32,7 @@ from picarones.measurements.lexical_modernization import (
32
  compute_lexical_modernization,
33
  top_modernized_tokens,
34
  )
35
- from picarones.report.lexical_modernization_render import (
36
  build_lexical_modernization_html,
37
  )
38
 
 
32
  compute_lexical_modernization,
33
  top_modernized_tokens,
34
  )
35
+ from picarones.reports_v2.html.renderers.lexical_modernization import (
36
  build_lexical_modernization_html,
37
  )
38
 
tests/report/test_sprint90_engine_unstable.py CHANGED
@@ -24,7 +24,7 @@ from pathlib import Path
24
  from picarones.measurements.narrative import build_synthesis
25
  from picarones.measurements.narrative.detectors import detect_engine_unstable
26
  from picarones.core.facts import FactImportance, FactType
27
- from picarones.report.multirun_stability_render import (
28
  build_multirun_stability_html,
29
  )
30
 
 
24
  from picarones.measurements.narrative import build_synthesis
25
  from picarones.measurements.narrative.detectors import detect_engine_unstable
26
  from picarones.core.facts import FactImportance, FactType
27
+ from picarones.reports_v2.html.renderers.multirun_stability import (
28
  build_multirun_stability_html,
29
  )
30
 
tests/report/test_sprint91_throughput.py CHANGED
@@ -28,7 +28,7 @@ from picarones.measurements.throughput import (
28
  aggregate_effective_throughput,
29
  compute_effective_throughput,
30
  )
31
- from picarones.report.throughput_render import build_throughput_html
32
 
33
 
34
  def _load_labels(lang: str) -> dict:
 
28
  aggregate_effective_throughput,
29
  compute_effective_throughput,
30
  )
31
+ from picarones.reports_v2.html.renderers.throughput import build_throughput_html
32
 
33
 
34
  def _load_labels(lang: str) -> dict:
tests/report/test_sprint92_longitudinal.py CHANGED
@@ -33,7 +33,7 @@ from picarones.measurements.longitudinal import (
33
  from picarones.measurements.narrative import build_synthesis
34
  from picarones.measurements.narrative.detectors import detect_regression_in_history
35
  from picarones.core.facts import FactImportance, FactType
36
- from picarones.report.longitudinal_render import build_longitudinal_html
37
 
38
 
39
  def _load_labels(lang: str) -> dict:
 
33
  from picarones.measurements.narrative import build_synthesis
34
  from picarones.measurements.narrative.detectors import detect_regression_in_history
35
  from picarones.core.facts import FactImportance, FactType
36
+ from picarones.reports_v2.html.renderers.longitudinal import build_longitudinal_html
37
 
38
 
39
  def _load_labels(lang: str) -> dict: