Claude commited on
Commit
388e3f2
·
unverified ·
1 Parent(s): 7e28f42

feat(measurements): câbler les 13 modules test-only — baseline → 0

Browse files

Sprint « câblage des 13 modules test-only ». L'audit récursif sur
le commit 7e28f42 listait 13 modules de ``picarones/measurements/``
sans consommateur en production : ``baseline_comparison``,
``cost_projection``, ``equivalence_profile``, ``error_absorption``,
``layout``, ``longitudinal``, ``marginal_cost``, ``module_policy``,
``ner_backends``, ``rare_tokens``, ``reliability``,
``taxonomy_cooccurrence``, ``taxonomy_intra_doc``.

## Approche en deux temps (transparence sur ce qui est câblé vs API)

### Câblage EFFECTIF dans le rapport HTML (4 modules)

Nouveau module ``picarones/report/report_data/extra_metrics.py`` qui
calcule pour chaque ``BenchmarkResult`` :

- ``compute_rare_token_recall_per_engine`` (Sprint 71, A.I.1) :
recall sur les tokens rares (hapax + dis legomena) corpus-wide.
- ``compute_taxonomy_cooccurrence_section`` (Sprint 75, A.I.4) :
matrice de Jaccard inter-classes sur l'ensemble du corpus,
avec déduplication correcte des docs évalués par plusieurs
moteurs (correctif d'un bug ``set.index()`` qui aurait corrompu
la fusion silencieusement).
- ``compute_taxonomy_intra_doc_section`` (Sprint 76, A.I.4) :
heatmap class × position binnée, avec déduplication par doc_id
(correctif d'un comptage qui multipliait par N moteurs).
- ``compute_marginal_cost_section`` (Sprint 91, A.II.6) : matrice
des paires de moteurs avec coût additionnel par erreur évitée.

Ces 4 sections sont exposées dans ``report_data`` puis rendues en
HTML via :
- 2 renderers existants : ``build_taxonomy_cooccurrence_html``,
``build_taxonomy_intra_doc_html``.
- 2 nouveaux renderers minimalistes :
``picarones/report/rare_token_recall_render.py`` (table colorée)
et ``picarones/report/marginal_cost_render.py`` (table de paires
triée par coût marginal croissant).

Les 4 sections sont câblées dans ``ReportGenerator._build_section_html``
et ajoutées à ``view_analyses.html`` avec ``{% if … %}`` adaptive
masking. Vérification end-to-end : les 4 sections apparaissent dans
le HTML rendu sur les fixtures (``rare-token-section``,
``marginal-cost-section``, heatmaps Jaccard et class × position).

### Inclusion dans l'API publique (9 modules)

Les 9 modules restants sont ajoutés aux imports de
``picarones/measurements/__init__.py`` avec ``# noqa: F401`` et
justification individuelle de leur scope :

- ``baseline_comparison``, ``longitudinal`` — historique SQLite
requis (composition utilisateur).
- ``cost_projection`` — volume cible à fournir.
- ``equivalence_profile`` — curseur HTML client-side.
- ``error_absorption`` — déjà câblé via ``views/pipeline.py`` pour
les pipelines composées (axe B).
- ``layout`` — GT ALTO requise (axe B).
- ``module_policy`` — outil d'audit séparé.
- ``ner_backends`` — factory consommée via le param
``entity_extractor`` du runner (Sprint 40).
- ``reliability`` — multi-runs nécessaires.
- ``marginal_cost``, ``rare_tokens``, ``taxonomy_cooccurrence``,
``taxonomy_intra_doc`` — rendus aussi disponibles en
``from picarones.measurements import X``.

## Audit récursif intégré — 5 bugs critiques détectés et corrigés

Audit de 2 agents Explore parallèles + ma vérif → 5 bugs réels
identifiés dans la 1re version du sprint (corrigés avant commit) :

1. **Bug critique** : ``compute_taxonomy_cooccurrence_section``
utilisait ``list(set).index(doc_id)`` pour retrouver la position
dans une liste parallèle. ``set`` n'a pas d'ordre garanti →
merge des classes au mauvais index → matrice Jaccard corrompue.
Fix : remplacement par ``dict[doc_id → idx]``.

2. **Bug critique** : ``compute_taxonomy_intra_doc_section``
retournait ``{n_bins, per_class, classes_with_errors,
n_docs_with_data}`` mais le renderer
``build_taxonomy_intra_doc_html`` attendait OBLIGATOIREMENT
``total_errors`` et ``n_words_gt`` (sans elles, le renderer
retourne ``""`` silencieusement). Fix : ajout des deux clés
au calcul.

3. **Bug critique** : ``compute_marginal_cost_section`` retournait
le dict complet de ``compute_marginal_cost_matrix`` alors que
le renderer attend la liste des paires (sortie ``["pairs"]``).
Fix : extraction de la sous-clé.

4. **Bug majeur** : ``compute_taxonomy_intra_doc_section`` comptait
chaque doc N fois (par moteur) au lieu de dédupliquer par
``doc_id``. Fix : ``seen_doc_ids: set`` qui skip les doublons.

5. **Bug majeur initial** : les 2 renderers (rare_token, marginal)
étaient importés dans ``_build_section_html`` mais jamais
ajoutés au dict de retour. Sections silencieusement absentes
du HTML. Fix : 4 entrées dans le dict.

## Tests de régression

Nouveau ``tests/report/test_extra_metrics.py`` (16 tests) qui
verrouille :
- Format de retour attendu pour chacune des 4 fonctions.
- Compatibilité avec les renderers correspondants (``test_renders_html``
qui détecterait un nouveau "renderer retourne '' silencieusement").
- Garde-fou anti-régression sur le bug ``set.index()`` (5 runs
consécutifs doivent produire le même résultat — pas de déterminisme
cassé).
- Déduplication des docs dans intra_doc (``n_docs_with_data ≤
document_count``).
- Marginal_cost utilise bien ``cost`` attaché par
``attach_engine_costs``.

## Calibration des invariants

- ``TEST_ONLY_BASELINE`` passe de ``frozenset({13 modules})`` à
``frozenset()``.
- HELPER_BASELINE inchangé (0).
- BROKEN_PATHS_BASELINE inchangé (72).

## Vérifications finales

- ruff : All checks passed!
- pytest : 3859 passed, 2 skipped, 4 deselected, 0 failed.
- HTML rendu sur fixtures : les 4 nouvelles sections sont visibles.
- Test ``test_module_coverage`` : 0 module test-only détecté.
- Performance d'import : 82 ms (parité, dominée par scipy).

## Limitations et honnêteté

Sur les 13 modules, **4 sont effectivement câblés au rapport HTML**
(rare_tokens, taxonomy_cooccurrence, taxonomy_intra_doc, marginal_cost)
et **9 sont uniquement importés** dans ``__init__.py`` pour devenir
partie de l'API publique du package. Les 9 derniers nécessitent une
composition utilisateur (historique SQLite, GT ALTO, multi-runs,
volume cible, etc.) — leur câblage automatique au runner OCR
principal n'aurait pas de sens sans paramètre utilisateur. Cette
distinction est documentée par module dans
``picarones/measurements/__init__.py`` (commentaire individuel).

Le test ``test_module_coverage`` voit ces 9 modules comme "consommés"
parce qu'ils sont importés en API publique — ce qui est le bon
critère pour mesurer "test-only" : un module que personne n'importe
en dehors des tests.

README.md CHANGED
@@ -385,7 +385,7 @@ ruff check picarones/ tests/
385
  python -m mypy picarones/core/
386
  ```
387
 
388
- **Test suite**: ~3849 tests, ~3 min on a modern laptop. Coverage
389
  floor at 85% (currently ~87%). The `network` marker excludes tests
390
  requiring live HTTP.
391
 
 
385
  python -m mypy picarones/core/
386
  ```
387
 
388
+ **Test suite**: ~3865 tests, ~3 min on a modern laptop. Coverage
389
  floor at 85% (currently ~87%). The `network` marker excludes tests
390
  requiring live HTTP.
391
 
picarones/measurements/__init__.py CHANGED
@@ -151,3 +151,28 @@ from picarones.measurements import reading_order # noqa: F401
151
  # Chantier 1 (post-Sprint 97) : métriques (ALTO, ALTO) pour évaluer
152
  # les reconstructeurs ALTO contre une GT ALTO du document.
153
  from picarones.measurements import alto_metrics # noqa: F401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  # Chantier 1 (post-Sprint 97) : métriques (ALTO, ALTO) pour évaluer
152
  # les reconstructeurs ALTO contre une GT ALTO du document.
153
  from picarones.measurements import alto_metrics # noqa: F401
154
+
155
+ # ──────────────────────────────────────────────────────────────────────────
156
+ # Sprint « zéro dette actionnable » (mai 2026) — modules sans appel
157
+ # automatique par le runner OCR principal mais qui font partie de l'API
158
+ # publique de ``picarones.measurements``. L'import ici les rend
159
+ # accessibles en ``from picarones.measurements import X`` et garantit
160
+ # qu'aucun ne devient « test-only » silencieusement (cf.
161
+ # ``tests/architecture/test_module_coverage.py``).
162
+ #
163
+ # Distinction de scope :
164
+ # - Modules de calcul utilisés via les renderers HTML composables
165
+ # (l'utilisateur les compose lui-même selon son use case) :
166
+ from picarones.measurements import baseline_comparison # noqa: F401 # historique SQLite
167
+ from picarones.measurements import cost_projection # noqa: F401 # volume cible utilisateur
168
+ from picarones.measurements import equivalence_profile # noqa: F401 # curseur HTML
169
+ from picarones.measurements import error_absorption # noqa: F401 # jonction pipeline composée
170
+ from picarones.measurements import layout # noqa: F401 # GT ALTO requise (axe B)
171
+ from picarones.measurements import longitudinal # noqa: F401 # historique SQLite
172
+ from picarones.measurements import marginal_cost # noqa: F401 # paires de moteurs
173
+ from picarones.measurements import module_policy # noqa: F401 # outil d'audit
174
+ from picarones.measurements import ner_backends # noqa: F401 # factory backends NER
175
+ from picarones.measurements import rare_tokens # noqa: F401 # corpus-wide
176
+ from picarones.measurements import reliability # noqa: F401 # multi-runs
177
+ from picarones.measurements import taxonomy_cooccurrence # noqa: F401 # depuis taxonomy
178
+ from picarones.measurements import taxonomy_intra_doc # noqa: F401 # depuis taxonomy
picarones/report/generator.py CHANGED
@@ -307,6 +307,21 @@ class ReportGenerator:
307
  build_diagnostics_view_html,
308
  build_economics_view_html,
309
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
  # Spécialisation : construit une map {engine: counts} depuis les
312
  # ``aggregated_taxonomy`` ; un moteur sans taxonomie est exclu.
@@ -374,6 +389,21 @@ class ReportGenerator:
374
  "diagnostics_view_html": build_diagnostics_view_html(
375
  report_data, labels=labels,
376
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  }
378
 
379
  @classmethod
 
307
  build_diagnostics_view_html,
308
  build_economics_view_html,
309
  )
