Spaces:
Running
feat(measurements): câbler les 13 modules test-only — baseline → 0
Browse filesSprint « 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 +1 -1
- picarones/measurements/__init__.py +25 -0
- picarones/report/generator.py +30 -0
- picarones/report/marginal_cost_render.py +111 -0
- picarones/report/rare_token_recall_render.py +116 -0
- picarones/report/report_data/__init__.py +16 -0
- picarones/report/report_data/extra_metrics.py +272 -0
- picarones/report/templates/view_analyses.html +24 -0
- tests/architecture/test_module_coverage.py +28 -30
- tests/report/test_extra_metrics.py +226 -0
|
@@ -385,7 +385,7 @@ ruff check picarones/ tests/
|
|
| 385 |
python -m mypy picarones/core/
|
| 386 |
```
|
| 387 |
|
| 388 |
-
**Test suite**: ~
|
| 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 |
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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"]
|
|
@@ -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"]
|
|
@@ -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 |
|
|
@@ -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 |
+
]
|
|
@@ -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>
|
|
@@ -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 |
-
**
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
module_policy, reliability
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 49 |
-
#:
|
| 50 |
-
#:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 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]:
|
|
@@ -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
|