Sprint 3 : protocoles de vote et boite a outils

Backend:
- Sessions de vote : list, close, tally, threshold details, auto-expiration
- Protocoles : update, simulate, meta-gouvernance, formulas CRUD
- Service vote enrichi : close_session, get_threshold_details, nuanced breakdown
- Schemas : ThresholdDetailOut, VoteResultOut, FormulaSimulationRequest/Result
- WebSocket broadcast sur chaque vote + fermeture session
- 25 nouveaux tests (threshold details, close, nuanced, simulation)

Frontend:
- 5 composants vote : VoteBinary, VoteNuanced, ThresholdGauge, FormulaDisplay, VoteHistory
- 3 composants protocoles : ProtocolPicker, FormulaEditor, ModeParamsDisplay
- Simulateur de formules interactif (page /protocols/formulas)
- Page detail protocole (/protocols/[id])
- Composable useWebSocket (live updates)
- Composable useVoteFormula (calcul client-side reactif)
- Integration KaTeX pour rendu LaTeX des formules

Documentation:
- API reference : 8 nouveaux endpoints documentes
- Formules : tables d'inertie, parametres detailles, simulation API
- Guide vote : vote binaire/nuance, jauge, historique, simulateur, meta-gouvernance

55 tests passes (+ 1 skipped), 126 fichiers total.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 13:29:31 +01:00
parent 2bdc731639
commit cede2a585f
25 changed files with 3964 additions and 188 deletions

View File

