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>
269 lines
9.5 KiB
Python
269 lines
9.5 KiB
Python
"""Protocols router: voting protocols, formula configurations, and simulation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy import select
|
|
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
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
|
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
|
|
result = await db.execute(
|
|
select(VotingProtocol)
|
|
.options(selectinload(VotingProtocol.formula_config))
|
|
.where(VotingProtocol.id == protocol_id)
|
|
)
|
|
protocol = result.scalar_one_or_none()
|
|
if protocol is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
|
|
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 ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/", response_model=list[VotingProtocolOut])
|
|
async def list_protocols(
|
|
db: AsyncSession = Depends(get_db),
|
|
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
|
skip: int = Query(default=0, ge=0),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
) -> list[VotingProtocolOut]:
|
|
"""List all voting protocols with their formula configurations."""
|
|
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
|
|
|
if vote_type is not None:
|
|
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
|
|
|
stmt = stmt.order_by(VotingProtocol.created_at.desc()).offset(skip).limit(limit)
|
|
result = await db.execute(stmt)
|
|
protocols = result.scalars().unique().all()
|
|
|
|
return [VotingProtocolOut.model_validate(p) for p in protocols]
|
|
|
|
|
|
@router.post("/", response_model=VotingProtocolOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_protocol(
|
|
payload: VotingProtocolCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> VotingProtocolOut:
|
|
"""Create a new voting protocol.
|
|
|
|
The formula_config_id must reference an existing FormulaConfig.
|
|
"""
|
|
# Verify formula config exists
|
|
fc_result = await db.execute(
|
|
select(FormulaConfig).where(FormulaConfig.id == payload.formula_config_id)
|
|
)
|
|
if fc_result.scalar_one_or_none() is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Configuration de formule introuvable",
|
|
)
|
|
|
|
protocol = VotingProtocol(**payload.model_dump())
|
|
db.add(protocol)
|
|
await db.commit()
|
|
await db.refresh(protocol)
|
|
|
|
# Reload with formula config
|
|
protocol = await _get_protocol(db, protocol.id)
|
|
return VotingProtocolOut.model_validate(protocol)
|
|
|
|
|
|
@router.get("/{id}", response_model=VotingProtocolOut)
|
|
async def get_protocol(
|
|
id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> VotingProtocolOut:
|
|
"""Get a single voting protocol with its formula configuration."""
|
|
protocol = await _get_protocol(db, id)
|
|
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 ───────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/formulas", response_model=list[FormulaConfigOut])
|
|
async def list_formulas(
|
|
db: AsyncSession = Depends(get_db),
|
|
skip: int = Query(default=0, ge=0),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
) -> list[FormulaConfigOut]:
|
|
"""List all formula configurations."""
|
|
stmt = (
|
|
select(FormulaConfig)
|
|
.order_by(FormulaConfig.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
)
|
|
result = await db.execute(stmt)
|
|
formulas = result.scalars().all()
|
|
|
|
return [FormulaConfigOut.model_validate(f) for f in formulas]
|
|
|
|
|
|
@router.post("/formulas", response_model=FormulaConfigOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_formula(
|
|
payload: FormulaConfigCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
identity: DuniterIdentity = Depends(get_current_identity),
|
|
) -> FormulaConfigOut:
|
|
"""Create a new formula configuration for WoT threshold computation."""
|
|
formula = FormulaConfig(**payload.model_dump())
|
|
db.add(formula)
|
|
await db.commit()
|
|
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),
|
|
)
|