Claude commited on
Commit
306e287
·
unverified ·
1 Parent(s): ed387f4

sprint73: détecteur narratif engine_off_baseline (A.I.3 chantier 2)

Browse files

L'historique SQLite (Sprint 8) existait mais aucun détecteur
narratif ne le lisait. Ce sprint répond à « comment ce moteur se
comporte-t-il sur ce corpus, par rapport à ses runs précédents
de mon institution ? ».

- Nouveau module picarones/core/baseline_comparison.py :
- compute_engine_baseline(history, engine, corpus, current_cer,
current_run_id, min_runs=5, threshold=0.20) avec filtre
apple-to-apple par moteur×corpus, exclusion run courant,
n_runs/mean/median/absolute/relative_delta/off_baseline.
- compute_corpus_difficulty_percentile place la difficulté
courante dans la distribution historique, flags
harder/easier_than_usual (P75/P25).
- Nouveau FactType.ENGINE_OFF_BASELINE dans narrative/facts.py.
- Nouveau détecteur detect_engine_off_baseline (priority 150) :
émet 1 Fact par moteur off_baseline, importance HIGH si
|delta|≥50% sinon MEDIUM, garde-fous (silent si pas de data,
relative=None, off=False).
- Templates FR/EN dans narrative/templates/.
- Mise à jour _FALLBACK_TYPE_ORDER dans arbiter.py.
- +21 tests : couche calcul (9 cas), percentile (4 cas),
détecteur (6 cas), traçabilité anti-hallucination FR+EN.

Tests : 2569 passed, 2 skipped, 0 failed.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,62 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 72 — A.I.1 chantier 1 : vue « Worst lines globale »
20
  (clôture A.I.1).** Suite directe Sprint 71 : la roadmap A.I.1
