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

@@ -1,4 +1,4 @@
"""Protocols router: voting protocols and formula configurations."""
"""Protocols router: voting protocols, formula configurations, and simulation."""
from __future__ import annotations
@@ -10,13 +10,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.engine.smith_threshold import smith_threshold as compute_smith_threshold
from app.engine.techcomm_threshold import techcomm_threshold as compute_techcomm_threshold
from app.engine.threshold import wot_threshold as compute_wot_threshold
from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.user import DuniterIdentity
from app.schemas.protocol import (
FormulaConfigCreate,
FormulaConfigOut,
FormulaConfigUpdate,
FormulaSimulationRequest,
FormulaSimulationResult,
VotingProtocolCreate,
VotingProtocolOut,
VotingProtocolUpdate,
)
from app.services.auth_service import get_current_identity
@@ -39,6 +46,17 @@ async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProto
return protocol
async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig:
"""Fetch a formula config by ID, or raise 404."""
result = await db.execute(
select(FormulaConfig).where(FormulaConfig.id == formula_id)
)
formula = result.scalar_one_or_none()
if formula is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Configuration de formule introuvable")
return formula
# ── Voting Protocol routes ──────────────────────────────────────────────────
@@ -102,6 +120,30 @@ async def get_protocol(
return VotingProtocolOut.model_validate(protocol)
@router.put("/{id}", response_model=VotingProtocolOut)
async def update_protocol(
id: uuid.UUID,
payload: VotingProtocolUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VotingProtocolOut:
"""Update a voting protocol (meta-governance).
Only provided fields will be updated. Requires authentication.
"""
protocol = await _get_protocol(db, id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(protocol, field, value)
await db.commit()
# Reload with formula config
protocol = await _get_protocol(db, protocol.id)
return VotingProtocolOut.model_validate(protocol)
# ── Formula Config routes ───────────────────────────────────────────────────
@@ -137,3 +179,90 @@ async def create_formula(
await db.refresh(formula)
return FormulaConfigOut.model_validate(formula)
@router.get("/formulas/{id}", response_model=FormulaConfigOut)
async def get_formula(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> FormulaConfigOut:
"""Get a single formula configuration."""
formula = await _get_formula(db, id)
return FormulaConfigOut.model_validate(formula)
@router.put("/formulas/{id}", response_model=FormulaConfigOut)
async def update_formula(
id: uuid.UUID,
payload: FormulaConfigUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> FormulaConfigOut:
"""Update a formula configuration (meta-governance).
Only provided fields will be updated. Requires authentication.
"""
formula = await _get_formula(db, id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(formula, field, value)
await db.commit()
await db.refresh(formula)
return FormulaConfigOut.model_validate(formula)
# ── Simulation ──────────────────────────────────────────────────────────────
@router.post("/simulate", response_model=FormulaSimulationResult)
async def simulate_formula(
payload: FormulaSimulationRequest,
) -> FormulaSimulationResult:
"""Simulate a WoT threshold formula computation.
Pure calculation endpoint -- no database access needed.
Accepts WoT size, total votes, and formula parameters,
returns the computed thresholds and derived values.
"""
# Compute WoT threshold
wot_thresh = compute_wot_threshold(
wot_size=payload.wot_size,
total_votes=payload.total_votes,
majority_pct=payload.majority_pct,
base_exponent=payload.base_exponent,
gradient_exponent=payload.gradient_exponent,
constant_base=payload.constant_base,
)
# Compute derived values for transparency
w = payload.wot_size
t = payload.total_votes
m = payload.majority_pct / 100.0
g = payload.gradient_exponent
participation_rate = t / w if w > 0 else 0.0
turnout_ratio = min(t / w, 1.0) if w > 0 else 0.0
inertia_factor = 1.0 - turnout_ratio ** g if t > 0 else 1.0
required_ratio = m + (1.0 - m) * inertia_factor
# Smith threshold (optional)
smith_thresh = None
if payload.smith_wot_size is not None and payload.smith_exponent is not None:
smith_thresh = compute_smith_threshold(payload.smith_wot_size, payload.smith_exponent)
# TechComm threshold (optional)
techcomm_thresh = None
if payload.techcomm_size is not None and payload.techcomm_exponent is not None:
techcomm_thresh = compute_techcomm_threshold(payload.techcomm_size, payload.techcomm_exponent)
return FormulaSimulationResult(
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),
)

View File

@@ -1,4 +1,4 @@
"""Votes router: vote sessions, individual votes, and result computation."""
"""Votes router: vote sessions, individual votes, result computation, and live updates."""
from __future__ import annotations
@@ -15,8 +15,22 @@ from app.database import get_db
from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.user import DuniterIdentity
from app.models.vote import Vote, VoteSession
from app.schemas.vote import VoteCreate, VoteOut, VoteSessionCreate, VoteSessionOut
from app.routers.websocket import manager
from app.schemas.vote import (
ThresholdDetailOut,
VoteCreate,
VoteOut,
VoteResultOut,
VoteSessionCreate,
VoteSessionListOut,
VoteSessionOut,
)
from app.services.auth_service import get_current_identity
from app.services.vote_service import (
close_session as svc_close_session,
compute_result as svc_compute_result,
get_threshold_details as svc_get_threshold_details,
)
router = APIRouter()
@@ -50,6 +64,37 @@ async def _get_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -
return protocol
async def _check_session_expired(session: VoteSession, db: AsyncSession) -> VoteSession:
"""Check if a session has passed its ends_at deadline and auto-close it.
If the session is still marked 'open' but the deadline has passed,
close it and compute the final tally via the vote service.
Returns the (possibly updated) session.
"""
if session.status == "open" and datetime.now(timezone.utc) > session.ends_at:
try:
result = await svc_close_session(session.id, db)
# Reload session to get updated fields
db_result = await db.execute(
select(VoteSession)
.options(selectinload(VoteSession.votes))
.where(VoteSession.id == session.id)
)
session = db_result.scalar_one()
# Broadcast session closed event
await manager.broadcast(session.id, {
"type": "session_closed",
"session_id": str(session.id),
"result": result.get("result"),
"votes_for": result.get("votes_for", 0),
"votes_against": result.get("votes_against", 0),
"votes_total": result.get("votes_total", 0),
})
except ValueError:
pass # Session already closed by another process
return session
def _compute_threshold(formula: FormulaConfig, wot_size: int, votes_total: int) -> float:
"""Compute the WoT-based threshold using the core formula.
@@ -122,6 +167,35 @@ def _compute_result(
# ── Routes ──────────────────────────────────────────────────────────────────
@router.get("/sessions", response_model=list[VoteSessionListOut])
async def list_vote_sessions(
db: AsyncSession = Depends(get_db),
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[VoteSessionListOut]:
"""List all vote sessions with optional filters by status and decision_id."""
stmt = select(VoteSession)
if session_status is not None:
stmt = stmt.where(VoteSession.status == session_status)
if decision_id is not None:
stmt = stmt.where(VoteSession.decision_id == decision_id)
stmt = stmt.order_by(VoteSession.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
sessions = result.scalars().all()
# Auto-close expired sessions before returning
checked_sessions = []
for s in sessions:
s = await _check_session_expired(s, db)
checked_sessions.append(s)
return [VoteSessionListOut.model_validate(s) for s in checked_sessions]
@router.post("/sessions", response_model=VoteSessionOut, status_code=status.HTTP_201_CREATED)
async def create_vote_session(
payload: VoteSessionCreate,
@@ -163,11 +237,52 @@ async def get_vote_session(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> VoteSessionOut:
"""Get a vote session with current tallies."""
"""Get a vote session with current tallies.
Automatically closes the session if its deadline has passed.
"""
session = await _get_session(db, id)
session = await _check_session_expired(session, db)
return VoteSessionOut.model_validate(session)
@router.post("/sessions/{id}/close", response_model=VoteResultOut)
async def close_vote_session(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteResultOut:
"""Manually close a vote session and compute the final result.
Requires authentication. The session must be in 'open' status.
"""
try:
result = await svc_close_session(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Broadcast session closed event via WebSocket
await manager.broadcast(id, {
"type": "session_closed",
"session_id": str(id),
"result": result.get("result"),
"votes_for": result.get("votes_for", 0),
"votes_against": result.get("votes_against", 0),
"votes_total": result.get("votes_total", 0),
})
return VoteResultOut(
session_id=id,
result=result.get("result"),
threshold_required=float(result.get("threshold", 0)),
votes_for=result.get("votes_for", 0),
votes_against=result.get("votes_against", 0),
votes_total=result.get("votes_total", 0),
adopted=result.get("adopted", False),
nuanced_breakdown=result.get("nuanced_breakdown"),
)
@router.post("/sessions/{id}/vote", response_model=VoteOut, status_code=status.HTTP_201_CREATED)
async def submit_vote(
id: uuid.UUID,
@@ -179,9 +294,13 @@ async def submit_vote(
Each identity can only vote once per session. Submitting again replaces the previous vote.
The vote must include a cryptographic signature for on-chain proof.
After submission, broadcasts a vote_update event via WebSocket.
"""
session = await _get_session(db, id)
# Auto-close check
session = await _check_session_expired(session, db)
# Verify session is open
if session.status != "open":
raise HTTPException(
@@ -249,6 +368,15 @@ async def submit_vote(
await db.commit()
await db.refresh(vote)
# Broadcast vote update via WebSocket
await manager.broadcast(session.id, {
"type": "vote_update",
"session_id": str(session.id),
"votes_for": session.votes_for,
"votes_against": session.votes_against,
"votes_total": session.votes_total,
})
return VoteOut.model_validate(vote)
@@ -273,6 +401,28 @@ async def list_votes(
return [VoteOut.model_validate(v) for v in votes]
@router.get("/sessions/{id}/threshold", response_model=ThresholdDetailOut)
async def get_threshold_details(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> ThresholdDetailOut:
"""Return computed threshold details for a vote session.
Includes WoT/Smith/TechComm thresholds, pass/fail status,
participation rate, and the formula parameters used.
Automatically closes the session if its deadline has passed.
"""
session = await _get_session(db, id)
session = await _check_session_expired(session, db)
try:
details = await svc_get_threshold_details(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return ThresholdDetailOut(**details)
@router.get("/sessions/{id}/result")
async def get_vote_result(
id: uuid.UUID,
@@ -282,8 +432,10 @@ async def get_vote_result(
Uses the WoT threshold formula linked through the voting protocol.
Returns current tallies, computed threshold, and whether the vote passes.
Automatically closes the session if its deadline has passed.
"""
session = await _get_session(db, id)
session = await _check_session_expired(session, db)
# Get the protocol and formula
protocol = await _get_protocol_with_formula(db, session.voting_protocol_id)
@@ -304,3 +456,41 @@ async def get_vote_result(
"techcomm_votes_for": session.techcomm_votes_for,
**result_data,
}
@router.post("/sessions/{id}/tally", response_model=VoteResultOut)
async def force_tally(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteResultOut:
"""Force a recount of a vote session.
Requires authentication. Useful after a chain snapshot update
or when recalculation is needed. Works on any session status.
"""
try:
result = await svc_compute_result(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Broadcast tally update via WebSocket
await manager.broadcast(id, {
"type": "tally_update",
"session_id": str(id),
"result": result.get("result"),
"votes_for": result.get("votes_for", 0),
"votes_against": result.get("votes_against", 0),
"votes_total": result.get("votes_total", 0),
})
return VoteResultOut(
session_id=id,
result=result.get("result"),
threshold_required=float(result.get("threshold", 0)),
votes_for=result.get("votes_for", 0),
votes_against=result.get("votes_against", 0),
votes_total=result.get("votes_total", 0),
adopted=result.get("adopted", False),
nuanced_breakdown=result.get("nuanced_breakdown"),
)