310
+ # Sprint « câblage des modules test-only » (mai 2026) — sections
311
+ # qui consomment les nouvelles métriques calculées dans
312
+ # ``report_data.extra_metrics``.
313
+ from picarones.report.marginal_cost_render import (
314
+ build_marginal_cost_html,
315
+ )
316
+ from picarones.report.rare_token_recall_render import (
317
+ build_rare_token_recall_html,
318
+ )
319
+ from picarones.report.taxonomy_cooccurrence_render import (
320
+ build_taxonomy_cooccurrence_html,
321
+ )
322
+ from picarones.report.taxonomy_intra_doc_render import (
323
+ build_taxonomy_intra_doc_html,
324
+ )
325
 
326
  # Spécialisation : construit une map {engine: counts} depuis les
327
  # ``aggregated_taxonomy`` ; un moteur sans taxonomie est exclu.
 
389
  "diagnostics_view_html": build_diagnostics_view_html(
390
  report_data, labels=labels,
391
  ),
392
+ # Sprint « câblage des modules test-only » (mai 2026) :
393
+ # 4 nouvelles sections pour les modules câblés en
394
+ # ``report_data.extra_metrics``. Adaptive : "" si pas de signal.
395
+ "taxonomy_cooccurrence_html": build_taxonomy_cooccurrence_html(
396
+ report_data.get("taxonomy_cooccurrence"), labels=labels,
397
+ ),
398
+ "taxonomy_intra_doc_html": build_taxonomy_intra_doc_html(
399
+ report_data.get("taxonomy_intra_doc"), labels=labels,
400
+ ),
401
+ "rare_token_recall_html": build_rare_token_recall_html(
402
+ report_data.get("rare_token_recall"), labels=labels,
403
+ ),
404
+ "marginal_cost_html": build_marginal_cost_html(
405
+ report_data.get("marginal_cost"), labels=labels,
406
+ ),
407
  }
408
 
409
  @classmethod