21
  comporte deux chantiers — la métrique rare-token recall (livrée)
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 73 — A.I.3 chantier 2 : détecteur narratif
20
+ ``engine_off_baseline`` (couche calcul + narrative).** L'historique
21
+ SQLite (Sprint 8) existait depuis longtemps mais aucun détecteur
22
+ narratif ne le lisait. Ce sprint répond à *« comment ce moteur
23
+ se comporte-t-il sur ce corpus, par rapport à ses runs précédents
24
+ de mon institution ? »*. L'encart HTML « Ce corpus est-il
25
+ habituel ? » (chantier 1 d'A.I.3, boxplot SVG) suit Sprint 74.
26
+ - Nouveau module `picarones/core/baseline_comparison.py` :
27
+ - ``compute_engine_baseline(history, engine_name, corpus_name,
28
+ current_cer, *, current_run_id, min_runs=5,
29
+ relative_delta_threshold=0.20)`` retourne un dict avec
30
+ ``cer_current``, ``cer_historical_mean``,
31
+ ``cer_historical_median``, ``n_runs``, ``absolute_delta``,
32
+ ``relative_delta``, ``off_baseline``. Filtre par moteur ×
33
+ corpus (apple-to-apple), exclut le run courant si fourni,
34
+ ignore les CER négatifs / None, retourne ``None`` si moins
35
+ de ``min_runs`` runs historiques.
36
+ - ``compute_corpus_difficulty_percentile(history,
37
+ current_difficulty, *, min_runs=5)`` place la difficulté du
38
+ corpus courant dans la distribution historique (lit
39
+ ``HistoryEntry.metadata["difficulty"]``). Retourne
40
+ ``percentile``, ``median_historical``, flags
41
+ ``harder_than_usual`` (P75+) et ``easier_than_usual`` (P25-).
42
+ - Nouveau ``FactType.ENGINE_OFF_BASELINE`` dans
43
+ ``narrative/facts.py``.
44
+ - Nouveau détecteur ``detect_engine_off_baseline`` dans
45
+ ``narrative/detectors.py`` (priority 150) :
46
+ - Lit ``benchmark_data["baseline_comparisons"]`` (liste de
47
+ dicts produits par ``compute_engine_baseline``).
48
+ - Émet 1 Fact par moteur off_baseline.
49
+ - Importance ``HIGH`` si ``|relative_delta| ≥ 50 %``,
50
+ ``MEDIUM`` sinon.
51
+ - Garde-fous : silencieux si ``baseline_comparisons`` absent
52
+ ou vide, si ``relative_delta`` est ``None`` (baseline = 0
53
+ non calculable), si ``off_baseline=False``.
54
+ - Nouveaux templates FR/EN dans
55
+ ``narrative/templates/{fr,en}.yaml``. Phrase factuelle type :
56
+ *« tess a obtenu 5,2 % CER ici, vs 4,1 % en moyenne sur les
57
+ 12 runs précédents… »*.
58
+ - +21 tests dans `test_sprint73_baseline_comparison.py` :
59
+ - couche calcul (off_baseline_higher, within_baseline,
60
+ min_runs filter, custom_min_runs, current_run_excluded,
61
+ filter par engine+corpus, CER None ignorés, baseline=0 →
62
+ relative None, current_cer invalide)
63
+ - difficulty_percentile (calcul, harder/easier, min_runs)
64
+ - détecteur (silent sans data, silent off=False, silent
65
+ relative=None, fact émis, importance HIGH si ≥50%, multiple
66
+ moteurs)
67
+ - **traçabilité anti-hallucination** FR + EN : chaque nombre
68
+ dans le texte rendu est traçable au payload.
69
+ - **Verrou levé** : un benchmark BnF qui pousse ses résultats
70
+ dans l'historique SQLite et qui passe ``baseline_comparisons``
71
+ au moteur narratif voit automatiquement, dans la synthèse en
72
+ tête de rapport, *« ce moteur a un CER inhabituel sur ce
73
+ corpus par rapport à vos 12 runs précédents »*.
74
+
75
  - **Sprint 72 — A.I.1 chantier 1 : vue « Worst lines globale »
76
  (clôture A.I.1).** Suite directe Sprint 71 : la roadmap A.I.1
77
  comporte deux chantiers — la métrique rare-token recall (livrée)
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
  | 72 | **Sprint 41 du plan d'évolution 2026 — A.I.1 chantier 1 : vue HTML « Worst lines globale » (clôture A.I.1)**. Suite directe Sprint 71 — la métrique rare-token recall est livrée, ce sprint livre la vue qui transcende les documents pour exposer les lignes individuelles les plus mal transcrites du corpus. Nouveau module `picarones/core/worst_lines.py` : dataclass `WorstLineEntry(rank, cer, engine_name, doc_id, line_index, gt_line, hyp_line, script_type)`, `extract_worst_lines(benchmark, top_n=20, engine_filter, script_type_filter)` collecte transversalement à tous les moteurs et docs, filtre par moteur et par strate (Sprint 45 doc_strata), trie par CER décroissant, retourne top_n avec rang 1-based. Récupère les textes GT/hyp par re-split du DocumentResult à l'index de ligne (limite : suppose BenchmarkResult non-compacté). Lignes CER=0 ignorées. Nouveau module `picarones/report/worst_lines_render.py` : `build_worst_lines_table_html(entries, labels)` server-side avec colonnes Rang/CER (gradient jaune→rouge)/Moteur/Doc/Ligne#/[Strate]/Diff GT→OCR. Colonne strate **adaptive** (omise si aucune entry n'en a). Diff caractère par caractère via `diff_utils.compute_char_diff` (Sprint 5), rouge barré pour suppressions, vert pour insertions. Anti-injection systématique. Retourne `""` si vide. +25 tests (extraction 5 cas, filtres 4 cas, edge cases 4 cas — pas de line_metrics, vide, sans doc_strata, hyp plus courte —, rendu 8 cas, anti-injection 4 cas). **Verrou levé** : un chercheur qui voit `5% de mes lignes ont un CER > 0.42` dans le rapport peut désormais voir **quelles** lignes — diff inline, document parent, ligne#, moteur — pour comprendre ce qui casse. |
211
  | 71 | **Sprint 40 du plan d'évolution 2026 — A.I.1 chantier 2 : rare-token recall (couche de calcul, démarrage de la résolution des critiques structurelles A.I)**. Premier sprint A.I qui s'attaque à la critique « la granularité ne s'arrête plus à la page ». Mesure le rappel sur les tokens rares (hapax + dis legomena, défaut `max_freq=2`) — répond à *« ce moteur préserve-t-il les noms propres rares qui m'intéressent pour l'indexation prosopographique ? »*. Nouveau module `picarones/core/rare_tokens.py` : `tokenize` Unicode-aware (contractions `L'an`/`d’une`, composés `peut-être`, apostrophe typographique `’` U+2019), `frequency_distribution(documents, case_sensitive)` → `{token: count}` corpus-wide, `extract_rare_tokens(documents, max_freq=2)` → `frozenset`, `compute_rare_token_recall(reference, hypothesis, rare_tokens)` retourne `{n_rare_tokens_in_reference, n_rare_tokens_recalled, recall, missed_tokens}` avec alignement bag-of-tokens multiplicitaire. **Pas d'enregistrement registre typé** (la métrique exige un 3ᵉ argument set des rares, calculé corpus-wide). +28 tests (tokenisation 8 cas, frequency 4 cas, extraction 4 cas, recall 10 cas avec multiplicité/casse/dégénérés, raccourci, **test propriété cas réaliste registre état civil** prouvant que rare-token recall discrimine plus que CER quand l'OCR rate les noms propres). **Verrou levé** : un bench BnF qui veut savoir « ce moteur préserve-t-il bien les noms de famille ? » a maintenant la métrique adaptée. Vue HTML « Worst lines + tokens rares manqués » suit Sprint 72 (chantier 1 d'A.I.1). |
212
  | 70 | **Sprint 39 du plan d'évolution 2026 — Étape 4 / axe B : CLI pour piloter les pipelines composées sans Python**. Permet de spécifier une pipeline ou une comparaison de N pipelines dans un YAML déclaratif et de les exécuter via la CLI, sans écrire de Python. Nouveau module `picarones/core/pipeline_spec_loader.py` : `load_pipeline_spec_from_yaml/dict` parse YAML → `PipelineSpec` (steps avec dotted path module, args kwargs, inputs_from optionnel pour DAG branchant), `load_comparison_specs_from_yaml` retourne `(specs, extras)` pour comparaison. Import dynamique via `importlib`, validation stricte que la classe hérite de `BaseModule`. Exception `PipelineSpecLoadError` avec messages explicites pour 8 cas d'erreur. Nouveau sous-groupe CLI `picarones pipeline` : `run <spec.yaml> --corpus <dir>` (avec --output-json/--output-html/--lang) et `compare <specs.yaml> --corpus <dir>` (avec --output-html/--baseline). Le CLI lit `rankings` du YAML pour configurer la vue HTML comparative. **Aucun module métier ajouté** : le YAML référence des classes tierces que l'utilisateur a installées. +27 tests (resolve_class 5 cas, load_from_dict 9 cas, load_from_yaml 3 cas, load_comparison 2, CLI run 2, CLI compare 2, CLI help 3). **Verrou levé** : workflow BnF type — `picarones pipeline run my_pipeline.yaml --corpus ./scans --output-html rapport.html` — sans ingénieur Python dans la boucle. Spec versionnable en git pour la reproductibilité. |
@@ -290,7 +291,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
290
  ## Contexte développement
291
 
292
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
293
- - **Tests** : 2548 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 — rare-token recall (chantier 2) + vue HTML Worst lines globale (chantier 1)**)
294
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
295
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
296
  - **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
+ | 73 | **Sprint 42 du plan d'évolution 2026 — A.I.3 chantier 2 : détecteur narratif `engine_off_baseline` (couche calcul + narrative)**. L'historique SQLite (Sprint 8) existait mais aucun détecteur narratif ne le lisait. Répond à « comment ce moteur se comporte-t-il sur ce corpus par rapport à ses runs précédents de mon institution ? ». L'encart HTML « Ce corpus est-il habituel ? » (chantier 1, boxplot SVG) suit Sprint 74. Nouveau module `picarones/core/baseline_comparison.py` : `compute_engine_baseline(history, engine_name, corpus_name, current_cer, current_run_id, min_runs=5, relative_delta_threshold=0.20)` filtre apple-to-apple par moteur×corpus, exclut le run courant si fourni, retourne dict avec cer_current/historical_mean/median, n_runs, absolute_delta, relative_delta, off_baseline ; `compute_corpus_difficulty_percentile` place la difficulté courante dans la distribution historique (lit metadata.difficulty), flags harder/easier_than_usual (P75/P25). Nouveau `FactType.ENGINE_OFF_BASELINE` + détecteur `detect_engine_off_baseline` (priority 150) qui émet 1 Fact par moteur off_baseline, importance HIGH si |delta|≥50% sinon MEDIUM, silencieux si baseline_comparisons absent/vide ou relative_delta=None. Templates FR/EN. +21 tests : couche calcul (9 cas dont min_runs/current_run_id/baseline=0/CER None), percentile (4 cas), détecteur (6 cas), **traçabilité anti-hallucination FR+EN** (chaque nombre rendu traçable au payload). **Verrou levé** : un bench BnF qui pousse ses résultats dans l'historique voit dans la synthèse « ce moteur a un CER inhabituel sur ce corpus par rapport à vos 12 runs précédents ». |
211
  | 72 | **Sprint 41 du plan d'évolution 2026 — A.I.1 chantier 1 : vue HTML « Worst lines globale » (clôture A.I.1)**. Suite directe Sprint 71 — la métrique rare-token recall est livrée, ce sprint livre la vue qui transcende les documents pour exposer les lignes individuelles les plus mal transcrites du corpus. Nouveau module `picarones/core/worst_lines.py` : dataclass `WorstLineEntry(rank, cer, engine_name, doc_id, line_index, gt_line, hyp_line, script_type)`, `extract_worst_lines(benchmark, top_n=20, engine_filter, script_type_filter)` collecte transversalement à tous les moteurs et docs, filtre par moteur et par strate (Sprint 45 doc_strata), trie par CER décroissant, retourne top_n avec rang 1-based. Récupère les textes GT/hyp par re-split du DocumentResult à l'index de ligne (limite : suppose BenchmarkResult non-compacté). Lignes CER=0 ignorées. Nouveau module `picarones/report/worst_lines_render.py` : `build_worst_lines_table_html(entries, labels)` server-side avec colonnes Rang/CER (gradient jaune→rouge)/Moteur/Doc/Ligne#/[Strate]/Diff GT→OCR. Colonne strate **adaptive** (omise si aucune entry n'en a). Diff caractère par caractère via `diff_utils.compute_char_diff` (Sprint 5), rouge barré pour suppressions, vert pour insertions. Anti-injection systématique. Retourne `""` si vide. +25 tests (extraction 5 cas, filtres 4 cas, edge cases 4 cas — pas de line_metrics, vide, sans doc_strata, hyp plus courte —, rendu 8 cas, anti-injection 4 cas). **Verrou levé** : un chercheur qui voit `5% de mes lignes ont un CER > 0.42` dans le rapport peut désormais voir **quelles** lignes — diff inline, document parent, ligne#, moteur — pour comprendre ce qui casse. |
212
  | 71 | **Sprint 40 du plan d'évolution 2026 — A.I.1 chantier 2 : rare-token recall (couche de calcul, démarrage de la résolution des critiques structurelles A.I)**. Premier sprint A.I qui s'attaque à la critique « la granularité ne s'arrête plus à la page ». Mesure le rappel sur les tokens rares (hapax + dis legomena, défaut `max_freq=2`) — répond à *« ce moteur préserve-t-il les noms propres rares qui m'intéressent pour l'indexation prosopographique ? »*. Nouveau module `picarones/core/rare_tokens.py` : `tokenize` Unicode-aware (contractions `L'an`/`d’une`, composés `peut-être`, apostrophe typographique `’` U+2019), `frequency_distribution(documents, case_sensitive)` → `{token: count}` corpus-wide, `extract_rare_tokens(documents, max_freq=2)` → `frozenset`, `compute_rare_token_recall(reference, hypothesis, rare_tokens)` retourne `{n_rare_tokens_in_reference, n_rare_tokens_recalled, recall, missed_tokens}` avec alignement bag-of-tokens multiplicitaire. **Pas d'enregistrement registre typé** (la métrique exige un 3ᵉ argument set des rares, calculé corpus-wide). +28 tests (tokenisation 8 cas, frequency 4 cas, extraction 4 cas, recall 10 cas avec multiplicité/casse/dégénérés, raccourci, **test propriété cas réaliste registre état civil** prouvant que rare-token recall discrimine plus que CER quand l'OCR rate les noms propres). **Verrou levé** : un bench BnF qui veut savoir « ce moteur préserve-t-il bien les noms de famille ? » a maintenant la métrique adaptée. Vue HTML « Worst lines + tokens rares manqués » suit Sprint 72 (chantier 1 d'A.I.1). |
213
  | 70 | **Sprint 39 du plan d'évolution 2026 — Étape 4 / axe B : CLI pour piloter les pipelines composées sans Python**. Permet de spécifier une pipeline ou une comparaison de N pipelines dans un YAML déclaratif et de les exécuter via la CLI, sans écrire de Python. Nouveau module `picarones/core/pipeline_spec_loader.py` : `load_pipeline_spec_from_yaml/dict` parse YAML → `PipelineSpec` (steps avec dotted path module, args kwargs, inputs_from optionnel pour DAG branchant), `load_comparison_specs_from_yaml` retourne `(specs, extras)` pour comparaison. Import dynamique via `importlib`, validation stricte que la classe hérite de `BaseModule`. Exception `PipelineSpecLoadError` avec messages explicites pour 8 cas d'erreur. Nouveau sous-groupe CLI `picarones pipeline` : `run <spec.yaml> --corpus <dir>` (avec --output-json/--output-html/--lang) et `compare <specs.yaml> --corpus <dir>` (avec --output-html/--baseline). Le CLI lit `rankings` du YAML pour configurer la vue HTML comparative. **Aucun module métier ajouté** : le YAML référence des classes tierces que l'utilisateur a installées. +27 tests (resolve_class 5 cas, load_from_dict 9 cas, load_from_yaml 3 cas, load_comparison 2, CLI run 2, CLI compare 2, CLI help 3). **Verrou levé** : workflow BnF type — `picarones pipeline run my_pipeline.yaml --corpus ./scans --output-html rapport.html` — sans ingénieur Python dans la boucle. Spec versionnable en git pour la reproductibilité. |
 
291
  ## Contexte développement
292
 
293
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
294
+ - **Tests** : 2569 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 — rare-token recall + vue HTML Worst lines globale ; **Sprint 73 = A.I.3 chantier 2 — détecteur narratif engine_off_baseline alimenté par l'historique SQLite**)
295
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
296
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
297
  - **Transcript de la conversation de développement** :
picarones/core/baseline_comparison.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Comparaison à la baseline historique — Sprint 73 (A.I.3).
2
+
3
+ Sprint 73 — chantier 2 d'A.I.3 du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ L'historique SQLite (``picarones/core/history.py``, Sprint 8)
8
+ existe mais aucun détecteur narratif ne le lit. Ce module fournit
9
+ la couche de calcul qui répond à *« comment ce moteur se
10
+ comporte-t-il sur ce corpus, **par rapport à ses runs précédents
11
+ de mon institution** ? »*.
12
+
13
+ Sortie typique
14
+ --------------
15
+ Un dict par moteur :
16
+
17
+ .. code-block:: python
18
+
19
+ {
20
+ "engine_name": "tesseract",
21
+ "cer_current": 0.052,
22
+ "cer_historical_mean": 0.041,
23
+ "cer_historical_median": 0.040,
24
+ "n_runs": 12,
25
+ "absolute_delta": 0.011,
26
+ "relative_delta": 0.268, # +26,8 % vs moyenne
27
+ "off_baseline": True,
28
+ }
29
+
30
+ Le détecteur narratif ``engine_off_baseline`` (Sprint 73)
31
+ consomme cette structure pour émettre des Facts.
32
+
33
+ Garde-fous
34
+ ----------
35
+ - ``min_runs`` (défaut 5) : si l'historique pour le moteur×corpus
36
+ contient moins de runs, on retourne ``None`` plutôt que de
37
+ comparer à un échantillon trop petit.
38
+ - ``corpus_name`` est utilisé pour ne comparer qu'aux runs **du
39
+ même corpus** (sinon on compare des pommes et des oranges :
40
+ registres paroissiaux vs imprimés modernes).
41
+ - Le run courant lui-même n'est pas inclus dans la baseline (on
42
+ passe le ``current_run_id`` à exclure).
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import logging
48
+ import statistics
49
+ from typing import Optional
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ def compute_engine_baseline(
55
+ history,
56
+ engine_name: str,
57
+ corpus_name: str,
58
+ current_cer: float,
59
+ *,
60
+ current_run_id: Optional[str] = None,
61
+ min_runs: int = 5,
62
+ relative_delta_threshold: float = 0.20,
63
+ ) -> Optional[dict]:
64
+ """Compare le CER courant d'un moteur à sa moyenne historique
65
+ sur le **même corpus**.
66
+
67
+ Parameters
68
+ ----------
69
+ history:
70
+ Instance de ``BenchmarkHistory`` (ou compatible : doit
71
+ exposer une méthode ``query(engine, corpus, limit)``
72
+ retournant une liste d'``HistoryEntry`` avec attribut
73
+ ``cer_mean`` et ``run_id``).
74
+ engine_name:
75
+ Nom du moteur dont on calcule la baseline.
76
+ corpus_name:
77
+ Nom du corpus — limite la comparaison aux runs antérieurs
78
+ sur ce même corpus.
79
+ current_cer:
80
+ CER moyen observé dans le run courant.
81
+ current_run_id:
82
+ Si fourni, le run portant cet identifiant est exclu de la
83
+ baseline (utile quand le run courant est déjà enregistré
84
+ dans l'historique avant d'appeler ce calcul).
85
+ min_runs:
86
+ Nombre minimum de runs historiques pour que la
87
+ comparaison soit considérée fiable. Sous ce seuil, on
88
+ retourne ``None``.
89
+ relative_delta_threshold:
90
+ Seuil au-delà duquel ``off_baseline`` vaut ``True``
91
+ (défaut : 0,20 = 20 % d'écart relatif).
92
+
93
+ Returns
94
+ -------
95
+ Optional[dict]
96
+ ``None`` si :
97
+ - moins de ``min_runs`` runs historiques disponibles
98
+ - ``current_cer`` est ``None`` ou négatif
99
+ - tous les CER historiques sont ``None``
100
+
101
+ Sinon, dict avec les champs documentés dans le module.
102
+ """
103
+ if current_cer is None or current_cer < 0:
104
+ return None
105
+ try:
106
+ entries = history.query(
107
+ engine=engine_name, corpus=corpus_name, limit=1000,
108
+ )
109
+ except Exception as exc: # pragma: no cover — défense
110
+ logger.warning(
111
+ "[baseline_comparison] query history a levé : %s", exc,
112
+ )
113
+ return None
114
+
115
+ historical_cers: list[float] = []
116
+ for entry in entries:
117
+ if current_run_id is not None and entry.run_id == current_run_id:
118
+ continue
119
+ cer = entry.cer_mean
120
+ if cer is None or cer < 0:
121
+ continue
122
+ historical_cers.append(float(cer))
123
+
124
+ if len(historical_cers) < min_runs:
125
+ return None
126
+
127
+ mean = statistics.fmean(historical_cers)
128
+ median = statistics.median(historical_cers)
129
+ absolute_delta = current_cer - mean
130
+ if mean > 0:
131
+ relative_delta = absolute_delta / mean
132
+ elif current_cer == 0:
133
+ relative_delta = 0.0
134
+ else:
135
+ # Baseline à 0 mais CER courant > 0 : écart infini —
136
+ # convention : on signale comme off_baseline avec
137
+ # relative_delta = None.
138
+ relative_delta = None
139
+
140
+ off_baseline = (
141
+ relative_delta is not None
142
+ and abs(relative_delta) > relative_delta_threshold
143
+ )
144
+
145
+ return {
146
+ "engine_name": engine_name,
147
+ "corpus_name": corpus_name,
148
+ "cer_current": float(current_cer),
149
+ "cer_historical_mean": mean,
150
+ "cer_historical_median": median,
151
+ "n_runs": len(historical_cers),
152
+ "absolute_delta": absolute_delta,
153
+ "relative_delta": relative_delta,
154
+ "off_baseline": off_baseline,
155
+ }
156
+
157
+
158
+ def compute_corpus_difficulty_percentile(
159
+ history,
160
+ current_difficulty: float,
161
+ *,
162
+ min_runs: int = 5,
163
+ ) -> Optional[dict]:
164
+ """Place la difficulté du corpus courant dans la distribution
165
+ des difficultés historiques.
166
+
167
+ Lit les difficultés stockées dans ``HistoryEntry.metadata``
168
+ sous la clé ``difficulty`` (convention de
169
+ ``picarones/core/difficulty.py``).
170
+
171
+ Returns
172
+ -------
173
+ Optional[dict]
174
+ ``{
175
+ "current_difficulty": float,
176
+ "percentile": float, # 0..100
177
+ "n_runs": int,
178
+ "median_historical": float,
179
+ "harder_than_usual": bool, # percentile > 75
180
+ "easier_than_usual": bool, # percentile < 25
181
+ }``
182
+ ou ``None`` si moins de ``min_runs`` runs historiques ont
183
+ une difficulté enregistrée.
184
+ """
185
+ if current_difficulty is None:
186
+ return None
187
+ try:
188
+ entries = history.query(limit=1000)
189
+ except Exception as exc: # pragma: no cover
190
+ logger.warning(
191
+ "[baseline_comparison] query history a levé : %s", exc,
192
+ )
193
+ return None
194
+
195
+ historical_difficulties: list[float] = []
196
+ for entry in entries:
197
+ diff = entry.metadata.get("difficulty") if entry.metadata else None
198
+ if diff is None:
199
+ continue
200
+ try:
201
+ historical_difficulties.append(float(diff))
202
+ except (TypeError, ValueError):
203
+ continue
204
+
205
+ if len(historical_difficulties) < min_runs:
206
+ return None
207
+
208
+ sorted_diff = sorted(historical_difficulties)
209
+ n = len(sorted_diff)
210
+ # Percentile = % de corpus historiques de difficulté ≤
211
+ # current_difficulty. Convention courante (P_i = i/n × 100).
212
+ n_below = sum(1 for d in sorted_diff if d <= current_difficulty)
213
+ percentile = (n_below / n) * 100.0
214
+ median = statistics.median(sorted_diff)
215
+
216
+ return {
217
+ "current_difficulty": float(current_difficulty),
218
+ "percentile": percentile,
219
+ "n_runs": n,
220
+ "median_historical": median,
221
+ "harder_than_usual": percentile > 75.0,
222
+ "easier_than_usual": percentile < 25.0,
223
+ }
224
+
225
+
226
+ __all__ = [
227
+ "compute_engine_baseline",
228
+ "compute_corpus_difficulty_percentile",
229
+ ]
picarones/core/narrative/arbiter.py CHANGED
@@ -69,6 +69,10 @@ _FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
69
  FactType.CONFIDENCE_WARNING,
