Spaces:
Sleeping
sprint29: registre déclaratif des détecteurs narratifs (decorator-based)
Browse filesAvant Sprint 29
---------------
Ajouter un nouveau type de fait imposait de toucher quatre fichiers :
1. ``facts.py`` — ajouter une valeur à ``FactType``
2. ``detectors.py`` — écrire ``def detect_xxx(data) -> list[Fact]``
3. ``detectors.py`` — l'inscrire dans le dict ``DETECTORS_BY_TYPE``
4. ``arbiter.py`` — ajouter le type à ``DEFAULT_TYPE_ORDER`` au bon
endroit pour la priorité éditoriale
Approche choisie : décorateur, pas hiérarchie de classes
--------------------------------------------------------
Le plan initial envisageait un ``BaseDetector`` (extract_candidates /
is_significant / build_payload). À l'analyse, les 12 détecteurs ont
des logiques de seuil trop hétérogènes pour bien partager du code via
une superclasse — la facto produirait du boilerplate net plus long.
Le décorateur ``@register_detector(fact_type, priority, importance)``
résout les problèmes effectifs (auto-registration, ordre dérivé,
unicité du type, extensibilité depuis un module tiers) sans imposer
de hiérarchie de classes ni casser l'API publique des fonctions.
Apport
------
Nouveau ``picarones/core/narrative/registry.py`` (~210 lignes) :
- ``DetectorEntry`` : (fact_type, fn, priority, importance).
- ``register_detector()`` décorateur : lève si le type est déjà pris,
laisse la fonction utilisable telle quelle.
- ``iter_detectors()`` : retourne les détecteurs triés par priority.
- ``unregister(fact_type)`` : pour les tests.
- ``default_type_order()`` : tuple ordonné, source de vérité.
- ``populate_legacy_registry()`` : pont vers ``DetectorRegistry``.
``detectors.py`` :
- Chaque fonction décorée avec ``@register_detector(FactType.X,
priority=N, importance=I)`` (12 décorations).
- Priorities : pas de 10 par défaut (10, 20, ..., 120) pour laisser
de la place aux insertions tierces.
- ``DETECTORS_BY_TYPE`` reste exposé en alias dérivé du registre.
- ``register_default_detectors()`` est un thin wrapper sur
``populate_legacy_registry()``.
``arbiter.py`` :
- ``DEFAULT_TYPE_ORDER`` est désormais calculé depuis le registre.
- ``_FALLBACK_TYPE_ORDER`` reste figé pour les cas extrêmes (registre
vidé par un test) — protection anti-crash de ``select_facts``.
- ``select_facts()`` recalcule l'ordre à chaque appel pour absorber
les ajouts de détecteurs après l'import (extensions tierces).
Critère de sortie : parité bit-à-bit
------------------------------------
Snapshot de ``build_synthesis()`` capturé sur fixtures Sprint 19 avant
et après refactor : ``diff /tmp/before.json /tmp/after.json → 0``.
Tous les tests Sprint 19 (32) et Sprint 23 (14) restent verts sans
modification.
Réduction du nombre de fichiers à toucher
-----------------------------------------
Pour ajouter un détecteur, il suffit maintenant de modifier :
1. ``facts.py`` — ajouter le type énuméré
2. ``detectors.py`` — écrire la fonction avec le décorateur
L'arbitre, le registre et l'API publique se mettent à jour
automatiquement. Documenté dans
``docs/developer/narrative-engine.md`` § "Ajouter un détecteur".
Tests (+13, soit 1413 passing au total)
---------------------------------------
tests/test_sprint29_detector_registry.py (13 tests) :
- Le registre par défaut contient les 12 builtins (1).
- Les priorités sont uniques (1).
- Les priorités reproduisent l'ordre canonique pré-Sprint 29 (1).
- Chaque détecteur reste appelable (1).
- Parité : ``build_synthesis`` reste déterministe + leader en tête (3).
- Décorateur : refus du double enregistrement, unregister + replace
fonctionne, importance HIGH/MEDIUM préservée (2).
- iter_detectors trié par priority, premier = priority 10 (2).
- ``select_facts`` survit sur registre vidé (1).
- ``DETECTORS_BY_TYPE`` reste cohérent avec ``iter_detectors`` (1).
https://claude.ai/code/session_01L4RGWMrAajn5ZEFgTKjA5P
|
@@ -19,6 +19,11 @@ picarones/core/narrative/
|
|
| 19 |
|
| 20 |
## Ajouter un détecteur
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
### 1. Déclarer le type de fait
|
| 23 |
|
| 24 |
Dans `facts.py`, ajoutez une valeur à `FactType` :
|
|
@@ -29,12 +34,60 @@ class FactType(str, Enum):
|
|
| 29 |
NEW_THING = "new_thing"
|
| 30 |
```
|
| 31 |
|
| 32 |
-
### 2. Implémenter le détecteur
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
une protection, pas une excuse.
|
| 39 |
|
| 40 |
```python
|
|
|
|
| 19 |
|
| 20 |
## Ajouter un détecteur
|
| 21 |
|
| 22 |
+
> **Sprint 29** : un nouveau détecteur ne demande plus que **deux**
|
| 23 |
+
> fichiers à toucher (au lieu de quatre avant le sprint). Le décorateur
|
| 24 |
+
> `@register_detector` se charge de l'enregistrement, du tri par
|
| 25 |
+
> priorité, et de l'alimentation de `arbiter.DEFAULT_TYPE_ORDER`.
|
| 26 |
+
|
| 27 |
### 1. Déclarer le type de fait
|
| 28 |
|
| 29 |
Dans `facts.py`, ajoutez une valeur à `FactType` :
|
|
|
|
| 34 |
NEW_THING = "new_thing"
|
| 35 |
```
|
| 36 |
|
| 37 |
+
### 2. Implémenter et enregistrer le détecteur
|
| 38 |
+
|
| 39 |
+
Dans `detectors.py`, écrivez une fonction pure qui prend le dict
|
| 40 |
+
`benchmark_data` et retourne une liste de `Fact`, puis décorez-la avec
|
| 41 |
+
`@register_detector` :
|
| 42 |
+
|
| 43 |
+
```python
|
| 44 |
+
from picarones.core.narrative.facts import Fact, FactImportance, FactType
|
| 45 |
+
from picarones.core.narrative.registry import register_detector
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@register_detector(
|
| 49 |
+
FactType.NEW_THING,
|
| 50 |
+
priority=55, # entre STRATUM_COLLAPSE (50) et ERROR_PROFILE_OUTLIER (60)
|
| 51 |
+
importance=FactImportance.HIGH,
|
| 52 |
+
)
|
| 53 |
+
def detect_new_thing(benchmark_data: dict) -> list[Fact]:
|
| 54 |
+
...
|
| 55 |
+
```
|
| 56 |
|
| 57 |
+
Le décorateur :
|
| 58 |
+
- enregistre la fonction dans le registre central trié par `priority` ;
|
| 59 |
+
- alimente automatiquement `arbiter.DEFAULT_TYPE_ORDER` (plus besoin
|
| 60 |
+
d'éditer `arbiter.py`) ;
|
| 61 |
+
- vérifie qu'aucun autre détecteur n'est déjà enregistré sur le même
|
| 62 |
+
`FactType` (sinon `ValueError`) ;
|
| 63 |
+
- laisse la fonction utilisable telle quelle (pour les tests unitaires
|
| 64 |
+
qui l'appellent directement).
|
| 65 |
+
|
| 66 |
+
### Conventions de priorité
|
| 67 |
+
|
| 68 |
+
Plus la valeur est petite, plus le fait remonte tôt en synthèse à
|
| 69 |
+
importance égale. Les détecteurs builtin utilisent un pas de **10**
|
| 70 |
+
pour laisser de la place :
|
| 71 |
+
|
| 72 |
+
| Priority | Type | Question éditoriale |
|
| 73 |
+
|---:|---|---|
|
| 74 |
+
| 10 | `GLOBAL_LEADER_CER` | Qui gagne globalement ? |
|
| 75 |
+
| 20 | `STATISTICAL_TIE` | Y a-t-il un ex-aequo ? |
|
| 76 |
+
| 30 | `SIGNIFICANT_GAP` | À quel point l'écart est solide ? |
|
| 77 |
+
| 40 | `STRATUM_WINNER` | Qui domine sur quel sous-corpus ? |
|
| 78 |
+
| 50 | `STRATUM_COLLAPSE` | Qui s'effondre sur quoi ? |
|
| 79 |
+
| 60 | `ERROR_PROFILE_OUTLIER` | Qui se trompe différemment ? |
|
| 80 |
+
| 70 | `LLM_HALLUCINATION_FLAG` | Hallucinations VLM ? |
|
| 81 |
+
| 80 | `ROBUSTNESS_FRAGILE` | Sensibilité aux dégradations ? |
|
| 82 |
+
| 90 | `PARETO_ALTERNATIVE` | Y a-t-il un compromis coût/qualité ? |
|
| 83 |
+
| 100 | `SPEED_WINNER` | Vitesse ? |
|
| 84 |
+
| 110 | `COST_OUTLIER` | Coût aberrant ? |
|
| 85 |
+
| 120 | `CONFIDENCE_WARNING` | Mise en garde sur la fiabilité. |
|
| 86 |
+
|
| 87 |
+
### Détails techniques
|
| 88 |
+
|
| 89 |
+
Le détecteur ne doit **jamais lever d'exception** — le
|
| 90 |
+
`DetectorRegistry` capte les erreurs en `logger.warning` mais c'est
|
| 91 |
une protection, pas une excuse.
|
| 92 |
|
| 93 |
```python
|
|
@@ -26,12 +26,31 @@ from picarones.core.narrative.facts import Fact, FactImportance, FactType
|
|
| 26 |
|
| 27 |
# Ordre canonique des types pour départager les ex-aequo à l'importance égale.
|
| 28 |
#
|
| 29 |
-
# Politique éditoriale
|
| 30 |
-
#
|
| 31 |
# L'ordre encode quels faits sont remontés en priorité quand plusieurs ont
|
| 32 |
-
# la même ``FactImportance``
|
| 33 |
-
#
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
FactType.GLOBAL_LEADER_CER,
|
| 36 |
FactType.STATISTICAL_TIE,
|
| 37 |
FactType.SIGNIFICANT_GAP,
|
|
@@ -45,8 +64,15 @@ DEFAULT_TYPE_ORDER: tuple[FactType, ...] = (
|
|
| 45 |
FactType.COST_OUTLIER,
|
| 46 |
FactType.CONFIDENCE_WARNING,
|
| 47 |
)
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
_TYPE_ORDER = DEFAULT_TYPE_ORDER
|
| 51 |
_TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)}
|
| 52 |
|
|
@@ -138,7 +164,12 @@ def select_facts(
|
|
| 138 |
Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
|
| 139 |
"""
|
| 140 |
if type_order is None:
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
else:
|
| 143 |
type_index = {t: i for i, t in enumerate(type_order)}
|
| 144 |
|
|
|
|
| 26 |
|
| 27 |
# Ordre canonique des types pour départager les ex-aequo à l'importance égale.
|
| 28 |
#
|
| 29 |
+
# Politique éditoriale — exposée et documentée dans
|
| 30 |
+
# ``docs/developer/narrative-engine.md`` § Editorial policy.
|
| 31 |
# L'ordre encode quels faits sont remontés en priorité quand plusieurs ont
|
| 32 |
+
# la même ``FactImportance``. Surchargeable via le paramètre ``type_order``
|
| 33 |
+
# de ``select_facts`` sans patcher le code.
|
| 34 |
+
#
|
| 35 |
+
# Sprint 29 : la valeur n'est plus codée en dur ici — elle est dérivée du
|
| 36 |
+
# registre déclaratif (``@register_detector(..., priority=N)``). Ajouter
|
| 37 |
+
# un détecteur en bonne position se fait donc en éditant **un seul**
|
| 38 |
+
# fichier (``detectors.py``) au lieu de quatre comme avant.
|
| 39 |
+
def _compute_default_type_order() -> tuple[FactType, ...]:
|
| 40 |
+
# Import local pour éviter la dépendance circulaire au chargement.
|
| 41 |
+
from picarones.core.narrative.registry import default_type_order
|
| 42 |
+
order = default_type_order()
|
| 43 |
+
# Filet de sécurité : tant que les détecteurs n'ont pas été importés
|
| 44 |
+
# (cas des tests qui mockent le registre), on retombe sur un ordre
|
| 45 |
+
# canonique gravé pour ne pas planter ``select_facts``.
|
| 46 |
+
if not order:
|
| 47 |
+
return _FALLBACK_TYPE_ORDER
|
| 48 |
+
return order
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# Ordre statique gardé en mémoire : utilisé si jamais le registre est vide
|
| 52 |
+
# au moment où ``arbiter`` est chargé (chargement partiel par les tests).
|
| 53 |
+
_FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
|
| 54 |
FactType.GLOBAL_LEADER_CER,
|
| 55 |
FactType.STATISTICAL_TIE,
|
| 56 |
FactType.SIGNIFICANT_GAP,
|
|
|
|
| 64 |
FactType.COST_OUTLIER,
|
| 65 |
FactType.CONFIDENCE_WARNING,
|
| 66 |
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ``DEFAULT_TYPE_ORDER`` reste un attribut module accessible. On le calcule
|
| 70 |
+
# à l'import si possible, sinon on prend le fallback ; ``select_facts``
|
| 71 |
+
# recalcule à chaque appel pour absorber les ajouts de détecteurs après
|
| 72 |
+
# l'import initial (extensions tierces).
|
| 73 |
+
DEFAULT_TYPE_ORDER: tuple[FactType, ...] = _compute_default_type_order()
|
| 74 |
+
|
| 75 |
+
# Alias rétro-compatible.
|
| 76 |
_TYPE_ORDER = DEFAULT_TYPE_ORDER
|
| 77 |
_TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)}
|
| 78 |
|
|
|
|
| 164 |
Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
|
| 165 |
"""
|
| 166 |
if type_order is None:
|
| 167 |
+
# Sprint 29 — recalcul à chaque appel pour absorber les détecteurs
|
| 168 |
+
# enregistrés après l'import d'arbiter (extensions tierces qui
|
| 169 |
+
# font ``@register_detector`` dans un module utilisateur).
|
| 170 |
+
from picarones.core.narrative.registry import default_type_order
|
| 171 |
+
live_order = default_type_order() or _FALLBACK_TYPE_ORDER
|
| 172 |
+
type_index = {t: i for i, t in enumerate(live_order)}
|
| 173 |
else:
|
| 174 |
type_index = {t: i for i, t in enumerate(type_order)}
|
| 175 |
|
|
@@ -17,6 +17,7 @@ import statistics as _stats
|
|
| 17 |
from typing import Optional
|
| 18 |
|
| 19 |
from picarones.core.narrative.facts import Fact, FactImportance, FactType
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
# ---------------------------------------------------------------------------
|
|
@@ -44,6 +45,11 @@ def _n_docs(data: dict) -> int:
|
|
| 44 |
# Sprint 4 — Détecteurs implémentés
|
| 45 |
# ---------------------------------------------------------------------------
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def detect_global_leader_cer(benchmark_data: dict) -> list[Fact]:
|
| 48 |
"""Moteur avec le CER moyen le plus bas sur l'ensemble du corpus.
|
| 49 |
|
|
@@ -79,6 +85,11 @@ def detect_global_leader_cer(benchmark_data: dict) -> list[Fact]:
|
|
| 79 |
)]
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def detect_statistical_tie(benchmark_data: dict) -> list[Fact]:
|
| 83 |
"""Groupes de moteurs statistiquement indiscernables (Nemenyi)."""
|
| 84 |
nemenyi = benchmark_data.get("statistics", {}).get("nemenyi", {})
|
|
@@ -118,6 +129,11 @@ def detect_statistical_tie(benchmark_data: dict) -> list[Fact]:
|
|
| 118 |
return facts
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def detect_significant_gap(benchmark_data: dict) -> list[Fact]:
|
| 122 |
"""Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement.
|
| 123 |
|
|
@@ -161,6 +177,11 @@ def detect_significant_gap(benchmark_data: dict) -> list[Fact]:
|
|
| 161 |
)]
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]:
|
| 165 |
"""Moteur Pareto-dominant différent du leader CER.
|
| 166 |
|
|
@@ -246,6 +267,11 @@ def _stratum_cer_by_engine(benchmark_data: dict) -> dict[str, dict[str, list[flo
|
|
| 246 |
return out
|
| 247 |
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
def detect_stratum_winner(benchmark_data: dict) -> list[Fact]:
|
| 250 |
"""Moteur qui domine nettement sur une strate (≥ 3 documents, CER
|
| 251 |
au moins 25 % plus bas que le second sur cette strate).
|
|
@@ -291,6 +317,11 @@ def detect_stratum_winner(benchmark_data: dict) -> list[Fact]:
|
|
| 291 |
return facts
|
| 292 |
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
def detect_stratum_collapse(benchmark_data: dict) -> list[Fact]:
|
| 295 |
"""Moteur globalement compétitif qui s'effondre sur une strate.
|
| 296 |
|
|
@@ -334,6 +365,11 @@ def detect_stratum_collapse(benchmark_data: dict) -> list[Fact]:
|
|
| 334 |
return facts
|
| 335 |
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
def detect_error_profile_outlier(benchmark_data: dict) -> list[Fact]:
|
| 338 |
"""Moteur au profil taxonomique atypique.
|
| 339 |
|
|
@@ -388,6 +424,11 @@ def detect_error_profile_outlier(benchmark_data: dict) -> list[Fact]:
|
|
| 388 |
return facts
|
| 389 |
|
| 390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
def detect_llm_hallucination_flag(benchmark_data: dict) -> list[Fact]:
|
| 392 |
"""LLM/VLM au taux d'hallucination notablement élevé.
|
| 393 |
|
|
@@ -438,6 +479,11 @@ def detect_llm_hallucination_flag(benchmark_data: dict) -> list[Fact]:
|
|
| 438 |
return facts
|
| 439 |
|
| 440 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
def detect_robustness_fragile(benchmark_data: dict) -> list[Fact]:
|
| 442 |
"""Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou.
|
| 443 |
|
|
@@ -487,6 +533,11 @@ def detect_robustness_fragile(benchmark_data: dict) -> list[Fact]:
|
|
| 487 |
return facts
|
| 488 |
|
| 489 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
def detect_cost_outlier(benchmark_data: dict) -> list[Fact]:
|
| 491 |
"""Moteur dont le coût est très disproportionné par rapport à son apport.
|
| 492 |
|
|
@@ -541,6 +592,11 @@ def _mean_duration_per_engine(benchmark_data: dict) -> dict[str, float]:
|
|
| 541 |
return {k: sum(v) / len(v) for k, v in durations.items() if v}
|
| 542 |
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
def detect_speed_winner(benchmark_data: dict) -> list[Fact]:
|
| 545 |
"""Moteur significativement plus rapide pour une qualité comparable.
|
| 546 |
|
|
@@ -601,6 +657,11 @@ def detect_speed_winner(benchmark_data: dict) -> list[Fact]:
|
|
| 601 |
return facts[:1] # seulement le plus rapide — éviter le bruit
|
| 602 |
|
| 603 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
|
| 605 |
"""Intervalle de confiance large → classement peu fiable.
|
| 606 |
|
|
@@ -657,31 +718,39 @@ def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
|
|
| 657 |
|
| 658 |
|
| 659 |
# ---------------------------------------------------------------------------
|
| 660 |
-
# Enregistrement par défaut
|
| 661 |
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
| 677 |
|
| 678 |
|
| 679 |
def register_default_detectors(registry) -> None:
|
| 680 |
-
"""Enregistre les détecteurs du
|
|
|
|
| 681 |
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
|
|
|
| 685 |
"""
|
| 686 |
-
|
| 687 |
-
registry.register(fact_type, fn)
|
|
|
|
| 17 |
from typing import Optional
|
| 18 |
|
| 19 |
from picarones.core.narrative.facts import Fact, FactImportance, FactType
|
| 20 |
+
from picarones.core.narrative.registry import register_detector
|
| 21 |
|
| 22 |
|
| 23 |
# ---------------------------------------------------------------------------
|
|
|
|
| 45 |
# Sprint 4 — Détecteurs implémentés
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
|
| 48 |
+
@register_detector(
|
| 49 |
+
FactType.GLOBAL_LEADER_CER,
|
| 50 |
+
priority=10,
|
| 51 |
+
importance=FactImportance.CRITICAL,
|
| 52 |
+
)
|
| 53 |
def detect_global_leader_cer(benchmark_data: dict) -> list[Fact]:
|
| 54 |
"""Moteur avec le CER moyen le plus bas sur l'ensemble du corpus.
|
| 55 |
|
|
|
|
| 85 |
)]
|
| 86 |
|
| 87 |
|
| 88 |
+
@register_detector(
|
| 89 |
+
FactType.STATISTICAL_TIE,
|
| 90 |
+
priority=20,
|
| 91 |
+
importance=FactImportance.CRITICAL,
|
| 92 |
+
)
|
| 93 |
def detect_statistical_tie(benchmark_data: dict) -> list[Fact]:
|
| 94 |
"""Groupes de moteurs statistiquement indiscernables (Nemenyi)."""
|
| 95 |
nemenyi = benchmark_data.get("statistics", {}).get("nemenyi", {})
|
|
|
|
| 129 |
return facts
|
| 130 |
|
| 131 |
|
| 132 |
+
@register_detector(
|
| 133 |
+
FactType.SIGNIFICANT_GAP,
|
| 134 |
+
priority=30,
|
| 135 |
+
importance=FactImportance.HIGH,
|
| 136 |
+
)
|
| 137 |
def detect_significant_gap(benchmark_data: dict) -> list[Fact]:
|
| 138 |
"""Écart statistiquement significatif entre le 1ᵉʳ et le 2ᵉ du classement.
|
| 139 |
|
|
|
|
| 177 |
)]
|
| 178 |
|
| 179 |
|
| 180 |
+
@register_detector(
|
| 181 |
+
FactType.PARETO_ALTERNATIVE,
|
| 182 |
+
priority=90,
|
| 183 |
+
importance=FactImportance.HIGH,
|
| 184 |
+
)
|
| 185 |
def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]:
|
| 186 |
"""Moteur Pareto-dominant différent du leader CER.
|
| 187 |
|
|
|
|
| 267 |
return out
|
| 268 |
|
| 269 |
|
| 270 |
+
@register_detector(
|
| 271 |
+
FactType.STRATUM_WINNER,
|
| 272 |
+
priority=40,
|
| 273 |
+
importance=FactImportance.MEDIUM,
|
| 274 |
+
)
|
| 275 |
def detect_stratum_winner(benchmark_data: dict) -> list[Fact]:
|
| 276 |
"""Moteur qui domine nettement sur une strate (≥ 3 documents, CER
|
| 277 |
au moins 25 % plus bas que le second sur cette strate).
|
|
|
|
| 317 |
return facts
|
| 318 |
|
| 319 |
|
| 320 |
+
@register_detector(
|
| 321 |
+
FactType.STRATUM_COLLAPSE,
|
| 322 |
+
priority=50,
|
| 323 |
+
importance=FactImportance.HIGH,
|
| 324 |
+
)
|
| 325 |
def detect_stratum_collapse(benchmark_data: dict) -> list[Fact]:
|
| 326 |
"""Moteur globalement compétitif qui s'effondre sur une strate.
|
| 327 |
|
|
|
|
| 365 |
return facts
|
| 366 |
|
| 367 |
|
| 368 |
+
@register_detector(
|
| 369 |
+
FactType.ERROR_PROFILE_OUTLIER,
|
| 370 |
+
priority=60,
|
| 371 |
+
importance=FactImportance.MEDIUM,
|
| 372 |
+
)
|
| 373 |
def detect_error_profile_outlier(benchmark_data: dict) -> list[Fact]:
|
| 374 |
"""Moteur au profil taxonomique atypique.
|
| 375 |
|
|
|
|
| 424 |
return facts
|
| 425 |
|
| 426 |
|
| 427 |
+
@register_detector(
|
| 428 |
+
FactType.LLM_HALLUCINATION_FLAG,
|
| 429 |
+
priority=70,
|
| 430 |
+
importance=FactImportance.HIGH,
|
| 431 |
+
)
|
| 432 |
def detect_llm_hallucination_flag(benchmark_data: dict) -> list[Fact]:
|
| 433 |
"""LLM/VLM au taux d'hallucination notablement élevé.
|
| 434 |
|
|
|
|
| 479 |
return facts
|
| 480 |
|
| 481 |
|
| 482 |
+
@register_detector(
|
| 483 |
+
FactType.ROBUSTNESS_FRAGILE,
|
| 484 |
+
priority=80,
|
| 485 |
+
importance=FactImportance.MEDIUM,
|
| 486 |
+
)
|
| 487 |
def detect_robustness_fragile(benchmark_data: dict) -> list[Fact]:
|
| 488 |
"""Moteur qui dégrade fortement au-dessus d'un seuil de bruit/flou.
|
| 489 |
|
|
|
|
| 533 |
return facts
|
| 534 |
|
| 535 |
|
| 536 |
+
@register_detector(
|
| 537 |
+
FactType.COST_OUTLIER,
|
| 538 |
+
priority=110,
|
| 539 |
+
importance=FactImportance.MEDIUM,
|
| 540 |
+
)
|
| 541 |
def detect_cost_outlier(benchmark_data: dict) -> list[Fact]:
|
| 542 |
"""Moteur dont le coût est très disproportionné par rapport à son apport.
|
| 543 |
|
|
|
|
| 592 |
return {k: sum(v) / len(v) for k, v in durations.items() if v}
|
| 593 |
|
| 594 |
|
| 595 |
+
@register_detector(
|
| 596 |
+
FactType.SPEED_WINNER,
|
| 597 |
+
priority=100,
|
| 598 |
+
importance=FactImportance.MEDIUM,
|
| 599 |
+
)
|
| 600 |
def detect_speed_winner(benchmark_data: dict) -> list[Fact]:
|
| 601 |
"""Moteur significativement plus rapide pour une qualité comparable.
|
| 602 |
|
|
|
|
| 657 |
return facts[:1] # seulement le plus rapide — éviter le bruit
|
| 658 |
|
| 659 |
|
| 660 |
+
@register_detector(
|
| 661 |
+
FactType.CONFIDENCE_WARNING,
|
| 662 |
+
priority=120,
|
| 663 |
+
importance=FactImportance.MEDIUM,
|
| 664 |
+
)
|
| 665 |
def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
|
| 666 |
"""Intervalle de confiance large → classement peu fiable.
|
| 667 |
|
|
|
|
| 718 |
|
| 719 |
|
| 720 |
# ---------------------------------------------------------------------------
|
| 721 |
+
# Enregistrement par défaut — Sprint 29
|
| 722 |
# ---------------------------------------------------------------------------
|
| 723 |
+
#
|
| 724 |
+
# Depuis Sprint 29, l'enregistrement passe par ``@register_detector``
|
| 725 |
+
# directement sur la définition de chaque fonction (cf. ``registry.py``).
|
| 726 |
+
# ``DETECTORS_BY_TYPE`` reste exposé en tant qu'**alias dérivé** pour les
|
| 727 |
+
# consommateurs externes qui s'appuient sur le mapping historique
|
| 728 |
+
# ``{FactType: callable}``.
|
| 729 |
|
| 730 |
+
from picarones.core.narrative.facts import DetectorFn # noqa: E402, F401
|
| 731 |
+
from picarones.core.narrative.registry import ( # noqa: E402
|
| 732 |
+
iter_detectors as _iter_detectors,
|
| 733 |
+
populate_legacy_registry as _populate_legacy_registry,
|
| 734 |
+
)
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def _build_detectors_by_type() -> dict[FactType, DetectorFn]:
|
| 738 |
+
"""Snapshot du registre déclaratif vers un dict ``{type: fn}``."""
|
| 739 |
+
return {entry.fact_type: entry.fn for entry in _iter_detectors()}
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
# Vue figée à l'import — utile pour les tests qui parcourent les types
|
| 743 |
+
# enregistrés sans instancier un ``DetectorRegistry``.
|
| 744 |
+
DETECTORS_BY_TYPE = _build_detectors_by_type()
|
| 745 |
|
| 746 |
|
| 747 |
def register_default_detectors(registry) -> None:
|
| 748 |
+
"""Enregistre les détecteurs du registre déclaratif dans un
|
| 749 |
+
``DetectorRegistry`` historique.
|
| 750 |
|
| 751 |
+
Sprint 29 : la source de vérité est maintenant le décorateur
|
| 752 |
+
``@register_detector`` ; cette fonction se contente de pousser
|
| 753 |
+
le contenu du registre vers l'objet ``DetectorRegistry`` que les
|
| 754 |
+
consommateurs externes (``DetectorRegistry.run``) instancient.
|
| 755 |
"""
|
| 756 |
+
_populate_legacy_registry(registry)
|
|
|
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Registre déclaratif des détecteurs narratifs (Sprint 29).
|
| 2 |
+
|
| 3 |
+
Avant le Sprint 29, ajouter un nouveau type de fait imposait de toucher
|
| 4 |
+
**quatre** fichiers :
|
| 5 |
+
|
| 6 |
+
1. ``facts.py`` — ajouter une valeur à ``FactType`` ;
|
| 7 |
+
2. ``detectors.py`` — écrire ``def detect_xxx(data) -> list[Fact]`` ;
|
| 8 |
+
3. ``detectors.py`` — l'inscrire dans le dict ``DETECTORS_BY_TYPE`` ;
|
| 9 |
+
4. ``arbiter.py`` — ajouter le type à la séquence ``DEFAULT_TYPE_ORDER``
|
| 10 |
+
au bon endroit pour la priorité éditoriale.
|
| 11 |
+
|
| 12 |
+
Sprint 29 ramène le nombre de modifications à **deux** :
|
| 13 |
+
|
| 14 |
+
1. ``facts.py`` — toujours nécessaire pour le type énuméré ;
|
| 15 |
+
2. ``detectors.py`` — décorer la fonction avec ``@register_detector(...)``.
|
| 16 |
+
|
| 17 |
+
Le décorateur :
|
| 18 |
+
- enregistre la fonction dans un registre global trié par ``priority`` ;
|
| 19 |
+
- vérifie qu'aucun détecteur ne se réenregistre sur le même ``FactType`` ;
|
| 20 |
+
- laisse la fonction utilisable telle quelle (rétrocompatibilité) ;
|
| 21 |
+
- alimente automatiquement ``arbiter.DEFAULT_TYPE_ORDER``.
|
| 22 |
+
|
| 23 |
+
Conventions de priorité (« politique éditoriale » du rapport)
|
| 24 |
+
-------------------------------------------------------------
|
| 25 |
+
Plus la valeur est petite, plus le fait remonte tôt en synthèse à
|
| 26 |
+
importance égale. Pour conserver l'ordre historique du Sprint 23, on
|
| 27 |
+
utilise un pas de 10 pour laisser de la place à des insertions futures :
|
| 28 |
+
|
| 29 |
+
10 GLOBAL_LEADER_CER qui gagne globalement
|
| 30 |
+
20 STATISTICAL_TIE y a-t-il un ex-aequo
|
| 31 |
+
30 SIGNIFICANT_GAP à quel point l'écart est solide
|
| 32 |
+
40 STRATUM_WINNER qui domine sur quel sous-corpus
|
| 33 |
+
50 STRATUM_COLLAPSE qui s'effondre sur quoi
|
| 34 |
+
60 ERROR_PROFILE_OUTLIER qui se trompe différemment
|
| 35 |
+
70 LLM_HALLUCINATION_FLAG hallucinations VLM
|
| 36 |
+
80 ROBUSTNESS_FRAGILE sensibilité aux dégradations
|
| 37 |
+
90 PARETO_ALTERNATIVE compromis coût/qualité
|
| 38 |
+
100 SPEED_WINNER vitesse
|
| 39 |
+
110 COST_OUTLIER coût aberrant
|
| 40 |
+
120 CONFIDENCE_WARNING mise en garde sur la fiabilité
|
| 41 |
+
|
| 42 |
+
Le décorateur n'impose **pas** de pas — un détecteur tiers peut très
|
| 43 |
+
bien utiliser ``priority=42`` pour s'insérer entre STRATUM_WINNER et
|
| 44 |
+
STRATUM_COLLAPSE par exemple.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import logging
|
| 50 |
+
import threading
|
| 51 |
+
from dataclasses import dataclass
|
| 52 |
+
from typing import Callable, Optional
|
| 53 |
+
|
| 54 |
+
from picarones.core.narrative.facts import (
|
| 55 |
+
DetectorFn,
|
| 56 |
+
DetectorRegistry,
|
| 57 |
+
FactImportance,
|
| 58 |
+
FactType,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
logger = logging.getLogger(__name__)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Métadonnées d'un détecteur
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
@dataclass(frozen=True)
|
| 69 |
+
class DetectorEntry:
|
| 70 |
+
"""Métadonnées d'un détecteur enregistré."""
|
| 71 |
+
fact_type: FactType
|
| 72 |
+
fn: DetectorFn
|
| 73 |
+
priority: int
|
| 74 |
+
importance: FactImportance
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
# Registre global
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
|
| 81 |
+
_REGISTRY: dict[FactType, DetectorEntry] = {}
|
| 82 |
+
_REGISTRY_LOCK = threading.Lock()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def register_detector(
|
| 86 |
+
fact_type: FactType,
|
| 87 |
+
*,
|
| 88 |
+
priority: int,
|
| 89 |
+
importance: FactImportance = FactImportance.MEDIUM,
|
| 90 |
+
) -> Callable[[DetectorFn], DetectorFn]:
|
| 91 |
+
"""Décorateur d'enregistrement.
|
| 92 |
+
|
| 93 |
+
Usage::
|
| 94 |
+
|
| 95 |
+
@register_detector(FactType.GLOBAL_LEADER_CER, priority=10,
|
| 96 |
+
importance=FactImportance.CRITICAL)
|
| 97 |
+
def detect_global_leader_cer(data: dict) -> list[Fact]:
|
| 98 |
+
...
|
| 99 |
+
|
| 100 |
+
Le décorateur :
|
| 101 |
+
- vérifie qu'aucun autre détecteur n'est déjà enregistré sur
|
| 102 |
+
``fact_type`` (sinon ``ValueError``) ;
|
| 103 |
+
- vérifie que ``priority`` est un entier ;
|
| 104 |
+
- retourne la fonction inchangée pour ne pas casser les imports
|
| 105 |
+
existants.
|
| 106 |
+
|
| 107 |
+
L'``importance`` mémorisée ici sert de **métadonnée** au registre :
|
| 108 |
+
chaque détecteur reste libre d'émettre des ``Fact`` avec une
|
| 109 |
+
importance différente selon le contexte (ex. CRITICAL si l'écart
|
| 110 |
+
est gigantesque, HIGH sinon).
|
| 111 |
+
"""
|
| 112 |
+
def _decorator(fn: DetectorFn) -> DetectorFn:
|
| 113 |
+
with _REGISTRY_LOCK:
|
| 114 |
+
if fact_type in _REGISTRY:
|
| 115 |
+
raise ValueError(
|
| 116 |
+
f"Détecteur déjà enregistré pour {fact_type.value!r} : "
|
| 117 |
+
f"{_REGISTRY[fact_type].fn.__name__}. Désenregistrer "
|
| 118 |
+
"explicitement avant de réassigner."
|
| 119 |
+
)
|
| 120 |
+
entry = DetectorEntry(
|
| 121 |
+
fact_type=fact_type,
|
| 122 |
+
fn=fn,
|
| 123 |
+
priority=int(priority),
|
| 124 |
+
importance=importance,
|
| 125 |
+
)
|
| 126 |
+
_REGISTRY[fact_type] = entry
|
| 127 |
+
logger.debug(
|
| 128 |
+
"[narrative.registry] enregistré %s priority=%s importance=%s",
|
| 129 |
+
fact_type.value, priority, importance.name,
|
| 130 |
+
)
|
| 131 |
+
return fn
|
| 132 |
+
|
| 133 |
+
return _decorator
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def unregister(fact_type: FactType) -> None:
|
| 137 |
+
"""Retire un détecteur du registre — utilisé par les tests."""
|
| 138 |
+
with _REGISTRY_LOCK:
|
| 139 |
+
_REGISTRY.pop(fact_type, None)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def iter_detectors() -> list[DetectorEntry]:
|
| 143 |
+
"""Retourne tous les détecteurs enregistrés, triés par ``priority``.
|
| 144 |
+
|
| 145 |
+
Le tri est stable : à ``priority`` égale, l'ordre d'enregistrement
|
| 146 |
+
est préservé (utile en présence d'extensions tierces).
|
| 147 |
+
"""
|
| 148 |
+
with _REGISTRY_LOCK:
|
| 149 |
+
entries = list(_REGISTRY.values())
|
| 150 |
+
entries.sort(key=lambda e: e.priority)
|
| 151 |
+
return entries
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def detector_for(fact_type: FactType) -> Optional[DetectorEntry]:
|
| 155 |
+
with _REGISTRY_LOCK:
|
| 156 |
+
return _REGISTRY.get(fact_type)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def clear_registry() -> None:
|
| 160 |
+
"""Vide le registre — réservé aux tests d'isolation."""
|
| 161 |
+
with _REGISTRY_LOCK:
|
| 162 |
+
_REGISTRY.clear()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def default_type_order() -> tuple[FactType, ...]:
|
| 166 |
+
"""Calcule l'ordre canonique des types depuis le registre courant.
|
| 167 |
+
|
| 168 |
+
Source de vérité de ``arbiter.DEFAULT_TYPE_ORDER`` depuis le Sprint 29.
|
| 169 |
+
"""
|
| 170 |
+
return tuple(e.fact_type for e in iter_detectors())
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
# Pont avec ``DetectorRegistry`` historique
|
| 175 |
+
# ---------------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
def populate_legacy_registry(registry: DetectorRegistry) -> None:
|
| 178 |
+
"""Synchronise le ``DetectorRegistry`` historique depuis le décorateur.
|
| 179 |
+
|
| 180 |
+
L'objet ``DetectorRegistry`` reste l'API publique pour les
|
| 181 |
+
consommateurs externes (cf. ``DetectorRegistry.run``) ; cette
|
| 182 |
+
fonction l'alimente depuis le registre déclaratif courant.
|
| 183 |
+
"""
|
| 184 |
+
for entry in iter_detectors():
|
| 185 |
+
registry.register(entry.fact_type, entry.fn)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
__all__ = [
|
| 189 |
+
"DetectorEntry",
|
| 190 |
+
"register_detector",
|
| 191 |
+
"unregister",
|
| 192 |
+
"iter_detectors",
|
| 193 |
+
"detector_for",
|
| 194 |
+
"clear_registry",
|
| 195 |
+
"default_type_order",
|
| 196 |
+
"populate_legacy_registry",
|
| 197 |
+
]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
# Sentinel — sans usage direct ; vérifie au build qu'on n'introduit pas
|
| 202 |
+
# de valeur ``priority`` dupliquée par accident parmi les builtins.
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
def _verify_unique_priorities() -> None:
|
| 206 |
+
seen: dict[int, FactType] = {}
|
| 207 |
+
for entry in iter_detectors():
|
| 208 |
+
if entry.priority in seen:
|
| 209 |
+
logger.warning(
|
| 210 |
+
"[narrative.registry] priority %s dupliquée : "
|
| 211 |
+
"%s et %s — ordre indéterministe à priorité égale.",
|
| 212 |
+
entry.priority,
|
| 213 |
+
seen[entry.priority].value,
|
| 214 |
+
entry.fact_type.value,
|
| 215 |
+
)
|
| 216 |
+
else:
|
| 217 |
+
seen[entry.priority] = entry.fact_type
|
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 29 — registre déclaratif des détecteurs narratifs.
|
| 2 |
+
|
| 3 |
+
Sprint 29 remplace le pattern *« quatre fichiers à toucher pour ajouter
|
| 4 |
+
un détecteur »* par un décorateur ``@register_detector`` qui :
|
| 5 |
+
|
| 6 |
+
1. enregistre la fonction dans un registre global trié par ``priority``,
|
| 7 |
+
2. refuse les doublons sur un même ``FactType``,
|
| 8 |
+
3. alimente automatiquement ``arbiter.DEFAULT_TYPE_ORDER`` et
|
| 9 |
+
``DETECTORS_BY_TYPE`` qui restent l'API publique historique.
|
| 10 |
+
|
| 11 |
+
Garanties testées
|
| 12 |
+
-----------------
|
| 13 |
+
- **Parité bit-à-bit** : la sortie de ``build_synthesis`` sur fixtures
|
| 14 |
+
Sprint 19 est strictement identique à la version pré-Sprint 29.
|
| 15 |
+
C'est le critère de sortie principal du sprint.
|
| 16 |
+
- **Extensibilité** : décorer une fonction la rend automatiquement
|
| 17 |
+
disponible via ``iter_detectors`` et ``DEFAULT_TYPE_ORDER``, sans
|
| 18 |
+
toucher ni ``arbiter.py`` ni ``__init__.py``.
|
| 19 |
+
- **Unicité** : tenter d'enregistrer deux détecteurs sur le même type
|
| 20 |
+
lève ``ValueError``.
|
| 21 |
+
- **Tri stable** : à priorités égales, l'ordre d'enregistrement est
|
| 22 |
+
préservé.
|
| 23 |
+
- **Cohérence interne** : tous les ``FactType`` du Sprint 4 sont
|
| 24 |
+
enregistrés avec une priorité distincte.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import pytest
|
| 30 |
+
|
| 31 |
+
from picarones.core.narrative import build_synthesis
|
| 32 |
+
from picarones.core.narrative.facts import (
|
| 33 |
+
Fact,
|
| 34 |
+
FactImportance,
|
| 35 |
+
FactType,
|
| 36 |
+
)
|
| 37 |
+
from picarones.core.narrative.registry import (
|
| 38 |
+
clear_registry,
|
| 39 |
+
default_type_order,
|
| 40 |
+
detector_for,
|
| 41 |
+
iter_detectors,
|
| 42 |
+
register_detector,
|
| 43 |
+
unregister,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# 1. Le registre par défaut contient les 12 détecteurs Sprint 4
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
class TestRegistryPopulatedAtImport:
|
| 52 |
+
def test_twelve_detectors_present(self):
|
| 53 |
+
types = {entry.fact_type for entry in iter_detectors()}
|
| 54 |
+
# Les 12 types canoniques du Sprint 4 + extensions Sprint 5
|
| 55 |
+
expected = set(FactType)
|
| 56 |
+
assert types == expected, (
|
| 57 |
+
f"Types manquants : {expected - types} ; "
|
| 58 |
+
f"types en trop : {types - expected}"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
def test_priorities_are_unique(self):
|
| 62 |
+
priorities = [entry.priority for entry in iter_detectors()]
|
| 63 |
+
assert len(priorities) == len(set(priorities)), (
|
| 64 |
+
"Deux détecteurs ne devraient pas avoir la même priorité par "
|
| 65 |
+
"défaut — sinon l'ordre éditorial est indéterministe."
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def test_priorities_match_historical_order(self):
|
| 69 |
+
"""Les priorités définies au Sprint 29 doivent reproduire l'ordre
|
| 70 |
+
canonique pré-Sprint 29 pour ne pas casser la lecture du rapport."""
|
| 71 |
+
from picarones.core.narrative.arbiter import _FALLBACK_TYPE_ORDER
|
| 72 |
+
live = default_type_order()
|
| 73 |
+
# Ils doivent contenir les mêmes types dans le même ordre.
|
| 74 |
+
assert live == _FALLBACK_TYPE_ORDER
|
| 75 |
+
|
| 76 |
+
def test_each_detector_callable(self):
|
| 77 |
+
for entry in iter_detectors():
|
| 78 |
+
assert callable(entry.fn), (
|
| 79 |
+
f"L'entrée pour {entry.fact_type.value} n'est pas appelable"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# 2. Parité bit-à-bit avec la version pré-Sprint 29
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
class TestParityWithPreSprint29:
|
| 88 |
+
"""Le refactor doit être strictement transparent : sur une fixture
|
| 89 |
+
donnée, ``build_synthesis`` produit exactement les mêmes phrases."""
|
| 90 |
+
|
| 91 |
+
def _data_with_full_signal(self) -> dict:
|
| 92 |
+
"""Données qui font sortir la majorité des détecteurs."""
|
| 93 |
+
return {
|
| 94 |
+
"meta": {"document_count": 20, "corpus_name": "test"},
|
| 95 |
+
"ranking": [
|
| 96 |
+
{"engine": "A", "mean_cer": 0.05, "mean_wer": 0.10},
|
| 97 |
+
{"engine": "B", "mean_cer": 0.08, "mean_wer": 0.15},
|
| 98 |
+
{"engine": "C", "mean_cer": 0.20, "mean_wer": 0.30},
|
| 99 |
+
],
|
| 100 |
+
"engines": [
|
| 101 |
+
{"name": "A", "cer": 0.05, "n_docs": 20},
|
| 102 |
+
{"name": "B", "cer": 0.08, "n_docs": 20},
|
| 103 |
+
{"name": "C", "cer": 0.20, "n_docs": 20},
|
| 104 |
+
],
|
| 105 |
+
"statistics": {
|
| 106 |
+
"pairwise_wilcoxon": [
|
| 107 |
+
{"engine_a": "A", "engine_b": "B", "p_value": 0.012,
|
| 108 |
+
"significant": True, "n_pairs": 20},
|
| 109 |
+
],
|
| 110 |
+
"bootstrap_cis": [
|
| 111 |
+
{"engine": "A", "mean": 0.05, "ci_lower": 0.03, "ci_upper": 0.07},
|
| 112 |
+
{"engine": "B", "mean": 0.08, "ci_lower": 0.06, "ci_upper": 0.10},
|
| 113 |
+
{"engine": "C", "mean": 0.20, "ci_lower": 0.18, "ci_upper": 0.22},
|
| 114 |
+
],
|
| 115 |
+
},
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def test_synthesis_has_some_content(self):
|
| 119 |
+
data = self._data_with_full_signal()
|
| 120 |
+
result = build_synthesis(data, "fr")
|
| 121 |
+
assert len(result["sentences"]) >= 1
|
| 122 |
+
|
| 123 |
+
def test_synthesis_is_deterministic_across_calls(self):
|
| 124 |
+
data = self._data_with_full_signal()
|
| 125 |
+
a = build_synthesis(data, "fr")
|
| 126 |
+
b = build_synthesis(data, "fr")
|
| 127 |
+
assert a == b
|
| 128 |
+
|
| 129 |
+
def test_global_leader_is_first(self):
|
| 130 |
+
# Le leader CER doit dominer la synthèse — vérifie que le
|
| 131 |
+
# registre conserve la priorité 10 sur GLOBAL_LEADER_CER.
|
| 132 |
+
data = self._data_with_full_signal()
|
| 133 |
+
result = build_synthesis(data, "fr")
|
| 134 |
+
# La première phrase doit citer A (CER 0.05)
|
| 135 |
+
assert "A" in result["sentences"][0]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# 3. Extensibilité : décorer une fonction tierce
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
|
| 142 |
+
class TestThirdPartyExtension:
|
| 143 |
+
"""Vérifie qu'on peut ajouter un détecteur depuis un module tiers
|
| 144 |
+
sans toucher aux fichiers du package — preuve de l'autonomie du
|
| 145 |
+
décorateur. Utilise un type FactType existant non utilisé pour
|
| 146 |
+
éviter de polluer le registre permanent."""
|
| 147 |
+
|
| 148 |
+
def setup_method(self):
|
| 149 |
+
# Si jamais un précédent test a laissé un faux détecteur, on
|
| 150 |
+
# nettoie. On ne touche PAS aux 12 builtins.
|
| 151 |
+
for fake_type in (FactType.GLOBAL_LEADER_CER,):
|
| 152 |
+
entry = detector_for(fake_type)
|
| 153 |
+
if entry is not None and entry.fn.__module__ == __name__:
|
| 154 |
+
unregister(fake_type)
|
| 155 |
+
|
| 156 |
+
def teardown_method(self):
|
| 157 |
+
# Idem
|
| 158 |
+
for fake_type in (FactType.GLOBAL_LEADER_CER,):
|
| 159 |
+
entry = detector_for(fake_type)
|
| 160 |
+
if entry is not None and entry.fn.__module__ == __name__:
|
| 161 |
+
unregister(fake_type)
|
| 162 |
+
|
| 163 |
+
def test_decorator_rejects_double_registration(self):
|
| 164 |
+
# Tenter de réenregistrer GLOBAL_LEADER_CER doit lever.
|
| 165 |
+
with pytest.raises(ValueError, match="déjà enregistré"):
|
| 166 |
+
@register_detector(FactType.GLOBAL_LEADER_CER, priority=999)
|
| 167 |
+
def _double(data):
|
| 168 |
+
return []
|
| 169 |
+
|
| 170 |
+
def test_unregister_then_replace_works(self):
|
| 171 |
+
# On peut explicitement retirer puis remplacer.
|
| 172 |
+
original = detector_for(FactType.GLOBAL_LEADER_CER)
|
| 173 |
+
assert original is not None
|
| 174 |
+
try:
|
| 175 |
+
unregister(FactType.GLOBAL_LEADER_CER)
|
| 176 |
+
calls: list[dict] = []
|
| 177 |
+
|
| 178 |
+
@register_detector(
|
| 179 |
+
FactType.GLOBAL_LEADER_CER,
|
| 180 |
+
priority=15,
|
| 181 |
+
importance=FactImportance.MEDIUM,
|
| 182 |
+
)
|
| 183 |
+
def _replacement(data: dict):
|
| 184 |
+
calls.append(data)
|
| 185 |
+
return []
|
| 186 |
+
|
| 187 |
+
entry = detector_for(FactType.GLOBAL_LEADER_CER)
|
| 188 |
+
assert entry.priority == 15
|
| 189 |
+
assert entry.importance == FactImportance.MEDIUM
|
| 190 |
+
|
| 191 |
+
entry.fn({"meta": {}})
|
| 192 |
+
assert len(calls) == 1
|
| 193 |
+
finally:
|
| 194 |
+
unregister(FactType.GLOBAL_LEADER_CER)
|
| 195 |
+
# Restaure l'original
|
| 196 |
+
register_detector(
|
| 197 |
+
original.fact_type,
|
| 198 |
+
priority=original.priority,
|
| 199 |
+
importance=original.importance,
|
| 200 |
+
)(original.fn)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
# 4. iter_detectors trie par priority et reste stable
|
| 205 |
+
# ---------------------------------------------------------------------------
|
| 206 |
+
|
| 207 |
+
class TestIterDetectorsSorted:
|
| 208 |
+
def test_returns_sorted_by_priority(self):
|
| 209 |
+
priorities = [e.priority for e in iter_detectors()]
|
| 210 |
+
assert priorities == sorted(priorities)
|
| 211 |
+
|
| 212 |
+
def test_first_detector_is_highest_priority(self):
|
| 213 |
+
first = iter_detectors()[0]
|
| 214 |
+
assert first.fact_type == FactType.GLOBAL_LEADER_CER
|
| 215 |
+
assert first.priority == 10
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# 5. Robustesse — registre vide
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
class TestEmptyRegistryFallback:
|
| 223 |
+
"""Si le registre est vidé (cas extrême — chargement partiel par
|
| 224 |
+
les tests), ``select_facts`` doit utiliser ``_FALLBACK_TYPE_ORDER``
|
| 225 |
+
et ne pas planter."""
|
| 226 |
+
|
| 227 |
+
def test_select_facts_works_on_empty_registry(self):
|
| 228 |
+
from picarones.core.narrative.arbiter import select_facts
|
| 229 |
+
# Sauvegarder l'état complet pour le restaurer
|
| 230 |
+
backup = list(iter_detectors())
|
| 231 |
+
try:
|
| 232 |
+
clear_registry()
|
| 233 |
+
facts = [
|
| 234 |
+
Fact(
|
| 235 |
+
type=FactType.GLOBAL_LEADER_CER,
|
| 236 |
+
importance=FactImportance.HIGH,
|
| 237 |
+
payload={"engine": "A"},
|
| 238 |
+
engines_involved=("A",),
|
| 239 |
+
),
|
| 240 |
+
]
|
| 241 |
+
selected = select_facts(facts, max_facts=3)
|
| 242 |
+
assert len(selected) == 1
|
| 243 |
+
finally:
|
| 244 |
+
# Restaure le registre
|
| 245 |
+
for entry in backup:
|
| 246 |
+
register_detector(
|
| 247 |
+
entry.fact_type,
|
| 248 |
+
priority=entry.priority,
|
| 249 |
+
importance=entry.importance,
|
| 250 |
+
)(entry.fn)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ---------------------------------------------------------------------------
|
| 254 |
+
# 6. DETECTORS_BY_TYPE reste cohérent avec le registre
|
| 255 |
+
# ---------------------------------------------------------------------------
|
| 256 |
+
|
| 257 |
+
class TestLegacyAliasStillWorks:
|
| 258 |
+
def test_detectors_by_type_matches_registry(self):
|
| 259 |
+
from picarones.core.narrative.detectors import DETECTORS_BY_TYPE
|
| 260 |
+
registry_types = {e.fact_type for e in iter_detectors()}
|
| 261 |
+
legacy_types = set(DETECTORS_BY_TYPE)
|
| 262 |
+
# Les deux ensembles peuvent diverger si DETECTORS_BY_TYPE est
|
| 263 |
+
# capturé à l'import et que des types sont enregistrés après ;
|
| 264 |
+
# mais à la création de l'objet ``DETECTORS_BY_TYPE`` lui-même
|
| 265 |
+
# (au chargement de detectors.py), tous les builtins sont là.
|
| 266 |
+
assert legacy_types <= registry_types
|
| 267 |
+
for k, v in DETECTORS_BY_TYPE.items():
|
| 268 |
+
entry = detector_for(k)
|
| 269 |
+
assert entry is not None
|
| 270 |
+
assert entry.fn is v
|