@@ -0,0 +1,478 @@
"""Tests for vote service engine functions: threshold details, session logic, nuanced evaluation, and simulation.
All tests are pure unit tests that exercise the engine functions directly
without any database dependency.
Real-world reference case:
Vote Engagement Forgeron v2.0.0 (Feb 2026)
wot_size=7224, votes_for=97, votes_against=23, total=120
params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
"""
from __future__ import annotations
import math
import pytest
from app.engine.nuanced_vote import evaluate_nuanced
from app.engine.smith_threshold import smith_threshold
from app.engine.techcomm_threshold import techcomm_threshold
from app.engine.threshold import wot_threshold
# ---------------------------------------------------------------------------
# Threshold details computation: real Forgeron case (97/23/7224)
# ---------------------------------------------------------------------------
class TestThresholdDetailsForgeron:
"""Simulate the threshold details computation that the service would return
for the real Engagement Forgeron v2.0.0 vote."""
WOT_SIZE = 7224
VOTES_FOR = 97
VOTES_AGAINST = 23
TOTAL = 120
SMITH_SIZE = 20
SMITH_EXPONENT = 0.1
TECHCOMM_SIZE = 5
TECHCOMM_EXPONENT = 0.1
def _compute_details(self) -> dict:
"""Reproduce the threshold details logic from vote_service.get_threshold_details."""
wot_thresh = wot_threshold(
wot_size=self.WOT_SIZE,
total_votes=self.TOTAL,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
constant_base=0.0,
)
wot_passed = self.VOTES_FOR >= wot_thresh
smith_thresh = smith_threshold(self.SMITH_SIZE, self.SMITH_EXPONENT)
# Assume all smith members voted for
smith_votes_for = 5
smith_passed = smith_votes_for >= smith_thresh
techcomm_thresh = techcomm_threshold(self.TECHCOMM_SIZE, self.TECHCOMM_EXPONENT)
# Assume 2 techcomm members voted for
techcomm_votes_for = 2
techcomm_passed = techcomm_votes_for >= techcomm_thresh
overall_passed = wot_passed and smith_passed and techcomm_passed
participation_rate = self.TOTAL / self.WOT_SIZE
return {
"wot_threshold": wot_thresh,
"smith_threshold": smith_thresh,
"techcomm_threshold": techcomm_thresh,
"votes_for": self.VOTES_FOR,
"votes_against": self.VOTES_AGAINST,
"votes_total": self.TOTAL,
"wot_passed": wot_passed,
"smith_passed": smith_passed,
"techcomm_passed": techcomm_passed,
"overall_passed": overall_passed,
"participation_rate": round(participation_rate, 6),
"formula_params": {
"M": 50, "B": 0.1, "G": 0.2, "C": 0.0,
"S": self.SMITH_EXPONENT, "T": self.TECHCOMM_EXPONENT,
},
}
def test_wot_threshold_value(self):
"""WoT threshold for Forgeron vote should be in the expected range."""
details = self._compute_details()
assert 80 <= details["wot_threshold"] <= 120
# 97 votes_for must pass
assert details["wot_passed"] is True
def test_smith_threshold_value(self):
"""Smith threshold: ceil(20^0.1) = ceil(1.35) = 2."""
details = self._compute_details()
assert details["smith_threshold"] == 2
assert details["smith_passed"] is True
def test_techcomm_threshold_value(self):
"""TechComm threshold: ceil(5^0.1) = ceil(1.175) = 2."""
details = self._compute_details()
assert details["techcomm_threshold"] == 2
assert details["techcomm_passed"] is True
def test_overall_pass(self):
"""All three criteria pass => overall adopted."""
details = self._compute_details()
assert details["overall_passed"] is True
def test_participation_rate(self):
"""Participation rate for 120/7224 ~ 1.66%."""
details = self._compute_details()
expected = round(120 / 7224, 6)
assert details["participation_rate"] == expected
assert details["participation_rate"] < 0.02 # less than 2%
def test_formula_params_present(self):
"""All formula params must be present and correct."""
details = self._compute_details()
params = details["formula_params"]
assert params["M"] == 50
assert params["B"] == 0.1
assert params["G"] == 0.2
assert params["C"] == 0.0
assert params["S"] == 0.1
assert params["T"] == 0.1
# ---------------------------------------------------------------------------
# Close session behavior (engine-level logic)
# ---------------------------------------------------------------------------
class TestCloseSessionLogic:
"""Test the logic that would execute when a session is closed:
computing the final tally and determining adopted/rejected."""
def test_binary_vote_adopted(self):
"""97 for / 23 against out of 7224 WoT => adopted."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
adopted = 97 >= threshold
assert adopted is True
result = "adopted" if adopted else "rejected"
assert result == "adopted"
def test_binary_vote_rejected(self):
"""50 for / 70 against out of 7224 WoT => rejected."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
adopted = 50 >= threshold
assert adopted is False
result = "adopted" if adopted else "rejected"
assert result == "rejected"
def test_close_with_zero_votes(self):
"""Session with 0 votes => threshold is ~0 (B^W), effectively rejected
because 0 votes_for cannot meet even a tiny threshold."""
threshold = wot_threshold(
wot_size=7224,
total_votes=0,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# B^W = 0.1^7224 -> effectively 0, ceil(0) = 0
# But with 0 votes_for, 0 >= 0 is True
# This is a degenerate case; in practice sessions with 0 votes
# would be marked invalid
assert threshold == 0 or threshold == math.ceil(0.1 ** 7224)
def test_close_high_participation(self):
"""3000/7224 participating, 2500 for / 500 against => should pass.
At ~41.5% participation with G=0.2, the inertia factor is low,
so a strong supermajority should pass.
"""
threshold = wot_threshold(
wot_size=7224,
total_votes=3000,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# At ~41.5% participation, threshold is lower than near-unanimity
# but still above simple majority (1500)
assert threshold > 1500, f"Threshold {threshold} should be above simple majority"
assert threshold < 3000, f"Threshold {threshold} should be below total votes"
adopted = 2500 >= threshold
assert adopted is True
def test_close_barely_fails(self):
"""Use exact threshold to verify borderline rejection."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# votes_for = threshold - 1 => should fail
barely_fail = (threshold - 1) >= threshold
assert barely_fail is False
def test_close_smith_criterion_blocks(self):
"""Even if WoT passes, failing Smith criterion blocks adoption."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
wot_pass = 97 >= threshold
smith_required = smith_threshold(20, 0.1) # ceil(20^0.1) = 2
smith_ok = 1 >= smith_required # Only 1 smith voted -> fails
adopted = wot_pass and smith_ok
assert wot_pass is True
assert smith_ok is False
assert adopted is False
# ---------------------------------------------------------------------------
# Nuanced vote result evaluation
# ---------------------------------------------------------------------------
class TestNuancedVoteResult:
"""Test nuanced vote evaluation with per-level breakdown,
as would be returned by compute_result for nuanced votes."""
def test_nuanced_adopted_with_breakdown(self):
"""Standard nuanced vote that passes threshold and min_participants."""
votes = [5] * 30 + [4] * 20 + [3] * 15 + [2] * 5 + [1] * 3 + [0] * 2
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Total = 75, positive = 30+20+15 = 65
assert result["total"] == 75
assert result["positive_count"] == 65
assert result["positive_pct"] == pytest.approx(86.67, abs=0.1)
assert result["adopted"] is True
# Verify per-level breakdown
assert result["per_level_counts"][5] == 30
assert result["per_level_counts"][4] == 20
assert result["per_level_counts"][3] == 15
assert result["per_level_counts"][2] == 5
assert result["per_level_counts"][1] == 3
assert result["per_level_counts"][0] == 2
def test_nuanced_rejected_threshold_not_met(self):
"""Nuanced vote where positive percentage is below threshold."""
votes = [5] * 10 + [4] * 10 + [3] * 10 + [2] * 15 + [1] * 15 + [0] * 10
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Total = 70, positive = 10+10+10 = 30
assert result["total"] == 70
assert result["positive_count"] == 30
assert result["positive_pct"] == pytest.approx(42.86, abs=0.1)
assert result["threshold_met"] is False
assert result["min_participants_met"] is True
assert result["adopted"] is False
def test_nuanced_all_neutre(self):
"""All voters at level 3 (NEUTRE) still counts as positive."""
votes = [3] * 60
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 60
assert result["positive_count"] == 60
assert result["positive_pct"] == 100.0
assert result["adopted"] is True
def test_nuanced_mixed_heavy_negative(self):
"""Majority at levels 0-2 => rejected."""
votes = [0] * 20 + [1] * 20 + [2] * 15 + [3] * 2 + [4] * 1 + [5] * 1
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Total = 59, positive = 2+1+1 = 4
assert result["total"] == 59
assert result["positive_count"] == 4
assert result["positive_pct"] < 10
assert result["adopted"] is False
def test_nuanced_breakdown_dict_structure(self):
"""Verify the breakdown structure matches what the service builds."""
votes = [5] * 20 + [4] * 20 + [3] * 19 + [2] * 5 + [1] * 3 + [0] * 2
evaluation = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Simulate the nuanced_breakdown dict the service builds
nuanced_breakdown = {
"per_level_counts": evaluation["per_level_counts"],
"positive_count": evaluation["positive_count"],
"positive_pct": evaluation["positive_pct"],
"threshold_met": evaluation["threshold_met"],
"min_participants_met": evaluation["min_participants_met"],
"threshold_pct": 80,
"min_participants": 59,
}
assert "per_level_counts" in nuanced_breakdown
assert nuanced_breakdown["threshold_pct"] == 80
assert nuanced_breakdown["min_participants"] == 59
assert nuanced_breakdown["positive_count"] == 59
assert nuanced_breakdown["threshold_met"] is True
# ---------------------------------------------------------------------------
# Simulation endpoint logic (pure engine functions)
# ---------------------------------------------------------------------------
class TestSimulationLogic:
"""Test the formula simulation that the /simulate endpoint performs,
exercising all three threshold engine functions together."""
def test_simulation_forgeron_case(self):
"""Simulate the Forgeron vote: wot=7224, total=120, M50 B.1 G.2."""
wot_thresh = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# Derived values (matching the simulate endpoint logic)
w = 7224
t = 120
m = 0.5
g = 0.2
participation_rate = t / w
turnout_ratio = min(t / w, 1.0)
inertia_factor = 1.0 - turnout_ratio ** g
required_ratio = m + (1.0 - m) * inertia_factor
assert 80 <= wot_thresh <= 120
assert participation_rate == pytest.approx(0.016611, abs=0.001)
assert 0 < inertia_factor < 1
assert required_ratio > m # Inertia pushes above simple majority
def test_simulation_full_participation(self):
"""At 100% participation, threshold approaches simple majority."""
wot_size = 100
total = 100
wot_thresh = wot_threshold(
wot_size=wot_size,
total_votes=total,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# At full participation, turnout_ratio = 1.0
# inertia_factor = 1.0 - 1.0^0.2 = 0
# required_ratio = 0.5 + 0.5*0 = 0.5
# threshold = 0 + 0.1^100 + 0.5 * 100 = ~50
assert wot_thresh == math.ceil(0.1 ** 100 + 0.5 * 100)
assert wot_thresh == 50
def test_simulation_with_smith_and_techcomm(self):
"""Simulate with all three criteria."""
wot_thresh = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
assert wot_thresh > 0
assert smith_thresh == 2 # ceil(20^0.1) = ceil(1.35) = 2
assert techcomm_thresh == 2 # ceil(5^0.1) = ceil(1.175) = 2
def test_simulation_varying_majority(self):
"""Higher majority_pct increases the threshold at full participation."""
thresh_50 = wot_threshold(wot_size=100, total_votes=100, majority_pct=50)
thresh_66 = wot_threshold(wot_size=100, total_votes=100, majority_pct=66)
thresh_80 = wot_threshold(wot_size=100, total_votes=100, majority_pct=80)
assert thresh_50 < thresh_66 < thresh_80
def test_simulation_varying_gradient(self):
"""Higher gradient exponent means more inertia at partial participation.
With T/W < 1, a higher G makes (T/W)^G smaller, so
inertia_factor = 1 - (T/W)^G becomes larger, raising the threshold.
At 50% participation: (0.5)^0.2 ~ 0.87, (0.5)^1.0 = 0.5.
"""
thresh_g02 = wot_threshold(
wot_size=200, total_votes=100,
majority_pct=50, gradient_exponent=0.2,
)
thresh_g10 = wot_threshold(
wot_size=200, total_votes=100,
majority_pct=50, gradient_exponent=1.0,
)
# Higher G = more inertia at partial participation = higher threshold
assert thresh_g10 > thresh_g02
def test_simulation_zero_votes(self):
"""Zero votes produces a minimal threshold."""
thresh = wot_threshold(
wot_size=7224,
total_votes=0,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# B^W = 0.1^7224 ~ 0, ceil(0) = 0
assert thresh == 0
def test_simulation_small_wot(self):
"""Small WoT size (e.g. 5 members)."""
thresh = wot_threshold(
wot_size=5,
total_votes=3,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# With 3/5 participation, should require more than simple majority of 3
assert thresh >= 2
assert thresh <= 3
def test_simulation_result_structure(self):
"""Verify the simulation produces all expected values for the API response."""
w = 7224
t = 120
m_pct = 50
m = m_pct / 100.0
b = 0.1
g = 0.2
c = 0.0
wot_thresh = wot_threshold(
wot_size=w, total_votes=t,
majority_pct=m_pct, base_exponent=b,
gradient_exponent=g, constant_base=c,
)
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
participation_rate = t / w
turnout_ratio = min(t / w, 1.0)
inertia_factor = 1.0 - turnout_ratio ** g
required_ratio = m + (1.0 - m) * inertia_factor
# Build the simulation result dict (matching FormulaSimulationResult schema)
result = {
"wot_threshold": wot_thresh,
"smith_threshold": smith_thresh,
"techcomm_threshold": techcomm_thresh,
"participation_rate": round(participation_rate, 6),
"required_ratio": round(required_ratio, 6),
"inertia_factor": round(inertia_factor, 6),
}
assert isinstance(result["wot_threshold"], int)
assert isinstance(result["smith_threshold"], int)
assert isinstance(result["techcomm_threshold"], int)
assert 0 < result["participation_rate"] < 1
assert 0 < result["required_ratio"] <= 1
assert 0 < result["inertia_factor"] < 1