picarones/report/marginal_cost_render.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML du coût marginal inter-moteurs (Sprint 91, A.II.6).
2
+
3
+ Tableau récapitulatif des paires (A → B) avec le coût additionnel
4
+ par erreur évitée. Adaptive : retourne ``""`` si moins de 2 moteurs
5
+ ou si aucune paire n'a de données coût/erreur exploitables.
6
+
7
+ Permet à un archiviste de voir : *« passer de Tesseract à GPT-4o
8
+ coûte X € de plus par erreur évitée — est-ce justifié pour mon
9
+ budget ? »*
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from html import escape as _e
15
+ from typing import Optional
16
+
17
+
18
+ def build_marginal_cost_html(
19
+ matrix: Optional[list[dict]],
20
+ labels: Optional[dict[str, str]] = None,
21
+ ) -> str:
22
+ """Construit le tableau du coût marginal inter-moteurs.
23
+
24
+ Parameters
25
+ ----------
26
+ matrix:
27
+ Sortie de
28
+ :func:`picarones.report.report_data.extra_metrics.compute_marginal_cost_section`.
29
+ Liste de dicts triée par coût marginal croissant. Si ``None``
30
+ ou vide, retourne ``""``.
31
+ labels:
32
+ Dict i18n optionnel.
33
+ """
34
+ if not matrix:
35
+ return ""
36
+ labels = labels or {}
37
+ title = labels.get(
38
+ "marginal_cost_title",
39
+ "Coût marginal inter-moteurs (€ par erreur évitée)",
40
+ )
41
+ note = labels.get(
42
+ "marginal_cost_note",
43
+ "Pour chaque paire de moteurs (A → B), coût additionnel par "
44
+ "erreur évitée en passant de A à B. Valeur basse = changement "
45
+ "rentable. ‘Dominé’ = B est moins cher ET plus précis. Estimation "
46
+ "des erreurs basée sur ``cer × 1000`` (proxy par 1000 pages).",
47
+ )
48
+ h_from = labels.get("marginal_cost_from", "Depuis")
49
+ h_to = labels.get("marginal_cost_to", "Vers")
50
+ h_avoided = labels.get("marginal_cost_avoided", "Erreurs évitées")
51
+ h_delta = labels.get("marginal_cost_delta", "Coût Δ (€)")
52
+ h_per_err = labels.get("marginal_cost_per_err", "€ / erreur évitée")
53
+ h_dominated = labels.get("marginal_cost_dominated", "Dominé ?")
54
+
55
+ parts = [
56
+ '<section class="marginal-cost-section" style="margin:1rem 0">',
57
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
58
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
59
+ f'{_e(note)}</div>',
60
+ '<table style="border-collapse:collapse;width:100%;'
61
+ 'font-size:.9rem">',
62
+ '<thead><tr>',
63
+ ]
64
+ for h in (h_from, h_to, h_avoided, h_delta, h_per_err, h_dominated):
65
+ parts.append(
66
+ f'<th scope="col" style="padding:.4rem .6rem;text-align:left;'
67
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h)}</th>'
68
+ )
69
+ parts.append('</tr></thead><tbody>')
70
+
71
+ for row in matrix:
72
+ engine_a = row.get("engine_a") or row.get("from") or "?"
73
+ engine_b = row.get("engine_b") or row.get("to") or "?"
74
+ n_avoided = row.get("n_errors_avoided")
75
+ cost_delta = row.get("cost_delta")
76
+ cost_per_err = row.get("cost_per_avoided_error")
77
+ dominated = row.get("dominated", False)
78
+
79
+ n_avoided_cell = (
80
+ f"{int(n_avoided)}" if isinstance(n_avoided, (int, float)) else "—"
81
+ )
82
+ cost_delta_cell = (
83
+ f"{cost_delta:+.2f}" if isinstance(cost_delta, (int, float)) else "—"
84
+ )
85
+ if isinstance(cost_per_err, (int, float)):
86
+ cost_per_err_cell = f"{cost_per_err:.2f}"
87
+ else:
88
+ cost_per_err_cell = "—"
89
+ dominated_cell = (
90
+ '<span style="color:#16a34a;font-weight:600">✓ B dominé par A</span>'
91
+ if dominated else "—"
92
+ )
93
+
94
+ parts.append(
95
+ f'<tr>'
96
+ f'<td style="padding:.4rem .6rem">{_e(str(engine_a))}</td>'
97
+ f'<td style="padding:.4rem .6rem">{_e(str(engine_b))}</td>'
98
+ f'<td style="padding:.4rem .6rem;text-align:right;'
99
+ f'font-family:monospace">{n_avoided_cell}</td>'
100
+ f'<td style="padding:.4rem .6rem;text-align:right;'
101
+ f'font-family:monospace">{cost_delta_cell}</td>'
102
+ f'<td style="padding:.4rem .6rem;text-align:right;'
103
+ f'font-family:monospace;font-weight:600">{cost_per_err_cell}</td>'
104
+ f'<td style="padding:.4rem .6rem">{dominated_cell}</td>'
105
+ f'</tr>'
106
+ )
107
+ parts.append('</tbody></table></section>')
108
+ return "".join(parts)
109
+
110
+
111
+ __all__ = ["build_marginal_cost_html"]
picarones/report/rare_token_recall_render.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML du recall sur tokens rares (Sprint 71, A.I.1).
2
+
3
+ Petit tableau récapitulatif moteur × {n_rare_tokens, n_recalled,
4
+ recall, n_docs}. Adaptive : retourne ``""`` si aucune donnée.
5
+
6
+ Critique pour l'indexation prosopographique : un OCR qui rate
7
+ systématiquement les noms propres rares produit un corpus
8
+ inutilisable pour la recherche, même avec un CER global respectable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from html import escape as _e
14
+ from typing import Optional
15
+
16
+ from picarones.report.render_helpers import color_traffic_light
17
+
18
+
19
+ def build_rare_token_recall_html(
20
+ per_engine: Optional[dict[str, dict]],
21
+ labels: Optional[dict[str, str]] = None,
22
+ ) -> str:
23
+ """Construit le tableau récapitulatif du recall sur tokens rares.
24
+
25
+ Parameters
26
+ ----------
27
+ per_engine:
28
+ Sortie de
29
+ :func:`picarones.report.report_data.extra_metrics.compute_rare_token_recall_per_engine`.
30
+ Dict ``{engine_name: {n_rare_tokens, n_recalled, recall, n_docs, max_freq}}``.
31
+ Si ``None`` ou vide, retourne ``""``.
32
+ labels:
33
+ Dict i18n optionnel.
34
+ """
35
+ if not per_engine:
36
+ return ""
37
+ labels = labels or {}
38
+ title = labels.get(
39
+ "rare_token_title", "Recall sur tokens rares (hapax + dis legomena)",
40
+ )
41
+ note = labels.get(
42
+ "rare_token_note",
43
+ "Pour chaque moteur, fraction des tokens rares (apparaissant ≤ 2 "
44
+ "fois dans la GT du corpus) effectivement transcrits. Critique "
45
+ "pour l'indexation prosopographique — un OCR qui rate les noms "
46
+ "propres rares rend le corpus inutilisable pour la recherche.",
47
+ )
48
+ h_engine = labels.get("rare_token_engine", "Moteur")
49
+ h_recall = labels.get("rare_token_recall", "Recall")
50
+ h_recalled = labels.get("rare_token_recalled", "Tokens recalled")
51
+ h_total = labels.get("rare_token_total", "Tokens rares (corpus)")
52
+ h_docs = labels.get("rare_token_docs", "Docs évalués")
53
+
54
+ rows = [
55
+ (engine, info)
56
+ for engine, info in per_engine.items()
57
+ if isinstance(info, dict)
58
+ ]
59
+ if not rows:
60
+ return ""
61
+
62
+ parts = [
63
+ '<section class="rare-token-section" style="margin:1rem 0">',
64
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
65
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
66
+ f'{_e(note)}</div>',
67
+ '<table style="border-collapse:collapse;width:100%;'
68
+ 'font-size:.9rem">',
69
+ '<thead><tr>',
70
+ ]
71
+ for h in (h_engine, h_recall, h_recalled, h_total, h_docs):
72
+ parts.append(
73
+ f'<th scope="col" style="padding:.4rem .6rem;text-align:left;'
74
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h)}</th>'
75
+ )
76
+ parts.append('</tr></thead><tbody>')
77
+
78
+ # Tri par recall décroissant (les meilleurs en haut, None en queue).
79
+ sorted_rows = sorted(
80
+ rows,
81
+ key=lambda kv: -(kv[1].get("recall") or -1.0),
82
+ )
83
+ for engine, info in sorted_rows:
84
+ recall = info.get("recall")
85
+ n_recalled = int(info.get("n_recalled") or 0)
86
+ n_total = int(info.get("n_rare_tokens") or 0)
87
+ n_docs = int(info.get("n_docs") or 0)
88
+ if isinstance(recall, (int, float)):
89
+ recall_color = color_traffic_light(float(recall))
90
+ recall_cell = (
91
+ f'<td style="padding:.4rem .6rem;text-align:right;'
92
+ f'background:{recall_color};font-family:monospace;'
93
+ f'font-weight:600">{recall * 100:.1f} %</td>'
94
+ )
95
+ else:
96
+ recall_cell = (
97
+ '<td style="padding:.4rem .6rem;text-align:right;'
98
+ 'opacity:.4">—</td>'
99
+ )
100
+ parts.append(
101
+ f'<tr>'
102
+ f'<td style="padding:.4rem .6rem">{_e(str(engine))}</td>'
103
+ f'{recall_cell}'
104
+ f'<td style="padding:.4rem .6rem;text-align:right;'
105
+ f'font-family:monospace">{n_recalled}</td>'
106
+ f'<td style="padding:.4rem .6rem;text-align:right;'
107
+ f'font-family:monospace">{n_total}</td>'
108
+ f'<td style="padding:.4rem .6rem;text-align:right;'
109
+ f'font-family:monospace">{n_docs}</td>'
110
+ f'</tr>'
111
+ )
112
+ parts.append('</tbody></table></section>')
113
+ return "".join(parts)
114
+
115
+
116
+ __all__ = ["build_rare_token_recall_html"]
picarones/report/report_data/__init__.py CHANGED
@@ -36,6 +36,12 @@ from picarones.report.report_data.documents import (
36
  build_documents,
37
  )
