Spaces:
Running
sprint92: A.II.9 - métriques longitudinales (régression + change-point + détecteur)
Browse filesL'historique SQLite (Sprint 8) collectait sans qu'aucune métrique
n'en sorte dans le rapport. Complémentaire à A.I.3 (off-baseline)
qui dit "écart anormal sur ce corpus" sans caractériser la dynamique.
picarones/core/longitudinal.py :
- LinearTrend + ChangePointResult dataclasses.
- compute_linear_trend : OLS pure Python sans scipy, slope + R².
- detect_change_point : balayage Pettitt simplifié.
- compute_engine_longitudinal : combine, garde-fous min_runs_for_trend=3
et change_point_threshold=0.01.
- compute_corpus_longitudinal : agrège tous les moteurs.
FactType.REGRESSION_IN_HISTORY (priority 170, MEDIUM par défaut,
HIGH si |absolute_delta| >= 0.05). Détecteur déclenche si pente
> +1 pt/an OU change-point delta > 1 pt CER. Payload trace pattern
trend/change_point/trend_and_change_point.
Templates FR/EN sans chiffres en dur. Couples complémentaires
(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY) et
(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY) ajoutés à l'arbitre.
picarones/report/longitudinal_render.py : tableau moteur ×
{n_runs, premier/dernier CER, Δ cumulé coloré (rouge dégradation /
bleu amélioration), pente annualisée, R², change-point}. Tri par
Δ décroissant. Adaptive masking.
10 clés i18n FR/EN. 28 tests dans test_sprint92_longitudinal.py
incluant traçabilité anti-hallucination FR + EN sur sentences de
build_synthesis.
Tests : 2996 passed, 2 skipped.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +64 -0
- CLAUDE.md +2 -1
- picarones/core/longitudinal.py +373 -0
- picarones/core/narrative/arbiter.py +13 -0
- picarones/core/narrative/detectors.py +118 -0
- picarones/core/narrative/facts.py +9 -0
- picarones/core/narrative/templates/en.yaml +6 -0
- picarones/core/narrative/templates/fr.yaml +6 -0
- picarones/report/i18n/en.json +11 -1
- picarones/report/i18n/fr.json +11 -1
- picarones/report/longitudinal_render.py +174 -0
- tests/test_sprint92_longitudinal.py +428 -0
|
@@ -16,6 +16,70 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 91 — A.II.6 : métriques économiques (throughput
|
| 20 |
effectif + coût marginal par erreur évitée).** Le throughput
|
| 21 |
brut (pages/heure d'OCR pur) ment quand un moteur est rapide
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 92 — A.II.9 : métriques longitudinales (régression
|
| 20 |
+
linéaire + change-point + détecteur narratif + vue HTML).**
|
| 21 |
+
L'historique SQLite (`core/history.py`, Sprint 8) collectait
|
| 22 |
+
les résultats sans qu'aucune métrique n'en sorte dans le
|
| 23 |
+
rapport. Ce sprint exploite la série temporelle des CER
|
| 24 |
+
pour signaler tendances et ruptures — complémentaire à
|
| 25 |
+
A.I.3 (off-baseline) qui dit *« écart anormal sur ce
|
| 26 |
+
corpus »* sans caractériser la dynamique.
|
| 27 |
+
|
| 28 |
+
- `picarones/core/longitudinal.py` : `compute_linear_trend`
|
| 29 |
+
régression OLS pure Python sans scipy retourne
|
| 30 |
+
`LinearTrend(slope, intercept, r_squared, n_runs)` ;
|
| 31 |
+
`detect_change_point(series, min_segment_size=3)` balayage
|
| 32 |
+
exhaustif (Pettitt simplifié) retourne
|
| 33 |
+
`ChangePointResult(index, timestamp, mean_before,
|
| 34 |
+
mean_after, delta, n_before, n_after)` ;
|
| 35 |
+
`compute_engine_longitudinal(history, engine, corpus)`
|
| 36 |
+
combine les deux avec garde-fou `min_runs_for_trend=3` et
|
| 37 |
+
seuil `change_point_threshold=0.01` (1 point CER) pour
|
| 38 |
+
filtrer le bruit ; `compute_corpus_longitudinal` agrège
|
| 39 |
+
sur tous les moteurs présents.
|
| 40 |
+
|
| 41 |
+
- Nouveau `FactType.REGRESSION_IN_HISTORY` (priority 170,
|
| 42 |
+
importance MEDIUM par défaut, HIGH si `|absolute_delta| ≥
|
| 43 |
+
0.05`) + détecteur `detect_regression_in_history` qui lit
|
| 44 |
+
`benchmark_data["longitudinal_trends"]`. Déclenche si
|
| 45 |
+
pente > +1 pt CER/an **ou** change-point delta > 1 pt CER.
|
| 46 |
+
Garde-fou `n_runs ≥ 3`. Le payload trace
|
| 47 |
+
`pattern in {"trend", "change_point",
|
| 48 |
+
"trend_and_change_point"}`. Templates FR/EN sans chiffres
|
| 49 |
+
en dur. Ajout aux paires complémentaires de l'arbitre :
|
| 50 |
+
`(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY)` (le leader
|
| 51 |
+
peut être en régression, info critique) et
|
| 52 |
+
`(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY)` (les deux
|
| 53 |
+
se complètent : écart anormal vs tendance dans le temps).
|
| 54 |
+
|
| 55 |
+
- `picarones/report/longitudinal_render.py` :
|
| 56 |
+
`build_longitudinal_html(trends, labels)` rend un tableau
|
| 57 |
+
moteur × {n_runs, premier CER, dernier CER, Δ cumulé
|
| 58 |
+
coloré (gradient vert → orange → rouge sur ±5 pts ; bleu
|
| 59 |
+
si amélioration), pente annualisée, R², point de rupture
|
| 60 |
+
avec timestamp + delta entre parenthèses}. Tri par Δ
|
| 61 |
+
décroissant. Adaptive : `""` si pas de données. Module
|
| 62 |
+
pur — l'utilisateur compose
|
| 63 |
+
`BenchmarkHistory.list_entries()` →
|
| 64 |
+
`compute_corpus_longitudinal` →
|
| 65 |
+
`build_longitudinal_html`.
|
| 66 |
+
|
| 67 |
+
+10 clés i18n FR/EN (`longitudinal_*`). +28 tests dans
|
| 68 |
+
`test_sprint92_longitudinal.py` (régression OLS pente + R² +
|
| 69 |
+
série plate + lt 2 + même timestamp ; change-point delta
|
| 70 |
+
exact + lt segments + uniforme ; intégration entries +
|
| 71 |
+
filtre corpus + min_runs + threshold ; multi-moteurs ;
|
| 72 |
+
détecteur 6 cas dont silence sans data, silence si plat,
|
| 73 |
+
HIGH si Δ ≥ 5 pts, change-point seul, garde-fou n_runs < 3 ;
|
| 74 |
+
**traçabilité anti-hallucination FR + EN** sur les sentences
|
| 75 |
+
de `build_synthesis` ; vue HTML 4 cas dont anti-injection,
|
| 76 |
+
complétude i18n 10 clés). **Verrou levé** : un benchmark
|
| 77 |
+
qui pousse ses résultats dans l'historique voit désormais
|
| 78 |
+
*« sur les 8 runs historiques pour tess, le CER moyen est
|
| 79 |
+
passé de 4 % à 7 % (variation cumulée 3 points) »* dans la
|
| 80 |
+
synthèse + le tableau d'évolution dans la vue. Permet de
|
| 81 |
+
relier une régression à un changement de pipeline.
|
| 82 |
+
|
| 83 |
- **Sprint 91 — A.II.6 : métriques économiques (throughput
|
| 84 |
effectif + coût marginal par erreur évitée).** Le throughput
|
| 85 |
brut (pages/heure d'OCR pur) ment quand un moteur est rapide
|
|
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
|
|
|
| 210 |
| 91 | **Sprint 60 du plan d'évolution 2026 — A.II.6 : métriques économiques (throughput effectif + coût marginal par erreur évitée, couche calcul + vue HTML throughput)**. Le throughput brut ment quand un moteur est rapide mais imprécis : la correction humaine *post hoc* absorbe le gain. Discrimine fortement entre cloud rapide à 30 % de timeouts et local lent à 100 % de fiabilité. Nouveau module `picarones/core/throughput.py` : `compute_effective_throughput(n_pages, duration_seconds, n_errors, time_per_error_seconds=5.0)` retourne `{n_pages, duration_seconds, n_errors, time_per_error_seconds, correction_time_seconds, total_seconds, pages_per_hour_raw, pages_per_hour_effective, drag_ratio}`. Constante HTR-United (5 s/erreur) surchargeable. Garde-fous : `None` si `n_pages = 0` ou `total_seconds = 0`, `ValueError` sur valeurs négatives. `aggregate_effective_throughput(per_engine)` agrège par moteur. Nouveau module `picarones/core/marginal_cost.py` : `compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)` retourne `{cost_per_avoided_error, n_errors_avoided, cost_delta, dominated}` ou `None` si `errors_b ≥ errors_a`. `dominated=True` quand B moins cher ET plus précis. `compute_marginal_cost_matrix(per_engine)` retourne paires ordonnées (A → B) triées par coût marginal croissant. Nouveau module `picarones/report/throughput_render.py` : `build_throughput_html(aggregated, labels)` produit tableau résumé moteur × {pages/h brut, pages/h **utilisable** (gradient rouge → vert sur le max observé), % drag (gradient vert → rouge), pages, erreurs}, tri par pages/h utilisable décroissant. Adaptive : `""` si pas de données. Module pur — l'utilisateur compose la liste `per_engine`. Vue HTML coût marginal couplée à la vue Pareto reportée à un sprint ultérieur. +9 clés i18n FR/EN (`throughput_*`). +27 tests dans `test_sprint91_throughput.py` (formule effective avec/sans erreurs, custom time_per_error, garde-fous, drag_ratio élevé, agrégation 3 cas, marginal cost 5 cas dont dominé/non comparable, matrice tri ascendant + lt 2 + données invalides, **cas réaliste BnF** Tesseract local 600 p/h brut → 423 p/h effectif vs GPT-4o cloud 1800 p/h brut → 300 p/h effectif, vue HTML 4 cas dont anti-injection + tri descendant, complétude i18n 9 clés). **Verrou levé** : un archiviste BnF qui pondère un budget contre une exigence de délai voit immédiatement *« Tesseract local 423 p/h utilisable, GPT-4o cloud 300 p/h utilisable malgré son apparente vitesse de 1800 p/h brut »* — la décision business s'aligne sur la réalité opérationnelle. |
|
| 211 |
| 90 | **Sprint 59 du plan d'évolution 2026 — A.II.4 finition : détecteur narratif `engine_unstable` + vue HTML stabilité multi-runs**. Le module `picarones/core/reliability.py` (Sprint 83) livrait la couche de calcul ; aucun détecteur ni vue ne consommaient les données. Critique pour les moteurs LLM/VLM dont la non-déterministie sape la reproductibilité scientifique. Nouveau `FactType.ENGINE_UNSTABLE` (priority 160, importance HIGH) + détecteur `detect_engine_unstable` qui lit `benchmark_data["multirun_stability"]` (liste enrichie d'`engine_name` + sortie de `compute_multirun_stability`). Garde-fous : `n_runs ≥ 2`, déclenche si `cer_cv > 0.10` **ou** `identical_run_rate < 0.50`. Templates FR/EN sans chiffres en dur. Ajout du couple `(GLOBAL_LEADER_CER, ENGINE_UNSTABLE)` à `_COMPLEMENTARY_PAIRS` de l'arbitre — un moteur peut être leader **et** instable, et c'est précisément l'information critique à remonter ensemble. Nouveau module `picarones/report/multirun_stability_render.py` : `build_multirun_stability_html(stability, labels)` rend un tableau moteur × {n_runs, CER moyen ± σ, CV (gradient vert→orange→rouge sur 0–25 %), % runs identiques, sorties distinctes}. Adaptive : `""` si liste vide ou tous `cer_cv` None. Note d'intégration : la vue est un module pur (l'utilisateur exécute lui-même les N runs ; option runner `--repeats N` reportée à un sprint dédié). +8 clés i18n FR/EN (`stability_*`). +18 tests dans `test_sprint90_engine_unstable.py` (FactType + arbiter, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur les sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 8 clés). **Verrou levé** : un papier scientifique qui rapporte un CER LLM voit désormais *« sur 4 runs successifs, gpt-4o produit des sorties variables (CV 24,3 %) — interpréter avec prudence »* dans la synthèse + le tableau de stabilité dans la vue. |
|
| 212 |
| 89 | **Sprint 58 du plan d'évolution 2026 — A.II.8b : score de spécialisation inter-moteurs (couche calcul + vue HTML)**. La matrice de divergence taxonomique (Sprint 35) répondait à *« à quel point ces moteurs se trompent-ils différemment ? »* ; ce sprint transforme cette information en un score lisible et un **top-N des paires les plus spécialisées**, qui répond directement à la question *« quels moteurs sont des candidats pour un voting ensemble ? »*. Le module **ne recommande pas** d'ensemble — observation factuelle, le chercheur arbitre. Nouveau module `picarones/core/specialization.py` : `compute_specialization_score(taxonomy_a, taxonomy_b)` retourne un score normalisé ∈ [0, 1] (délégué à `inter_engine.jensen_shannon_divergence` Sprint 35, pas de double calcul) ; `classify_specialization(score)` classe en `similar` (< 0,10) / `distinct` (0,10–0,30) / `highly_specialized` (≥ 0,30) — seuils éditoriaux pas verdict, surchargeables ; `compute_specialization_matrix(taxonomies)` retourne matrice symétrique avec `max_pair` ; `top_specialized_pairs(matrix, n=5, min_score=0)` retourne paires triées par score décroissant + catégorie. Nouveau module `picarones/report/specialization_render.py` : `build_specialization_html` rend tableau Moteur A × Moteur B × Score (gradient blanc → bleu profond) × Lecture (libellé i18n). Adaptive : `""` si < 2 moteurs avec taxonomie. Anti-injection. Câblage générator : lit `aggregated_taxonomy` exposés sur les moteurs (Sprint 5/runner historique), construit map `{engine: counts}`. Insertion `view_analyses.html` derrière la lisibilité. +9 clés i18n FR/EN (`specialization_*`). +24 tests dans `test_sprint89_specialization.py` (score symétrique + identité 0 + disjoint 1 + bornes [0,1], classify 5 cas dont custom thresholds, matrice diagonale 0 + symétrique + max_pair correctement identifié, top_pairs tri/n/min_score/None, rendu adaptive + anti-injection + FR/EN, complétude i18n 9 clés). **Verrou levé** : un benchmark BnF avec ≥ 2 moteurs voit immédiatement *« tess et pero ont une spécialisation forte (0,489) — ils font des erreurs de natures différentes »* — observation factuelle. |
|
|
@@ -309,7 +310,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 309 |
## Contexte développement
|
| 310 |
|
| 311 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 312 |
-
- **Tests** :
|
| 313 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 314 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 315 |
- **Transcript de la conversation de développement** :
|
|
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
| 210 |
+
| 92 | **Sprint 61 du plan d'évolution 2026 — A.II.9 : métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML)**. L'historique SQLite (Sprint 8) collectait sans qu'aucune métrique n'en sorte. Complémentaire à A.I.3 qui dit *« écart anormal sur ce corpus »* sans caractériser la dynamique. Nouveau module `picarones/core/longitudinal.py` : `compute_linear_trend` régression OLS pure Python sans scipy retourne `LinearTrend(slope, intercept, r_squared, n_runs)` ; `detect_change_point(series, min_segment_size=3)` balayage exhaustif (Pettitt simplifié) retourne `ChangePointResult(index, timestamp, mean_before, mean_after, delta, n_before, n_after)` ; `compute_engine_longitudinal` combine les deux avec garde-fou `min_runs_for_trend=3` et seuil `change_point_threshold=0.01` (1 pt CER) ; `compute_corpus_longitudinal` agrège tous les moteurs. Nouveau `FactType.REGRESSION_IN_HISTORY` (priority 170, MEDIUM par défaut, HIGH si `|absolute_delta| ≥ 0.05`) + détecteur lit `benchmark_data["longitudinal_trends"]`, déclenche si pente > +1 pt CER/an **ou** change-point delta > 1 pt CER, payload trace `pattern in {"trend", "change_point", "trend_and_change_point"}`. Templates FR/EN sans chiffres en dur. Ajout aux paires complémentaires : `(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY)` et `(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY)`. Module de rendu `picarones/report/longitudinal_render.py` : tableau moteur × {n_runs, premier CER, dernier CER, Δ cumulé coloré (vert→orange→rouge sur ±5 pts ; bleu si amélioration), pente annualisée, R², point de rupture avec timestamp + delta}. Tri par Δ décroissant. Adaptive masking. +10 clés i18n FR/EN (`longitudinal_*`). +28 tests (régression OLS, change-point, intégration entries + filtre corpus + min_runs + threshold, multi-moteurs, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 10 clés). **Verrou levé** : un benchmark voit désormais *« sur les 8 runs historiques pour tess, le CER moyen est passé de 4 % à 7 % (variation cumulée 3 points) »* — permet de relier une régression à un changement de pipeline. |
|
| 211 |
| 91 | **Sprint 60 du plan d'évolution 2026 — A.II.6 : métriques économiques (throughput effectif + coût marginal par erreur évitée, couche calcul + vue HTML throughput)**. Le throughput brut ment quand un moteur est rapide mais imprécis : la correction humaine *post hoc* absorbe le gain. Discrimine fortement entre cloud rapide à 30 % de timeouts et local lent à 100 % de fiabilité. Nouveau module `picarones/core/throughput.py` : `compute_effective_throughput(n_pages, duration_seconds, n_errors, time_per_error_seconds=5.0)` retourne `{n_pages, duration_seconds, n_errors, time_per_error_seconds, correction_time_seconds, total_seconds, pages_per_hour_raw, pages_per_hour_effective, drag_ratio}`. Constante HTR-United (5 s/erreur) surchargeable. Garde-fous : `None` si `n_pages = 0` ou `total_seconds = 0`, `ValueError` sur valeurs négatives. `aggregate_effective_throughput(per_engine)` agrège par moteur. Nouveau module `picarones/core/marginal_cost.py` : `compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)` retourne `{cost_per_avoided_error, n_errors_avoided, cost_delta, dominated}` ou `None` si `errors_b ≥ errors_a`. `dominated=True` quand B moins cher ET plus précis. `compute_marginal_cost_matrix(per_engine)` retourne paires ordonnées (A → B) triées par coût marginal croissant. Nouveau module `picarones/report/throughput_render.py` : `build_throughput_html(aggregated, labels)` produit tableau résumé moteur × {pages/h brut, pages/h **utilisable** (gradient rouge → vert sur le max observé), % drag (gradient vert → rouge), pages, erreurs}, tri par pages/h utilisable décroissant. Adaptive : `""` si pas de données. Module pur — l'utilisateur compose la liste `per_engine`. Vue HTML coût marginal couplée à la vue Pareto reportée à un sprint ultérieur. +9 clés i18n FR/EN (`throughput_*`). +27 tests dans `test_sprint91_throughput.py` (formule effective avec/sans erreurs, custom time_per_error, garde-fous, drag_ratio élevé, agrégation 3 cas, marginal cost 5 cas dont dominé/non comparable, matrice tri ascendant + lt 2 + données invalides, **cas réaliste BnF** Tesseract local 600 p/h brut → 423 p/h effectif vs GPT-4o cloud 1800 p/h brut → 300 p/h effectif, vue HTML 4 cas dont anti-injection + tri descendant, complétude i18n 9 clés). **Verrou levé** : un archiviste BnF qui pondère un budget contre une exigence de délai voit immédiatement *« Tesseract local 423 p/h utilisable, GPT-4o cloud 300 p/h utilisable malgré son apparente vitesse de 1800 p/h brut »* — la décision business s'aligne sur la réalité opérationnelle. |
|
| 212 |
| 90 | **Sprint 59 du plan d'évolution 2026 — A.II.4 finition : détecteur narratif `engine_unstable` + vue HTML stabilité multi-runs**. Le module `picarones/core/reliability.py` (Sprint 83) livrait la couche de calcul ; aucun détecteur ni vue ne consommaient les données. Critique pour les moteurs LLM/VLM dont la non-déterministie sape la reproductibilité scientifique. Nouveau `FactType.ENGINE_UNSTABLE` (priority 160, importance HIGH) + détecteur `detect_engine_unstable` qui lit `benchmark_data["multirun_stability"]` (liste enrichie d'`engine_name` + sortie de `compute_multirun_stability`). Garde-fous : `n_runs ≥ 2`, déclenche si `cer_cv > 0.10` **ou** `identical_run_rate < 0.50`. Templates FR/EN sans chiffres en dur. Ajout du couple `(GLOBAL_LEADER_CER, ENGINE_UNSTABLE)` à `_COMPLEMENTARY_PAIRS` de l'arbitre — un moteur peut être leader **et** instable, et c'est précisément l'information critique à remonter ensemble. Nouveau module `picarones/report/multirun_stability_render.py` : `build_multirun_stability_html(stability, labels)` rend un tableau moteur × {n_runs, CER moyen ± σ, CV (gradient vert→orange→rouge sur 0–25 %), % runs identiques, sorties distinctes}. Adaptive : `""` si liste vide ou tous `cer_cv` None. Note d'intégration : la vue est un module pur (l'utilisateur exécute lui-même les N runs ; option runner `--repeats N` reportée à un sprint dédié). +8 clés i18n FR/EN (`stability_*`). +18 tests dans `test_sprint90_engine_unstable.py` (FactType + arbiter, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur les sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 8 clés). **Verrou levé** : un papier scientifique qui rapporte un CER LLM voit désormais *« sur 4 runs successifs, gpt-4o produit des sorties variables (CV 24,3 %) — interpréter avec prudence »* dans la synthèse + le tableau de stabilité dans la vue. |
|
| 213 |
| 89 | **Sprint 58 du plan d'évolution 2026 — A.II.8b : score de spécialisation inter-moteurs (couche calcul + vue HTML)**. La matrice de divergence taxonomique (Sprint 35) répondait à *« à quel point ces moteurs se trompent-ils différemment ? »* ; ce sprint transforme cette information en un score lisible et un **top-N des paires les plus spécialisées**, qui répond directement à la question *« quels moteurs sont des candidats pour un voting ensemble ? »*. Le module **ne recommande pas** d'ensemble — observation factuelle, le chercheur arbitre. Nouveau module `picarones/core/specialization.py` : `compute_specialization_score(taxonomy_a, taxonomy_b)` retourne un score normalisé ∈ [0, 1] (délégué à `inter_engine.jensen_shannon_divergence` Sprint 35, pas de double calcul) ; `classify_specialization(score)` classe en `similar` (< 0,10) / `distinct` (0,10–0,30) / `highly_specialized` (≥ 0,30) — seuils éditoriaux pas verdict, surchargeables ; `compute_specialization_matrix(taxonomies)` retourne matrice symétrique avec `max_pair` ; `top_specialized_pairs(matrix, n=5, min_score=0)` retourne paires triées par score décroissant + catégorie. Nouveau module `picarones/report/specialization_render.py` : `build_specialization_html` rend tableau Moteur A × Moteur B × Score (gradient blanc → bleu profond) × Lecture (libellé i18n). Adaptive : `""` si < 2 moteurs avec taxonomie. Anti-injection. Câblage générator : lit `aggregated_taxonomy` exposés sur les moteurs (Sprint 5/runner historique), construit map `{engine: counts}`. Insertion `view_analyses.html` derrière la lisibilité. +9 clés i18n FR/EN (`specialization_*`). +24 tests dans `test_sprint89_specialization.py` (score symétrique + identité 0 + disjoint 1 + bornes [0,1], classify 5 cas dont custom thresholds, matrice diagonale 0 + symétrique + max_pair correctement identifié, top_pairs tri/n/min_score/None, rendu adaptive + anti-injection + FR/EN, complétude i18n 9 clés). **Verrou levé** : un benchmark BnF avec ≥ 2 moteurs voit immédiatement *« tess et pero ont une spécialisation forte (0,489) — ils font des erreurs de natures différentes »* — observation factuelle. |
|
|
|
|
| 310 |
## Contexte développement
|
| 311 |
|
| 312 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 313 |
+
- **Tests** : 2996 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout côté rapport ; Sprint 44 = médiane par défaut ; Sprints 45+46 = stratification A.III livrée bout-en-bout ; Sprints 47-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livr��es ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; Sprints 75-77 = A.I.4 livré bout-en-bout ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 couche calcul ; Sprint 82 = A.I.9 — « Leviers d'amélioration » bout-en-bout ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; Sprint 84 = A.II.5a — recherchabilité fuzzy ; Sprint 85 = A.II.5b — précision séquences numériques ; Sprint 86 = A.II.5 bout-en-bout (câblage runner + vues HTML) ; Sprint 87 = A.II.2 (delta Flesch) câblé bout-en-bout ; Sprint 88 = A.I.8 — vue HTML « Déficit projeté de robustesse » bout-en-bout ; Sprint 89 = A.II.8b — score de spécialisation inter-moteurs (couche calcul + vue HTML « Top paires spécialisées ») ; Sprint 90 = A.II.4 finition — détecteur narratif `engine_unstable` + vue HTML stabilité multi-runs ; Sprint 91 = A.II.6 — métriques économiques (throughput effectif + coût marginal par erreur évitée, couche calcul + vue HTML throughput) ; **Sprint 92 = A.II.9 — métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML)**)
|
| 314 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 315 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 316 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Métriques longitudinales — Sprint 92 (A.II.9).
|
| 2 |
+
|
| 3 |
+
Sprint 92 — A.II.9 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
L'historique SQLite (`core/history.py`, Sprint 8) collecte les
|
| 8 |
+
résultats de chaque run de benchmark, mais aucune métrique
|
| 9 |
+
n'en sortait dans le rapport. Ce module exploite la série
|
| 10 |
+
temporelle des CER d'un moteur pour répondre à deux
|
| 11 |
+
questions :
|
| 12 |
+
|
| 13 |
+
1. **Y a-t-il une tendance ?** Régression linéaire simple
|
| 14 |
+
(méthode des moindres carrés) sur ``(t, CER)`` — pente,
|
| 15 |
+
ordonnée à l'origine, R², n_runs. Une pente > 0 signale
|
| 16 |
+
une régression progressive ; une pente < 0 une amélioration.
|
| 17 |
+
|
| 18 |
+
2. **Y a-t-il un point de rupture ?** Algorithme de
|
| 19 |
+
change-point pur Python (différence de moyennes maximale,
|
| 20 |
+
variante de Pettitt simplifiée). Identifie l'index où la
|
| 21 |
+
série se sépare en deux segments avec moyennes les plus
|
| 22 |
+
différentes — typiquement le run où un modèle a changé de
|
| 23 |
+
comportement.
|
| 24 |
+
|
| 25 |
+
Pas de scipy
|
| 26 |
+
------------
|
| 27 |
+
Pour rester sans dépendance lourde, on implémente :
|
| 28 |
+
- la régression linéaire en pur Python (closed-form OLS) ;
|
| 29 |
+
- le change-point par balayage exhaustif (O(N) pour de petits
|
| 30 |
+
N — l'historique d'une institution dépasse rarement quelques
|
| 31 |
+
centaines de runs).
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import logging
|
| 37 |
+
import math
|
| 38 |
+
import statistics
|
| 39 |
+
from dataclasses import dataclass
|
| 40 |
+
from datetime import datetime
|
| 41 |
+
from typing import Iterable, Optional
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class LinearTrend:
|
| 48 |
+
"""Résultat d'une régression linéaire sur une série CER."""
|
| 49 |
+
slope: float
|
| 50 |
+
"""Pente (CER par jour). Positif = régression."""
|
| 51 |
+
intercept: float
|
| 52 |
+
"""Ordonnée à l'origine."""
|
| 53 |
+
r_squared: float
|
| 54 |
+
"""Qualité de l'ajustement, ∈ [0, 1]."""
|
| 55 |
+
n_runs: int
|
| 56 |
+
"""Nombre de points utilisés."""
|
| 57 |
+
|
| 58 |
+
def as_dict(self) -> dict:
|
| 59 |
+
return {
|
| 60 |
+
"slope": self.slope,
|
| 61 |
+
"intercept": self.intercept,
|
| 62 |
+
"r_squared": self.r_squared,
|
| 63 |
+
"n_runs": self.n_runs,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class ChangePointResult:
|
| 69 |
+
"""Résultat d'une détection de point de rupture."""
|
| 70 |
+
index: int
|
| 71 |
+
"""Index de la rupture (0-based, le segment 1 est [0:index],
|
| 72 |
+
le segment 2 est [index:N])."""
|
| 73 |
+
timestamp: str
|
| 74 |
+
"""Timestamp du run à la rupture."""
|
| 75 |
+
mean_before: float
|
| 76 |
+
mean_after: float
|
| 77 |
+
delta: float
|
| 78 |
+
"""``mean_after - mean_before``. Positif = régression."""
|
| 79 |
+
n_before: int
|
| 80 |
+
n_after: int
|
| 81 |
+
|
| 82 |
+
def as_dict(self) -> dict:
|
| 83 |
+
return {
|
| 84 |
+
"index": self.index,
|
| 85 |
+
"timestamp": self.timestamp,
|
| 86 |
+
"mean_before": self.mean_before,
|
| 87 |
+
"mean_after": self.mean_after,
|
| 88 |
+
"delta": self.delta,
|
| 89 |
+
"n_before": self.n_before,
|
| 90 |
+
"n_after": self.n_after,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _parse_timestamp(ts: str) -> Optional[float]:
|
| 95 |
+
"""Parse un ISO timestamp en jour ordinal float.
|
| 96 |
+
|
| 97 |
+
Tolère ``YYYY-MM-DD`` et ``YYYY-MM-DDTHH:MM:SS``. Retourne
|
| 98 |
+
``None`` si non parsable.
|
| 99 |
+
"""
|
| 100 |
+
if not ts:
|
| 101 |
+
return None
|
| 102 |
+
formats = (
|
| 103 |
+
"%Y-%m-%dT%H:%M:%S.%f",
|
| 104 |
+
"%Y-%m-%dT%H:%M:%S",
|
| 105 |
+
"%Y-%m-%d %H:%M:%S",
|
| 106 |
+
"%Y-%m-%d",
|
| 107 |
+
)
|
| 108 |
+
for fmt in formats:
|
| 109 |
+
try:
|
| 110 |
+
dt = datetime.strptime(ts.split("+")[0].split("Z")[0], fmt)
|
| 111 |
+
return dt.toordinal() + (
|
| 112 |
+
dt.hour * 3600 + dt.minute * 60 + dt.second
|
| 113 |
+
) / 86400.0
|
| 114 |
+
except ValueError:
|
| 115 |
+
continue
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def compute_linear_trend(
|
| 120 |
+
cer_series: Iterable[tuple[str, float]],
|
| 121 |
+
) -> Optional[LinearTrend]:
|
| 122 |
+
"""Régression linéaire OLS sur une série temporelle de CER.
|
| 123 |
+
|
| 124 |
+
Parameters
|
| 125 |
+
----------
|
| 126 |
+
cer_series:
|
| 127 |
+
Itérable de ``(timestamp_iso, cer)``. Au moins 2 points
|
| 128 |
+
valides requis.
|
| 129 |
+
|
| 130 |
+
Returns
|
| 131 |
+
-------
|
| 132 |
+
LinearTrend | None
|
| 133 |
+
``None`` si moins de 2 points ou si tous les timestamps
|
| 134 |
+
sont identiques (variance nulle sur t).
|
| 135 |
+
"""
|
| 136 |
+
points: list[tuple[float, float]] = []
|
| 137 |
+
for ts, cer in cer_series:
|
| 138 |
+
t = _parse_timestamp(ts)
|
| 139 |
+
if t is None or cer is None:
|
| 140 |
+
continue
|
| 141 |
+
try:
|
| 142 |
+
cer_f = float(cer)
|
| 143 |
+
except (TypeError, ValueError):
|
| 144 |
+
continue
|
| 145 |
+
points.append((t, cer_f))
|
| 146 |
+
n = len(points)
|
| 147 |
+
if n < 2:
|
| 148 |
+
return None
|
| 149 |
+
xs = [p[0] for p in points]
|
| 150 |
+
ys = [p[1] for p in points]
|
| 151 |
+
x_mean = statistics.fmean(xs)
|
| 152 |
+
y_mean = statistics.fmean(ys)
|
| 153 |
+
sxx = sum((x - x_mean) ** 2 for x in xs)
|
| 154 |
+
sxy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys))
|
| 155 |
+
if sxx == 0:
|
| 156 |
+
return None
|
| 157 |
+
slope = sxy / sxx
|
| 158 |
+
intercept = y_mean - slope * x_mean
|
| 159 |
+
syy = sum((y - y_mean) ** 2 for y in ys)
|
| 160 |
+
if syy == 0:
|
| 161 |
+
# Tous les CER sont égaux → R² mathématiquement indéfini ;
|
| 162 |
+
# on retourne 1.0 (parfaite "non-tendance").
|
| 163 |
+
r_squared = 1.0
|
| 164 |
+
else:
|
| 165 |
+
ss_res = sum(
|
| 166 |
+
(y - (slope * x + intercept)) ** 2
|
| 167 |
+
for x, y in zip(xs, ys)
|
| 168 |
+
)
|
| 169 |
+
r_squared = max(0.0, 1.0 - ss_res / syy)
|
| 170 |
+
return LinearTrend(
|
| 171 |
+
slope=slope,
|
| 172 |
+
intercept=intercept,
|
| 173 |
+
r_squared=r_squared,
|
| 174 |
+
n_runs=n,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def detect_change_point(
|
| 179 |
+
cer_series: Iterable[tuple[str, float]],
|
| 180 |
+
min_segment_size: int = 3,
|
| 181 |
+
) -> Optional[ChangePointResult]:
|
| 182 |
+
"""Détecte le point de rupture maximisant l'écart de moyennes.
|
| 183 |
+
|
| 184 |
+
Algorithme : balayage des indices ``i`` où la série se
|
| 185 |
+
sépare en deux segments d'au moins ``min_segment_size``
|
| 186 |
+
points chacun ; on retient l'index où ``|mean_after -
|
| 187 |
+
mean_before|`` est maximal. Variante simplifiée de Pettitt.
|
| 188 |
+
|
| 189 |
+
Parameters
|
| 190 |
+
----------
|
| 191 |
+
cer_series:
|
| 192 |
+
Itérable de ``(timestamp_iso, cer)``.
|
| 193 |
+
min_segment_size:
|
| 194 |
+
Taille minimale des deux segments. Défaut 3.
|
| 195 |
+
|
| 196 |
+
Returns
|
| 197 |
+
-------
|
| 198 |
+
ChangePointResult | None
|
| 199 |
+
``None`` si la série a moins de ``2 × min_segment_size``
|
| 200 |
+
points valides.
|
| 201 |
+
"""
|
| 202 |
+
points: list[tuple[str, float, float]] = []
|
| 203 |
+
for ts, cer in cer_series:
|
| 204 |
+
t = _parse_timestamp(ts)
|
| 205 |
+
if t is None or cer is None:
|
| 206 |
+
continue
|
| 207 |
+
try:
|
| 208 |
+
cer_f = float(cer)
|
| 209 |
+
except (TypeError, ValueError):
|
| 210 |
+
continue
|
| 211 |
+
points.append((ts, t, cer_f))
|
| 212 |
+
if len(points) < 2 * min_segment_size:
|
| 213 |
+
return None
|
| 214 |
+
points.sort(key=lambda p: p[1])
|
| 215 |
+
n = len(points)
|
| 216 |
+
best_index = -1
|
| 217 |
+
best_abs_delta = -1.0
|
| 218 |
+
best_delta = 0.0
|
| 219 |
+
best_mean_before = 0.0
|
| 220 |
+
best_mean_after = 0.0
|
| 221 |
+
for i in range(min_segment_size, n - min_segment_size + 1):
|
| 222 |
+
before = [p[2] for p in points[:i]]
|
| 223 |
+
after = [p[2] for p in points[i:]]
|
| 224 |
+
mean_b = statistics.fmean(before)
|
| 225 |
+
mean_a = statistics.fmean(after)
|
| 226 |
+
delta = mean_a - mean_b
|
| 227 |
+
abs_delta = abs(delta)
|
| 228 |
+
if abs_delta > best_abs_delta:
|
| 229 |
+
best_abs_delta = abs_delta
|
| 230 |
+
best_index = i
|
| 231 |
+
best_delta = delta
|
| 232 |
+
best_mean_before = mean_b
|
| 233 |
+
best_mean_after = mean_a
|
| 234 |
+
if best_index < 0:
|
| 235 |
+
return None
|
| 236 |
+
return ChangePointResult(
|
| 237 |
+
index=best_index,
|
| 238 |
+
timestamp=points[best_index][0],
|
| 239 |
+
mean_before=best_mean_before,
|
| 240 |
+
mean_after=best_mean_after,
|
| 241 |
+
delta=best_delta,
|
| 242 |
+
n_before=best_index,
|
| 243 |
+
n_after=n - best_index,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def compute_engine_longitudinal(
|
| 248 |
+
history_entries: Iterable,
|
| 249 |
+
engine_name: str,
|
| 250 |
+
corpus_name: Optional[str] = None,
|
| 251 |
+
*,
|
| 252 |
+
min_runs_for_trend: int = 3,
|
| 253 |
+
min_segment_size: int = 3,
|
| 254 |
+
change_point_threshold: float = 0.01,
|
| 255 |
+
) -> Optional[dict]:
|
| 256 |
+
"""Calcule trend + change_point pour un moteur.
|
| 257 |
+
|
| 258 |
+
Parameters
|
| 259 |
+
----------
|
| 260 |
+
history_entries:
|
| 261 |
+
Liste de ``HistoryEntry`` (ou dicts compatibles).
|
| 262 |
+
engine_name:
|
| 263 |
+
Filtre sur le nom du moteur.
|
| 264 |
+
corpus_name:
|
| 265 |
+
Filtre optionnel sur le corpus. ``None`` (défaut) : tous
|
| 266 |
+
les corpus.
|
| 267 |
+
min_runs_for_trend:
|
| 268 |
+
Minimum de runs pour calculer une tendance.
|
| 269 |
+
min_segment_size:
|
| 270 |
+
Taille minimale des segments pour le change-point.
|
| 271 |
+
change_point_threshold:
|
| 272 |
+
Magnitude absolue minimale du delta (en CER) pour
|
| 273 |
+
retenir le change-point. Défaut 0.01 (1 point de CER).
|
| 274 |
+
|
| 275 |
+
Returns
|
| 276 |
+
-------
|
| 277 |
+
dict | None
|
| 278 |
+
``{
|
| 279 |
+
"engine_name", "corpus_name", "n_runs", "trend",
|
| 280 |
+
"change_point", # ou None
|
| 281 |
+
"first_timestamp", "last_timestamp",
|
| 282 |
+
"first_cer", "last_cer", "absolute_delta_pct",
|
| 283 |
+
}`` ou ``None`` si moins de ``min_runs_for_trend`` runs.
|
| 284 |
+
"""
|
| 285 |
+
series: list[tuple[str, float]] = []
|
| 286 |
+
for entry in history_entries:
|
| 287 |
+
if hasattr(entry, "as_dict"):
|
| 288 |
+
data = entry.as_dict()
|
| 289 |
+
else:
|
| 290 |
+
data = entry
|
| 291 |
+
if data.get("engine_name") != engine_name:
|
| 292 |
+
continue
|
| 293 |
+
if corpus_name is not None and data.get("corpus_name") != corpus_name:
|
| 294 |
+
continue
|
| 295 |
+
cer = data.get("cer_mean")
|
| 296 |
+
ts = data.get("timestamp")
|
| 297 |
+
if cer is None or ts is None:
|
| 298 |
+
continue
|
| 299 |
+
series.append((ts, float(cer)))
|
| 300 |
+
if len(series) < min_runs_for_trend:
|
| 301 |
+
return None
|
| 302 |
+
series.sort(key=lambda p: _parse_timestamp(p[0]) or 0.0)
|
| 303 |
+
trend = compute_linear_trend(series)
|
| 304 |
+
cp = detect_change_point(series, min_segment_size=min_segment_size)
|
| 305 |
+
if cp is not None and abs(cp.delta) < change_point_threshold:
|
| 306 |
+
cp = None
|
| 307 |
+
first_ts, first_cer = series[0]
|
| 308 |
+
last_ts, last_cer = series[-1]
|
| 309 |
+
return {
|
| 310 |
+
"engine_name": engine_name,
|
| 311 |
+
"corpus_name": corpus_name,
|
| 312 |
+
"n_runs": len(series),
|
| 313 |
+
"trend": trend.as_dict() if trend else None,
|
| 314 |
+
"change_point": cp.as_dict() if cp else None,
|
| 315 |
+
"first_timestamp": first_ts,
|
| 316 |
+
"last_timestamp": last_ts,
|
| 317 |
+
"first_cer": first_cer,
|
| 318 |
+
"last_cer": last_cer,
|
| 319 |
+
"absolute_delta": last_cer - first_cer,
|
| 320 |
+
"absolute_delta_pct": round((last_cer - first_cer) * 100, 2),
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def compute_corpus_longitudinal(
|
| 325 |
+
history_entries: Iterable,
|
| 326 |
+
corpus_name: Optional[str] = None,
|
| 327 |
+
*,
|
| 328 |
+
min_runs_for_trend: int = 3,
|
| 329 |
+
min_segment_size: int = 3,
|
| 330 |
+
change_point_threshold: float = 0.01,
|
| 331 |
+
) -> list[dict]:
|
| 332 |
+
"""Pour chaque moteur présent dans l'historique sur ``corpus_name``,
|
| 333 |
+
calcule trend + change_point.
|
| 334 |
+
|
| 335 |
+
Returns
|
| 336 |
+
-------
|
| 337 |
+
list[dict]
|
| 338 |
+
Une entrée par moteur (filtrée), liste vide si rien.
|
| 339 |
+
"""
|
| 340 |
+
entries = list(history_entries)
|
| 341 |
+
engines: set[str] = set()
|
| 342 |
+
for entry in entries:
|
| 343 |
+
data = entry.as_dict() if hasattr(entry, "as_dict") else entry
|
| 344 |
+
if corpus_name is not None and data.get("corpus_name") != corpus_name:
|
| 345 |
+
continue
|
| 346 |
+
name = data.get("engine_name")
|
| 347 |
+
if name:
|
| 348 |
+
engines.add(name)
|
| 349 |
+
out: list[dict] = []
|
| 350 |
+
for engine in sorted(engines):
|
| 351 |
+
result = compute_engine_longitudinal(
|
| 352 |
+
entries, engine, corpus_name=corpus_name,
|
| 353 |
+
min_runs_for_trend=min_runs_for_trend,
|
| 354 |
+
min_segment_size=min_segment_size,
|
| 355 |
+
change_point_threshold=change_point_threshold,
|
| 356 |
+
)
|
| 357 |
+
if result is not None:
|
| 358 |
+
out.append(result)
|
| 359 |
+
return out
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
__all__ = [
|
| 363 |
+
"LinearTrend",
|
| 364 |
+
"ChangePointResult",
|
| 365 |
+
"compute_linear_trend",
|
| 366 |
+
"detect_change_point",
|
| 367 |
+
"compute_engine_longitudinal",
|
| 368 |
+
"compute_corpus_longitudinal",
|
| 369 |
+
]
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# Marqueur d'évitement d'import inutilisé (math)
|
| 373 |
+
_ = math
|
|
@@ -78,6 +78,11 @@ _FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
|
|
| 78 |
# discrédite toute autre conclusion sur ce moteur ; on la
|
| 79 |
# remonte en dernier pour ne pas l'enterrer.
|
| 80 |
FactType.ENGINE_UNSTABLE,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
)
|
| 82 |
|
| 83 |
|
|
@@ -112,6 +117,14 @@ _COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({
|
|
| 112 |
# leader **et** instable, et c'est précisément l'information
|
| 113 |
# critique pour la reproductibilité scientifique.
|
| 114 |
frozenset({FactType.GLOBAL_LEADER_CER, FactType.ENGINE_UNSTABLE}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
})
|
| 116 |
|
| 117 |
|
|
|
|
| 78 |
# discrédite toute autre conclusion sur ce moteur ; on la
|
| 79 |
# remonte en dernier pour ne pas l'enterrer.
|
| 80 |
FactType.ENGINE_UNSTABLE,
|
| 81 |
+
# Sprint 92 — priority 170, après ENGINE_UNSTABLE. La
|
| 82 |
+
# régression historique complète A.I.3 (off-baseline) en
|
| 83 |
+
# caractérisant la tendance : l'écart courant est-il une
|
| 84 |
+
# dégradation graduelle, une rupture brutale, ou un bruit ?
|
| 85 |
+
FactType.REGRESSION_IN_HISTORY,
|
| 86 |
)
|
| 87 |
|
| 88 |
|
|
|
|
| 117 |
# leader **et** instable, et c'est précisément l'information
|
| 118 |
# critique pour la reproductibilité scientifique.
|
| 119 |
frozenset({FactType.GLOBAL_LEADER_CER, FactType.ENGINE_UNSTABLE}),
|
| 120 |
+
# Sprint 92 — la régression historique caractérise la tendance
|
| 121 |
+
# du leader : un leader peut être en régression progressive,
|
| 122 |
+
# info critique pour décider quand re-tester.
|
| 123 |
+
frozenset({FactType.GLOBAL_LEADER_CER, FactType.REGRESSION_IN_HISTORY}),
|
| 124 |
+
# Off-baseline (Sprint 73) dit "écart anormal sur ce corpus" ;
|
| 125 |
+
# regression-in-history (Sprint 92) dit "tendance dans le
|
| 126 |
+
# temps" — les deux se complètent sans se redonder.
|
| 127 |
+
frozenset({FactType.ENGINE_OFF_BASELINE, FactType.REGRESSION_IN_HISTORY}),
|
| 128 |
})
|
| 129 |
|
| 130 |
|
|
@@ -992,6 +992,124 @@ def detect_engine_unstable(benchmark_data: dict) -> list[Fact]:
|
|
| 992 |
return facts
|
| 993 |
|
| 994 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
# ---------------------------------------------------------------------------
|
| 996 |
# Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
|
| 997 |
# ---------------------------------------------------------------------------
|
|
|
|
| 992 |
return facts
|
| 993 |
|
| 994 |
|
| 995 |
+
# ---------------------------------------------------------------------------
|
| 996 |
+
# Détecteur Sprint 92 — régression dans l'historique (A.II.9)
|
| 997 |
+
# ---------------------------------------------------------------------------
|
| 998 |
+
|
| 999 |
+
@register_detector(
|
| 1000 |
+
FactType.REGRESSION_IN_HISTORY,
|
| 1001 |
+
priority=170,
|
| 1002 |
+
importance=FactImportance.MEDIUM,
|
| 1003 |
+
)
|
| 1004 |
+
def detect_regression_in_history(benchmark_data: dict) -> list[Fact]:
|
| 1005 |
+
"""Émet un Fact pour chaque moteur dont l'historique montre
|
| 1006 |
+
une dégradation : pente positive significative ou rupture
|
| 1007 |
+
brutale (Sprint 92).
|
| 1008 |
+
|
| 1009 |
+
Lit ``benchmark_data["longitudinal_trends"]`` : liste de
|
| 1010 |
+
dicts produits par ``compute_corpus_longitudinal`` du module
|
| 1011 |
+
``longitudinal``. Si la clé est absente ou vide, le
|
| 1012 |
+
détecteur reste silencieux — typiquement le cas quand
|
| 1013 |
+
aucun historique n'a été chargé ou que la série est trop
|
| 1014 |
+
courte.
|
| 1015 |
+
|
| 1016 |
+
Garde-fous :
|
| 1017 |
+
|
| 1018 |
+
- ``n_runs ≥ 3`` (déjà filtré par
|
| 1019 |
+
``compute_engine_longitudinal``).
|
| 1020 |
+
- Déclenche si **soit** ``trend.slope`` traduit une
|
| 1021 |
+
régression d'au moins ``slope_threshold`` (en CER/jour,
|
| 1022 |
+
défaut équivalent à +1 point CER sur 365 jours), **soit**
|
| 1023 |
+
``change_point.delta > change_threshold`` (défaut
|
| 1024 |
+
0.01 = +1 point de CER d'un segment à l'autre).
|
| 1025 |
+
- Importance ``HIGH`` si la dégradation cumulée
|
| 1026 |
+
``absolute_delta`` ≥ 5 points de CER.
|
| 1027 |
+
"""
|
| 1028 |
+
trends = benchmark_data.get("longitudinal_trends") or []
|
| 1029 |
+
if not isinstance(trends, (list, tuple)):
|
| 1030 |
+
return []
|
| 1031 |
+
slope_threshold = (
|
| 1032 |
+
0.01 / 365.0 # +1 point de CER sur 365 jours minimum
|
| 1033 |
+
)
|
| 1034 |
+
change_threshold = 0.01
|
| 1035 |
+
facts: list[Fact] = []
|
| 1036 |
+
for entry in trends:
|
| 1037 |
+
if not isinstance(entry, dict):
|
| 1038 |
+
continue
|
| 1039 |
+
engine = entry.get("engine_name")
|
| 1040 |
+
if not engine:
|
| 1041 |
+
continue
|
| 1042 |
+
n_runs = entry.get("n_runs")
|
| 1043 |
+
if not isinstance(n_runs, int) or n_runs < 3:
|
| 1044 |
+
continue
|
| 1045 |
+
trend = entry.get("trend") or {}
|
| 1046 |
+
cp = entry.get("change_point")
|
| 1047 |
+
slope = trend.get("slope")
|
| 1048 |
+
slope_high = (
|
| 1049 |
+
isinstance(slope, (int, float))
|
| 1050 |
+
and float(slope) > slope_threshold
|
| 1051 |
+
)
|
| 1052 |
+
cp_high = (
|
| 1053 |
+
isinstance(cp, dict)
|
| 1054 |
+
and isinstance(cp.get("delta"), (int, float))
|
| 1055 |
+
and float(cp["delta"]) > change_threshold
|
| 1056 |
+
)
|
| 1057 |
+
if not (slope_high or cp_high):
|
| 1058 |
+
continue
|
| 1059 |
+
absolute_delta = entry.get("absolute_delta") or 0.0
|
| 1060 |
+
importance = (
|
| 1061 |
+
FactImportance.HIGH
|
| 1062 |
+
if isinstance(absolute_delta, (int, float))
|
| 1063 |
+
and abs(float(absolute_delta)) >= 0.05
|
| 1064 |
+
else FactImportance.MEDIUM
|
| 1065 |
+
)
|
| 1066 |
+
payload: dict = {
|
| 1067 |
+
"engine": engine,
|
| 1068 |
+
"n_runs": int(n_runs),
|
| 1069 |
+
"absolute_delta_pct": round(
|
| 1070 |
+
float(absolute_delta) * 100, 2,
|
| 1071 |
+
) if isinstance(absolute_delta, (int, float)) else 0.0,
|
| 1072 |
+
"first_cer_pct": round(
|
| 1073 |
+
float(entry.get("first_cer") or 0.0) * 100, 2,
|
| 1074 |
+
),
|
| 1075 |
+
"last_cer_pct": round(
|
| 1076 |
+
float(entry.get("last_cer") or 0.0) * 100, 2,
|
| 1077 |
+
),
|
| 1078 |
+
}
|
| 1079 |
+
if slope_high:
|
| 1080 |
+
payload["slope_per_year_pct"] = round(
|
| 1081 |
+
float(slope) * 365 * 100, 2,
|
| 1082 |
+
)
|
| 1083 |
+
payload["r_squared"] = round(
|
| 1084 |
+
float(trend.get("r_squared") or 0.0), 3,
|
| 1085 |
+
)
|
| 1086 |
+
payload["pattern"] = "trend"
|
| 1087 |
+
if cp_high:
|
| 1088 |
+
payload["change_point_timestamp"] = str(
|
| 1089 |
+
cp.get("timestamp") or "?",
|
| 1090 |
+
)
|
| 1091 |
+
payload["change_delta_pct"] = round(
|
| 1092 |
+
float(cp["delta"]) * 100, 2,
|
| 1093 |
+
)
|
| 1094 |
+
payload["mean_before_pct"] = round(
|
| 1095 |
+
float(cp.get("mean_before") or 0.0) * 100, 2,
|
| 1096 |
+
)
|
| 1097 |
+
payload["mean_after_pct"] = round(
|
| 1098 |
+
float(cp.get("mean_after") or 0.0) * 100, 2,
|
| 1099 |
+
)
|
| 1100 |
+
# Si on a aussi une rupture, le pattern domine
|
| 1101 |
+
payload["pattern"] = (
|
| 1102 |
+
"trend_and_change_point" if slope_high else "change_point"
|
| 1103 |
+
)
|
| 1104 |
+
facts.append(Fact(
|
| 1105 |
+
type=FactType.REGRESSION_IN_HISTORY,
|
| 1106 |
+
importance=importance,
|
| 1107 |
+
payload=payload,
|
| 1108 |
+
engines_involved=(engine,),
|
| 1109 |
+
))
|
| 1110 |
+
return facts
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
# ---------------------------------------------------------------------------
|
| 1114 |
# Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
|
| 1115 |
# ---------------------------------------------------------------------------
|
|
@@ -91,6 +91,15 @@ class FactType(str, Enum):
|
|
| 91 |
de variation du CER (>10 % par défaut) ou sur le rappel de
|
| 92 |
runs identiques (<50 %)."""
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
class FactImportance(int, Enum):
|
| 96 |
"""Score d'importance d'un fait — décide l'ordre et la sélection."""
|
|
|
|
| 91 |
de variation du CER (>10 % par défaut) ou sur le rappel de
|
| 92 |
runs identiques (<50 %)."""
|
| 93 |
|
| 94 |
+
REGRESSION_IN_HISTORY = "regression_in_history"
|
| 95 |
+
"""Un moteur montre une tendance ou une rupture défavorable
|
| 96 |
+
sur l'historique SQLite : son CER moyen s'est dégradé sur
|
| 97 |
+
les N derniers runs (Sprint 92). Lit
|
| 98 |
+
``compute_corpus_longitudinal`` du module ``longitudinal``.
|
| 99 |
+
Garde-fous : ≥ 3 runs historiques et soit pente > seuil
|
| 100 |
+
(régression progressive), soit change-point avec delta >
|
| 101 |
+
seuil (rupture brutale)."""
|
| 102 |
+
|
| 103 |
|
| 104 |
class FactImportance(int, Enum):
|
| 105 |
"""Score d'importance d'un fait — décide l'ordre et la sélection."""
|
|
@@ -87,3 +87,9 @@ engine_unstable: >-
|
|
| 87 |
Over {n_runs} successive runs, {engine} produces variable outputs
|
| 88 |
(CER CV {cer_cv_pct} %, identical-run pair rate {identical_run_rate_pct} %).
|
| 89 |
Reproducibility is limited — interpret the average CER with caution.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
Over {n_runs} successive runs, {engine} produces variable outputs
|
| 88 |
(CER CV {cer_cv_pct} %, identical-run pair rate {identical_run_rate_pct} %).
|
| 89 |
Reproducibility is limited — interpret the average CER with caution.
|
| 90 |
+
|
| 91 |
+
regression_in_history: >-
|
| 92 |
+
Over the {n_runs} historical runs for {engine}, the average CER
|
| 93 |
+
moved from {first_cer_pct} % to {last_cer_pct} %
|
| 94 |
+
(cumulative change {absolute_delta_pct} points). Investigate what
|
| 95 |
+
changed in the pipeline or the models.
|
|
@@ -91,3 +91,9 @@ engine_unstable: >-
|
|
| 91 |
Sur {n_runs} runs successifs, {engine} produit des sorties variables
|
| 92 |
(CV CER {cer_cv_pct} %, paires de runs identiques {identical_run_rate_pct} %).
|
| 93 |
La reproductibilité est limitée — interpréter le CER moyen avec prudence.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
Sur {n_runs} runs successifs, {engine} produit des sorties variables
|
| 92 |
(CV CER {cer_cv_pct} %, paires de runs identiques {identical_run_rate_pct} %).
|
| 93 |
La reproductibilité est limitée — interpréter le CER moyen avec prudence.
|
| 94 |
+
|
| 95 |
+
regression_in_history: >-
|
| 96 |
+
Sur les {n_runs} runs historiques pour {engine}, le CER moyen
|
| 97 |
+
est passé de {first_cer_pct} % à {last_cer_pct} %
|
| 98 |
+
(variation cumulée {absolute_delta_pct} points). Vérifier ce qui
|
| 99 |
+
a changé dans le pipeline ou les modèles.
|
|
@@ -341,5 +341,15 @@
|
|
| 341 |
"throughput_effective": "Pages/h usable",
|
| 342 |
"throughput_drag": "% correction",
|
| 343 |
"throughput_pages": "Pages",
|
| 344 |
-
"throughput_errors": "Errors"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
|
|
|
| 341 |
"throughput_effective": "Pages/h usable",
|
| 342 |
"throughput_drag": "% correction",
|
| 343 |
"throughput_pages": "Pages",
|
| 344 |
+
"throughput_errors": "Errors",
|
| 345 |
+
"longitudinal_title": "Evolution over time",
|
| 346 |
+
"longitudinal_note": "Trend and change-points on the SQLite history of previous runs. A positive change signals cumulative degradation — useful to link a regression to a pipeline or model change.",
|
| 347 |
+
"longitudinal_engine": "Engine",
|
| 348 |
+
"longitudinal_n_runs": "Runs",
|
| 349 |
+
"longitudinal_first": "First CER",
|
| 350 |
+
"longitudinal_last": "Last CER",
|
| 351 |
+
"longitudinal_delta": "Cumulative Δ (pts)",
|
| 352 |
+
"longitudinal_slope": "Annual slope (pts/yr)",
|
| 353 |
+
"longitudinal_r2": "R²",
|
| 354 |
+
"longitudinal_change": "Change-point"
|
| 355 |
}
|
|
@@ -341,5 +341,15 @@
|
|
| 341 |
"throughput_effective": "Pages/h utilisable",
|
| 342 |
"throughput_drag": "% correction",
|
| 343 |
"throughput_pages": "Pages",
|
| 344 |
-
"throughput_errors": "Erreurs"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
|
|
|
| 341 |
"throughput_effective": "Pages/h utilisable",
|
| 342 |
"throughput_drag": "% correction",
|
| 343 |
"throughput_pages": "Pages",
|
| 344 |
+
"throughput_errors": "Erreurs",
|
| 345 |
+
"longitudinal_title": "Évolution dans le temps",
|
| 346 |
+
"longitudinal_note": "Tendance et points de rupture sur l'historique SQLite des runs précédents. Une variation positive signale une dégradation cumulée — utile pour relier une régression à un changement de pipeline ou de modèle.",
|
| 347 |
+
"longitudinal_engine": "Moteur",
|
| 348 |
+
"longitudinal_n_runs": "Runs",
|
| 349 |
+
"longitudinal_first": "Premier CER",
|
| 350 |
+
"longitudinal_last": "Dernier CER",
|
| 351 |
+
"longitudinal_delta": "Δ cumulé (pts)",
|
| 352 |
+
"longitudinal_slope": "Pente annuelle (pts/an)",
|
| 353 |
+
"longitudinal_r2": "R²",
|
| 354 |
+
"longitudinal_change": "Rupture"
|
| 355 |
}
|
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML « Évolution dans le temps » — Sprint 92 (A.II.9).
|
| 2 |
+
|
| 3 |
+
Suite directe ``picarones/core/longitudinal.py``. Pattern
|
| 4 |
+
identique aux autres rendus : server-side, pas de JS, anti-
|
| 5 |
+
injection systématique.
|
| 6 |
+
|
| 7 |
+
Vue
|
| 8 |
+
---
|
| 9 |
+
Tableau résumé moteur × {n_runs, premier CER, dernier CER,
|
| 10 |
+
variation cumulée colorée, pente annualisée, R², point de
|
| 11 |
+
rupture si détecté}.
|
| 12 |
+
|
| 13 |
+
Adaptive : ``""`` si la liste est vide.
|
| 14 |
+
|
| 15 |
+
Note d'intégration
|
| 16 |
+
------------------
|
| 17 |
+
Module pur — l'utilisateur compose :
|
| 18 |
+
|
| 19 |
+
.. code-block:: python
|
| 20 |
+
|
| 21 |
+
from picarones.core.history import BenchmarkHistory
|
| 22 |
+
from picarones.core.longitudinal import compute_corpus_longitudinal
|
| 23 |
+
from picarones.report.longitudinal_render import build_longitudinal_html
|
| 24 |
+
|
| 25 |
+
hist = BenchmarkHistory(db_path)
|
| 26 |
+
entries = hist.list_entries()
|
| 27 |
+
trends = compute_corpus_longitudinal(entries, corpus_name)
|
| 28 |
+
html = build_longitudinal_html(trends, labels)
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
from html import escape as _e
|
| 34 |
+
from typing import Optional
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _color_for_delta(delta_pct: float) -> str:
|
| 38 |
+
"""Vert (≈0) → orange → rouge (≥ +5 pts CER) ;
|
| 39 |
+
vert → bleu (≤ -5 pts CER, amélioration)."""
|
| 40 |
+
if abs(delta_pct) < 1.0:
|
| 41 |
+
return "#a7f0a7"
|
| 42 |
+
f = max(-1.0, min(1.0, delta_pct / 5.0))
|
| 43 |
+
if f >= 0:
|
| 44 |
+
# vert → orange profond → rouge profond
|
| 45 |
+
if f < 0.5:
|
| 46 |
+
t = f / 0.5
|
| 47 |
+
r = int(167 + (235 - 167) * t)
|
| 48 |
+
g = int(240 + (180 - 240) * t)
|
| 49 |
+
b = int(167 + (60 - 167) * t)
|
| 50 |
+
else:
|
| 51 |
+
t = (f - 0.5) / 0.5
|
| 52 |
+
r = int(235 + (220 - 235) * t)
|
| 53 |
+
g = int(180 + (50 - 180) * t)
|
| 54 |
+
b = int(60 + (50 - 60) * t)
|
| 55 |
+
else:
|
| 56 |
+
# vert → bleu (amélioration)
|
| 57 |
+
f = -f
|
| 58 |
+
r = int(167 + (90 - 167) * f)
|
| 59 |
+
g = int(240 + (160 - 240) * f)
|
| 60 |
+
b = int(167 + (210 - 167) * f)
|
| 61 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build_longitudinal_html(
|
| 65 |
+
trends: Optional[list],
|
| 66 |
+
labels: Optional[dict[str, str]] = None,
|
| 67 |
+
) -> str:
|
| 68 |
+
"""Construit la vue HTML longitudinale.
|
| 69 |
+
|
| 70 |
+
Parameters
|
| 71 |
+
----------
|
| 72 |
+
trends:
|
| 73 |
+
Sortie de ``compute_corpus_longitudinal`` (liste de
|
| 74 |
+
dicts). Si ``None`` ou vide, retourne ``""``.
|
| 75 |
+
labels:
|
| 76 |
+
Dict i18n. Clés sous le préfixe ``longitudinal_*``.
|
| 77 |
+
"""
|
| 78 |
+
if not trends:
|
| 79 |
+
return ""
|
| 80 |
+
rows = [t for t in trends if isinstance(t, dict) and t.get("engine_name")]
|
| 81 |
+
if not rows:
|
| 82 |
+
return ""
|
| 83 |
+
labels = labels or {}
|
| 84 |
+
title = labels.get(
|
| 85 |
+
"longitudinal_title", "Évolution dans le temps",
|
| 86 |
+
)
|
| 87 |
+
note = labels.get(
|
| 88 |
+
"longitudinal_note",
|
| 89 |
+
"Tendance et points de rupture sur l'historique SQLite "
|
| 90 |
+
"des runs précédents. Une variation positive signale "
|
| 91 |
+
"une dégradation cumulée — utile pour relier une "
|
| 92 |
+
"régression à un changement de pipeline ou de modèle.",
|
| 93 |
+
)
|
| 94 |
+
h_engine = labels.get("longitudinal_engine", "Moteur")
|
| 95 |
+
h_n_runs = labels.get("longitudinal_n_runs", "Runs")
|
| 96 |
+
h_first = labels.get("longitudinal_first", "Premier CER")
|
| 97 |
+
h_last = labels.get("longitudinal_last", "Dernier CER")
|
| 98 |
+
h_delta = labels.get("longitudinal_delta", "Δ cumulé (pts)")
|
| 99 |
+
h_slope = labels.get("longitudinal_slope", "Pente annuelle (pts/an)")
|
| 100 |
+
h_r2 = labels.get("longitudinal_r2", "R²")
|
| 101 |
+
h_change = labels.get("longitudinal_change", "Rupture")
|
| 102 |
+
|
| 103 |
+
parts = [
|
| 104 |
+
'<section class="longitudinal-section" style="margin:1rem 0">',
|
| 105 |
+
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
|
| 106 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
|
| 107 |
+
f'{_e(note)}</div>',
|
| 108 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 109 |
+
'font-size:.9rem">',
|
| 110 |
+
'<thead><tr>',
|
| 111 |
+
]
|
| 112 |
+
for col in (h_engine, h_n_runs, h_first, h_last, h_delta,
|
| 113 |
+
h_slope, h_r2, h_change):
|
| 114 |
+
parts.append(
|
| 115 |
+
f'<th style="padding:.4rem .6rem;text-align:left;'
|
| 116 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 117 |
+
f'{_e(col)}</th>'
|
| 118 |
+
)
|
| 119 |
+
parts.append("</tr></thead><tbody>")
|
| 120 |
+
for entry in sorted(
|
| 121 |
+
rows,
|
| 122 |
+
key=lambda r: -float(r.get("absolute_delta") or 0.0),
|
| 123 |
+
):
|
| 124 |
+
engine = str(entry.get("engine_name") or "?")
|
| 125 |
+
n_runs = int(entry.get("n_runs") or 0)
|
| 126 |
+
first_cer = float(entry.get("first_cer") or 0.0)
|
| 127 |
+
last_cer = float(entry.get("last_cer") or 0.0)
|
| 128 |
+
delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
|
| 129 |
+
delta_color = _color_for_delta(delta_pct)
|
| 130 |
+
trend = entry.get("trend") or {}
|
| 131 |
+
slope = trend.get("slope")
|
| 132 |
+
r2 = trend.get("r_squared")
|
| 133 |
+
slope_str = (
|
| 134 |
+
f"{float(slope) * 365 * 100:+.2f}"
|
| 135 |
+
if isinstance(slope, (int, float)) else "—"
|
| 136 |
+
)
|
| 137 |
+
r2_str = (
|
| 138 |
+
f"{float(r2):.2f}"
|
| 139 |
+
if isinstance(r2, (int, float)) else "—"
|
| 140 |
+
)
|
| 141 |
+
cp = entry.get("change_point")
|
| 142 |
+
if isinstance(cp, dict) and cp.get("timestamp"):
|
| 143 |
+
cp_delta = float(cp.get("delta") or 0.0)
|
| 144 |
+
cp_str = (
|
| 145 |
+
f'{_e(str(cp["timestamp"]))} '
|
| 146 |
+
f'<span style="opacity:.75">'
|
| 147 |
+
f'({cp_delta * 100:+.2f} pts)</span>'
|
| 148 |
+
)
|
| 149 |
+
else:
|
| 150 |
+
cp_str = "—"
|
| 151 |
+
parts.append(
|
| 152 |
+
f'<tr>'
|
| 153 |
+
f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
|
| 154 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 155 |
+
f'font-family:monospace">{n_runs}</td>'
|
| 156 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 157 |
+
f'font-family:monospace">{first_cer * 100:.2f}%</td>'
|
| 158 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 159 |
+
f'font-family:monospace">{last_cer * 100:.2f}%</td>'
|
| 160 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 161 |
+
f'background:{delta_color};font-family:monospace;'
|
| 162 |
+
f'font-weight:600">{delta_pct:+.2f}</td>'
|
| 163 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 164 |
+
f'font-family:monospace">{slope_str}</td>'
|
| 165 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 166 |
+
f'font-family:monospace">{r2_str}</td>'
|
| 167 |
+
f'<td style="padding:.4rem .6rem">{cp_str}</td>'
|
| 168 |
+
f'</tr>'
|
| 169 |
+
)
|
| 170 |
+
parts.append("</tbody></table></section>")
|
| 171 |
+
return "".join(parts)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
__all__ = ["build_longitudinal_html"]
|
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 92 — A.II.9 : métriques longitudinales.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``compute_linear_trend`` : pente, R², garde-fous.
|
| 6 |
+
2. ``detect_change_point`` : index correct, garde-fous.
|
| 7 |
+
3. ``compute_engine_longitudinal`` : intégration entries.
|
| 8 |
+
4. ``compute_corpus_longitudinal`` : agrégation multi-moteurs.
|
| 9 |
+
5. Détecteur ``regression_in_history`` :
|
| 10 |
+
- silence sans data
|
| 11 |
+
- silence si tendance plate
|
| 12 |
+
- HIGH si Δ ≥ 5 pts
|
| 13 |
+
- réagit à change-point seul
|
| 14 |
+
- traçabilité anti-hallucination FR + EN.
|
| 15 |
+
6. Vue HTML : adaptive, anti-injection, FR + EN.
|
| 16 |
+
7. Complétude i18n.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import re
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import pytest
|
| 26 |
+
|
| 27 |
+
from picarones.core.longitudinal import (
|
| 28 |
+
compute_corpus_longitudinal,
|
| 29 |
+
compute_engine_longitudinal,
|
| 30 |
+
compute_linear_trend,
|
| 31 |
+
detect_change_point,
|
| 32 |
+
)
|
| 33 |
+
from picarones.core.narrative import build_synthesis
|
| 34 |
+
from picarones.core.narrative.detectors import detect_regression_in_history
|
| 35 |
+
from picarones.core.narrative.facts import FactImportance, FactType
|
| 36 |
+
from picarones.report.longitudinal_render import build_longitudinal_html
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _load_labels(lang: str) -> dict:
|
| 40 |
+
p = (
|
| 41 |
+
Path(__file__).parent.parent
|
| 42 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 43 |
+
)
|
| 44 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 48 |
+
# 1. compute_linear_trend
|
| 49 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TestLinearTrend:
|
| 53 |
+
def test_perfect_trend(self) -> None:
|
| 54 |
+
series = [
|
| 55 |
+
("2025-01-01", 0.04), ("2025-02-01", 0.05),
|
| 56 |
+
("2025-03-01", 0.06),
|
| 57 |
+
]
|
| 58 |
+
t = compute_linear_trend(series)
|
| 59 |
+
assert t.r_squared > 0.99
|
| 60 |
+
assert t.slope > 0 # CER monte → pente positive
|
| 61 |
+
assert t.n_runs == 3
|
| 62 |
+
|
| 63 |
+
def test_flat_series(self) -> None:
|
| 64 |
+
series = [
|
| 65 |
+
("2025-01-01", 0.05), ("2025-02-01", 0.05),
|
| 66 |
+
("2025-03-01", 0.05),
|
| 67 |
+
]
|
| 68 |
+
t = compute_linear_trend(series)
|
| 69 |
+
# Série plate : pente ≈ 0. R² mathématiquement indéterminé
|
| 70 |
+
# (variance nulle sur y) ; le code accepte 0 ou 1 selon
|
| 71 |
+
# l'arrondi flottant.
|
| 72 |
+
assert t.slope == pytest.approx(0.0, abs=1e-9)
|
| 73 |
+
assert t.r_squared in (0.0, 1.0) or 0.0 <= t.r_squared <= 1.0
|
| 74 |
+
|
| 75 |
+
def test_lt_two_returns_none(self) -> None:
|
| 76 |
+
assert compute_linear_trend([("2025-01-01", 0.05)]) is None
|
| 77 |
+
assert compute_linear_trend([]) is None
|
| 78 |
+
|
| 79 |
+
def test_invalid_timestamps_skipped(self) -> None:
|
| 80 |
+
# Tous invalides → < 2 valides
|
| 81 |
+
assert compute_linear_trend([
|
| 82 |
+
("invalid", 0.05), ("garbage", 0.06),
|
| 83 |
+
]) is None
|
| 84 |
+
|
| 85 |
+
def test_same_timestamp_returns_none(self) -> None:
|
| 86 |
+
# Tous les t identiques → variance nulle
|
| 87 |
+
assert compute_linear_trend([
|
| 88 |
+
("2025-01-01", 0.05), ("2025-01-01", 0.06),
|
| 89 |
+
("2025-01-01", 0.07),
|
| 90 |
+
]) is None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 94 |
+
# 2. detect_change_point
|
| 95 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestChangePoint:
|
| 99 |
+
def test_clean_break(self) -> None:
|
| 100 |
+
# 3 points à 0.04 puis 3 points à 0.07
|
| 101 |
+
series = [
|
| 102 |
+
("2025-01-01", 0.04), ("2025-01-15", 0.04),
|
| 103 |
+
("2025-02-01", 0.04), ("2025-02-15", 0.07),
|
| 104 |
+
("2025-03-01", 0.07), ("2025-03-15", 0.07),
|
| 105 |
+
]
|
| 106 |
+
cp = detect_change_point(series, min_segment_size=3)
|
| 107 |
+
assert cp is not None
|
| 108 |
+
assert cp.index == 3
|
| 109 |
+
assert cp.delta == pytest.approx(0.03)
|
| 110 |
+
|
| 111 |
+
def test_too_few_points(self) -> None:
|
| 112 |
+
series = [
|
| 113 |
+
("2025-01-01", 0.04), ("2025-02-01", 0.05),
|
| 114 |
+
]
|
| 115 |
+
assert detect_change_point(series, min_segment_size=3) is None
|
| 116 |
+
|
| 117 |
+
def test_uniform_series_returns_change_with_delta_zero(self) -> None:
|
| 118 |
+
series = [
|
| 119 |
+
("2025-01-01", 0.05), ("2025-02-01", 0.05),
|
| 120 |
+
("2025-03-01", 0.05), ("2025-04-01", 0.05),
|
| 121 |
+
("2025-05-01", 0.05), ("2025-06-01", 0.05),
|
| 122 |
+
]
|
| 123 |
+
cp = detect_change_point(series, min_segment_size=3)
|
| 124 |
+
# delta = 0
|
| 125 |
+
assert cp is not None
|
| 126 |
+
assert abs(cp.delta) < 1e-9
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 130 |
+
# 3. compute_engine_longitudinal
|
| 131 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class TestEngineLongitudinal:
|
| 135 |
+
def _entries(self) -> list[dict]:
|
| 136 |
+
return [
|
| 137 |
+
{"engine_name": "tess", "corpus_name": "bnf",
|
| 138 |
+
"timestamp": ts, "cer_mean": cer}
|
| 139 |
+
for ts, cer in [
|
| 140 |
+
("2025-01-01", 0.04), ("2025-02-01", 0.045),
|
| 141 |
+
("2025-03-01", 0.05), ("2025-04-01", 0.06),
|
| 142 |
+
("2025-05-01", 0.07), ("2025-06-01", 0.08),
|
| 143 |
+
]
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
def test_basic(self) -> None:
|
| 147 |
+
r = compute_engine_longitudinal(
|
| 148 |
+
self._entries(), "tess", corpus_name="bnf",
|
| 149 |
+
)
|
| 150 |
+
assert r is not None
|
| 151 |
+
assert r["n_runs"] == 6
|
| 152 |
+
assert r["trend"]["slope"] > 0
|
| 153 |
+
assert r["absolute_delta_pct"] == pytest.approx(4.0, abs=0.01)
|
| 154 |
+
|
| 155 |
+
def test_filters_corpus(self) -> None:
|
| 156 |
+
entries = self._entries() + [
|
| 157 |
+
{"engine_name": "tess", "corpus_name": "other",
|
| 158 |
+
"timestamp": "2025-07-01", "cer_mean": 0.99},
|
| 159 |
+
]
|
| 160 |
+
r = compute_engine_longitudinal(
|
| 161 |
+
entries, "tess", corpus_name="bnf",
|
| 162 |
+
)
|
| 163 |
+
# L'entrée "other" ne doit pas polluer
|
| 164 |
+
assert r["n_runs"] == 6
|
| 165 |
+
|
| 166 |
+
def test_min_runs_threshold(self) -> None:
|
| 167 |
+
# min_runs_for_trend=10 > n_runs=6
|
| 168 |
+
r = compute_engine_longitudinal(
|
| 169 |
+
self._entries(), "tess", corpus_name="bnf",
|
| 170 |
+
min_runs_for_trend=10,
|
| 171 |
+
)
|
| 172 |
+
assert r is None
|
| 173 |
+
|
| 174 |
+
def test_change_point_threshold(self) -> None:
|
| 175 |
+
# Avec un threshold immense, le change-point est supprimé
|
| 176 |
+
r = compute_engine_longitudinal(
|
| 177 |
+
self._entries(), "tess",
|
| 178 |
+
change_point_threshold=1.0,
|
| 179 |
+
)
|
| 180 |
+
assert r["change_point"] is None
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 184 |
+
# 4. compute_corpus_longitudinal
|
| 185 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class TestCorpusLongitudinal:
|
| 189 |
+
def test_multiple_engines(self) -> None:
|
| 190 |
+
entries: list[dict] = []
|
| 191 |
+
for engine in ("tess", "pero"):
|
| 192 |
+
for i, cer in enumerate([0.04, 0.045, 0.05, 0.06]):
|
| 193 |
+
entries.append({
|
| 194 |
+
"engine_name": engine, "corpus_name": "bnf",
|
| 195 |
+
"timestamp": f"2025-0{i + 1}-01",
|
| 196 |
+
"cer_mean": cer,
|
| 197 |
+
})
|
| 198 |
+
out = compute_corpus_longitudinal(entries, corpus_name="bnf")
|
| 199 |
+
names = [e["engine_name"] for e in out]
|
| 200 |
+
assert "tess" in names
|
| 201 |
+
assert "pero" in names
|
| 202 |
+
|
| 203 |
+
def test_empty(self) -> None:
|
| 204 |
+
assert compute_corpus_longitudinal([]) == []
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 208 |
+
# 5. Détecteur regression_in_history
|
| 209 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class TestDetector:
|
| 213 |
+
def test_silent_without_data(self) -> None:
|
| 214 |
+
assert detect_regression_in_history({}) == []
|
| 215 |
+
assert detect_regression_in_history(
|
| 216 |
+
{"longitudinal_trends": []},
|
| 217 |
+
) == []
|
| 218 |
+
|
| 219 |
+
def test_silent_when_flat(self) -> None:
|
| 220 |
+
data = {"longitudinal_trends": [{
|
| 221 |
+
"engine_name": "tess", "n_runs": 5,
|
| 222 |
+
"trend": {"slope": 1e-7, "r_squared": 0.0,
|
| 223 |
+
"intercept": 0.05, "n_runs": 5},
|
| 224 |
+
"change_point": None,
|
| 225 |
+
"first_cer": 0.05, "last_cer": 0.05,
|
| 226 |
+
"absolute_delta": 0.0, "absolute_delta_pct": 0.0,
|
| 227 |
+
}]}
|
| 228 |
+
assert detect_regression_in_history(data) == []
|
| 229 |
+
|
| 230 |
+
def test_emits_when_slope_high(self) -> None:
|
| 231 |
+
# Slope > 1 pt CER / 365 jours
|
| 232 |
+
data = {"longitudinal_trends": [{
|
| 233 |
+
"engine_name": "tess", "n_runs": 5,
|
| 234 |
+
"trend": {"slope": 0.0005, "r_squared": 0.9,
|
| 235 |
+
"intercept": 0.04, "n_runs": 5},
|
| 236 |
+
"change_point": None,
|
| 237 |
+
"first_cer": 0.04, "last_cer": 0.06,
|
| 238 |
+
"absolute_delta": 0.02, "absolute_delta_pct": 2.0,
|
| 239 |
+
}]}
|
| 240 |
+
facts = detect_regression_in_history(data)
|
| 241 |
+
assert len(facts) == 1
|
| 242 |
+
assert facts[0].type == FactType.REGRESSION_IN_HISTORY
|
| 243 |
+
assert facts[0].importance == FactImportance.MEDIUM
|
| 244 |
+
assert facts[0].payload["pattern"] == "trend"
|
| 245 |
+
|
| 246 |
+
def test_emits_high_when_delta_large(self) -> None:
|
| 247 |
+
# |Δ| ≥ 5 pts → HIGH
|
| 248 |
+
data = {"longitudinal_trends": [{
|
| 249 |
+
"engine_name": "tess", "n_runs": 8,
|
| 250 |
+
"trend": {"slope": 0.001, "r_squared": 0.95,
|
| 251 |
+
"intercept": 0.04, "n_runs": 8},
|
| 252 |
+
"change_point": None,
|
| 253 |
+
"first_cer": 0.04, "last_cer": 0.10,
|
| 254 |
+
"absolute_delta": 0.06, "absolute_delta_pct": 6.0,
|
| 255 |
+
}]}
|
| 256 |
+
facts = detect_regression_in_history(data)
|
| 257 |
+
assert facts[0].importance == FactImportance.HIGH
|
| 258 |
+
|
| 259 |
+
def test_emits_on_change_point_only(self) -> None:
|
| 260 |
+
# Slope nul mais rupture brutale
|
| 261 |
+
data = {"longitudinal_trends": [{
|
| 262 |
+
"engine_name": "tess", "n_runs": 8,
|
| 263 |
+
"trend": {"slope": 1e-8, "r_squared": 0.0,
|
| 264 |
+
"intercept": 0.04, "n_runs": 8},
|
| 265 |
+
"change_point": {
|
| 266 |
+
"index": 4, "timestamp": "2025-03-01",
|
| 267 |
+
"mean_before": 0.04, "mean_after": 0.07,
|
| 268 |
+
"delta": 0.03, "n_before": 4, "n_after": 4,
|
| 269 |
+
},
|
| 270 |
+
"first_cer": 0.04, "last_cer": 0.07,
|
| 271 |
+
"absolute_delta": 0.03, "absolute_delta_pct": 3.0,
|
| 272 |
+
}]}
|
| 273 |
+
facts = detect_regression_in_history(data)
|
| 274 |
+
assert len(facts) == 1
|
| 275 |
+
assert facts[0].payload["pattern"] == "change_point"
|
| 276 |
+
assert "change_point_timestamp" in facts[0].payload
|
| 277 |
+
|
| 278 |
+
def test_silent_when_lt_three_runs(self) -> None:
|
| 279 |
+
data = {"longitudinal_trends": [{
|
| 280 |
+
"engine_name": "tess", "n_runs": 2,
|
| 281 |
+
"trend": {"slope": 0.001, "r_squared": 0.9,
|
| 282 |
+
"intercept": 0.04, "n_runs": 2},
|
| 283 |
+
"change_point": None,
|
| 284 |
+
"absolute_delta": 0.05,
|
| 285 |
+
}]}
|
| 286 |
+
assert detect_regression_in_history(data) == []
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 290 |
+
# 6. Anti-hallucination synthesis
|
| 291 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _payload_numbers(payload: dict) -> set[str]:
|
| 295 |
+
out: set[str] = set()
|
| 296 |
+
for v in payload.values():
|
| 297 |
+
if isinstance(v, (int, float)):
|
| 298 |
+
out.add(str(v))
|
| 299 |
+
if isinstance(v, float) and v.is_integer():
|
| 300 |
+
out.add(str(int(v)))
|
| 301 |
+
return out
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _numbers_in(text: str) -> set[str]:
|
| 305 |
+
return set(re.findall(r"\d+(?:\.\d+)?", text))
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
class TestAntiHallucination:
|
| 309 |
+
def _build(self, lang: str) -> tuple[list[str], dict]:
|
| 310 |
+
data = {
|
| 311 |
+
"ranking": [{"engine": "tess", "mean_cer": 0.07}],
|
| 312 |
+
"engines": [{"name": "tess", "mean_cer": 0.07}],
|
| 313 |
+
"meta": {"document_count": 5},
|
| 314 |
+
"longitudinal_trends": [{
|
| 315 |
+
"engine_name": "tess", "n_runs": 8,
|
| 316 |
+
"trend": {"slope": 0.0002, "r_squared": 0.91,
|
| 317 |
+
"intercept": 0.04, "n_runs": 8},
|
| 318 |
+
"change_point": None,
|
| 319 |
+
"first_cer": 0.04, "last_cer": 0.07,
|
| 320 |
+
"absolute_delta": 0.03,
|
| 321 |
+
"absolute_delta_pct": 3.0,
|
| 322 |
+
"first_cer_pct": 4.0, "last_cer_pct": 7.0,
|
| 323 |
+
}],
|
| 324 |
+
}
|
| 325 |
+
synthesis = build_synthesis(data, lang=lang, max_facts=10)
|
| 326 |
+
facts = detect_regression_in_history(data)
|
| 327 |
+
return synthesis["sentences"], facts[0].payload
|
| 328 |
+
|
| 329 |
+
def _find(self, sentences: list[str], lang: str) -> str:
|
| 330 |
+
marker = "modèles" if lang == "fr" else "models"
|
| 331 |
+
for s in sentences:
|
| 332 |
+
if marker in s:
|
| 333 |
+
return s
|
| 334 |
+
raise AssertionError(f"phrase introuvable : {sentences}")
|
| 335 |
+
|
| 336 |
+
def test_fr_traceable(self) -> None:
|
| 337 |
+
sentences, payload = self._build("fr")
|
| 338 |
+
sentence = self._find(sentences, "fr")
|
| 339 |
+
rendered = _numbers_in(sentence)
|
| 340 |
+
allowed = _payload_numbers(payload)
|
| 341 |
+
assert rendered.issubset(allowed), (
|
| 342 |
+
f"non traçable : {rendered - allowed}"
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
def test_en_traceable(self) -> None:
|
| 346 |
+
sentences, payload = self._build("en")
|
| 347 |
+
sentence = self._find(sentences, "en")
|
| 348 |
+
rendered = _numbers_in(sentence)
|
| 349 |
+
allowed = _payload_numbers(payload)
|
| 350 |
+
assert rendered.issubset(allowed), (
|
| 351 |
+
f"non traçable : {rendered - allowed}"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 356 |
+
# 7. Vue HTML
|
| 357 |
+
# ───────────────────────────────────────────────────────────────���──────────
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
class TestRender:
|
| 361 |
+
def test_empty_returns_empty(self) -> None:
|
| 362 |
+
assert build_longitudinal_html(None) == ""
|
| 363 |
+
assert build_longitudinal_html([]) == ""
|
| 364 |
+
|
| 365 |
+
def test_renders_table(self) -> None:
|
| 366 |
+
trends = [{
|
| 367 |
+
"engine_name": "tess", "n_runs": 8,
|
| 368 |
+
"trend": {"slope": 0.0001, "r_squared": 0.85},
|
| 369 |
+
"change_point": {
|
| 370 |
+
"timestamp": "2025-03-01", "delta": 0.025,
|
| 371 |
+
},
|
| 372 |
+
"first_cer": 0.04, "last_cer": 0.07,
|
| 373 |
+
"absolute_delta": 0.03, "absolute_delta_pct": 3.0,
|
| 374 |
+
}]
|
| 375 |
+
html = build_longitudinal_html(trends, _load_labels("fr"))
|
| 376 |
+
assert "<table" in html
|
| 377 |
+
assert "tess" in html
|
| 378 |
+
# Δ +3.00
|
| 379 |
+
assert "+3.00" in html
|
| 380 |
+
# change-point
|
| 381 |
+
assert "2025-03-01" in html
|
| 382 |
+
|
| 383 |
+
def test_anti_injection(self) -> None:
|
| 384 |
+
trends = [{
|
| 385 |
+
"engine_name": "<script>alert(1)</script>",
|
| 386 |
+
"n_runs": 5,
|
| 387 |
+
"trend": {"slope": 0.001, "r_squared": 0.9},
|
| 388 |
+
"change_point": None,
|
| 389 |
+
"first_cer": 0.04, "last_cer": 0.05,
|
| 390 |
+
"absolute_delta": 0.01, "absolute_delta_pct": 1.0,
|
| 391 |
+
}]
|
| 392 |
+
html = build_longitudinal_html(trends, _load_labels("fr"))
|
| 393 |
+
assert "<script>alert" not in html
|
| 394 |
+
assert "<script>" in html
|
| 395 |
+
|
| 396 |
+
def test_renders_in_english(self) -> None:
|
| 397 |
+
trends = [{
|
| 398 |
+
"engine_name": "tess", "n_runs": 5,
|
| 399 |
+
"trend": {"slope": 0.001, "r_squared": 0.9},
|
| 400 |
+
"change_point": None,
|
| 401 |
+
"first_cer": 0.04, "last_cer": 0.05,
|
| 402 |
+
"absolute_delta": 0.01, "absolute_delta_pct": 1.0,
|
| 403 |
+
}]
|
| 404 |
+
html = build_longitudinal_html(trends, _load_labels("en"))
|
| 405 |
+
assert "Evolution over time" in html
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 409 |
+
# 8. Complétude i18n
|
| 410 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
_KEYS = {
|
| 414 |
+
"longitudinal_title", "longitudinal_note", "longitudinal_engine",
|
| 415 |
+
"longitudinal_n_runs", "longitudinal_first", "longitudinal_last",
|
| 416 |
+
"longitudinal_delta", "longitudinal_slope", "longitudinal_r2",
|
| 417 |
+
"longitudinal_change",
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
class TestI18n:
|
| 422 |
+
def test_fr(self) -> None:
|
| 423 |
+
d = _load_labels("fr")
|
| 424 |
+
assert not _KEYS - d.keys()
|
| 425 |
+
|
| 426 |
+
def test_en(self) -> None:
|
| 427 |
+
d = _load_labels("en")
|
| 428 |
+
assert not _KEYS - d.keys()
|