Claude commited on
Commit
92de89a
·
unverified ·
1 Parent(s): bec0d42

sprint29: registre déclaratif des détecteurs narratifs (decorator-based)

Browse files

Avant 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

docs/developer/narrative-engine.md CHANGED
@@ -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
- Dans `detectors.py`, ajoutez une fonction pure qui prend le dict
35
- `benchmark_data` (le JSON de résultats du rapport) et retourne une
36
- liste de `Fact`. Le détecteur ne doit **jamais lever d'exception** —
37
- le `DetectorRegistry` capte les erreurs en `logger.warning` mais c'est
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
picarones/core/narrative/arbiter.py CHANGED
@@ -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 (Sprint 23) — exposée et documentée :
30
- # voir ``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`` ; il peut être surchargé via le paramètre
33
- # ``type_order`` de ``select_facts`` sans patcher le code.
34
- DEFAULT_TYPE_ORDER: tuple[FactType, ...] = (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Alias rétro-compatible — l'ancien nom privé reste exporté pour
49
- # les tests et le code utilisateur qui s'y appuyaient.
 
 
 
 
 
 
 
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
- type_index = _TYPE_INDEX
 
 
 
 
 
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
 
picarones/core/narrative/detectors.py CHANGED
@@ -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 �� activé au Sprint 4
661
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
662
 
663
- DETECTORS_BY_TYPE = {
664
- FactType.GLOBAL_LEADER_CER: detect_global_leader_cer,
665
- FactType.STATISTICAL_TIE: detect_statistical_tie,
666
- FactType.SIGNIFICANT_GAP: detect_significant_gap,
667
- FactType.PARETO_ALTERNATIVE: detect_pareto_alternative,
668
- FactType.STRATUM_WINNER: detect_stratum_winner,
669
- FactType.STRATUM_COLLAPSE: detect_stratum_collapse,
670
- FactType.ERROR_PROFILE_OUTLIER: detect_error_profile_outlier,
671
- FactType.LLM_HALLUCINATION_FLAG: detect_llm_hallucination_flag,
672
- FactType.ROBUSTNESS_FRAGILE: detect_robustness_fragile,
673
- FactType.COST_OUTLIER: detect_cost_outlier,
674
- FactType.SPEED_WINNER: detect_speed_winner,
675
- FactType.CONFIDENCE_WARNING: detect_confidence_warning,
676
- }
 
677
 
678
 
679
  def register_default_detectors(registry) -> None:
680
- """Enregistre les détecteurs du Sprint 4 dans un ``DetectorRegistry``.
 
681
 
682
- Les types ``PARETO_ALTERNATIVE`` et ``COST_OUTLIER`` restent des stubs
683
- jusqu'au Sprint 5 : les enregistrer maintenant ne fait rien de visible
684
- (liste vide toujours retournée), ce qui est sûr et simplifie le parcours.
 
685
  """
686
- for fact_type, fn in DETECTORS_BY_TYPE.items():
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)
 
picarones/core/narrative/registry.py ADDED
@@ -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
tests/test_sprint29_detector_registry.py ADDED
@@ -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