70
  FactType.ENSEMBLE_OPPORTUNITY,
71
  FactType.MEDIAN_MEAN_GAP_WARNING,
 
 
 
 
72
  )
73
 
74
 
 
69
  FactType.CONFIDENCE_WARNING,
70
  FactType.ENSEMBLE_OPPORTUNITY,
71
  FactType.MEDIAN_MEAN_GAP_WARNING,
72
+ # Sprint 73 — priority 150, après MEDIAN_MEAN_GAP_WARNING (140).
73
+ # Le détecteur off-baseline donne le contexte historique, qui
74
+ # vient en fin de synthèse comme « note ».
75
+ FactType.ENGINE_OFF_BASELINE,
76
  )
77
 
78
 
picarones/core/narrative/detectors.py CHANGED
@@ -840,6 +840,73 @@ def detect_stratification_recommended(benchmark_data: dict) -> list[Fact]:
840
  )]
841
 
842
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  # ---------------------------------------------------------------------------
844
  # Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
845
  # ---------------------------------------------------------------------------
 
840
  )]
841
 
842
 
843
+ # ---------------------------------------------------------------------------
844
+ # Détecteur Sprint 73 — moteur hors baseline historique (A.I.3)
845
+ # ---------------------------------------------------------------------------
846
+
847
+ @register_detector(
848
+ FactType.ENGINE_OFF_BASELINE,
849
+ priority=150,
850
+ importance=FactImportance.MEDIUM,
851
+ )
852
+ def detect_engine_off_baseline(benchmark_data: dict) -> list[Fact]:
853
+ """Émet un Fact pour chaque moteur dont le CER courant s'écarte
854
+ significativement de sa moyenne historique sur le **même corpus**.
855
+
856
+ Lit ``benchmark_data["baseline_comparisons"]`` (liste de dicts
857
+ produits par ``compute_engine_baseline`` du module
858
+ ``baseline_comparison`` Sprint 73). Si la clé est absente ou
859
+ vide, le détecteur reste silencieux — typiquement le cas quand
860
+ aucun historique SQLite n'a été chargé.
861
+
862
+ Garde-fous :
863
+
864
+ - Si ``n_runs < 5`` (déjà filtré par ``compute_engine_baseline``
865
+ qui retourne ``None`` dans ce cas).
866
+ - Si ``relative_delta`` n'est pas calculable (baseline = 0).
867
+ - Importance ``HIGH`` si ``|relative_delta| ≥ 50 %``, sinon
868
+ ``MEDIUM``.
869
+ """
870
+ comparisons = benchmark_data.get("baseline_comparisons") or []
871
+ if not isinstance(comparisons, (list, tuple)):
872
+ return []
873
+ facts: list[Fact] = []
874
+ for comp in comparisons:
875
+ if not isinstance(comp, dict):
876
+ continue
877
+ if not comp.get("off_baseline"):
878
+ continue
879
+ rel = comp.get("relative_delta")
880
+ if rel is None:
881
+ continue
882
+ engine = comp.get("engine_name")
883
+ cer_current = comp.get("cer_current")
884
+ cer_hist_mean = comp.get("cer_historical_mean")
885
+ n_runs = comp.get("n_runs")
886
+ if engine is None or cer_current is None or cer_hist_mean is None:
887
+ continue
888
+ importance = (
889
+ FactImportance.HIGH if abs(float(rel)) >= 0.50
890
+ else FactImportance.MEDIUM
891
+ )
892
+ facts.append(Fact(
893
+ type=FactType.ENGINE_OFF_BASELINE,
894
+ importance=importance,
895
+ payload={
896
+ "engine": engine,
897
+ "cer_current_pct": round(float(cer_current) * 100, 2),
898
+ "cer_historical_mean_pct": round(
899
+ float(cer_hist_mean) * 100, 2,
900
+ ),
901
+ "n_runs": int(n_runs or 0),
902
+ "relative_delta_pct": round(float(rel) * 100, 1),
903
+ "direction": "higher" if float(rel) > 0 else "lower",
904
+ },
905
+ engines_involved=(engine,),
906
+ ))
907
+ return facts
908
+
909
+
910
  # ---------------------------------------------------------------------------
