Claude commited on
Commit
cf6df23
·
unverified ·
1 Parent(s): a68b00d

sprint92: A.II.9 - métriques longitudinales (régression + change-point + détecteur)

Browse files

L'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 CHANGED
@@ -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
CLAUDE.md CHANGED
@@ -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** : 2968 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)**)
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** :
picarones/core/longitudinal.py ADDED
@@ -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
picarones/core/narrative/arbiter.py CHANGED
@@ -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
 
picarones/core/narrative/detectors.py CHANGED
@@ -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
  # ---------------------------------------------------------------------------
picarones/core/narrative/facts.py CHANGED
@@ -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."""
picarones/core/narrative/templates/en.yaml CHANGED
@@ -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.
picarones/core/narrative/templates/fr.yaml CHANGED
@@ -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.
picarones/report/i18n/en.json CHANGED
@@ -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
  }
picarones/report/i18n/fr.json CHANGED
@@ -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
  }
picarones/report/longitudinal_render.py ADDED
@@ -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"]
tests/test_sprint92_longitudinal.py ADDED
@@ -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 "&lt;script&gt;" 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()