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:
@@ -1,8 +1,9 @@
|
||||
"""Vote service: compute results and verify vote signatures."""
|
||||
"""Vote service: compute results, close sessions, threshold details, and signature verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,6 +18,52 @@ from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.vote import Vote, VoteSession
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _load_session_with_votes(db: AsyncSession, session_id: uuid.UUID) -> VoteSession:
|
||||
"""Load a vote session with its votes eagerly, raising ValueError if not found."""
|
||||
result = await db.execute(
|
||||
select(VoteSession)
|
||||
.options(selectinload(VoteSession.votes))
|
||||
.where(VoteSession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise ValueError(f"Session de vote introuvable : {session_id}")
|
||||
return session
|
||||
|
||||
|
||||
async def _load_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
||||
"""Load a voting protocol with its formula config, raising ValueError if not found."""
|
||||
proto_result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == protocol_id)
|
||||
)
|
||||
protocol = proto_result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise ValueError(f"Protocole de vote introuvable : {protocol_id}")
|
||||
return protocol
|
||||
|
||||
|
||||
def _extract_params(protocol: VotingProtocol, formula: FormulaConfig) -> dict:
|
||||
"""Extract formula parameters from mode_params or formula_config."""
|
||||
if protocol.mode_params:
|
||||
return parse_mode_params(protocol.mode_params)
|
||||
return {
|
||||
"majority_pct": formula.majority_pct,
|
||||
"base_exponent": formula.base_exponent,
|
||||
"gradient_exponent": formula.gradient_exponent,
|
||||
"constant_base": formula.constant_base,
|
||||
"smith_exponent": formula.smith_exponent,
|
||||
"techcomm_exponent": formula.techcomm_exponent,
|
||||
}
|
||||
|
||||
|
||||
# ── Compute Result ───────────────────────────────────────────────
|
||||
|
||||
|
||||
async def compute_result(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
"""Load a vote session, its protocol and formula, compute thresholds, and tally.
|
||||
|
||||
@@ -33,40 +80,10 @@ async def compute_result(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
Result dict with keys: threshold, votes_for, votes_against,
|
||||
votes_total, adopted, smith_ok, techcomm_ok, details.
|
||||
"""
|
||||
# Load session with votes eagerly
|
||||
result = await db.execute(
|
||||
select(VoteSession)
|
||||
.options(selectinload(VoteSession.votes))
|
||||
.where(VoteSession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise ValueError(f"Session de vote introuvable : {session_id}")
|
||||
|
||||
# Load protocol + formula config
|
||||
proto_result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == session.voting_protocol_id)
|
||||
)
|
||||
protocol = proto_result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise ValueError(f"Protocole de vote introuvable pour la session {session_id}")
|
||||
|
||||
session = await _load_session_with_votes(db, session_id)
|
||||
protocol = await _load_protocol_with_formula(db, session.voting_protocol_id)
|
||||
formula: FormulaConfig = protocol.formula_config
|
||||
|
||||
# If mode_params is set on the protocol, it overrides formula_config values
|
||||
if protocol.mode_params:
|
||||
params = parse_mode_params(protocol.mode_params)
|
||||
else:
|
||||
params = {
|
||||
"majority_pct": formula.majority_pct,
|
||||
"base_exponent": formula.base_exponent,
|
||||
"gradient_exponent": formula.gradient_exponent,
|
||||
"constant_base": formula.constant_base,
|
||||
"smith_exponent": formula.smith_exponent,
|
||||
"techcomm_exponent": formula.techcomm_exponent,
|
||||
}
|
||||
params = _extract_params(protocol, formula)
|
||||
|
||||
# Separate vote types
|
||||
active_votes: list[Vote] = [v for v in session.votes if v.is_active]
|
||||
@@ -138,7 +155,7 @@ async def _compute_nuanced(
|
||||
params: dict,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Compute a nuanced vote result."""
|
||||
"""Compute a nuanced vote result with full per-level breakdown."""
|
||||
vote_levels = [v.nuanced_level for v in active_votes if v.nuanced_level is not None]
|
||||
|
||||
threshold_pct = formula.nuanced_threshold_pct or 80
|
||||
@@ -163,10 +180,157 @@ async def _compute_nuanced(
|
||||
return {
|
||||
"vote_type": "nuanced",
|
||||
"result": vote_result,
|
||||
"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": threshold_pct,
|
||||
"min_participants": min_participants,
|
||||
},
|
||||
**evaluation,
|
||||
}
|
||||
|
||||
|
||||
# ── Close Session ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def close_session(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
"""Close a vote session and compute its final result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session_id:
|
||||
UUID of the VoteSession to close.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Result dict from compute_result, augmented with close metadata.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the session is not found or already closed/tallied.
|
||||
"""
|
||||
session = await _load_session_with_votes(db, session_id)
|
||||
|
||||
if session.status not in ("open",):
|
||||
raise ValueError(
|
||||
f"Impossible de fermer la session {session_id} : statut actuel '{session.status}'"
|
||||
)
|
||||
|
||||
# Mark as closed before tallying
|
||||
session.status = "closed"
|
||||
await db.commit()
|
||||
|
||||
# Compute the final result (this will set status to "tallied")
|
||||
result = await compute_result(session_id, db)
|
||||
|
||||
return {
|
||||
"closed_at": datetime.now(timezone.utc).isoformat(),
|
||||
**result,
|
||||
}
|
||||
|
||||
|
||||
# ── Threshold Details ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_threshold_details(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
"""Return detailed threshold computation for a vote session.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session_id:
|
||||
UUID of the VoteSession.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Detailed breakdown including wot/smith/techcomm thresholds,
|
||||
pass/fail booleans, participation rate, and formula params.
|
||||
"""
|
||||
session = await _load_session_with_votes(db, session_id)
|
||||
protocol = await _load_protocol_with_formula(db, session.voting_protocol_id)
|
||||
formula: FormulaConfig = protocol.formula_config
|
||||
params = _extract_params(protocol, formula)
|
||||
|
||||
active_votes: list[Vote] = [v for v in session.votes if v.is_active]
|
||||
votes_for = sum(1 for v in active_votes if v.vote_value == "for")
|
||||
votes_against = sum(1 for v in active_votes if v.vote_value == "against")
|
||||
total = votes_for + votes_against
|
||||
|
||||
# WoT threshold
|
||||
wot_thresh = wot_threshold(
|
||||
wot_size=session.wot_size,
|
||||
total_votes=total,
|
||||
majority_pct=params.get("majority_pct", 50),
|
||||
base_exponent=params.get("base_exponent", 0.1),
|
||||
gradient_exponent=params.get("gradient_exponent", 0.2),
|
||||
constant_base=params.get("constant_base", 0.0),
|
||||
)
|
||||
wot_passed = votes_for >= wot_thresh
|
||||
|
||||
# Smith criterion (optional)
|
||||
smith_thresh = None
|
||||
smith_passed = None
|
||||
if params.get("smith_exponent") is not None and session.smith_size > 0:
|
||||
smith_thresh = smith_threshold(session.smith_size, params["smith_exponent"])
|
||||
smith_votes = sum(1 for v in active_votes if v.voter_is_smith and v.vote_value == "for")
|
||||
smith_passed = smith_votes >= smith_thresh
|
||||
|
||||
# TechComm criterion (optional)
|
||||
techcomm_thresh = None
|
||||
techcomm_passed = None
|
||||
if params.get("techcomm_exponent") is not None and session.techcomm_size > 0:
|
||||
techcomm_thresh = techcomm_threshold(session.techcomm_size, params["techcomm_exponent"])
|
||||
techcomm_votes = sum(1 for v in active_votes if v.voter_is_techcomm and v.vote_value == "for")
|
||||
techcomm_passed = techcomm_votes >= techcomm_thresh
|
||||
|
||||
# Overall pass: all applicable criteria must pass
|
||||
overall_passed = wot_passed
|
||||
if smith_passed is not None:
|
||||
overall_passed = overall_passed and smith_passed
|
||||
if techcomm_passed is not None:
|
||||
overall_passed = overall_passed and techcomm_passed
|
||||
|
||||
# Participation rate
|
||||
participation_rate = (total / session.wot_size) if session.wot_size > 0 else 0.0
|
||||
|
||||
# Formula params used
|
||||
formula_params = {
|
||||
"M": params.get("majority_pct", 50),
|
||||
"B": params.get("base_exponent", 0.1),
|
||||
"G": params.get("gradient_exponent", 0.2),
|
||||
"C": params.get("constant_base", 0.0),
|
||||
"S": params.get("smith_exponent"),
|
||||
"T": params.get("techcomm_exponent"),
|
||||
}
|
||||
|
||||
return {
|
||||
"wot_threshold": wot_thresh,
|
||||
"smith_threshold": smith_thresh,
|
||||
"techcomm_threshold": techcomm_thresh,
|
||||
"votes_for": votes_for,
|
||||
"votes_against": votes_against,
|
||||
"votes_total": 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": formula_params,
|
||||
}
|
||||
|
||||
|
||||
# ── Signature Verification ───────────────────────────────────────
|
||||
|
||||
|
||||
async def verify_vote_signature(address: str, signature: str, payload: str) -> bool:
|
||||
"""Verify an Ed25519 signature from a Duniter V2 address.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user