Spaces:
Sleeping
fix(audit): 5 corrections suite à l'audit complet de mes derniers sprints
Browse filesTrois agents Explore indépendants ont audité les commits ee86836
(invariants), 2d6c41d (helpers consolidés) et d641f6e/6724f94
(découpage generator.py). Cinq problèmes réels identifiés et
corrigés :
1. **CRITIQUE — test_module_coverage.py basé sur regex incomplète**
Le pattern regex ne capturait que `from picarones.measurements.X
import Y` et oubliait `from picarones.measurements import X`,
`from . import X`, `from picarones.measurements import (X, Y)`.
Conséquence : 3 faux positifs dans la baseline (alto_metrics,
builtin_metrics, reading_order — importés en __init__.py mais
classés comme test-only) ET 4 faux négatifs (error_absorption,
longitudinal, module_policy, reliability — détectés à tort comme
ayant un consommateur via des imports DANS DES DOCSTRINGS).
Refactor : remplacement de la regex par un parser AST qui
- reconnaît les 5 syntaxes d'import valides Python ;
- ignore correctement les chaînes / docstrings.
Helpers extraits : `_imports_target_module` (absolu) et
`_imports_target_relative` (depuis le package). Baseline
recalibrée : 12 → 13 modules réellement test-only.
2. **MAJEURE — asymétrie d'API dans build_pareto_section**
La fonction (a) prenait `engines_summary` en premier (les autres
`build_*` prenaient `benchmark`) et (b) mutait `engines_summary`
en place. Mutation cachée + signature inhomogène.
Refactor : split en deux fonctions au nom explicite :
- `attach_engine_costs(engines_summary, benchmark)` mute
(le verbe "attach" annonce la mutation) ;
- `build_pareto_section(engines_summary)` est pure et lit les
coûts déjà attachés.
L'orchestrateur appelle les deux dans l'ordre, documenté en
docstring de `build_report_data`.
3. **MINEURE — except Exception silencieux dans assets.py**
`encode_image_b64` retournait `""` sans logguer, en violation
de la règle CLAUDE.md "remplacer except: pass par
logger.warning". Ajout d'un `logger.warning()` avec contexte
(chemin de l'image + exception) — le rapport reste fonctionnel
mais l'absence d'image n'est plus invisible.
4. **MINEURE — tests NaN/Inf manquants dans render_helpers**
`color_traffic_light` n'avait aucun test pour valeurs spéciales
IEEE 754. Ajout de 3 tests :
- NaN → fonction ne crash pas, retourne hex valide ;
- +inf → clamp à scale_max → vert (high_is_good) ;
- -inf → clamp à 0 → rouge ;
- scale_min > scale_max → ne crash pas.
5. **MINEURE — documentation des 3 conventions de bornes**
render_helpers.py expose 3 fonctions de coloration avec 3
conventions différentes (scale_min/max, max_value, max_abs).
Section "Conventions de bornes" ajoutée dans la docstring de
module pour expliquer le pourquoi de chaque convention (lié à
la sémantique métier des cellules concernées) et le choix des
palettes (rouge/jaune/vert pour traffic, bleu/vert/orange
diverging pour daltonisme).
Suite : 3834 passed, 2 skipped (vs 3830 précédemment, +3 NaN/Inf
+ 1 ajustement). 1 échec pré-existant (test_readme_dual_lang).
ruff : All checks passed!
- picarones/report/assets.py +17 -5
- picarones/report/render_helpers.py +23 -0
- picarones/report/report_data/__init__.py +20 -6
- picarones/report/report_data/pareto.py +49 -13
- tests/architecture/test_file_budgets.py +4 -0
- tests/architecture/test_module_coverage.py +98 -36
- tests/report/test_render_helpers.py +22 -0
|
@@ -43,13 +43,19 @@ def load_vendor_js(name: str) -> str:
|
|
| 43 |
|
| 44 |
|
| 45 |
def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
|
| 46 |
-
"""Lit une image, la redimensionne si besoin, et retourne un data-URI base64.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
try:
|
| 48 |
from PIL import Image
|
| 49 |
|
| 50 |
-
p = Path(image_path)
|
| 51 |
-
if not p.exists():
|
| 52 |
-
return ""
|
| 53 |
with Image.open(p) as img:
|
| 54 |
if img.width > max_width:
|
| 55 |
ratio = max_width / img.width
|
|
@@ -64,7 +70,13 @@ def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
|
|
| 64 |
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
| 65 |
mime = "image/jpeg" if fmt == "JPEG" else "image/png"
|
| 66 |
return f"data:{mime};base64,{b64}"
|
| 67 |
-
except Exception: # noqa: BLE001 — fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return ""
|
| 69 |
|
| 70 |
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
|
| 46 |
+
"""Lit une image, la redimensionne si besoin, et retourne un data-URI base64.
|
| 47 |
+
|
| 48 |
+
Retourne ``""`` si l'image est introuvable ou si l'encodage
|
| 49 |
+
échoue (Pillow indisponible, format non géré, fichier corrompu).
|
| 50 |
+
Logue un avertissement dans ce dernier cas — le rapport reste
|
| 51 |
+
fonctionnel mais l'image manquera dans la galerie.
|
| 52 |
+
"""
|
| 53 |
+
p = Path(image_path)
|
| 54 |
+
if not p.exists():
|
| 55 |
+
return ""
|
| 56 |
try:
|
| 57 |
from PIL import Image
|
| 58 |
|
|
|
|
|
|
|
|
|
|
| 59 |
with Image.open(p) as img:
|
| 60 |
if img.width > max_width:
|
| 61 |
ratio = max_width / img.width
|
|
|
|
| 70 |
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
| 71 |
mime = "image/jpeg" if fmt == "JPEG" else "image/png"
|
| 72 |
return f"data:{mime};base64,{b64}"
|
| 73 |
+
except Exception as exc: # noqa: BLE001 — fallback gracieux + warning
|
| 74 |
+
logger.warning(
|
| 75 |
+
"[report] échec d'encodage base64 de l'image %s : %s — "
|
| 76 |
+
"le rapport ignorera cette image",
|
| 77 |
+
image_path,
|
| 78 |
+
exc,
|
| 79 |
+
)
|
| 80 |
return ""
|
| 81 |
|
| 82 |
|
|
@@ -21,6 +21,29 @@ API
|
|
| 21 |
- :func:`text_color_for_bg` — noir ou blanc selon la luminosité du fond.
|
| 22 |
- :func:`build_grid_svg` — builder de heatmap SVG paramétré.
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
Palette
|
| 25 |
-------
|
| 26 |
Les bornes RGB des dégradés traffic-light sont la moyenne des palettes
|
|
|
|
| 21 |
- :func:`text_color_for_bg` — noir ou blanc selon la luminosité du fond.
|
| 22 |
- :func:`build_grid_svg` — builder de heatmap SVG paramétré.
|
| 23 |
|
| 24 |
+
Conventions de bornes
|
| 25 |
+
---------------------
|
| 26 |
+
Trois conventions de paramétrage cohabitent (par dessein, pas par
|
| 27 |
+
maladresse) :
|
| 28 |
+
|
| 29 |
+
- :func:`color_traffic_light` accepte ``scale_min`` + ``scale_max``
|
| 30 |
+
parce que les cellules concernées (CER, ECE, deficit) peuvent
|
| 31 |
+
démarrer à une borne basse non nulle (rang 1 = vert, ou
|
| 32 |
+
``scale_min=0.30`` pour démarrer le dégradé à partir d'un seuil).
|
| 33 |
+
- :func:`color_single_gradient` accepte ``max_value`` parce que ces
|
| 34 |
+
cellules (Jaccard, densité) sont toujours bornées en bas par 0 —
|
| 35 |
+
pas besoin de ``scale_min``.
|
| 36 |
+
- :func:`color_diverging` accepte ``max_abs`` parce que ces cellules
|
| 37 |
+
(deltas signés) sont symétriques autour de 0 — la borne est la
|
| 38 |
+
même des deux côtés.
|
| 39 |
+
|
| 40 |
+
Le choix des couleurs reflète la sémantique métier : traffic-light
|
| 41 |
+
utilise rouge/jaune/vert (échelle universelle de qualité), diverging
|
| 42 |
+
utilise bleu/vert/orange par défaut (vert au centre = neutre,
|
| 43 |
+
extrémités opposées sémantiquement, et ces 3 teintes restent
|
| 44 |
+
distinguables en daltonisme deutéranope contrairement au
|
| 45 |
+
rouge/vert).
|
| 46 |
+
|
| 47 |
Palette
|
| 48 |
-------
|
| 49 |
Les bornes RGB des dégradés traffic-light sont la moyenne des palettes
|
|
@@ -14,10 +14,14 @@ Ce sous-package éclate la construction en modules thématiques :
|
|
| 14 |
reliability curves, Venn, error clusters, corrélations.
|
| 15 |
- :mod:`scatter` — Sprint 10 : Gini vs CER, ratio vs anchor.
|
| 16 |
- :mod:`pareto` — Sprint 19 : 3 fronts Pareto + métadonnées pricing.
|
|
|
|
|
|
|
| 17 |
|
| 18 |
L'API publique :func:`build_report_data` orchestre ces modules dans
|
| 19 |
-
le bon ordre
|
| 20 |
-
``
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
|
| 23 |
from __future__ import annotations
|
|
@@ -32,7 +36,10 @@ from picarones.report.report_data.documents import (
|
|
| 32 |
build_documents,
|
| 33 |
)
|
| 34 |
from picarones.report.report_data.engines import build_engines_summary
|
| 35 |
-
from picarones.report.report_data.pareto import
|
|
|
|
|
|
|
|
|
|
| 36 |
from picarones.report.report_data.scatter import (
|
| 37 |
build_gini_vs_cer,
|
| 38 |
build_ratio_vs_anchor,
|
|
@@ -53,14 +60,21 @@ def build_report_data(
|
|
| 53 |
) -> dict:
|
| 54 |
"""Transforme un :class:`BenchmarkResult` en dict pour le rapport HTML.
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
"""
|
| 59 |
engines_summary = build_engines_summary(benchmark)
|
| 60 |
documents = build_documents(benchmark, images_b64)
|
| 61 |
annotate_documents_with_difficulty(benchmark, documents)
|
| 62 |
|
| 63 |
-
|
|
|
|
| 64 |
|
| 65 |
return {
|
| 66 |
"meta": {
|
|
|
|
| 14 |
reliability curves, Venn, error clusters, corrélations.
|
| 15 |
- :mod:`scatter` — Sprint 10 : Gini vs CER, ratio vs anchor.
|
| 16 |
- :mod:`pareto` — Sprint 19 : 3 fronts Pareto + métadonnées pricing.
|
| 17 |
+
Expose deux fonctions séparées : :func:`attach_engine_costs`
|
| 18 |
+
(mute) et :func:`build_pareto_section` (pure).
|
| 19 |
|
| 20 |
L'API publique :func:`build_report_data` orchestre ces modules dans
|
| 21 |
+
le bon ordre. La séquence Pareto en deux temps
|
| 22 |
+
(``attach_engine_costs`` → ``build_pareto_section``) rend la
|
| 23 |
+
mutation explicite — les fonctions ``build_*`` du sous-package
|
| 24 |
+
sont pures sauf ``attach_engine_costs`` dont le nom le dit.
|
| 25 |
"""
|
| 26 |
|
| 27 |
from __future__ import annotations
|
|
|
|
| 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,
|
| 42 |
+
)
|
| 43 |
from picarones.report.report_data.scatter import (
|
| 44 |
build_gini_vs_cer,
|
| 45 |
build_ratio_vs_anchor,
|
|
|
|
| 60 |
) -> dict:
|
| 61 |
"""Transforme un :class:`BenchmarkResult` en dict pour le rapport HTML.
|
| 62 |
|
| 63 |
+
Ordre critique :
|
| 64 |
+
|
| 65 |
+
1. Construire ``engines_summary`` (pur).
|
| 66 |
+
2. Construire ``documents`` puis annoter avec la difficulté (mute
|
| 67 |
+
``documents``).
|
| 68 |
+
3. **Attacher** les coûts à ``engines_summary`` (mute, nom
|
| 69 |
+
explicite).
|
| 70 |
+
4. **Construire** le bloc Pareto (pure, lit les coûts attachés).
|
| 71 |
"""
|
| 72 |
engines_summary = build_engines_summary(benchmark)
|
| 73 |
documents = build_documents(benchmark, images_b64)
|
| 74 |
annotate_documents_with_difficulty(benchmark, documents)
|
| 75 |
|
| 76 |
+
attach_engine_costs(engines_summary, benchmark)
|
| 77 |
+
pareto_data = build_pareto_section(engines_summary)
|
| 78 |
|
| 79 |
return {
|
| 80 |
"meta": {
|
|
@@ -6,11 +6,24 @@ Construit trois fronts Pareto avec des axes alternatifs :
|
|
| 6 |
- ``speed`` — CER vs durée moyenne par page.
|
| 7 |
- ``co2`` — CER vs empreinte carbone (g CO₂ / 1000 pages, expérimental).
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -27,13 +40,20 @@ if TYPE_CHECKING:
|
|
| 27 |
from picarones.core.results import BenchmarkResult
|
| 28 |
|
| 29 |
|
| 30 |
-
def
|
| 31 |
engines_summary: list[dict], benchmark: "BenchmarkResult",
|
| 32 |
-
) ->
|
| 33 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
"""
|
| 38 |
durations_by_engine: dict[str, float] = {}
|
| 39 |
for report in benchmark.engine_reports:
|
|
@@ -45,11 +65,9 @@ def build_pareto_section(
|
|
| 45 |
if durs:
|
| 46 |
durations_by_engine[report.engine_name] = sum(durs) / len(durs)
|
| 47 |
|
| 48 |
-
pricing_defaults, _ = load_pricing_database()
|
| 49 |
costs_by_engine = build_costs_for_benchmark(
|
| 50 |
engines_summary, durations_by_engine,
|
| 51 |
)
|
| 52 |
-
# Annoter en place chaque résumé moteur avec son coût et sa durée.
|
| 53 |
for entry in engines_summary:
|
| 54 |
name = entry["name"]
|
| 55 |
entry["mean_duration_seconds"] = (
|
|
@@ -58,6 +76,24 @@ def build_pareto_section(
|
|
| 58 |
)
|
| 59 |
entry["cost"] = costs_by_engine.get(name)
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
pareto_points = []
|
| 62 |
for entry in engines_summary:
|
| 63 |
cer = entry.get("cer")
|
|
@@ -120,4 +156,4 @@ def build_pareto_section(
|
|
| 120 |
}
|
| 121 |
|
| 122 |
|
| 123 |
-
__all__ = ["build_pareto_section"]
|
|
|
|
| 6 |
- ``speed`` — CER vs durée moyenne par page.
|
| 7 |
- ``co2`` — CER vs empreinte carbone (g CO₂ / 1000 pages, expérimental).
|
| 8 |
|
| 9 |
+
API
|
| 10 |
+
---
|
| 11 |
+
Deux fonctions séparées pour rendre le contrat explicite :
|
| 12 |
+
|
| 13 |
+
1. :func:`attach_engine_costs` — **mute en place** ``engines_summary``
|
| 14 |
+
en y ajoutant ``mean_duration_seconds`` et ``cost`` (extraits du
|
| 15 |
+
benchmark et de la table de pricing). Le nom dit clairement qu'il
|
| 16 |
+
y a mutation.
|
| 17 |
+
2. :func:`build_pareto_section` — **fonction pure**, lit les coûts
|
| 18 |
+
déjà attachés à ``engines_summary``. Retourne le dict ``pareto``
|
| 19 |
+
prêt pour le template.
|
| 20 |
+
|
| 21 |
+
L'orchestrateur (``__init__.py``) appelle les deux dans l'ordre.
|
| 22 |
+
Cette séparation rend possible :
|
| 23 |
+
|
| 24 |
+
- Tester :func:`build_pareto_section` indépendamment avec un
|
| 25 |
+
``engines_summary`` pré-fabriqué.
|
| 26 |
+
- Réutiliser les coûts attachés sans recalculer Pareto.
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
|
|
|
| 40 |
from picarones.core.results import BenchmarkResult
|
| 41 |
|
| 42 |
|
| 43 |
+
def attach_engine_costs(
|
| 44 |
engines_summary: list[dict], benchmark: "BenchmarkResult",
|
| 45 |
+
) -> None:
|
| 46 |
+
"""Annote chaque entrée de ``engines_summary`` avec son coût.
|
| 47 |
+
|
| 48 |
+
**Mute en place** : ajoute deux champs à chaque dict moteur :
|
| 49 |
+
|
| 50 |
+
- ``mean_duration_seconds`` (float ou ``None`` si pas de durée).
|
| 51 |
+
- ``cost`` : dict de la forme ``{cost_per_1k_pages_eur: ...,
|
| 52 |
+
co2_per_1k_pages_g: ..., ...}`` ou ``None`` si pricing
|
| 53 |
+
indisponible.
|
| 54 |
|
| 55 |
+
Doit être appelée AVANT :func:`build_pareto_section`, qui lit
|
| 56 |
+
ces deux champs.
|
| 57 |
"""
|
| 58 |
durations_by_engine: dict[str, float] = {}
|
| 59 |
for report in benchmark.engine_reports:
|
|
|
|
| 65 |
if durs:
|
| 66 |
durations_by_engine[report.engine_name] = sum(durs) / len(durs)
|
| 67 |
|
|
|
|
| 68 |
costs_by_engine = build_costs_for_benchmark(
|
| 69 |
engines_summary, durations_by_engine,
|
| 70 |
)
|
|
|
|
| 71 |
for entry in engines_summary:
|
| 72 |
name = entry["name"]
|
| 73 |
entry["mean_duration_seconds"] = (
|
|
|
|
| 76 |
)
|
| 77 |
entry["cost"] = costs_by_engine.get(name)
|
| 78 |
|
| 79 |
+
|
| 80 |
+
def build_pareto_section(engines_summary: list[dict]) -> dict:
|
| 81 |
+
"""Construit le bloc ``pareto`` du dict de rapport.
|
| 82 |
+
|
| 83 |
+
**Fonction pure** : ne mute rien. Lit ``mean_duration_seconds``
|
| 84 |
+
et ``cost`` qui doivent avoir été attachés en amont par
|
| 85 |
+
:func:`attach_engine_costs`. Si ces champs sont absents, le
|
| 86 |
+
moteur est silencieusement omis du front (cohérent avec un
|
| 87 |
+
moteur qui n'a pas de prix connu).
|
| 88 |
+
|
| 89 |
+
Retour
|
| 90 |
+
------
|
| 91 |
+
dict
|
| 92 |
+
Trois fronts Pareto (``cost``, ``speed``, ``co2``) plus
|
| 93 |
+
``pricing_meta`` (table de pricing utilisée).
|
| 94 |
+
"""
|
| 95 |
+
pricing_defaults, _ = load_pricing_database()
|
| 96 |
+
|
| 97 |
pareto_points = []
|
| 98 |
for entry in engines_summary:
|
| 99 |
cer = entry.get("cer")
|
|
|
|
| 156 |
}
|
| 157 |
|
| 158 |
|
| 159 |
+
__all__ = ["attach_engine_costs", "build_pareto_section"]
|
|
@@ -68,6 +68,10 @@ FILE_BUDGETS: dict[str, int] = {
|
|
| 68 |
"picarones/measurements/numerical_sequences.py": 500, # actuel 422
|
| 69 |
"picarones/measurements/normalization.py": 500, # actuel 420
|
| 70 |
"picarones/report/comparison.py": 500, # actuel 409
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
|
|
|
|
| 68 |
"picarones/measurements/numerical_sequences.py": 500, # actuel 422
|
| 69 |
"picarones/measurements/normalization.py": 500, # actuel 420
|
| 70 |
"picarones/report/comparison.py": 500, # actuel 409
|
| 71 |
+
# --- Module mutualisé créé par le sprint des render helpers
|
| 72 |
+
# (Sprint « consolidation des renderers » 2026-05-02). Budget
|
| 73 |
+
# calibré sur la taille post-documentation des conventions.
|
| 74 |
+
"picarones/report/render_helpers.py": 480, # actuel 415
|
| 75 |
}
|
| 76 |
|
| 77 |
|
|
@@ -5,14 +5,19 @@ au moins un fichier de production (hors lui-même, hors ``tests/``).
|
|
| 5 |
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
|
| 9 |
-
n'ont aucun consommateur
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
Trois actions possibles, par module :
|
| 18 |
|
|
@@ -32,26 +37,28 @@ Test ratchet :
|
|
| 32 |
|
| 33 |
from __future__ import annotations
|
| 34 |
|
| 35 |
-
import
|
| 36 |
from pathlib import Path
|
| 37 |
|
| 38 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 39 |
PICARONES_DIR = REPO_ROOT / "picarones"
|
| 40 |
MEASUREMENTS_DIR = PICARONES_DIR / "measurements"
|
| 41 |
|
| 42 |
-
#: Snapshot v1.0.0. Modules de
|
| 43 |
-
#: consommateur en production.
|
|
|
|
| 44 |
TEST_ONLY_BASELINE: frozenset[str] = frozenset({
|
| 45 |
-
"alto_metrics",
|
| 46 |
"baseline_comparison",
|
| 47 |
-
"builtin_metrics",
|
| 48 |
"cost_projection",
|
| 49 |
"equivalence_profile",
|
|
|
|
| 50 |
"layout",
|
|
|
|
| 51 |
"marginal_cost",
|
|
|
|
| 52 |
"ner_backends",
|
| 53 |
"rare_tokens",
|
| 54 |
-
"
|
| 55 |
"taxonomy_cooccurrence",
|
| 56 |
"taxonomy_intra_doc",
|
| 57 |
})
|
|
@@ -65,39 +72,94 @@ def _measurements_modules() -> list[str]:
|
|
| 65 |
)
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def _has_production_consumer(module_name: str) -> bool:
|
| 69 |
"""True si ``module_name`` est importé par un fichier de production.
|
| 70 |
|
| 71 |
"Production" = sous ``picarones/``, hors le module lui-même.
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
"""
|
| 76 |
own_file = MEASUREMENTS_DIR / f"{module_name}.py"
|
| 77 |
-
absolute_pattern = re.compile(
|
| 78 |
-
rf"\bfrom\s+picarones\.measurements\.{re.escape(module_name)}\b"
|
| 79 |
-
rf"|\bimport\s+picarones\.measurements\.{re.escape(module_name)}\b"
|
| 80 |
-
)
|
| 81 |
-
relative_pattern = re.compile(
|
| 82 |
-
rf"\bfrom\s+\.\s*{re.escape(module_name)}\b"
|
| 83 |
-
rf"|\bfrom\s+\.measurements\.{re.escape(module_name)}\b"
|
| 84 |
-
)
|
| 85 |
for path in PICARONES_DIR.rglob("*.py"):
|
| 86 |
if path == own_file:
|
| 87 |
continue
|
| 88 |
try:
|
| 89 |
-
|
| 90 |
-
except OSError:
|
| 91 |
continue
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
except ValueError:
|
| 98 |
-
continue
|
| 99 |
-
if relative_pattern.search(text):
|
| 100 |
-
return True
|
| 101 |
return False
|
| 102 |
|
| 103 |
|
|
|
|
| 5 |
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 |
|
|
|
|
| 37 |
|
| 38 |
from __future__ import annotations
|
| 39 |
|
| 40 |
+
import ast
|
| 41 |
from pathlib import Path
|
| 42 |
|
| 43 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 44 |
PICARONES_DIR = REPO_ROOT / "picarones"
|
| 45 |
MEASUREMENTS_DIR = PICARONES_DIR / "measurements"
|
| 46 |
|
| 47 |
+
#: Snapshot v1.0.0 (post-audit AST). Modules de
|
| 48 |
+
#: ``picarones/measurements/`` sans consommateur en production.
|
| 49 |
+
#: À résorber par paliers.
|
| 50 |
TEST_ONLY_BASELINE: frozenset[str] = frozenset({
|
|
|
|
| 51 |
"baseline_comparison",
|
|
|
|
| 52 |
"cost_projection",
|
| 53 |
"equivalence_profile",
|
| 54 |
+
"error_absorption",
|
| 55 |
"layout",
|
| 56 |
+
"longitudinal",
|
| 57 |
"marginal_cost",
|
| 58 |
+
"module_policy",
|
| 59 |
"ner_backends",
|
| 60 |
"rare_tokens",
|
| 61 |
+
"reliability",
|
| 62 |
"taxonomy_cooccurrence",
|
| 63 |
"taxonomy_intra_doc",
|
| 64 |
})
|
|
|
|
| 72 |
)
|
| 73 |
|
| 74 |
|
| 75 |
+
def _imports_target_module(node: ast.AST, module_name: str) -> bool:
|
| 76 |
+
"""True si ce nœud AST importe ``picarones.measurements.<module_name>``.
|
| 77 |
+
|
| 78 |
+
Couvre les 5 syntaxes valides Python :
|
| 79 |
+
|
| 80 |
+
- ``import picarones.measurements.X``
|
| 81 |
+
- ``import picarones.measurements.X.sub`` (sous-module)
|
| 82 |
+
- ``from picarones.measurements.X import Y``
|
| 83 |
+
- ``from picarones.measurements import X``
|
| 84 |
+
- ``from picarones.measurements import (X, Y)`` (forme parenthésée)
|
| 85 |
+
"""
|
| 86 |
+
target_dotted = f"picarones.measurements.{module_name}"
|
| 87 |
+
if isinstance(node, ast.Import):
|
| 88 |
+
for alias in node.names:
|
| 89 |
+
if alias.name == target_dotted or alias.name.startswith(
|
| 90 |
+
target_dotted + ".",
|
| 91 |
+
):
|
| 92 |
+
return True
|
| 93 |
+
return False
|
| 94 |
+
if isinstance(node, ast.ImportFrom):
|
| 95 |
+
# ``from picarones.measurements.X import …``
|
| 96 |
+
if node.module == target_dotted:
|
| 97 |
+
return True
|
| 98 |
+
# ``from picarones.measurements import X``
|
| 99 |
+
if node.module == "picarones.measurements":
|
| 100 |
+
for alias in node.names:
|
| 101 |
+
if alias.name == module_name:
|
| 102 |
+
return True
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _imports_target_relative(
|
| 107 |
+
node: ast.AST, module_name: str, source_dir: Path,
|
| 108 |
+
) -> bool:
|
| 109 |
+
"""True si ce nœud AST importe ``module_name`` via un import relatif
|
| 110 |
+
valide depuis le package ``measurements``.
|
| 111 |
+
|
| 112 |
+
Couvre :
|
| 113 |
+
|
| 114 |
+
- ``from . import X`` (depuis ``measurements/__init__.py`` ou
|
| 115 |
+
tout autre module dans le package).
|
| 116 |
+
- ``from .X import Y``.
|
| 117 |
+
"""
|
| 118 |
+
if not isinstance(node, ast.ImportFrom):
|
| 119 |
+
return False
|
| 120 |
+
if node.level < 1:
|
| 121 |
+
return False
|
| 122 |
+
if source_dir != MEASUREMENTS_DIR:
|
| 123 |
+
return False
|
| 124 |
+
# ``from .X import …``
|
| 125 |
+
if node.module == module_name:
|
| 126 |
+
return True
|
| 127 |
+
# ``from . import X``
|
| 128 |
+
if node.module is None:
|
| 129 |
+
for alias in node.names:
|
| 130 |
+
if alias.name == module_name:
|
| 131 |
+
return True
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
|
| 135 |
def _has_production_consumer(module_name: str) -> bool:
|
| 136 |
"""True si ``module_name`` est importé par un fichier de production.
|
| 137 |
|
| 138 |
"Production" = sous ``picarones/``, hors le module lui-même.
|
| 139 |
+
|
| 140 |
+
Le check parse l'AST de chaque fichier (au lieu de grep) pour deux
|
| 141 |
+
raisons :
|
| 142 |
+
|
| 143 |
+
1. **Toutes les syntaxes d'import sont reconnues** sans bricolage
|
| 144 |
+
de regex (``from picarones.measurements import X`` était la
|
| 145 |
+
grosse cible manquée par la regex initiale).
|
| 146 |
+
2. **Les chaînes/docstrings ne déclenchent pas de faux positif**
|
| 147 |
+
(un exemple de code dans une docstring ne compte pas comme
|
| 148 |
+
import réel).
|
| 149 |
"""
|
| 150 |
own_file = MEASUREMENTS_DIR / f"{module_name}.py"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
for path in PICARONES_DIR.rglob("*.py"):
|
| 152 |
if path == own_file:
|
| 153 |
continue
|
| 154 |
try:
|
| 155 |
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
| 156 |
+
except (OSError, SyntaxError):
|
| 157 |
continue
|
| 158 |
+
for node in ast.walk(tree):
|
| 159 |
+
if _imports_target_module(node, module_name):
|
| 160 |
+
return True
|
| 161 |
+
if _imports_target_relative(node, module_name, path.parent):
|
| 162 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
return False
|
| 164 |
|
| 165 |
|
|
@@ -75,6 +75,28 @@ class TestColorTrafficLight:
|
|
| 75 |
assert len(c) == 7
|
| 76 |
assert c == c.lower()
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
# ──────────────────────────────────────────────────────────────────
|
| 80 |
# color_single_gradient
|
|
|
|
| 75 |
assert len(c) == 7
|
| 76 |
assert c == c.lower()
|
| 77 |
|
| 78 |
+
def test_nan_falls_back_to_red(self) -> None:
|
| 79 |
+
# max(0, min(1, NaN)) = NaN → mais ensuite f <= 0.5 est False, donc
|
| 80 |
+
# branche "yellow → green" avec t = (NaN - 0.5) / 0.5 = NaN.
|
| 81 |
+
# Le résultat est techniquement indéfini ; on vérifie au moins que
|
| 82 |
+
# la fonction ne crash pas et retourne un hex valide.
|
| 83 |
+
c = color_traffic_light(float("nan"))
|
| 84 |
+
assert c.startswith("#")
|
| 85 |
+
assert len(c) == 7
|
| 86 |
+
|
| 87 |
+
def test_inf_clamped_to_max(self) -> None:
|
| 88 |
+
# +inf > scale_max → clamp à scale_max → vert (high_is_good)
|
| 89 |
+
assert _hex_to_rgb(color_traffic_light(float("inf"))) == GRADIENT_GREEN_RGB
|
| 90 |
+
# -inf < 0 → clamp à 0 → rouge
|
| 91 |
+
assert _hex_to_rgb(color_traffic_light(float("-inf"))) == GRADIENT_RED_RGB
|
| 92 |
+
|
| 93 |
+
def test_inverted_scale_returns_yellow(self) -> None:
|
| 94 |
+
# scale_min > scale_max → span négatif → géré comme zero span.
|
| 95 |
+
# La fonction ne doit pas crash et retourne une couleur valide.
|
| 96 |
+
c = color_traffic_light(5.0, scale_min=10, scale_max=5)
|
| 97 |
+
assert c.startswith("#")
|
| 98 |
+
assert len(c) == 7
|
| 99 |
+
|
| 100 |
|
| 101 |
# ──────────────────────────────────────────────────────────────────
|
| 102 |
# color_single_gradient
|