Spaces:
Running
sprint73: détecteur narratif engine_off_baseline (A.I.3 chantier 2)
Browse filesL'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 +56 -0
- CLAUDE.md +2 -1
- picarones/core/baseline_comparison.py +229 -0
- picarones/core/narrative/arbiter.py +4 -0
- picarones/core/narrative/detectors.py +67 -0
- picarones/core/narrative/facts.py +7 -0
- picarones/core/narrative/templates/en.yaml +6 -0
- picarones/core/narrative/templates/fr.yaml +6 -0
- tests/test_sprint73_baseline_comparison.py +363 -0
|
@@ -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)
|
|
@@ -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** :
|
| 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** :
|
|
@@ -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 |
+
]
|
|
@@ -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 |
|
|
@@ -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 |
# ---------------------------------------------------------------------------
|
|
@@ -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."""
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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 |
+
)
|