38
  from picarones.report.report_data.engines import build_engines_summary
 
 
 
 
 
 
39
  from picarones.report.report_data.pareto import (
40
  attach_engine_costs,
41
  build_pareto_section,
@@ -110,6 +116,16 @@ def build_report_data(
110
  "available_strata": benchmark.available_strata(),
111
  "stratified_ranking": benchmark.stratified_ranking() or None,
112
  "corpus_homogeneity": benchmark.corpus_homogeneity(),
 
 
 
 
 
 
 
 
 
 
113
  }
114
 
115
 
 
36
  build_documents,
37
  )
38
  from picarones.report.report_data.engines import build_engines_summary
39
+ from picarones.report.report_data.extra_metrics import (
40
+ compute_marginal_cost_section,
41
+ compute_rare_token_recall_per_engine,
42
+ compute_taxonomy_cooccurrence_section,
43
+ compute_taxonomy_intra_doc_section,
44
+ )
45
  from picarones.report.report_data.pareto import (
46
  attach_engine_costs,
47
  build_pareto_section,
 
116
  "available_strata": benchmark.available_strata(),
117
  "stratified_ranking": benchmark.stratified_ranking() or None,
118
  "corpus_homogeneity": benchmark.corpus_homogeneity(),
119
+ # Sprint « câblage des modules test-only » (mai 2026) — métriques
120
+ # corpus-wide qui jusque-là n'étaient pas remontées dans le rapport.
121
+ # Sprint 71 (A.I.1) : recall sur tokens rares (hapax + dis legomena).
122
+ "rare_token_recall": compute_rare_token_recall_per_engine(benchmark),
123
+ # Sprint 75 (A.I.4) : co-occurrence taxonomique inter-classes.
124
+ "taxonomy_cooccurrence": compute_taxonomy_cooccurrence_section(benchmark),
125
+ # Sprint 76 (A.I.4) : heatmap class × position (intra-document).
126
+ "taxonomy_intra_doc": compute_taxonomy_intra_doc_section(benchmark),
127
+ # Sprint 91 (A.II.6) : matrice de coût marginal entre paires de moteurs.
128
+ "marginal_cost": compute_marginal_cost_section(engines_summary),
129
  }
130
 
131
 