911
  # Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
912
  # ---------------------------------------------------------------------------
picarones/core/narrative/facts.py CHANGED
@@ -76,6 +76,13 @@ class FactType(str, Enum):
76
  la vue stratifiée plutôt que de se fier au seul classement global
77
  (Sprint 46)."""
78
 
 
 
 
 
 
 
 
79
 
80
  class FactImportance(int, Enum):
81
  """Score d'importance d'un fait — décide l'ordre et la sélection."""
 
76
  la vue stratifiée plutôt que de se fier au seul classement global
77
  (Sprint 46)."""
78
 
79
+ ENGINE_OFF_BASELINE = "engine_off_baseline"
80
+ """Le CER courant d'un moteur s'écarte significativement de sa
81
+ moyenne historique sur le même corpus (lue depuis l'historique
82
+ SQLite, Sprint 8). Lit ``BenchmarkHistory`` via le module
83
+ ``baseline_comparison`` (Sprint 73). Garde-fous : ≥ 5 runs
84
+ historiques même corpus + |delta_relatif| > 20 %."""
85
+
86
 
87
  class FactImportance(int, Enum):
88
  """Score d'importance d'un fait — décide l'ordre et la sélection."""
picarones/core/narrative/templates/en.yaml CHANGED
@@ -76,3 +76,9 @@ stratification_recommended: >-
76
  {max_stratum_cer_pct} % on "{max_stratum}", a gap of {gap_pct}
