TDD Qualifier : moteur de qualification des décisions (R1–R12)

- engine/qualifier.py : QualificationConfig (seuils configurables),
  QualificationInput, QualificationResult, DecisionType enum, qualify()
- Règles : within_mandate→consultation_avis, affected_count→routing,
  is_structural→recommend_onchain, seuils lus depuis config
- Stub suggest_modalities_from_context() — interface LLM définie,
  intégration Qwen3.6 (MacStudio) à venir
- 22 tests, 212/212 verts, zéro régression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-04-23 16:16:35 +02:00
parent fc84600f97
commit 428299c9c8
2 changed files with 535 additions and 0 deletions

View File

@@ -0,0 +1,307 @@
"""TDD — Moteur de qualification des décisions.
Ce fichier est la source de vérité exécutable des règles métier du tunnel "Décider".
Chaque test est nommé d'après la règle qu'il encode.
Règles implémentées dans ce fichier (RED → GREEN au fur et à mesure) :
R1 within_mandate → individual + consultation_avis
R2 within_mandate → aucune modalité de vote n'est proposée
R3 affected_count == 1 (hors mandat) → individual
R4 affected_count ≤ small_group_max → individual recommandé / collective possible
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommandé
R6 affected_count > collective_wot_min → collective WoT obligatoire
R7 is_structural=True → recommend_onchain=True + raison explicite
R8 is_structural=False → recommend_onchain=False (on ne pollue pas avec l'option)
R9 decision_type est toujours dans l'enum autorisé
R10 individual n'expose jamais de modalités de vote
R11 collective expose au moins une modalité
R12 les seuils sont lus depuis QualificationConfig (configurables)
"""
from __future__ import annotations
import pytest
from app.engine.qualifier import (
DecisionType,
QualificationConfig,
QualificationInput,
QualificationResult,
qualify,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
DEFAULT_CONFIG = QualificationConfig() # valeurs par défaut
# ---------------------------------------------------------------------------
# R1 — within_mandate → individual + consultation_avis
# ---------------------------------------------------------------------------
def test_r1_within_mandate_gives_individual():
"""Une décision dans le périmètre d'un mandat est toujours individuelle."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.decision_type == DecisionType.INDIVIDUAL
def test_r1_within_mandate_gives_consultation_avis():
"""La décision dans un mandat utilise le processus 'consultation_avis'."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.process == "consultation_avis"
def test_r1_within_mandate_overrides_large_affected_count():
"""Même si de nombreuses personnes sont concernées, un mandat impose 'individual'."""
result = qualify(
QualificationInput(within_mandate=True, affected_count=500),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL
assert result.process == "consultation_avis"
# ---------------------------------------------------------------------------
# R2 — within_mandate → aucune modalité de vote
# ---------------------------------------------------------------------------
def test_r2_within_mandate_no_modalities():
"""Le mandataire décide seul après consultation — pas de vote collectif."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.recommended_modalities == []
# ---------------------------------------------------------------------------
# R3 — affected_count == 1 hors mandat → individual
# ---------------------------------------------------------------------------
def test_r3_single_person_is_individual():
result = qualify(
QualificationInput(within_mandate=False, affected_count=1),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL
def test_r3_single_person_process_is_personal():
result = qualify(
QualificationInput(within_mandate=False, affected_count=1),
DEFAULT_CONFIG,
)
assert result.process == "personal"
# ---------------------------------------------------------------------------
# R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé
# ---------------------------------------------------------------------------
def test_r4_small_group_recommends_individual():
"""2 à small_group_max personnes : individual recommandé, collective possible."""
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
result = qualify(
QualificationInput(within_mandate=False, affected_count=count),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL, (
f"affected_count={count} devrait recommander individual"
)
assert result.collective_available is True, (
f"affected_count={count} : collective doit rester disponible"
)
def test_r4_small_group_confidence_is_recommended():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé
# ---------------------------------------------------------------------------
def test_r5_medium_group_recommends_collective():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r5_medium_group_confidence_is_recommended():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R6 — affected_count > collective_wot_min → collective WoT requis
# ---------------------------------------------------------------------------
def test_r6_large_group_requires_collective():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r6_large_group_confidence_is_required():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.confidence == "required"
def test_r6_large_group_includes_vote_wot_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert "vote_wot" in result.recommended_modalities
# ---------------------------------------------------------------------------
# R7 — is_structural=True → recommend_onchain + raison
# ---------------------------------------------------------------------------
def test_r7_structural_recommends_onchain():
"""Décision structurante (force de loi ou action machine) → on-chain proposé."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
def test_r7_structural_provides_onchain_reason():
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.onchain_reason is not None
assert len(result.onchain_reason) > 0
def test_r7_structural_individual_can_also_recommend_onchain():
"""Même une décision individuelle structurante peut être gravée on-chain."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=1, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
# ---------------------------------------------------------------------------
# R8 — is_structural=False → recommend_onchain=False
# ---------------------------------------------------------------------------
def test_r8_non_structural_never_recommends_onchain():
"""On ne pollue pas les décisions ordinaires avec l'option on-chain."""
for count in [1, 3, 10, 100]:
result = qualify(
QualificationInput(within_mandate=False, affected_count=count, is_structural=False),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is False, (
f"affected_count={count}, is_structural=False : on-chain ne doit pas être proposé"
)
# ---------------------------------------------------------------------------
# R9 — decision_type toujours dans l'enum autorisé
# ---------------------------------------------------------------------------
def test_r9_decision_type_always_valid():
valid_types = set(DecisionType)
inputs = [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=1),
QualificationInput(within_mandate=False, affected_count=3),
QualificationInput(within_mandate=False, affected_count=10),
QualificationInput(within_mandate=False, affected_count=100),
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
]
for inp in inputs:
result = qualify(inp, DEFAULT_CONFIG)
assert result.decision_type in valid_types, (
f"Type invalide '{result.decision_type}' pour input {inp}"
)
# ---------------------------------------------------------------------------
# R10 — individual n'expose jamais de modalités
# ---------------------------------------------------------------------------
def test_r10_individual_no_modalities():
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=1),
QualificationInput(within_mandate=False, affected_count=3),
]:
result = qualify(inp, DEFAULT_CONFIG)
if result.decision_type == DecisionType.INDIVIDUAL:
assert result.recommended_modalities == [], (
f"Individual ne doit pas exposer de modalités : {inp}"
)
# ---------------------------------------------------------------------------
# R11 — collective expose au moins une modalité
# ---------------------------------------------------------------------------
def test_r11_collective_has_at_least_one_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=20),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
assert len(result.recommended_modalities) >= 1
# ---------------------------------------------------------------------------
# R12 — les seuils sont lus depuis QualificationConfig
# ---------------------------------------------------------------------------
def test_r12_custom_config_overrides_thresholds():
"""Les seuils viennent de QualificationConfig, pas de constantes hardcodées."""
custom = QualificationConfig(
individual_max=1,
small_group_max=2, # au lieu de 5
collective_wot_min=10,
)
# affected_count=3 est au-delà de small_group_max=2 → collective
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
custom,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r12_default_config_thresholds_are_expected():
"""Valeurs par défaut documentées et vérifiables."""
cfg = QualificationConfig()
assert cfg.individual_max == 1
assert cfg.small_group_max == 5
assert cfg.collective_wot_min == 50