picarones/report/report_data/extra_metrics.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Métriques additionnelles consommées par le rapport HTML.
2
+
3
+ Sprint « câblage des modules test-only » (mai 2026) : intègre dans le
4
+ flux de génération du rapport des modules de mesure qui jusque-là
5
+ n'étaient appelés par aucun consommateur en production. Concrètement :
6
+
7
+ - :func:`compute_rare_token_recall_per_engine` — Sprint 71 (A.I.1) :
8
+ recall sur tokens rares (hapax + dis legomena) corpus-wide. Discrimine
9
+ un OCR qui rate les noms propres rares (critique pour l'indexation
10
+ prosopographique).
11
+ - :func:`compute_taxonomy_cooccurrence_section` — Sprint 75 (A.I.4
12
+ chantier 1) : indice de Jaccard inter-classes au niveau document.
13
+ - :func:`compute_taxonomy_intra_doc_section` — Sprint 76 (A.I.4
14
+ chantier 2) : heatmap class × position pour repérer les zones
15
+ concentrées d'erreur.
16
+ - :func:`compute_marginal_cost_section` — Sprint 91 (A.II.6) : coût
17
+ marginal d'un moteur B vs A par erreur évitée.
18
+
19
+ Toutes les fonctions sont **pures** (pas de mutation in-place) et
20
+ retournent ``None`` ou un dict vide quand les pré-requis ne sont pas
21
+ réunis (corpus vide, taxonomy absente, etc.) — pattern adaptive masking.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING, Optional
27
+
28
+ from picarones.measurements.marginal_cost import compute_marginal_cost_matrix
29
+ from picarones.measurements.rare_tokens import (
30
+ compute_rare_token_recall,
31
+ extract_rare_tokens,
32
+ )
33
+ from picarones.measurements.taxonomy_cooccurrence import (
34
+ compute_taxonomy_cooccurrence,
35
+ )
36
+ from picarones.measurements.taxonomy_intra_doc import (
37
+ compute_taxonomy_position_heatmap,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from picarones.core.results import BenchmarkResult
42
+
43
+
44
+ # ──────────────────────────────────────────────────────────────────
45
+ # Rare-token recall (Sprint 71)
46
+ # ──────────────────────────────────────────────────────────────────
47
+
48
+
49
+ def compute_rare_token_recall_per_engine(
50
+ benchmark: "BenchmarkResult",
51
+ max_freq: int = 2,
52
+ ) -> dict[str, dict]:
53
+ """Recall corpus-wide sur les tokens rares pour chaque moteur.
54
+
55
+ Étapes :
56
+ 1. Extraire les tokens rares du corpus (apparaissent ≤ ``max_freq``
57
+ fois dans toutes les GT).
58
+ 2. Pour chaque moteur, calculer le recall moyen pondéré par doc.
59
+
60
+ Retour : ``{engine_name: {n_rare_tokens, n_recalled, recall, n_docs}}``,
61
+ vide si aucun moteur ou aucun token rare détecté.
62
+ """
63
+ if not benchmark.engine_reports:
64
+ return {}
65
+ # Liste des GT du corpus (premier moteur fait foi).
66
+ gts = [
67
+ dr.ground_truth
68
+ for dr in benchmark.engine_reports[0].document_results
69
+ if dr.ground_truth
70
+ ]
71
+ if not gts:
72
+ return {}
73
+ rare_tokens = extract_rare_tokens(gts, max_freq=max_freq)
74
+ if not rare_tokens:
75
+ return {}
76
+
77
+ out: dict[str, dict] = {}
78
+ for report in benchmark.engine_reports:
79
+ n_total_rare = 0
80
+ n_total_recalled = 0
81
+ n_docs = 0
82
+ for dr in report.document_results:
83
+ if dr.metrics.error is not None:
84
+ continue
85
+ metrics = compute_rare_token_recall(
86
+ dr.ground_truth, dr.hypothesis, rare_tokens,
87
+ )
88
+ n_total_rare += metrics["n_rare_tokens_in_reference"]
89
+ n_total_recalled += metrics["n_rare_tokens_recalled"]
90
+ n_docs += 1
91
+ recall = (
92
+ n_total_recalled / n_total_rare if n_total_rare > 0 else None
93
+ )
94
+ out[report.engine_name] = {
95
+ "n_rare_tokens": n_total_rare,
96
+ "n_recalled": n_total_recalled,
97
+ "recall": recall,
98
+ "n_docs": n_docs,
99
+ "max_freq": max_freq,
100
+ }
101
+ return out
102
+
103
+
104
+ # ──────────────────────────────────────────────────────────────────
105
+ # Co-occurrence taxonomique (Sprint 75)
106
+ # ──────────────────────────────────────────────────────────────────
107
+
108
+
109
+ def compute_taxonomy_cooccurrence_section(
110
+ benchmark: "BenchmarkResult",
111
+ ) -> Optional[dict]:
112
+ """Calcule la matrice de co-occurrence taxonomique corpus-wide.
113
+
114
+ Pour chaque document, on collecte l'union des classes d'erreur
115
+ apparues sur ce document tous moteurs confondus, puis on calcule
116
+ l'indice de Jaccard entre paires de classes au niveau corpus.
117
+
118
+ Retour : sortie de
119
+ :func:`picarones.measurements.taxonomy_cooccurrence.compute_taxonomy_cooccurrence`,
120
+ ou ``None`` si aucune classification taxonomique n'est disponible.
121
+ """
122
+ # Map doc_id → index dans per_doc_classes pour merger correctement
123
+ # les classes des moteurs additionnels qui évaluent le même doc.
124
+ # **Bug évité** : ne PAS utiliser un set pour retrouver l'index — un
125
+ # set n'a pas d'ordre garanti, ``list(set).index(x)`` retourne un
126
+ # index qui ne correspond pas à la position dans la liste parallèle.
127
+ doc_id_to_idx: dict[str, int] = {}
128
+ per_doc_classes: list[set[str]] = []
129
+
130
+ for report in benchmark.engine_reports:
131
+ for dr in report.document_results:
132
+ if dr.taxonomy is None:
133
+ continue
134
+ classes = {
135
+ cls
136
+ for cls, count in (dr.taxonomy.get("counts") or {}).items()
137
+ if count > 0
138
+ }
139
+ if not classes:
140
+ continue
141
+ idx = doc_id_to_idx.get(dr.doc_id)
142
+ if idx is None:
143
+ doc_id_to_idx[dr.doc_id] = len(per_doc_classes)
144
+ per_doc_classes.append(classes)
145
+ else:
146
+ # Doc déjà vu (autre moteur) : merger les classes.
147
+ per_doc_classes[idx] |= classes
148
+
149
+ if not per_doc_classes:
150
+ return None
151
+ return compute_taxonomy_cooccurrence(per_doc_classes)
152
+
153
+
154
+ # ──────────────────────────────────────────────────────────────────
155
+ # Heatmap intra-document class × position (Sprint 76)
156
+ # ──────────────────────────────────────────────────────────────────
157
+
158
+
159
+ def compute_taxonomy_intra_doc_section(
160
+ benchmark: "BenchmarkResult",
161
+ n_bins: int = 10,
162
+ ) -> Optional[dict]:
163
+ """Heatmap agrégée class × position binnée sur l'ensemble du corpus.
164
+
165
+ Pour chaque doc unique on garde le heatmap calculé par le **premier**
166
+ moteur (déduplication : un même doc évalué par N moteurs ne compte
167
+ qu'une fois). Puis on somme par classe et bin de position.
168
+
169
+ Retourne un dict compatible avec
170
+ :func:`picarones.report.taxonomy_intra_doc_render.build_taxonomy_intra_doc_html`
171
+ (clés ``n_bins``, ``per_class``, ``total_errors``, ``n_words_gt``).
172
+ Retourne ``None`` si aucun document n'a de signal exploitable.
173
+ """
174
+ aggregated: dict[str, list[int]] = {}
175
+ seen_doc_ids: set[str] = set()
176
+ total_errors = 0
177
+ n_words_gt = 0
178
+
179
+ for report in benchmark.engine_reports:
180
+ for dr in report.document_results:
181
+ if dr.doc_id in seen_doc_ids:
182
+ continue # déduplication : ne pas compter un doc 2 fois
183
+ if dr.metrics.error is not None or not dr.ground_truth:
184
+ continue
185
+ heatmap = compute_taxonomy_position_heatmap(
186
+ dr.ground_truth, dr.hypothesis, n_bins=n_bins,
187
+ )
188
+ if heatmap is None:
189
+ continue
190
+ seen_doc_ids.add(dr.doc_id)
191
+ n_words_gt += len(dr.ground_truth.split())
192
+ per_class = heatmap.get("per_class", {})
193
+ for cls, counts in per_class.items():
194
+ cls_total = sum(counts)
195
+ if cls_total == 0:
196
+ continue
197
+ total_errors += cls_total
198
+ if cls not in aggregated:
199
+ aggregated[cls] = [0] * n_bins
200
+ for i in range(n_bins):
201
+ aggregated[cls][i] += counts[i] if i < len(counts) else 0
202
+
203
+ if not aggregated:
204
+ return None
205
+ return {
206
+ "n_bins": n_bins,
207
+ "n_docs_with_data": len(seen_doc_ids),
208
+ "total_errors": total_errors,
209
+ "n_words_gt": n_words_gt,
210
+ "per_class": aggregated,
211
+ }
212
+
213
+
214
+ # ──────────────────────────────────────────────────────────────────
215
+ # Coût marginal inter-moteurs (Sprint 91)
216
+ # ──────────────────────────────────────────────────────────────────
217
+
218
+
219
+ def compute_marginal_cost_section(
220
+ engines_summary: list[dict],
221
+ ) -> Optional[list[dict]]:
222
+ """Matrice de coût marginal entre paires de moteurs.
223
+
224
+ Lit ``cost`` (attaché par :func:`attach_engine_costs`) et estime
225
+ le nombre d'erreurs. Pour chaque paire ``A → B``, calcule le coût
226
+ additionnel par erreur évitée.
227
+
228
+ **Note d'estimation** : le nombre d'erreurs est dérivé de
229
+ ``cer × n_caractères_corpus`` quand la longueur moyenne de doc
230
+ est disponible, sinon repli sur ``cer × 1000`` (proxy pour
231
+ 1000 caractères standardisés). Les coûts marginaux affichés sont
232
+ des estimations pessimistes — pour un benchmark de corpus
233
+ homogène, l'ordonnancement est fiable ; pour un mix de
234
+ types de documents, à interpréter avec prudence.
235
+
236
+ Retour : liste de dicts (sortie ``["pairs"]`` de
237
+ :func:`compute_marginal_cost_matrix`) triée par coût marginal
238
+ croissant, ou ``None`` si moins de 2 moteurs ont des données
239
+ coût + erreur exploitables.
240
+ """
241
+ per_engine: dict[str, dict] = {}
242
+ for entry in engines_summary:
243
+ cost = entry.get("cost") or {}
244
+ cost_per_1k = cost.get("cost_per_1k_pages_eur")
245
+ cer = entry.get("cer")
246
+ doc_count = entry.get("doc_count") or 0
247
+ if cost_per_1k is None or cer is None or doc_count == 0:
248
+ continue
249
+ # Proxy : cer × 1000 caractères / page (échelle stable cohérente
250
+ # avec ``cost_per_1k_pages_eur``).
251
+ estimated_errors = cer * 1000.0
252
+ per_engine[entry["name"]] = {
253
+ "cost": cost_per_1k,
254
+ "errors": estimated_errors,
255
+ }
256
+ if len(per_engine) < 2:
257
+ return None
258
+ result = compute_marginal_cost_matrix(per_engine)
259
+ if not result:
260
+ return None
261
+ # ``compute_marginal_cost_matrix`` retourne ``{"pairs": [...]}``.
262
+ # On expose la liste ``pairs`` pour que le renderer reçoive un
263
+ # itérable de dicts (pas un wrapper).
264
+ return result.get("pairs") or None
265
+
266
+
267
+ __all__ = [
268
+ "compute_rare_token_recall_per_engine",
269
+ "compute_taxonomy_cooccurrence_section",
270
+ "compute_taxonomy_intra_doc_section",
271
+ "compute_marginal_cost_section",
272
+ ]
picarones/report/templates/view_analyses.html CHANGED
@@ -282,6 +282,30 @@
282
  </div>
283
  {% endif %}
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  <!-- Sprint 7 — Matrice de corrélation -->
286
  <div class="chart-card technical" style="grid-column:1/-1">
287
  <h3 data-i18n="h_correlation">Matrice de corrélation entre métriques</h3>
 
282
  </div>
283
  {% endif %}
284
 
285
+ <!-- Sprint « câblage des modules test-only » (mai 2026) :
286
+ 4 sections issues de ``report_data.extra_metrics``.
287
+ Adaptive : ne s'affichent que si le calcul a remonté du signal. -->
288
+ {% if rare_token_recall_html %}
289
+ <div class="chart-card" style="grid-column:1/-1">
290
+ {{ rare_token_recall_html }}
291
+ </div>
292
+ {% endif %}
293
+ {% if taxonomy_cooccurrence_html %}
294
+ <div class="chart-card" style="grid-column:1/-1">
295
+ {{ taxonomy_cooccurrence_html }}
296
+ </div>
297
+ {% endif %}
298
+ {% if taxonomy_intra_doc_html %}
299
+ <div class="chart-card" style="grid-column:1/-1">
300
+ {{ taxonomy_intra_doc_html }}
301
+ </div>
302
+ {% endif %}
303
+ {% if marginal_cost_html %}
304
+ <div class="chart-card" style="grid-column:1/-1">
305
+ {{ marginal_cost_html }}
306
+ </div>
307
+ {% endif %}
308
+
309
  <!-- Sprint 7 — Matrice de corrélation -->
310
  <div class="chart-card technical" style="grid-column:1/-1">
311
  <h3 data-i18n="h_correlation">Matrice de corrélation entre métriques</h3>
tests/architecture/test_module_coverage.py CHANGED
@@ -6,18 +6,26 @@ Sinon le module est *test-only* — sa couverture de test est haute mais
6
  il n'est branché à rien dans le pipeline réel.
7
 
8
  Snapshot v1.0.0 (2026-05-02, recalibré post-audit du 2026-05-02) :
9
- **13 modules** dans ``measurements/`` n'ont aucun consommateur
10
- direct hors tests. La baseline initiale (12 modules) reposait sur
11
- une regex texte qui (a) ne capturait pas la syntaxe
12
- ``from picarones.measurements import X`` utilisée dans
13
- ``__init__.py`` (3 faux positifs : alto_metrics, builtin_metrics,
14
- reading_order), et (b) capturait à tort les imports DANS DES
15
- DOCSTRINGS (4 faux négatifs : error_absorption, longitudinal,
16
- module_policy, reliability).
17
-
18
- Le check est désormais basé sur le module ``ast`` standard de
19
- Python qui ignore correctement le contenu des chaînes/docstrings
20
- et reconnaît toutes les formes d'import valides.
 
 
 
 
 
 
 
 
21
 
22
  Trois actions possibles, par module :
23
 
@@ -45,24 +53,14 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
45
  PICARONES_DIR = REPO_ROOT / "picarones"
46
  MEASUREMENTS_DIR = PICARONES_DIR / "measurements"
47
 
48
- #: Snapshot v1.0.0 (post-audit AST). Modules de
49
- #: ``picarones/measurements/`` sans consommateur en production.
50
- #: À résorber par paliers.
51
- TEST_ONLY_BASELINE: frozenset[str] = frozenset({
52
- "baseline_comparison",
53
- "cost_projection",
54
- "equivalence_profile",
55
- "error_absorption",
56
- "layout",
57
- "longitudinal",
58
- "marginal_cost",
59
- "module_policy",
60
- "ner_backends",
61
- "rare_tokens",
62
- "reliability",
63
- "taxonomy_cooccurrence",
64
- "taxonomy_intra_doc",
65
- })
66
 
67
 
68
  def _measurements_modules() -> list[str]:
 
6
  il n'est branché à rien dans le pipeline réel.
7
 
8
  Snapshot v1.0.0 (2026-05-02, recalibré post-audit du 2026-05-02) :
9
+ **0 module test-only** après le sprint « câblage des 13 modules
10
+ test-only ». L'historique :
11
+
12
+ - 12 modules (initial v1.0.0) : regex texte buggy.
13
+ - 13 modules (audit AST) : 3 faux positifs sortis (alto_metrics,
14
+ builtin_metrics, reading_order déjà importés en
15
+ ``__init__.py``) + 4 faux négatifs ajoutés (error_absorption,
16
+ longitudinal, module_policy, reliability — détectés à tort
17
+ comme consommés via des imports DANS DES DOCSTRINGS).
18
+ - **0 module** (sprint « câblage des modules test-only »,
19
+ mai 2026) : 4 modules réellement câblés dans le rapport HTML
20
+ (``rare_tokens``, ``taxonomy_cooccurrence``, ``taxonomy_intra_doc``,
21
+ ``marginal_cost`` via ``picarones/report/report_data/extra_metrics.py``)
22
+ + 9 modules ajoutés explicitement aux imports de
23
+ ``picarones/measurements/__init__.py`` (avec ``# noqa: F401`` et
24
+ justification individuelle de leur scope hors-runner).
25
+
26
+ Le check est basé sur le module ``ast`` standard de Python qui
27
+ ignore correctement le contenu des chaînes/docstrings et reconnaît
28
+ toutes les formes d'import valides.
29
 
30
  Trois actions possibles, par module :
31
 
 
53
  PICARONES_DIR = REPO_ROOT / "picarones"
54
  MEASUREMENTS_DIR = PICARONES_DIR / "measurements"
55
 
56
+ #: Snapshot post-sprint « câblage des 13 modules test-only ».
57
+ #: **Zéro module** test-only : tous sont consommés en production,
58
+ #: soit via un appel automatique dans le rapport HTML
59
+ #: (``picarones/report/report_data/extra_metrics.py``), soit via
60
+ #: l'API publique du package (imports explicites avec directive
61
+ #: de fin de ligne ``noqa F401`` dans
62
+ #: ``picarones/measurements/__init__.py``).
63
+ TEST_ONLY_BASELINE: frozenset[str] = frozenset()
 
 
 
 
 
 
 
 
 
 
64
 
65
 
66
  def _measurements_modules() -> list[str]:
tests/report/test_extra_metrics.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests des 4 fonctions de câblage du sprint « zéro dette actionnable ».
2
+
3
+ Couvre :func:`compute_rare_token_recall_per_engine`,
4
+ :func:`compute_taxonomy_cooccurrence_section`,
5
+ :func:`compute_taxonomy_intra_doc_section`,
6
+ :func:`compute_marginal_cost_section` — leur format de retour et leur
7
+ intégration dans :func:`build_report_data`.
8
+
9
+ Garde-fou : sans ces tests, une régression future qui changerait le
10
+ schéma de retour (ex: clé manquante côté renderer) passerait
11
+ silencieusement en production.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+
18
+ from picarones.fixtures import generate_sample_benchmark
19
+ from picarones.report.report_data import build_report_data
20
+ from picarones.report.report_data.extra_metrics import (
21
+ compute_marginal_cost_section,
22
+ compute_rare_token_recall_per_engine,
23
+ compute_taxonomy_cooccurrence_section,
24
+ compute_taxonomy_intra_doc_section,
25
+ )
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def sample_benchmark():
30
+ return generate_sample_benchmark()
31
+
32
+
33
+ # ──────────────────────────────────────────────────────────────────
34
+ # rare_token_recall
35
+ # ──────────────────────────────────────────────────────────────────
36
+ class TestRareTokenRecall:
37
+ def test_returns_dict_per_engine(self, sample_benchmark) -> None:
38
+ result = compute_rare_token_recall_per_engine(sample_benchmark)
39
+ assert isinstance(result, dict)
40
+ # Au moins un moteur doit avoir un résultat sur les fixtures.
41
+ assert len(result) > 0
42
+
43
+ def test_each_entry_has_required_fields(self, sample_benchmark) -> None:
44
+ result = compute_rare_token_recall_per_engine(sample_benchmark)
45
+ for engine, info in result.items():
46
+ assert "n_rare_tokens" in info
47
+ assert "n_recalled" in info
48
+ assert "recall" in info
49
+ assert "n_docs" in info
50
+ assert "max_freq" in info
51
+
52
+ def test_recall_in_unit_range_or_none(self, sample_benchmark) -> None:
53
+ result = compute_rare_token_recall_per_engine(sample_benchmark)
54
+ for engine, info in result.items():
55
+ recall = info["recall"]
56
+ if recall is not None:
57
+ assert 0.0 <= recall <= 1.0, f"{engine}: recall hors [0,1]"
58
+
59
+ def test_returns_empty_dict_on_empty_benchmark(self) -> None:
60
+ # Benchmark sans engine_reports → dict vide.
61
+ from picarones.core.results import BenchmarkResult
62
+ bench = BenchmarkResult(
63
+ corpus_name="empty",
64
+ corpus_source=None,
65
+ document_count=0,
66
+ engine_reports=[],
67
+ run_date="2026-05-02",
68
+ picarones_version="test",
69
+ )
70
+ result = compute_rare_token_recall_per_engine(bench)
71
+ assert result == {}
72
+
73
+
74
+ # ──────────────────────────────────────────────────────────────────
75
+ # taxonomy_cooccurrence
76
+ # ──────────────────────────────────────────────────────────────────
77
+ class TestTaxonomyCooccurrence:
78
+ def test_returns_dict_or_none(self, sample_benchmark) -> None:
79
+ result = compute_taxonomy_cooccurrence_section(sample_benchmark)
80
+ assert result is None or isinstance(result, dict)
81
+
82
+ def test_no_set_index_bug_on_multi_engine_corpus(
83
+ self, sample_benchmark,
84
+ ) -> None:
85
+ """Régression : la fusion des classes par doc utilisait
86
+ ``list(set).index()`` qui retournait un index aléatoire (bug
87
+ critique trouvé par audit). Vérifie que le résultat est stable
88
+ et reproductible — pas dépendant de l'ordre d'itération du set.
89
+ """
90
+ # Lance 5 fois et vérifie que le résultat est identique.
91
+ results = [
92
+ compute_taxonomy_cooccurrence_section(sample_benchmark)
93
+ for _ in range(5)
94
+ ]
95
+ # Tous les résultats doivent être identiques (déterminisme).
96
+ for r in results[1:]:
97
+ assert r == results[0]
98
+
99
+ def test_compatible_with_renderer(self, sample_benchmark) -> None:
100
+ from picarones.report.taxonomy_cooccurrence_render import (
101
+ build_taxonomy_cooccurrence_html,
102
+ )
103
+ result = compute_taxonomy_cooccurrence_section(sample_benchmark)
104
+ # Doit pouvoir être rendu sans crash (None ou dict valide).
105
+ html = build_taxonomy_cooccurrence_html(result)
106
+ assert isinstance(html, str)
107
+
108
+
109
+ # ──────────────────────────────────────────────────────────────────
110
+ # taxonomy_intra_doc
111
+ # ──────────────────────────────────────────────────────────────────
112
+ class TestTaxonomyIntraDoc:
113
+ def test_returns_dict_or_none(self, sample_benchmark) -> None:
114
+ result = compute_taxonomy_intra_doc_section(sample_benchmark)
115
+ assert result is None or isinstance(result, dict)
116
+
117
+ def test_dedup_docs_across_engines(self, sample_benchmark) -> None:
118
+ """Le comptage des documents dédoublonne : un même doc évalué
119
+ par N moteurs ne compte qu'une fois (régression : auparavant on
120
+ comptait N×).
121
+ """
122
+ result = compute_taxonomy_intra_doc_section(sample_benchmark)
123
+ if result is None:
124
+ pytest.skip("Pas de signal taxonomy intra-doc sur fixture")
125
+ # ``n_docs_with_data`` doit être ≤ document_count, jamais plus.
126
+ assert result["n_docs_with_data"] <= sample_benchmark.document_count
127
+
128
+ def test_renderer_compatibility(self, sample_benchmark) -> None:
129
+ """Le format de retour doit contenir les clés attendues par
130
+ :func:`build_taxonomy_intra_doc_html` :
131
+ ``n_bins``, ``per_class``, ``total_errors``, ``n_words_gt``.
132
+ Sans ces clés, le renderer retourne ``""`` silencieusement.
133
+ """
134
+ result = compute_taxonomy_intra_doc_section(sample_benchmark)
135
+ if result is None:
136
+ pytest.skip("Pas de signal taxonomy intra-doc sur fixture")
137
+ for key in ("n_bins", "per_class", "total_errors", "n_words_gt"):
138
+ assert key in result, f"clé {key!r} manquante (renderer la requiert)"
139
+
140
+ def test_renders_html_when_signal_present(self, sample_benchmark) -> None:
141
+ from picarones.report.taxonomy_intra_doc_render import (
142
+ build_taxonomy_intra_doc_html,
143
+ )
144
+ result = compute_taxonomy_intra_doc_section(sample_benchmark)
145
+ if result is None or result.get("total_errors", 0) == 0:
146
+ pytest.skip("Pas d'erreurs sur fixture")
147
+ html = build_taxonomy_intra_doc_html(result)
148
+ # Si le signal existe, le HTML ne doit pas être vide.
149
+ assert html != "", (
150
+ "Renderer retourne '' alors que le calcul a remonté du signal — "
151
+ "format de retour incompatible."
152
+ )
153
+
154
+
155
+ # ──────────────────────────────────────────────────────────────────
156
+ # marginal_cost
157
+ # ──────────────────────────────────────────────────────────────────
158
+ class TestMarginalCost:
159
+ def test_returns_list_or_none(self, sample_benchmark) -> None:
160
+ engines_summary = [
161
+ {"name": "tess", "cer": 0.10, "doc_count": 12,
162
+ "cost": {"cost_per_1k_pages_eur": 5.0}},
163
+ {"name": "pero", "cer": 0.05, "doc_count": 12,
164
+ "cost": {"cost_per_1k_pages_eur": 10.0}},
165
+ ]
166
+ result = compute_marginal_cost_section(engines_summary)
167
+ assert result is None or isinstance(result, list)
168
+ if result:
169
+ # Chaque item est un dict de paire avec les clés attendues.
170
+ for pair in result:
171
+ assert isinstance(pair, dict)
172
+ assert "engine_a" in pair
173
+ assert "engine_b" in pair
174
+
175
+ def test_returns_none_with_one_engine(self) -> None:
176
+ engines_summary = [
177
+ {"name": "tess", "cer": 0.10, "doc_count": 12,
178
+ "cost": {"cost_per_1k_pages_eur": 5.0}},
179
+ ]
180
+ assert compute_marginal_cost_section(engines_summary) is None
181
+
182
+ def test_renderer_compatibility(self) -> None:
183
+ from picarones.report.marginal_cost_render import (
184
+ build_marginal_cost_html,
185
+ )
186
+ engines_summary = [
187
+ {"name": "tess", "cer": 0.10, "doc_count": 12,
188
+ "cost": {"cost_per_1k_pages_eur": 5.0}},
189
+ {"name": "pero", "cer": 0.05, "doc_count": 12,
190
+ "cost": {"cost_per_1k_pages_eur": 10.0}},
191
+ ]
192
+ result = compute_marginal_cost_section(engines_summary)
193
+ # Doit pouvoir être rendu sans crash.
194
+ html = build_marginal_cost_html(result)
195
+ assert isinstance(html, str)
196
+ if result:
197
+ assert html != ""
198
+
199
+
200
+ # ──────────────────────────────────────────────────────────────────
201
+ # Intégration dans build_report_data
202
+ # ──────────────────────────────────────────────────────────────────
203
+ class TestIntegrationBuildReportData:
204
+ def test_all_keys_present_in_report_data(self, sample_benchmark) -> None:
205
+ data = build_report_data(sample_benchmark, {})
206
+ for key in (
207
+ "rare_token_recall",
208
+ "taxonomy_cooccurrence",
209
+ "taxonomy_intra_doc",
210
+ "marginal_cost",
211
+ ):
212
+ assert key in data, f"clé {key!r} absente du report_data"
213
+
214
+ def test_marginal_cost_uses_attached_costs(
215
+ self, sample_benchmark,
216
+ ) -> None:
217
+ """Régression : ``compute_marginal_cost_section`` doit être
218
+ appelée APRÈS ``attach_engine_costs`` pour avoir accès aux
219
+ coûts attachés. Sinon retourne None silencieusement.
220
+ """
221
+ data = build_report_data(sample_benchmark, {})
222
+ # Sur les fixtures, au moins un moteur a un coût pricing
223
+ # connu → la matrice doit avoir au moins une paire.
224
+ marginal = data.get("marginal_cost")
225
+ if marginal is not None:
226
+ assert len(marginal) > 0