77
  points. The global ranking hides this disparity; consult the
78
  stratified view.
 
 
 
 
 
 
 
76
  {max_stratum_cer_pct} % on "{max_stratum}", a gap of {gap_pct}
77
  points. The global ranking hides this disparity; consult the
78
  stratified view.
79
+
80
+ engine_off_baseline: >-
81
+ {engine} achieved {cer_current_pct} % CER here, vs {cer_historical_mean_pct} %
82
+ on average over the last {n_runs} runs of your institution on this
83
+ same corpus (relative delta {relative_delta_pct} %). This corpus is
84
+ harder for it than usual.
picarones/core/narrative/templates/fr.yaml CHANGED
@@ -80,3 +80,9 @@ stratification_recommended: >-
80
  {max_stratum_cer_pct} % sur « {max_stratum} », soit {gap_pct} points
81
  d'écart. Le classement global masque cette disparité ; consulter la
82
  vue stratifiée.
 
 
 
 
 
 
 
80
  {max_stratum_cer_pct} % sur « {max_stratum} », soit {gap_pct} points
81
  d'écart. Le classement global masque cette disparité ; consulter la
82
  vue stratifiée.
83
+
84
+ engine_off_baseline: >-
85
+ {engine} a obtenu {cer_current_pct} % CER ici, vs {cer_historical_mean_pct} %
86
+ en moyenne sur les {n_runs} runs précédents de votre institution sur
87
+ ce même corpus (écart relatif {relative_delta_pct} %). Ce corpus lui
88
+ est plus difficile que d'habitude.
tests/test_sprint73_baseline_comparison.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 73 — A.I.3 : détecteur ``engine_off_baseline``.
2
+
3
+ Couvre :
4
+
5
+ 1. ``compute_engine_baseline`` :
6
+ - Cas standard : ≥ min_runs, écart > seuil → off_baseline=True
7
+ - Écart faible → off_baseline=False
8
+ - Moins de min_runs → ``None``
9
+ - Baseline = 0 → ``relative_delta = None`` (et off si CER > 0)
10
+ - ``current_run_id`` exclu de la baseline
11
+ - Filtre par engine + corpus respecté
12
+ - CER historiques None ignorés
13
+ 2. ``compute_corpus_difficulty_percentile`` :
14
+ - Calcul de percentile correct
15
+ - ``harder_than_usual`` au-dessus de P75
16
+ - ``easier_than_usual`` en-dessous de P25
17
+ - Moins de min_runs → ``None``
18
+ 3. Détecteur ``detect_engine_off_baseline`` :
19
+ - Silencieux si pas de ``baseline_comparisons``
20
+ - Émet 1 Fact par moteur off_baseline
21
+ - Importance HIGH si |delta| ≥ 50 %, MEDIUM sinon
22
+ - Payload contient les nombres exacts pour traçabilité
23
+ 4. Rendu narratif : chaque nombre rendu est traçable au payload
24
+ (anti-hallucination, FR + EN).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from typing import Any, Optional
31
+
32
+ import pytest
33
+
34
+ from picarones.core.baseline_comparison import (
35
+ compute_corpus_difficulty_percentile,
36
+ compute_engine_baseline,
37
+ )
38
+ from picarones.core.narrative.detectors import detect_engine_off_baseline
39
+ from picarones.core.narrative.facts import FactImportance, FactType
40
+ from picarones.core.narrative.renderer import render_fact
41
+
42
+
43
+ # ──────────────────────────────────────────────────────────────────────────
44
+ # Mock BenchmarkHistory
45
+ # ──────────────────────────────────────────────────────────────────────────
46
+
47
+
48
+ @dataclass
49
+ class _Entry:
50
+ run_id: str
51
+ engine_name: str
52
+ corpus_name: str
53
+ cer_mean: Optional[float]
54
+ metadata: dict = field(default_factory=dict)
55
+
56
+
57
+ class _MockHistory:
58
+ def __init__(self, entries: list[_Entry]) -> None:
59
+ self._entries = entries
60
+
61
+ def query(
62
+ self,
63
+ engine: Optional[str] = None,
64
+ corpus: Optional[str] = None,
65
+ since: Optional[str] = None,
66
+ limit: int = 100,
67
+ ) -> list[Any]:
68
+ out = []
69
+ for e in self._entries:
70
+ if engine and e.engine_name != engine:
71
+ continue
72
+ if corpus and e.corpus_name != corpus:
73
+ continue
74
+ out.append(e)
75
+ return out[:limit]
76
+
77
+
78
+ # ──────────────────────────────────────────────────────────────────────────
79
+ # 1. compute_engine_baseline
80
+ # ──────────────────────────────────────────────────────────────────────────
81
+
82
+
83
+ class TestEngineBaseline:
84
+ def test_off_baseline_higher(self) -> None:
85
+ # 10 runs historiques à 4 % CER, run courant à 5,2 % → +30 %
86
+ history = _MockHistory([
87
+ _Entry(f"r{i}", "tess", "corpus_A", 0.04)
88
+ for i in range(10)
89
+ ])
90
+ result = compute_engine_baseline(
91
+ history, "tess", "corpus_A", current_cer=0.052,
92
+ )
93
+ assert result is not None
94
+ assert result["n_runs"] == 10
95
+ assert result["cer_current"] == 0.052
96
+ assert result["cer_historical_mean"] == pytest.approx(0.04)
97
+ assert result["absolute_delta"] == pytest.approx(0.012)
98
+ assert result["relative_delta"] == pytest.approx(0.30)
99
+ assert result["off_baseline"] is True
100
+
101
+ def test_within_baseline(self) -> None:
102
+ history = _MockHistory([
103
+ _Entry(f"r{i}", "tess", "c", 0.04)
104
+ for i in range(10)
105
+ ])
106
+ # Run courant à 4,1 % → écart 2,5 %, sous le seuil 20 %
107
+ result = compute_engine_baseline(
108
+ history, "tess", "c", current_cer=0.041,
109
+ )
110
+ assert result is not None
111
+ assert result["off_baseline"] is False
112
+
113
+ def test_min_runs_filter(self) -> None:
114
+ # Seulement 4 runs → sous le min_runs=5
115
+ history = _MockHistory([
116
+ _Entry(f"r{i}", "tess", "c", 0.04) for i in range(4)
117
+ ])
118
+ assert compute_engine_baseline(
119
+ history, "tess", "c", current_cer=0.05,
120
+ ) is None
121
+
122
+ def test_custom_min_runs(self) -> None:
123
+ history = _MockHistory([
124
+ _Entry(f"r{i}", "tess", "c", 0.04) for i in range(3)
125
+ ])
126
+ # min_runs=2 → assez
127
+ result = compute_engine_baseline(
128
+ history, "tess", "c", current_cer=0.05, min_runs=2,
129
+ )
130
+ assert result is not None
131
+ assert result["n_runs"] == 3
132
+
133
+ def test_current_run_excluded(self) -> None:
134
+ history = _MockHistory([
135
+ _Entry("current", "tess", "c", 0.20), # run courant déjà loggé
136
+ *[_Entry(f"r{i}", "tess", "c", 0.04) for i in range(5)],
137
+ ])
138
+ result = compute_engine_baseline(
139
+ history, "tess", "c", current_cer=0.05,
140
+ current_run_id="current",
141
+ )
142
+ assert result is not None
143
+ # Le 0,20 ne doit pas tirer la moyenne historique
144
+ assert result["n_runs"] == 5
145
+ assert result["cer_historical_mean"] == pytest.approx(0.04)
146
+
147
+ def test_filter_by_engine_and_corpus(self) -> None:
148
+ history = _MockHistory([
149
+ *[_Entry(f"r{i}", "tess", "corpus_A", 0.04) for i in range(5)],
150
+ # Mêmes runs sur autre corpus — ne doivent pas compter
151
+ *[_Entry(f"o{i}", "tess", "corpus_B", 0.20) for i in range(5)],
152
+ # Autre moteur, même corpus — ne doivent pas compter
153
+ *[_Entry(f"p{i}", "pero", "corpus_A", 0.99) for i in range(5)],
154
+ ])
155
+ result = compute_engine_baseline(
156
+ history, "tess", "corpus_A", current_cer=0.05,
157
+ )
158
+ assert result is not None
159
+ assert result["n_runs"] == 5
160
+ assert result["cer_historical_mean"] == pytest.approx(0.04)
161
+
162
+ def test_cer_none_ignored(self) -> None:
163
+ history = _MockHistory([
164
+ _Entry("r1", "tess", "c", None),
165
+ _Entry("r2", "tess", "c", -0.5), # négatif → ignoré
166
+ *[_Entry(f"r{i}", "tess", "c", 0.04) for i in range(3, 8)],
167
+ ])
168
+ result = compute_engine_baseline(
169
+ history, "tess", "c", current_cer=0.05,
170
+ )
171
+ assert result is not None
172
+ assert result["n_runs"] == 5
173
+
174
+ def test_baseline_zero_returns_none_relative(self) -> None:
175
+ history = _MockHistory([
176
+ _Entry(f"r{i}", "tess", "c", 0.0) for i in range(5)
177
+ ])
178
+ result = compute_engine_baseline(
179
+ history, "tess", "c", current_cer=0.05,
180
+ )
181
+ assert result is not None
182
+ assert result["relative_delta"] is None
183
+ assert result["off_baseline"] is False # not calculable
184
+
185
+ def test_invalid_current_cer(self) -> None:
186
+ history = _MockHistory([
187
+ _Entry(f"r{i}", "tess", "c", 0.04) for i in range(5)
188
+ ])
189
+ assert compute_engine_baseline(
190
+ history, "tess", "c", current_cer=None, # type: ignore
191
+ ) is None
192
+ assert compute_engine_baseline(
193
+ history, "tess", "c", current_cer=-0.1,
194
+ ) is None
195
+
196
+
197
+ # ──────────────────────────────────────────────────────────────────────────
198
+ # 2. compute_corpus_difficulty_percentile
199
+ # ──────────────────────────────────────────────────────────────────────────
200
+
201
+
202
+ class TestCorpusDifficultyPercentile:
203
+ def test_percentile_calculation(self) -> None:
204
+ history = _MockHistory([
205
+ _Entry(f"r{i}", "x", "c", 0.04, metadata={"difficulty": d})
206
+ for i, d in enumerate([0.1, 0.2, 0.3, 0.4, 0.5])
207
+ ])
208
+ result = compute_corpus_difficulty_percentile(history, 0.45)
209
+ assert result is not None
210
+ # 4 sur 5 valeurs ≤ 0.45 → P80
211
+ assert result["percentile"] == pytest.approx(80.0)
212
+ assert result["n_runs"] == 5
213
+
214
+ def test_harder_than_usual(self) -> None:
215
+ history = _MockHistory([
216
+ _Entry(f"r{i}", "x", "c", 0.04, metadata={"difficulty": 0.1 * i})
217
+ for i in range(1, 11) # 10 valeurs : 0.1 .. 1.0
218
+ ])
219
+ # 0.95 → percentile 90 → harder
220
+ result = compute_corpus_difficulty_percentile(history, 0.95)
221
+ assert result is not None
222
+ assert result["harder_than_usual"] is True
223
+ assert result["easier_than_usual"] is False
224
+
225
+ def test_easier_than_usual(self) -> None:
226
+ history = _MockHistory([
227
+ _Entry(f"r{i}", "x", "c", 0.04, metadata={"difficulty": 0.1 * i})
228
+ for i in range(1, 11)
229
+ ])
230
+ result = compute_corpus_difficulty_percentile(history, 0.05)
231
+ assert result is not None
232
+ assert result["easier_than_usual"] is True
233
+ assert result["harder_than_usual"] is False
234
+
235
+ def test_min_runs_filter(self) -> None:
236
+ history = _MockHistory([
237
+ _Entry("r1", "x", "c", 0.04, metadata={"difficulty": 0.5}),
238
+ ])
239
+ assert compute_corpus_difficulty_percentile(history, 0.5) is None
240
+
241
+
242
+ # ──────────────────────────────────────────────────────────────────────────
243
+ # 3. Détecteur narratif
244
+ # ────────────��─────────────────────────────────────────────────────────────
245
+
246
+
247
+ class TestDetector:
248
+ def test_silent_without_baseline_data(self) -> None:
249
+ assert detect_engine_off_baseline({}) == []
250
+ assert detect_engine_off_baseline(
251
+ {"baseline_comparisons": []},
252
+ ) == []
253
+
254
+ def test_silent_when_off_baseline_false(self) -> None:
255
+ facts = detect_engine_off_baseline({
256
+ "baseline_comparisons": [
257
+ {
258
+ "engine_name": "t", "cer_current": 0.04,
259
+ "cer_historical_mean": 0.04, "n_runs": 10,
260
+ "relative_delta": 0.0, "off_baseline": False,
261
+ },
262
+ ],
263
+ })
264
+ assert facts == []
265
+
266
+ def test_silent_when_relative_delta_none(self) -> None:
267
+ # Baseline = 0 → relative None → on s'abstient
268
+ facts = detect_engine_off_baseline({
269
+ "baseline_comparisons": [
270
+ {
271
+ "engine_name": "t", "cer_current": 0.05,
272
+ "cer_historical_mean": 0.0, "n_runs": 10,
273
+ "relative_delta": None, "off_baseline": True,
274
+ },
275
+ ],
276
+ })
277
+ assert facts == []
278
+
279
+ def test_emits_fact_for_off_baseline(self) -> None:
280
+ facts = detect_engine_off_baseline({
281
+ "baseline_comparisons": [
282
+ {
283
+ "engine_name": "tess", "cer_current": 0.052,
284
+ "cer_historical_mean": 0.041, "n_runs": 12,
285
+ "relative_delta": 0.268, "off_baseline": True,
286
+ },
287
+ ],
288
+ })
289
+ assert len(facts) == 1
290
+ f = facts[0]
291
+ assert f.type == FactType.ENGINE_OFF_BASELINE
292
+ assert f.importance == FactImportance.MEDIUM
293
+ assert f.payload["engine"] == "tess"
294
+ assert f.payload["cer_current_pct"] == 5.2
295
+ assert f.payload["cer_historical_mean_pct"] == 4.1
296
+ assert f.payload["n_runs"] == 12
297
+ assert f.payload["relative_delta_pct"] == 26.8
298
+ assert f.payload["direction"] == "higher"
299
+ assert f.engines_involved == ("tess",)
300
+
301
+ def test_high_importance_above_50pct(self) -> None:
302
+ facts = detect_engine_off_baseline({
303
+ "baseline_comparisons": [
304
+ {
305
+ "engine_name": "x", "cer_current": 0.08,
306
+ "cer_historical_mean": 0.04, "n_runs": 10,
307
+ "relative_delta": 1.0, "off_baseline": True,
308
+ },
309
+ ],
310
+ })
311
+ assert facts[0].importance == FactImportance.HIGH
312
+
313
+ def test_multiple_engines(self) -> None:
314
+ facts = detect_engine_off_baseline({
315
+ "baseline_comparisons": [
316
+ {
317
+ "engine_name": "tess", "cer_current": 0.05,
318
+ "cer_historical_mean": 0.04, "n_runs": 10,
319
+ "relative_delta": 0.25, "off_baseline": True,
320
+ },
321
+ {
322
+ "engine_name": "pero", "cer_current": 0.03,
323
+ "cer_historical_mean": 0.04, "n_runs": 10,
324
+ "relative_delta": -0.25, "off_baseline": True,
325
+ },
326
+ ],
327
+ })
328
+ assert len(facts) == 2
329
+ assert facts[1].payload["direction"] == "lower"
330
+
331
+
332
+ # ──────────────────────────────────────────────────────────────────────────
333
+ # 4. Traçabilité anti-hallucination
334
+ # ──────────────────────────────────────────────────────────────────────────
335
+
336
+
337
+ class TestTraceability:
338
+ @pytest.mark.parametrize("lang", ["fr", "en"])
339
+ def test_each_number_in_rendered_text_is_in_payload(
340
+ self, lang: str,
341
+ ) -> None:
342
+ import re
343
+ facts = detect_engine_off_baseline({
344
+ "baseline_comparisons": [
345
+ {
346
+ "engine_name": "tess", "cer_current": 0.052,
347
+ "cer_historical_mean": 0.041, "n_runs": 12,
348
+ "relative_delta": 0.268, "off_baseline": True,
349
+ },
350
+ ],
351
+ })
352
+ text = render_fact(facts[0], lang=lang)
353
+ assert text # non vide
354
+ # Chaque nombre dans le texte doit venir du payload (ou d'une
355
+ # constante de template — ici aucune)
356
+ payload_nums = {
357
+ "5.2", "4.1", "12", "26.8",
358
+ }
359
+ rendered_nums = set(re.findall(r"\d+\.?\d*", text))
360
+ for num in rendered_nums:
361
+ assert num in payload_nums, (
362
+ f"nombre rendu {num!r} non traçable au payload"
363
+ )