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 from __future__ import annotations
@@ -10,13 +10,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db 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.protocol import FormulaConfig, VotingProtocol
from app.models.user import DuniterIdentity from app.models.user import DuniterIdentity
from app.schemas.protocol import ( from app.schemas.protocol import (
FormulaConfigCreate, FormulaConfigCreate,
FormulaConfigOut, FormulaConfigOut,
FormulaConfigUpdate,
FormulaSimulationRequest,
FormulaSimulationResult,
VotingProtocolCreate, VotingProtocolCreate,
VotingProtocolOut, VotingProtocolOut,
VotingProtocolUpdate,
) )
from app.services.auth_service import get_current_identity 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 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 ────────────────────────────────────────────────── # ── Voting Protocol routes ──────────────────────────────────────────────────
@@ -102,6 +120,30 @@ async def get_protocol(
return VotingProtocolOut.model_validate(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 ─────────────────────────────────────────────────── # ── Formula Config routes ───────────────────────────────────────────────────
@@ -137,3 +179,90 @@ async def create_formula(
await db.refresh(formula) await db.refresh(formula)
return FormulaConfigOut.model_validate(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 from __future__ import annotations
@@ -15,8 +15,22 @@ from app.database import get_db
from app.models.protocol import FormulaConfig, VotingProtocol from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.user import DuniterIdentity from app.models.user import DuniterIdentity
from app.models.vote import Vote, VoteSession 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.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() router = APIRouter()
@@ -50,6 +64,37 @@ async def _get_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -
return protocol 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: def _compute_threshold(formula: FormulaConfig, wot_size: int, votes_total: int) -> float:
"""Compute the WoT-based threshold using the core formula. """Compute the WoT-based threshold using the core formula.
@@ -122,6 +167,35 @@ def _compute_result(
# ── Routes ────────────────────────────────────────────────────────────────── # ── 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) @router.post("/sessions", response_model=VoteSessionOut, status_code=status.HTTP_201_CREATED)
async def create_vote_session( async def create_vote_session(
payload: VoteSessionCreate, payload: VoteSessionCreate,
@@ -163,11 +237,52 @@ async def get_vote_session(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> VoteSessionOut: ) -> 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 _get_session(db, id)
session = await _check_session_expired(session, db)
return VoteSessionOut.model_validate(session) 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) @router.post("/sessions/{id}/vote", response_model=VoteOut, status_code=status.HTTP_201_CREATED)
async def submit_vote( async def submit_vote(
id: uuid.UUID, 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. 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. 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) session = await _get_session(db, id)
# Auto-close check
session = await _check_session_expired(session, db)
# Verify session is open # Verify session is open
if session.status != "open": if session.status != "open":
raise HTTPException( raise HTTPException(
@@ -249,6 +368,15 @@ async def submit_vote(
await db.commit() await db.commit()
await db.refresh(vote) 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) return VoteOut.model_validate(vote)
@@ -273,6 +401,28 @@ async def list_votes(
return [VoteOut.model_validate(v) for v in 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") @router.get("/sessions/{id}/result")
async def get_vote_result( async def get_vote_result(
id: uuid.UUID, id: uuid.UUID,
@@ -282,8 +432,10 @@ async def get_vote_result(
Uses the WoT threshold formula linked through the voting protocol. Uses the WoT threshold formula linked through the voting protocol.
Returns current tallies, computed threshold, and whether the vote passes. 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 _get_session(db, id)
session = await _check_session_expired(session, db)
# Get the protocol and formula # Get the protocol and formula
protocol = await _get_protocol_with_formula(db, session.voting_protocol_id) 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, "techcomm_votes_for": session.techcomm_votes_for,
**result_data, **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"),
)

View File

@@ -33,6 +33,25 @@ class FormulaConfigCreate(BaseModel):
nuanced_threshold_pct: int | None = Field(default=None, ge=0, le=100, description="Threshold percentage for nuanced vote") nuanced_threshold_pct: int | None = Field(default=None, ge=0, le=100, description="Threshold percentage for nuanced vote")
class FormulaConfigUpdate(BaseModel):
"""Partial update payload for a formula configuration (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = None
duration_days: int | None = Field(default=None, ge=1)
majority_pct: int | None = Field(default=None, ge=1, le=100)
base_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
gradient_exponent: float | None = Field(default=None, ge=0.0, le=2.0)
constant_base: float | None = Field(default=None, ge=0.0, le=1.0)
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
nuanced_min_participants: int | None = Field(default=None, ge=0)
nuanced_threshold_pct: int | None = Field(default=None, ge=0, le=100)
class FormulaConfigOut(BaseModel): class FormulaConfigOut(BaseModel):
"""Full formula configuration representation.""" """Full formula configuration representation."""
@@ -67,6 +86,16 @@ class VotingProtocolCreate(BaseModel):
is_meta_governed: bool = Field(default=False, description="Whether this protocol is itself governed by meta-vote") is_meta_governed: bool = Field(default=False, description="Whether this protocol is itself governed by meta-vote")
class VotingProtocolUpdate(BaseModel):
"""Partial update payload for a voting protocol (meta-governance)."""
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = None
vote_type: str | None = Field(default=None, max_length=32)
mode_params: str | None = Field(default=None, max_length=64)
is_meta_governed: bool | None = None
class VotingProtocolOut(BaseModel): class VotingProtocolOut(BaseModel):
"""Full voting protocol representation including formula config.""" """Full voting protocol representation including formula config."""
@@ -81,3 +110,32 @@ class VotingProtocolOut(BaseModel):
is_meta_governed: bool is_meta_governed: bool
created_at: datetime created_at: datetime
formula_config: FormulaConfigOut formula_config: FormulaConfigOut
# ── Formula Simulation ───────────────────────────────────────────
class FormulaSimulationRequest(BaseModel):
"""Request payload for simulating a WoT threshold formula computation."""
wot_size: int = Field(..., ge=1, description="Size of the WoT (eligible voter corpus)")
total_votes: int = Field(..., ge=0, description="Number of votes cast (for + against)")
majority_pct: int = Field(default=50, ge=1, le=100, description="Majority percentage M")
base_exponent: float = Field(default=0.1, ge=0.0, le=1.0, description="Base exponent B")
gradient_exponent: float = Field(default=0.2, ge=0.0, le=2.0, description="Gradient exponent G")
constant_base: float = Field(default=0.0, ge=0.0, le=1.0, description="Constant base C")
smith_wot_size: int | None = Field(default=None, ge=1, description="Smith sub-WoT size")
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="Smith exponent S")
techcomm_size: int | None = Field(default=None, ge=1, description="TechComm size")
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="TechComm exponent T")
class FormulaSimulationResult(BaseModel):
"""Result of a formula simulation."""
wot_threshold: int
smith_threshold: int | None = None
techcomm_threshold: int | None = None
participation_rate: float
required_ratio: float
inertia_factor: float

View File

@@ -55,6 +55,74 @@ class VoteSessionOut(BaseModel):
created_at: datetime created_at: datetime
class VoteSessionListOut(BaseModel):
"""Lighter vote session representation for list endpoints (no nested votes)."""
model_config = ConfigDict(from_attributes=True)
id: UUID
decision_id: UUID | None = None
item_version_id: UUID | None = None
voting_protocol_id: UUID
# Snapshot at session start
wot_size: int
smith_size: int
techcomm_size: int
# Dates
starts_at: datetime
ends_at: datetime
# Status
status: str
# Tallies
votes_for: int
votes_against: int
votes_total: int
threshold_required: float
result: str | None = None
created_at: datetime
# ── Threshold Details ────────────────────────────────────────────
class ThresholdDetailOut(BaseModel):
"""Detailed threshold computation result for a vote session."""
wot_threshold: int
smith_threshold: int | None = None
techcomm_threshold: int | None = None
votes_for: int
votes_against: int
votes_total: int
wot_passed: bool
smith_passed: bool | None = None
techcomm_passed: bool | None = None
overall_passed: bool
participation_rate: float
formula_params: dict
# ── Vote Result ──────────────────────────────────────────────────
class VoteResultOut(BaseModel):
"""Structured vote result response."""
session_id: UUID
result: str | None = None # adopted, rejected
threshold_required: float
votes_for: int
votes_against: int
votes_total: int
adopted: bool
nuanced_breakdown: dict | None = None # for nuanced votes
# ── Vote ───────────────────────────────────────────────────────── # ── Vote ─────────────────────────────────────────────────────────

View File

@@ -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 from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,6 +18,52 @@ from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.vote import Vote, VoteSession 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: async def compute_result(session_id: uuid.UUID, db: AsyncSession) -> dict:
"""Load a vote session, its protocol and formula, compute thresholds, and tally. """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, Result dict with keys: threshold, votes_for, votes_against,
votes_total, adopted, smith_ok, techcomm_ok, details. votes_total, adopted, smith_ok, techcomm_ok, details.
""" """
# Load session with votes eagerly session = await _load_session_with_votes(db, session_id)
result = await db.execute( protocol = await _load_protocol_with_formula(db, session.voting_protocol_id)
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}")
formula: FormulaConfig = protocol.formula_config formula: FormulaConfig = protocol.formula_config
params = _extract_params(protocol, formula)
# 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,
}
# Separate vote types # Separate vote types
active_votes: list[Vote] = [v for v in session.votes if v.is_active] active_votes: list[Vote] = [v for v in session.votes if v.is_active]
@@ -138,7 +155,7 @@ async def _compute_nuanced(
params: dict, params: dict,
db: AsyncSession, db: AsyncSession,
) -> dict: ) -> 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] 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 threshold_pct = formula.nuanced_threshold_pct or 80
@@ -163,10 +180,157 @@ async def _compute_nuanced(
return { return {
"vote_type": "nuanced", "vote_type": "nuanced",
"result": vote_result, "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, **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: async def verify_vote_signature(address: str, signature: str, payload: str) -> bool:
"""Verify an Ed25519 signature from a Duniter V2 address. """Verify an Ed25519 signature from a Duniter V2 address.

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

View File

@@ -48,13 +48,17 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
## Votes (`/api/v1/votes`) ## Votes (`/api/v1/votes`)
| Methode | Endpoint | Description | Auth | | Methode | Endpoint | Description | Auth |
| ------- | --------------------------- | -------------------------------------------- | ---- | | ------- | ------------------------------- | -------------------------------------------- | ---- |
| POST | `/sessions` | Creer une session de vote | Oui | | POST | `/sessions` | Creer une session de vote | Oui |
| GET | `/sessions/{id}` | Obtenir une session de vote | Non | | GET | `/sessions` | Lister les sessions de vote (filtres: status, protocol_id, decision_id) | Non |
| POST | `/sessions/{id}/vote` | Soumettre un vote (signe) | Oui | | GET | `/sessions/{id}` | Obtenir une session de vote | Non |
| GET | `/sessions/{id}/votes` | Lister les votes d'une session | Non | | POST | `/sessions/{id}/vote` | Soumettre un vote (signe) | Oui |
| GET | `/sessions/{id}/result` | Calculer et retourner le resultat courant | Non | | GET | `/sessions/{id}/votes` | Lister les votes d'une session | Non |
| GET | `/sessions/{id}/result` | Calculer et retourner le resultat courant | Non |
| POST | `/sessions/{id}/close` | Cloturer la session et calculer le resultat final | Oui |
| GET | `/sessions/{id}/threshold` | Obtenir le detail du calcul de seuil (formule, parametres, valeurs intermediaires) | Non |
| POST | `/sessions/{id}/tally` | Forcer un recomptage des votes et recalcul du seuil | Oui |
## Mandats (`/api/v1/mandates`) ## Mandats (`/api/v1/mandates`)
@@ -70,13 +74,17 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
## Protocoles (`/api/v1/protocols`) ## Protocoles (`/api/v1/protocols`)
| Methode | Endpoint | Description | Auth | | Methode | Endpoint | Description | Auth |
| ------- | --------------- | -------------------------------------------------- | ---- | | ------- | ------------------- | -------------------------------------------------- | ---- |
| GET | `/` | Lister les protocoles de vote | Non | | GET | `/` | Lister les protocoles de vote | Non |
| POST | `/` | Creer un protocole de vote | Oui | | POST | `/` | Creer un protocole de vote | Oui |
| GET | `/{id}` | Obtenir un protocole avec sa configuration formule | Non | | GET | `/{id}` | Obtenir un protocole avec sa configuration formule | Non |
| GET | `/formulas` | Lister les configurations de formules | Non | | PUT | `/{id}` | Mettre a jour un protocole (meta-gouvernance) | Oui |
| POST | `/formulas` | Creer une configuration de formule | Oui | | GET | `/formulas` | Lister les configurations de formules | Non |
| POST | `/formulas` | Creer une configuration de formule | Oui |
| GET | `/formulas/{id}` | Obtenir une configuration de formule par son ID | Non |
| PUT | `/formulas/{id}` | Mettre a jour une configuration de formule (meta-gouvernance) | Oui |
| POST | `/simulate` | Simuler le calcul d'une formule avec des parametres arbitraires | Non |
## Sanctuaire (`/api/v1/sanctuary`) ## Sanctuaire (`/api/v1/sanctuary`)
@@ -221,6 +229,225 @@ Retourne toutes les entrees du sanctuaire liees a une entite source (document, d
**Reponse** : `200 OK` avec une liste de `SanctuaryEntryOut`. **Reponse** : `200 OK` avec une liste de `SanctuaryEntryOut`.
## Details des endpoints Sprint 3
### `GET /api/v1/votes/sessions` -- Lister les sessions de vote
Retourne la liste des sessions de vote avec pagination et filtres optionnels.
**Parametres de requete** :
| Parametre | Type | Description |
| ------------- | ------ | ---------------------------------------------- |
| `status` | string | Filtrer par statut (`open`, `closed`, `draft`) |
| `protocol_id` | uuid | Filtrer par protocole de vote |
| `decision_id` | uuid | Filtrer par decision associee |
| `skip` | int | Offset de pagination (defaut 0) |
| `limit` | int | Nombre max de resultats (defaut 50, max 200) |
**Reponse** : `200 OK` avec une liste de `VoteSessionOut`.
---
### `POST /api/v1/votes/sessions/{id}/close` -- Cloturer une session
Cloture une session de vote ouverte et calcule le resultat final. Le calcul inclut la formule WoT, les criteres Smith et TechComm le cas echeant. La session passe au statut `closed` et le resultat est fige.
**Preconditions** :
- La session doit etre au statut `open`.
- L'utilisateur doit etre le createur de la session ou un membre du Comite Technique.
**Reponse** :
```json
{
"session_id": "uuid",
"status": "closed",
"closed_at": "2026-02-28T12:00:00Z",
"result": {
"votes_for": 97,
"votes_against": 23,
"total_votes": 120,
"wot_size": 7224,
"threshold": 94,
"adopted": true,
"smith_threshold": 2,
"smith_votes_for": 5,
"smith_met": true,
"techcomm_threshold": null,
"techcomm_votes_for": null,
"techcomm_met": null
}
}
```
---
### `GET /api/v1/votes/sessions/{id}/threshold` -- Detail du calcul de seuil
Retourne le detail complet du calcul de seuil pour une session de vote, incluant les valeurs intermediaires de la formule. Utile pour la transparence et le debug.
**Reponse** :
```json
{
"session_id": "uuid",
"formula_config_id": "uuid",
"mode_params": "D30M50B.1G.2",
"parameters": {
"constant_base": 0.0,
"base_exponent": 0.1,
"majority_pct": 50,
"gradient_exponent": 0.2,
"smith_exponent": null,
"techcomm_exponent": null
},
"inputs": {
"wot_size": 7224,
"total_votes": 120,
"smith_wot_size": 20,
"techcomm_size": 5
},
"computation": {
"base_term": 7.943e-08,
"participation_ratio": 0.0166,
"participation_ratio_powered": 0.4217,
"inertia_factor": 0.7892,
"effective_threshold": 94.70,
"threshold_ceiled": 95,
"threshold_final": 94
},
"criteria": {
"smith_threshold": null,
"techcomm_threshold": null
}
}
```
---
### `POST /api/v1/votes/sessions/{id}/tally` -- Forcer un recomptage
Force le recalcul du decompte des votes et du seuil pour une session. Utile si des votes ont ete invalides manuellement ou si la taille WoT a ete corrigee.
**Preconditions** :
- L'utilisateur doit etre le createur de la session ou un membre du Comite Technique.
**Corps de la requete** (optionnel) :
```json
{
"wot_size_override": 7250,
"smith_wot_size_override": null,
"techcomm_size_override": null
}
```
Si aucun corps n'est fourni, les tailles sont reprises du snapshot initial.
**Reponse** : `200 OK` avec le meme format que le resultat de cloture.
---
### `PUT /api/v1/protocols/{id}` -- Mettre a jour un protocole
Met a jour un protocole de vote existant. Les modifications de protocoles actifs (utilises par des sessions ouvertes) sont soumises a **meta-gouvernance** : une session de vote doit d'abord valider la modification.
**Corps de la requete** (champs optionnels) :
```json
{
"name": "Nouveau nom du protocole",
"description": "Description mise a jour",
"vote_type": "nuanced",
"duration_days": 60,
"formula_config_id": "uuid-de-la-nouvelle-formule"
}
```
**Reponse** : `200 OK` avec le protocole mis a jour (`ProtocolOut`).
**Code 409** : Si le protocole est utilise par une session ouverte et qu'aucune validation meta-gouvernance n'est fournie.
---
### `GET /api/v1/protocols/formulas/{id}` -- Obtenir une formule
Retourne une configuration de formule par son identifiant, incluant tous les parametres et le `mode_params` encode.
**Reponse** : `200 OK` avec un `FormulaConfigOut`.
---
### `PUT /api/v1/protocols/formulas/{id}` -- Mettre a jour une formule
Met a jour une configuration de formule existante. Comme pour les protocoles, les formules actives sont protegees par la meta-gouvernance.
**Corps de la requete** (champs optionnels) :
```json
{
"name": "Configuration amendee",
"majority_pct": 66,
"base_exponent": 0.05,
"gradient_exponent": 0.3,
"constant_base": 0.0,
"smith_exponent": 0.1,
"techcomm_exponent": 0.1,
"ratio_multiplier": null,
"is_ratio_mode": false
}
```
Le champ `mode_params` est recalcule automatiquement a partir des valeurs fournies.
**Reponse** : `200 OK` avec la formule mise a jour (`FormulaConfigOut`).
**Code 409** : Si la formule est liee a un protocole actif sans validation meta-gouvernance.
---
### `POST /api/v1/protocols/simulate` -- Simuler une formule
Simule le calcul de seuil d'une formule avec des parametres arbitraires, sans creer ni modifier aucune donnee. Endpoint ouvert (pas d'authentification requise) pour permettre l'experimentation.
**Corps de la requete** :
```json
{
"wot_size": 7224,
"total_votes": 120,
"majority_pct": 50,
"base_exponent": 0.1,
"gradient_exponent": 0.2,
"constant_base": 0.0,
"smith_wot_size": 20,
"smith_exponent": 0.1,
"techcomm_size": 5,
"techcomm_exponent": 0.1,
"votes_for": 97
}
```
**Reponse** :
```json
{
"threshold": 94,
"inertia_factor": 0.7892,
"participation_ratio": 0.0166,
"base_term": 7.943e-08,
"smith_threshold": 2,
"techcomm_threshold": 2,
"adopted": true,
"details": {
"wot_threshold_met": true,
"smith_met": true,
"techcomm_met": true
}
}
```
## Pagination ## Pagination
Les endpoints de liste acceptent les parametres `skip` (offset, defaut 0) et `limit` (max 200, defaut 50). Les endpoints de liste acceptent les parametres `skip` (offset, defaut 0) et `limit` (max 200, defaut 50).

View File

@@ -1,6 +1,6 @@
--- ---
title: Formules title: Formules
description: Formules mathematiques de seuil WoT, criteres Smith et TechComm description: Formules mathematiques de seuil WoT, criteres Smith et TechComm, simulation et meta-gouvernance
--- ---
# Formules de seuil # Formules de seuil
@@ -13,36 +13,78 @@ $$
\text{Result} = C + B^W + \left( M + (1 - M) \cdot \left(1 - \left(\frac{T}{W}\right)^G \right) \right) \cdot \max(0,\; T - C) \text{Result} = C + B^W + \left( M + (1 - M) \cdot \left(1 - \left(\frac{T}{W}\right)^G \right) \right) \cdot \max(0,\; T - C)
$$ $$
### Variables ### Variables et plages de valeurs
| Symbole | Parametre | Description | Defaut | | Symbole | Parametre | Description | Plage | Defaut |
| ------- | ------------------- | ------------------------------------------------ | ------ | | ------- | ------------------- | ------------------------------------------------ | ------------------- | ------ |
| $C$ | `constant_base` | Base constante additive (plancher) | 0.0 | | $C$ | `constant_base` | Base constante additive (plancher minimum de votes favorables requis) | $[0, +\infty[$ | 0.0 |
| $B$ | `base_exponent` | Exposant de base. $B^W$ devient negligeable quand $W$ est grand ($0 < B < 1$) | 0.1 | | $B$ | `base_exponent` | Exposant de base. $B^W$ ajoute un terme negligeable quand $W$ est grand | $]0, 1[$ | 0.1 |
| $W$ | `wot_size` | Taille du corpus des votants eligibles (membres WoT) | -- | | $W$ | `wot_size` | Taille du corpus des votants eligibles (membres WoT) | $[1, +\infty[$ | -- |
| $T$ | `total_votes` | Nombre total de votes exprimes (pour + contre) | -- | | $T$ | `total_votes` | Nombre total de votes exprimes (pour + contre) | $[0, W]$ | -- |
| $M$ | `majority_pct / 100`| Ratio de majorite. 0.5 = majorite simple a pleine participation | 50 | | $M$ | `majority_pct / 100`| Ratio de majorite cible a pleine participation | $]0, 1]$ | 0.50 |
| $G$ | `gradient_exponent` | Controle la vitesse de convergence de la super-majorite vers $M$ | 0.2 | | $G$ | `gradient_exponent` | Controle la vitesse de convergence de la super-majorite vers $M$ | $]0, +\infty[$ | 0.2 |
#### Detail des parametres
**$C$ -- Base constante** : Nombre minimum absolu de votes favorables requis, independamment de la participation. Avec $C = 3$, il faut au moins 3 votes favorables meme si la formule calcule un seuil inferieur. En general, $C = 0$.
**$B$ -- Exposant de base** : Genere un terme $B^W$ qui decroit exponentiellement avec la taille de la WoT. Pour $B = 0.1$ et $W = 7224$, $B^W = 0.1^{7224} \approx 0$. Ce terme n'est pertinent que pour de tres petites communautes. Plus $B$ est proche de 0, plus le terme decroit vite. Plus $B$ est proche de 1, plus il reste significatif.
**$M$ -- Ratio de majorite** : Determine la proportion de votes favorables necessaire quand toute la WoT vote. $M = 0.5$ signifie qu'a pleine participation, la majorite simple (50%+1) suffit. $M = 0.66$ impose une super-majorite des 2/3 meme a pleine participation.
**$G$ -- Gradient** : Controle la courbure de la transition entre quasi-unanimite (faible participation) et majorite $M$ (pleine participation). Plus $G$ est petit, plus la transition est rapide : l'inertie chute vite des les premieres participations. Plus $G$ est grand, plus l'exigence de quasi-unanimite persiste a des taux de participation eleves.
### Mecanisme d'inertie ### Mecanisme d'inertie
Le coeur de la formule est le facteur d'inertie : Le coeur de la formule est le facteur d'inertie :
$$ $$
\text{inertia} = M + (1 - M) \cdot \left(1 - \left(\frac{T}{W}\right)^G \right) \text{inertia}(T, W) = M + (1 - M) \cdot \left(1 - \left(\frac{T}{W}\right)^G \right)
$$ $$
- Quand la **participation est faible** ($T \ll W$) : le ratio $T/W$ est petit, $(T/W)^G$ est proche de 0, donc l'inertie tend vers $M + (1-M) = 1$. Il faut quasiment l'unanimite. - Quand la **participation est faible** ($T \ll W$) : le ratio $T/W$ est petit, $(T/W)^G$ est proche de 0, donc l'inertie tend vers $M + (1-M) = 1$. Il faut quasiment l'unanimite.
- Quand la **participation est elevee** ($T \to W$) : le ratio $T/W$ tend vers 1, $(T/W)^G$ tend vers 1, donc l'inertie tend vers $M$. La majorite simple suffit. - Quand la **participation est elevee** ($T \to W$) : le ratio $T/W$ tend vers 1, $(T/W)^G$ tend vers 1, donc l'inertie tend vers $M$. La majorite simple suffit.
### Exemple de reference Le facteur d'inertie represente la **proportion de votes favorables necessaire** parmi les votants effectifs. Il multiplie ensuite $(T - C)$ pour obtenir le seuil absolu.
Avec les parametres `M50 B.1 G.2` et le vote de l'Engagement Forgeron v2.0.0 : ### Table d'inertie (WoT = 7224, M = 0.50, G = 0.2)
- $W = 7224$ (membres WoT) Le tableau suivant montre comment le facteur d'inertie evolue en fonction de la participation, pour les parametres de reference `M50 G.2` avec une WoT de 7224 membres :
- $T = 120$ (97 pour + 23 contre)
- Seuil calcule : $94$ | Participation $T$ | Ratio $T/W$ | $(T/W)^G$ | Inertie | Seuil (votes favorables requis) | % favorables requis |
- Resultat : **adopte** (97 >= 94) | -----------------: | ----------: | ---------: | ------: | ------------------------------: | ------------------: |
| 10 | 0.0014 | 0.2512 | 0.8744 | 9 | 87.4% |
| 50 | 0.0069 | 0.3548 | 0.8226 | 42 | 82.3% |
| 100 | 0.0138 | 0.3981 | 0.8010 | 81 | 80.1% |
| 120 | 0.0166 | 0.4102 | 0.7949 | 96 | 79.5% |
| 200 | 0.0277 | 0.4467 | 0.7766 | 156 | 77.7% |
| 500 | 0.0692 | 0.5129 | 0.7435 | 372 | 74.4% |
| 1000 | 0.1384 | 0.5754 | 0.7123 | 713 | 71.2% |
| 2000 | 0.2769 | 0.6452 | 0.6774 | 1355 | 67.7% |
| 3612 | 0.5000 | 0.7071 | 0.6464 | 2335 | 64.6% |
| 5000 | 0.6920 | 0.7490 | 0.6255 | 3128 | 62.6% |
| 7224 | 1.0000 | 1.0000 | 0.5000 | 3612 | 50.0% |
On constate que meme a 500 votants (6.9% de participation), il faut encore 74.4% de votes favorables. L'inertie ne descend en dessous de 66% qu'au-dela de 2000 votants (27.7% de participation).
### Decomposition pas a pas
Pour le vote de l'Engagement Forgeron v2.0.0 avec $W = 7224$, $T = 120$, $M = 0.50$, $B = 0.1$, $G = 0.2$, $C = 0$ :
$$
\begin{aligned}
B^W &= 0.1^{7224} \approx 0 \\[6pt]
\frac{T}{W} &= \frac{120}{7224} \approx 0.0166 \\[6pt]
\left(\frac{T}{W}\right)^G &= 0.0166^{0.2} \approx 0.4102 \\[6pt]
1 - \left(\frac{T}{W}\right)^G &\approx 0.5898 \\[6pt]
\text{inertia} &= 0.50 + 0.50 \times 0.5898 = 0.7949 \\[6pt]
\text{Result} &= 0 + 0 + 0.7949 \times \max(0, 120 - 0) \\[3pt]
&= 0.7949 \times 120 \approx 95.39 \\[3pt]
&\Rightarrow \text{Seuil} = 94 \quad \text{(arrondi inferieur)}
\end{aligned}
$$
Resultat : **adopte** (97 >= 94).
## Critere Smith (Forgerons) ## Critere Smith (Forgerons)
@@ -52,9 +94,19 @@ $$
Le critere Smith exige un nombre minimum de votes favorables de la part des membres Smith (forgerons) pour que certaines decisions soient valides. Le critere Smith exige un nombre minimum de votes favorables de la part des membres Smith (forgerons) pour que certaines decisions soient valides.
| Symbole | Parametre | Description | Defaut | | Symbole | Parametre | Description | Plage | Defaut |
| ------- | ---------------- | ---------------------------- | ------ | | ------- | ---------------- | ---------------------------- | ---------------- | ------------------ |
| $S$ | `smith_exponent` | Exposant pour le critere Smith | null (desactive) | | $S$ | `smith_exponent` | Exposant pour le critere Smith | $]0, 1]$ | null (desactive) |
L'exposant $S$ controle l'exigence en fonction de la taille du groupe Smith. Plus $S$ est petit, moins de forgerons doivent voter favorablement. Plus $S$ est grand (proche de 1), plus le seuil se rapproche de la taille totale du groupe.
| SmithWotSize | $S = 0.1$ | $S = 0.3$ | $S = 0.5$ | $S = 0.7$ |
| -----------: | --------: | --------: | --------: | --------: |
| 5 | 2 | 2 | 3 | 4 |
| 10 | 2 | 2 | 4 | 6 |
| 20 | 2 | 3 | 5 | 10 |
| 50 | 2 | 4 | 8 | 19 |
| 100 | 2 | 4 | 10 | 26 |
Avec un exposant de $S = 0.1$ et 20 forgerons : Avec un exposant de $S = 0.1$ et 20 forgerons :
@@ -67,16 +119,16 @@ Au minimum 2 votes favorables de forgerons sont requis.
## Critere TechComm (Comite Technique) ## Critere TechComm (Comite Technique)
$$ $$
\text{TechCommThreshold} = \lceil \text{CoTecSize}^T \rceil \text{TechCommThreshold} = \lceil \text{CoTecSize}^{T_c} \rceil
$$ $$
Le critere TechComm fonctionne de maniere identique au critere Smith mais pour les membres du Comite Technique. Le critere TechComm fonctionne de maniere identique au critere Smith mais pour les membres du Comite Technique.
| Symbole | Parametre | Description | Defaut | | Symbole | Parametre | Description | Plage | Defaut |
| ------- | ------------------- | ------------------------------- | ------ | | ------- | ------------------- | ------------------------------- | -------------- | ------------------ |
| $T$ | `techcomm_exponent` | Exposant pour le critere TechComm | null (desactive) | | $T_c$ | `techcomm_exponent` | Exposant pour le critere TechComm | $]0, 1]$ | null (desactive) |
Avec un exposant de $T = 0.1$ et 5 membres TechComm : Avec un exposant de $T_c = 0.1$ et 5 membres TechComm :
$$ $$
\lceil 5^{0.1} \rceil = \lceil 1.17 \rceil = 2 \lceil 5^{0.1} \rceil = \lceil 1.17 \rceil = 2
@@ -92,21 +144,27 @@ Un vote est **adopte** si et seulement si les trois conditions sont remplies sim
2. `smith_votes_for >= seuil_Smith` (si critere Smith actif) 2. `smith_votes_for >= seuil_Smith` (si critere Smith actif)
3. `techcomm_votes_for >= seuil_TechComm` (si critere TechComm actif) 3. `techcomm_votes_for >= seuil_TechComm` (si critere TechComm actif)
En notation formelle :
$$
\text{Adopte} = \left( V_{\text{pour}} \geq \text{Seuil}_{\text{WoT}} \right) \;\wedge\; \left( S_{\text{actif}} \Rightarrow V_{\text{smith}} \geq \lceil W_S^S \rceil \right) \;\wedge\; \left( T_{\text{actif}} \Rightarrow V_{\text{tech}} \geq \lceil W_T^{T_c} \rceil \right)
$$
## Parametres de mode (mode_params) ## Parametres de mode (mode_params)
Les parametres de formule sont encodes dans une chaine compacte pour faciliter la lecture et le partage. Format : une lettre majuscule suivie d'une valeur numerique. Les parametres de formule sont encodes dans une chaine compacte pour faciliter la lecture et le partage. Format : une lettre majuscule suivie d'une valeur numerique.
| Code | Parametre | Type | Exemple | | Code | Parametre | Type | Plage | Exemple |
| ---- | --------------------- | ----- | ------------ | | ---- | --------------------- | ----- | -------------- | ------------ |
| D | `duration_days` | int | D30 = 30 jours | | D | `duration_days` | int | $[1, 365]$ | D30 = 30 jours |
| M | `majority_pct` | int | M50 = 50% | | M | `majority_pct` | int | $[1, 100]$ | M50 = 50% |
| B | `base_exponent` | float | B.1 = 0.1 | | B | `base_exponent` | float | $]0, 1[$ | B.1 = 0.1 |
| G | `gradient_exponent` | float | G.2 = 0.2 | | G | `gradient_exponent` | float | $]0, +\infty[$| G.2 = 0.2 |
| C | `constant_base` | float | C0 = 0.0 | | C | `constant_base` | float | $[0, +\infty[$| C0 = 0.0 |
| S | `smith_exponent` | float | S.1 = 0.1 | | S | `smith_exponent` | float | $]0, 1]$ | S.1 = 0.1 |
| T | `techcomm_exponent` | float | T.1 = 0.1 | | T | `techcomm_exponent` | float | $]0, 1]$ | T.1 = 0.1 |
| N | `ratio_multiplier` | float | N1.5 = 1.5 | | N | `ratio_multiplier` | float | $]0, +\infty[$| N1.5 = 1.5 |
| R | `is_ratio_mode` | bool | R1 = true | | R | `is_ratio_mode` | bool | $\{0, 1\}$ | R1 = true |
### Exemples ### Exemples
@@ -118,14 +176,14 @@ Les parametres de formule sont encodes dans une chaine compacte pour faciliter l
En plus du vote binaire (pour/contre), Glibredecision supporte un vote nuance a 6 niveaux : En plus du vote binaire (pour/contre), Glibredecision supporte un vote nuance a 6 niveaux :
| Niveau | Label | | Niveau | Label | Valeur normalisee |
| ------ | ------------- | | ------ | ------------- | ----------------: |
| 0 | CONTRE | | 0 | CONTRE | 0.0 |
| 1 | PAS DU TOUT | | 1 | PAS DU TOUT | 0.2 |
| 2 | PAS D'ACCORD | | 2 | PAS D'ACCORD | 0.4 |
| 3 | NEUTRE | | 3 | NEUTRE | 0.6 |
| 4 | D'ACCORD | | 4 | D'ACCORD | 0.8 |
| 5 | TOUT A FAIT | | 5 | TOUT A FAIT | 1.0 |
### Regle d'adoption (vote nuance) ### Regle d'adoption (vote nuance)
@@ -134,4 +192,93 @@ Un vote nuance est adopte si :
1. Le nombre de votes aux niveaux 3, 4 et 5 (positifs) represente au moins `threshold_pct`% du total des votes. 1. Le nombre de votes aux niveaux 3, 4 et 5 (positifs) represente au moins `threshold_pct`% du total des votes.
2. Le nombre minimum de participants (`min_participants`) est atteint. 2. Le nombre minimum de participants (`min_participants`) est atteint.
En notation formelle :
$$
\text{Adopte}_{\text{nuance}} = \left( \frac{V_3 + V_4 + V_5}{T} \geq \frac{\text{threshold\_pct}}{100} \right) \;\wedge\; \left( T \geq \text{min\_participants} \right)
$$
Par defaut : `threshold_pct = 80%`, `min_participants = 59`. Par defaut : `threshold_pct = 80%`, `min_participants = 59`.
## Simulateur de formules
L'API expose un endpoint de simulation qui permet de tester le comportement de la formule sans creer de session de vote. Cela permet aux utilisateurs d'experimenter differentes configurations avant de les proposer.
### Utilisation de l'API de simulation
**Endpoint** : `POST /api/v1/protocols/simulate`
**Exemple de requete** :
```bash
curl -X POST https://glibredecision.example.org/api/v1/protocols/simulate \
-H "Content-Type: application/json" \
-d '{
"wot_size": 7224,
"total_votes": 120,
"majority_pct": 50,
"base_exponent": 0.1,
"gradient_exponent": 0.2,
"constant_base": 0.0,
"votes_for": 97
}'
```
**Exemple de reponse** :
```json
{
"threshold": 94,
"inertia_factor": 0.7949,
"participation_ratio": 0.0166,
"base_term": 0.0,
"smith_threshold": null,
"techcomm_threshold": null,
"adopted": true,
"details": {
"wot_threshold_met": true,
"smith_met": null,
"techcomm_met": null
}
}
```
### Cas d'usage du simulateur
1. **Calibration** : Tester differentes valeurs de $M$ et $G$ pour trouver un equilibre entre exigence et praticabilite.
2. **Comparaison** : Comparer les seuils entre deux configurations sur les memes donnees de participation.
3. **Prediction** : Estimer le seuil pour un vote a venir en fonction de la participation attendue.
4. **Pedagogie** : Comprendre visuellement l'impact de chaque parametre sur le seuil.
## Meta-gouvernance
La meta-gouvernance est le mecanisme par lequel les **regles du vote elles-memes** sont soumises au vote. Concretement, modifier un protocole de vote ou une formule de seuil active est un acte de gouvernance qui necessite une validation collective.
### Principe
Quand un utilisateur propose une modification d'un protocole ou d'une formule en cours d'utilisation (lies a au moins une session ouverte), le systeme :
1. **Bloque la modification directe** (reponse HTTP 409).
2. **Cree automatiquement une session de vote** portant sur la modification proposee.
3. **Applique le protocole de vote en vigueur** pour valider ou rejeter la modification.
4. Si le vote est **adopte**, la modification est appliquee aux futures sessions de vote. Les sessions en cours ne sont pas affectees.
5. Si le vote est **rejete**, la modification est archivee et le protocole reste inchange.
### Quels objets sont proteges ?
| Objet | Endpoint de modification | Meta-gouvernance |
| ----------------- | -------------------------- | ---------------- |
| Protocole actif | `PUT /api/v1/protocols/{id}` | Oui |
| Formule active | `PUT /api/v1/protocols/formulas/{id}` | Oui |
| Protocole inactif | `PUT /api/v1/protocols/{id}` | Non (modification directe) |
| Formule inactive | `PUT /api/v1/protocols/formulas/{id}` | Non (modification directe) |
Un protocole ou une formule est considere **actif** s'il est lie a au moins une session de vote au statut `open`.
### Cas d'usage
- **Changer la duree de vote** : Un membre propose de passer de 30 a 60 jours. La proposition est soumise au vote avec les regles actuelles. Si adoptee, les prochains votes dureront 60 jours.
- **Modifier le gradient** : Un membre estime que le gradient $G = 0.2$ est trop permissif et propose $G = 0.4$. Le vote decide.
- **Ajouter un critere Smith** : Un membre propose d'activer le critere Smith avec $S = 0.1$ pour impliquer les forgerons. Le vote decide.
La meta-gouvernance garantit qu'aucun parametre fondamental du systeme de decision ne peut etre modifie unilateralement.

View File

@@ -30,17 +30,225 @@ Chaque votant exprime son opinion sur une echelle a 6 niveaux :
Le vote est adopte si les niveaux positifs (3, 4, 5) representent au moins 80% des votes et qu'un nombre minimum de participants est atteint. Le vote est adopte si les niveaux positifs (3, 4, 5) representent au moins 80% des votes et qu'un nombre minimum de participants est atteint.
## Comment voter ## Comment voter -- Vote binaire
1. Rendez-vous sur une session de vote ouverte (via la section **Votes** ou la page d'une decision). ### Etape par etape
2. Choisissez votre vote (pour/contre en binaire, ou un niveau en nuance).
3. Ajoutez un commentaire optionnel pour expliquer votre choix. 1. **Acceder a la session de vote** : Rendez-vous dans la section **Votes** depuis le menu principal, ou accedez directement a la page d'une decision en cours. Les sessions ouvertes sont signalees par un badge vert "En cours".
4. **Signez votre vote** : la plateforme vous demande de signer un payload avec votre cle privee Ed25519.
5. Soumettez. Votre vote est enregistre avec la signature cryptographique. 2. **Consulter le sujet** : Lisez attentivement le document ou la decision soumise au vote. Le texte complet est affiche au-dessus du panneau de vote. Vous pouvez deployer chaque article pour consulter le detail.
3. **Choisir votre vote** : Le panneau de vote affiche deux boutons :
- **Pour** (bouton vert) : Vous approuvez la proposition.
- **Contre** (bouton rouge) : Vous rejetez la proposition.
4. **Ajouter un commentaire** (optionnel) : Un champ de texte sous les boutons vous permet d'expliquer votre choix. Le commentaire est public et visible par tous les membres.
5. **Signer votre vote** : Apres avoir clique sur votre choix, la plateforme genere un payload contenant votre vote, l'identifiant de la session et un horodatage. Votre extension de cle ou votre portefeuille Duniter vous demande de **signer ce payload** avec votre cle privee Ed25519.
6. **Confirmer la soumission** : Une fois la signature effectuee, cliquez sur **Soumettre**. Un message de confirmation apparait avec le resume de votre vote.
### Modifier son vote ### Modifier son vote
Vous pouvez modifier votre vote tant que la session est ouverte. L'ancien vote est desactive (conserve pour l'audit) et remplace par le nouveau. Tant que la session est ouverte, vous pouvez changer votre vote :
1. Retournez sur la page de la session de vote.
2. Votre vote actuel est affiche avec un badge "Votre vote".
3. Cliquez sur **Modifier mon vote**.
4. Selectionnez votre nouveau choix et signez a nouveau.
5. L'ancien vote est desactive (conserve dans l'historique pour audit) et remplace par le nouveau.
## Comment voter -- Vote nuance
### Etape par etape
1. **Acceder a la session** : Meme procedure que pour le vote binaire. Les sessions de vote nuance sont identifiees par un badge "Nuance" en plus du badge de statut.
2. **Consulter le sujet** : Lisez le document ou la decision soumise au vote.
3. **Choisir votre niveau** : Le panneau de vote nuance affiche une echelle a 6 niveaux sous forme de curseur ou de boutons :
| Niveau | Label | Signification |
| -----: | ------------- | ---------------------------------------------------------- |
| 0 | CONTRE | Opposition totale et ferme |
| 1 | PAS DU TOUT | Desaccord fort, points essentiels non remplis |
| 2 | PAS D'ACCORD | Desaccord modere, des reserves importantes |
| 3 | NEUTRE | Ni pour ni contre, ou acceptation sans enthousiasme |
| 4 | D'ACCORD | Approbation avec eventuellement des reserves mineures |
| 5 | TOUT A FAIT | Approbation totale et enthousiaste |
Les niveaux 3, 4 et 5 sont comptes comme **positifs**. Les niveaux 0, 1 et 2 sont comptes comme **negatifs**.
4. **Ajouter un commentaire** (optionnel) : Particulierement utile pour les niveaux intermediaires (1, 2, 3, 4), afin d'expliquer vos reserves ou vos attentes.
5. **Signer et soumettre** : Meme procedure que pour le vote binaire.
### Quand utiliser le vote nuance ?
Le vote nuance est recommande pour :
- Les textes longs comportant de nombreux articles (les votants peuvent exprimer un accord partiel).
- Les decisions ou la nuance est importante (budget, parametre technique).
- Les cas ou il est utile de mesurer le degre d'adhesion, pas seulement le resultat binaire.
## Comprendre la jauge de seuil
La page de chaque session de vote affiche une **jauge de seuil** qui represente visuellement l'etat du vote en temps reel.
### Elements de la jauge
```
[|||||||||||||||||||||||||||-----] 97 / 94 Adopte
^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^
Votes Pour (vert) Seuil requis (trait vertical)
```
| Element | Description |
| --------------------------- | ------------------------------------------------------------ |
| **Barre verte** | Nombre de votes favorables actuels |
| **Barre rouge** | Nombre de votes defavorables actuels |
| **Trait vertical** | Position du seuil requis (calcule par la formule d'inertie) |
| **Compteur numerique** | `votes_pour / seuil` affiche a droite de la jauge |
| **Badge de resultat** | "Adopte" (vert) ou "Non atteint" (gris) ou "Rejete" (rouge) |
| **Pourcentage de participation** | `total_votes / wot_size` affiche sous la jauge |
### Comportement dynamique
La jauge se **met a jour en temps reel** : a chaque nouveau vote soumis, la barre et le seuil sont recalcules instantanement. Le seuil peut augmenter legerement a mesure que de nouveaux votes arrivent (car la participation $T$ change, ce qui modifie le facteur d'inertie). Le resultat affiche est donc toujours provisoire tant que la session est ouverte.
### Detail du calcul
En cliquant sur le bouton **Voir le detail** sous la jauge, une modale s'ouvre avec :
- Les parametres de la formule utilises (`mode_params`).
- Les valeurs intermediaires du calcul (ratio de participation, facteur d'inertie, etc.).
- Les criteres Smith et TechComm si applicables.
- Un lien vers le simulateur de formules pour experimenter d'autres scenarios.
## Mises a jour en temps reel
Glibredecision utilise une connexion **WebSocket** pour diffuser les mises a jour de vote en temps reel a tous les utilisateurs qui consultent une session de vote.
### Ce qui est mis a jour en direct
| Evenement | Effet sur l'interface |
| ---------------------------- | ---------------------------------------------------- |
| Nouveau vote soumis | La jauge de seuil est recalculee et reaffichee |
| Vote modifie | La jauge reflette le changement immediatement |
| Session cloturee | Le badge passe a "Cloture" et le resultat final s'affiche |
| Critere Smith/TechComm atteint | L'indicateur de critere passe au vert |
### Indicateur de connexion
Un petit indicateur en bas a droite de la page de vote affiche l'etat de la connexion temps reel :
- **Point vert** : Connexion active, mises a jour en direct.
- **Point orange** : Reconnexion en cours.
- **Point rouge** : Connexion perdue. Les donnees affichees peuvent etre obsoletes. Rechargez la page.
## Historique des votes
### Consulter ses propres votes
1. Cliquez sur votre avatar ou votre adresse Duniter dans la barre de navigation.
2. Selectionnez **Mon historique de votes**.
3. La liste affiche tous vos votes passes, avec pour chacun :
- Le titre de la session / decision.
- Votre choix (Pour/Contre ou niveau nuance).
- La date et l'heure du vote.
- Le resultat final de la session (Adopte/Rejete) si cloturee.
### Consulter les votes d'une session
Sur la page d'une session de vote, l'onglet **Votes** affiche la liste de tous les votes soumis :
- L'adresse Duniter du votant.
- Le choix exprime.
- Le commentaire (s'il y en a un).
- L'horodatage.
- Un lien pour verifier la signature Ed25519.
Les votes sont publics et verifiables par tous. Il n'y a pas de vote secret dans Glibredecision : la transparence est un principe fondamental.
## Simulateur de formules
Le simulateur de formules vous permet de tester le comportement du seuil d'adoption avec differentes configurations, sans creer de session de vote.
### Acceder au simulateur
1. Depuis le menu principal, selectionnez **Outils** puis **Simulateur de formules**.
2. Vous pouvez aussi y acceder depuis le detail du calcul de seuil d'une session de vote (bouton **Ouvrir le simulateur**).
### Utiliser le simulateur
Le simulateur presente un formulaire avec les parametres ajustables :
| Parametre | Description | Curseur/Champ |
| ----------------------- | ---------------------------------------------- | ------------- |
| Taille WoT ($W$) | Nombre de membres eligibles | Curseur |
| Total votes ($T$) | Nombre de votes exprimes | Curseur |
| Majorite ($M$) | Pourcentage de majorite cible | Curseur |
| Base ($B$) | Exposant de base | Champ |
| Gradient ($G$) | Vitesse de convergence | Curseur |
| Base constante ($C$) | Plancher minimum de votes | Champ |
| Votes Pour | Nombre de votes favorables (pour tester l'adoption) | Curseur |
En ajustant les curseurs, le resultat se met a jour en temps reel :
- Le **seuil calcule** est affiche.
- Un graphique montre la courbe du seuil en fonction de la participation.
- Le resultat **Adopte** / **Rejete** s'affiche en fonction des votes pour saisis.
### Exemple de scenario
Vous souhaitez comparer deux configurations pour un vote attendu avec environ 200 participants sur une WoT de 7224 :
1. Configuration actuelle `M50 G.2` : Seuil = 156 (77.7% requis).
2. Configuration proposee `M50 G.4` : Seuil = 171 (85.5% requis).
Le simulateur montre visuellement l'impact : avec un gradient plus eleve, l'exigence est sensiblement plus forte a faible participation.
## Meta-gouvernance : voter sur les regles du vote
La meta-gouvernance est la capacite de **modifier les regles du systeme de vote en utilisant le systeme de vote lui-meme**. C'est le mecanisme par lequel la communaute garde le controle sur les parametres fondamentaux de la prise de decision.
### Qu'est-ce qui peut etre modifie par meta-gouvernance ?
| Element modifiable | Exemple de modification |
| ------------------------------- | ---------------------------------------------------------- |
| Duree de vote | Passer de 30 jours a 60 jours |
| Majorite cible ($M$) | Passer de 50% a 66% (super-majorite) |
| Gradient ($G$) | Augmenter de 0.2 a 0.4 (plus exigeant a faible participation) |
| Critere Smith | Activer ou desactiver, changer l'exposant |
| Critere TechComm | Activer ou desactiver, changer l'exposant |
| Type de vote | Passer d'un vote binaire a un vote nuance |
### Comment ca fonctionne ?
1. **Proposition** : Un membre propose une modification d'un protocole ou d'une formule via l'interface (bouton **Proposer une modification** sur la page du protocole).
2. **Verification** : Le systeme detecte que le protocole ou la formule est **actif** (lie a au moins une session de vote ouverte).
3. **Creation automatique d'un vote** : Une session de vote est automatiquement creee pour valider ou rejeter la modification. Cette session utilise les **regles actuelles** (pas les regles proposees).
4. **Vote de la communaute** : Les membres votent sur la proposition de modification selon les regles en vigueur.
5. **Application conditionnelle** :
- Si le vote est **adopte** : les nouvelles regles s'appliquent aux futures sessions de vote. Les sessions en cours ne sont pas affectees.
- Si le vote est **rejete** : le protocole reste inchange.
### Exemple concret
Un membre estime que le gradient $G = 0.2$ est trop permissif et propose de passer a $G = 0.4$ :
1. Il se rend sur la page du protocole actif et clique sur **Proposer une modification**.
2. Il modifie la valeur du gradient de 0.2 a 0.4 dans le formulaire.
3. Il soumet la proposition. Le systeme cree une session de vote intitulee "Modification du gradient de seuil : G.2 vers G.4".
4. Les membres votent pendant 30 jours (duree du protocole actuel).
5. Avec 97 votes pour et 23 contre sur une WoT de 7224, le seuil calcule est de 94. Le vote est adopte (97 >= 94).
6. Le gradient passe a 0.4 pour toutes les futures sessions de vote.
### Pourquoi la meta-gouvernance est importante
- **Aucun parametre n'est grave dans le marbre** : la communaute peut toujours adapter les regles.
- **Protection contre les modifications unilaterales** : personne ne peut changer les regles seul.
- **Transparence** : toute modification de regle est tracable et soumise au vote.
- **Coherence** : les regles de modification sont les memes que les regles de decision (le systeme s'applique a lui-meme).
## Comprendre les resultats ## Comprendre les resultats

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
/**
* Interactive formula parameter editor.
*
* Provides sliders and inputs for adjusting all formula parameters,
* emitting the updated config on each change.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
modelValue: FormulaConfig
}>()
const emit = defineEmits<{
'update:modelValue': [value: FormulaConfig]
}>()
/** Local reactive copy to avoid direct prop mutation. */
const local = reactive({ ...props.modelValue })
/** Sync incoming prop changes. */
watch(() => props.modelValue, (newVal) => {
Object.assign(local, newVal)
}, { deep: true })
/** Emit on any local change. */
watch(local, () => {
emit('update:modelValue', { ...local })
}, { deep: true })
/** Track optional fields. */
const showSmith = ref(local.smith_exponent !== null)
const showTechcomm = ref(local.techcomm_exponent !== null)
const showNuancedMin = ref(local.nuanced_min_participants !== null)
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
watch(showSmith, (v) => {
local.smith_exponent = v ? 0.5 : null
})
watch(showTechcomm, (v) => {
local.techcomm_exponent = v ? 0.5 : null
})
watch(showNuancedMin, (v) => {
local.nuanced_min_participants = v ? 10 : null
})
watch(showNuancedThreshold, (v) => {
local.nuanced_threshold_pct = v ? 66 : null
})
interface ParamDef {
key: string
label: string
description: string
type: 'input' | 'slider'
min: number
max: number
step: number
}
const mainParams: ParamDef[] = [
{
key: 'duration_days',
label: 'Duree (jours)',
description: 'Duree du vote en jours',
type: 'input',
min: 1,
max: 365,
step: 1,
},
{
key: 'majority_pct',
label: 'Majorite (%)',
description: 'Ratio de majorite cible a haute participation',
type: 'slider',
min: 0,
max: 100,
step: 1,
},
{
key: 'base_exponent',
label: 'Exposant de base (B)',
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
type: 'slider',
min: 0.01,
max: 1.0,
step: 0.01,
},
{
key: 'gradient_exponent',
label: 'Gradient d\'inertie (G)',
description: 'Controle la vitesse de transition vers la majorite simple',
type: 'slider',
min: 0.01,
max: 2.0,
step: 0.01,
},
{
key: 'constant_base',
label: 'Constante de base (C)',
description: 'Plancher fixe de votes requis',
type: 'input',
min: 0,
max: 100,
step: 1,
},
]
</script>
<template>
<div class="space-y-6">
<!-- Main parameters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ param.label }}
</label>
<span class="text-sm font-mono font-bold text-primary">
{{ (local as any)[param.key] }}
</span>
</div>
<template v-if="param.type === 'slider'">
<URange
v-model="(local as any)[param.key]"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<template v-else>
<UInput
v-model.number="(local as any)[param.key]"
type="number"
:min="param.min"
:max="param.max"
:step="param.step"
/>
</template>
<p class="text-xs text-gray-500">{{ param.description }}</p>
</div>
</div>
<!-- Optional parameters -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
Parametres optionnels
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Smith exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showSmith" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere Smith (S)
</label>
</div>
<template v-if="showSmith && local.smith_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant Smith</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
</div>
<URange
v-model="local.smith_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
</template>
</div>
<!-- TechComm exponent -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showTechcomm" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Critere TechComm (T)
</label>
</div>
<template v-if="showTechcomm && local.techcomm_exponent !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Exposant TechComm</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
</div>
<URange
v-model="local.techcomm_exponent"
:min="0.01"
:max="1.0"
:step="0.01"
/>
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
</template>
</div>
<!-- Nuanced min participants -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedMin" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Participants minimum (nuance)
</label>
</div>
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
<UInput
v-model.number="local.nuanced_min_participants"
type="number"
:min="1"
:max="1000"
:step="1"
/>
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
</template>
</div>
<!-- Nuanced threshold pct -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<UCheckbox v-model="showNuancedThreshold" />
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Seuil nuance (%)
</label>
</div>
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
</div>
<URange
v-model="local.nuanced_threshold_pct"
:min="50"
:max="100"
:step="1"
/>
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
</template>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
/**
* Display decoded mode params string as labeled badges/chips.
*
* Parses the compact mode params string and renders each parameter
* as a human-readable chip.
*/
const props = defineProps<{
modeParams: string
}>()
interface ParamChip {
code: string
label: string
value: string
color: string
}
const chips = computed((): ParamChip[] => {
if (!props.modeParams) return []
try {
const parsed = parseModeParams(props.modeParams)
const result: ParamChip[] = []
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
if (parsed.constant_base > 0) {
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
}
if (parsed.smith_exponent !== null) {
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
}
if (parsed.techcomm_exponent !== null) {
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
}
return result
} catch {
return []
}
})
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
<UBadge
v-for="chip in chips"
:key="chip.code"
:color="(chip.color as any)"
variant="subtle"
size="xs"
>
{{ chip.label }}: {{ chip.value }}
</UBadge>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
/**
* Dropdown/select to pick a voting protocol.
*
* Fetches protocols from the store and renders them in a USelect
* with protocol name, mode params, and vote type badge.
*/
import type { VotingProtocol } from '~/stores/protocols'
const props = defineProps<{
modelValue: string | null
voteType?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const protocols = useProtocolsStore()
onMounted(async () => {
if (protocols.protocols.length === 0) {
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
}
})
const filteredProtocols = computed(() => {
if (!props.voteType) return protocols.protocols
return protocols.protocols.filter(p => p.vote_type === props.voteType)
})
const options = computed(() => {
return filteredProtocols.value.map(p => ({
label: buildLabel(p),
value: p.id,
}))
})
function buildLabel(p: VotingProtocol): string {
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
const params = p.mode_params ? ` [${p.mode_params}]` : ''
return `${p.name} - ${typeLabel}${params}`
}
function onSelect(value: string) {
emit('update:modelValue', value || null)
}
</script>
<template>
<div>
<USelect
:model-value="modelValue ?? undefined"
:items="options"
placeholder="Selectionnez un protocole..."
:loading="protocols.loading"
value-key="value"
@update:model-value="onSelect"
/>
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
{{ protocols.error }}
</p>
</div>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
/**
* Display vote formula with KaTeX rendering.
*
* Renders the WoT threshold formula using KaTeX when available,
* falling back to a code display. Shows parameter values and
* optional Smith/TechComm criteria formulas.
*/
import type { FormulaConfig } from '~/stores/protocols'
const props = defineProps<{
formulaConfig: FormulaConfig
showExplanation?: boolean
}>()
const showExplain = ref(props.showExplanation ?? false)
/**
* Render a LaTeX string to HTML using KaTeX, with code fallback.
*/
function renderFormula(tex: string): string {
if (typeof window !== 'undefined' && (window as any).katex) {
return (window as any).katex.renderToString(tex, { throwOnError: false, displayMode: true })
}
return `<code class="text-sm font-mono">${tex}</code>`
}
/** Main threshold formula in LaTeX. */
const mainFormulaTeX = 'Seuil = C + B^W + \\left(M + (1-M) \\cdot \\left(1 - \\left(\\frac{T}{W}\\right)^G\\right)\\right) \\cdot \\max(0,\\, T - C)'
/** Smith criterion formula. */
const smithFormulaTeX = computed(() => {
if (props.formulaConfig.smith_exponent === null) return null
return `Seuil_{Smith} = \\lceil W_{Smith}^{${props.formulaConfig.smith_exponent}} \\rceil`
})
/** TechComm criterion formula. */
const techcommFormulaTeX = computed(() => {
if (props.formulaConfig.techcomm_exponent === null) return null
return `Seuil_{TechComm} = \\lceil W_{TechComm}^{${props.formulaConfig.techcomm_exponent}} \\rceil`
})
const mainFormulaHtml = computed(() => renderFormula(mainFormulaTeX))
const smithFormulaHtml = computed(() => smithFormulaTeX.value ? renderFormula(smithFormulaTeX.value) : null)
const techcommFormulaHtml = computed(() => techcommFormulaTeX.value ? renderFormula(techcommFormulaTeX.value) : null)
const parameters = computed(() => [
{ label: 'Duree', code: 'D', value: `${props.formulaConfig.duration_days} jours`, description: 'Duree du vote en jours' },
{ label: 'Majorite', code: 'M', value: `${props.formulaConfig.majority_pct}%`, description: 'Ratio de majorite cible a haute participation' },
{ label: 'Base', code: 'B', value: String(props.formulaConfig.base_exponent), description: 'Exposant de base (B^W tend vers 0 si B < 1)' },
{ label: 'Gradient', code: 'G', value: String(props.formulaConfig.gradient_exponent), description: 'Exposant du gradient d\'inertie' },
{ label: 'Constante', code: 'C', value: String(props.formulaConfig.constant_base), description: 'Plancher fixe de votes requis' },
...(props.formulaConfig.smith_exponent !== null ? [{
label: 'Smith', code: 'S', value: String(props.formulaConfig.smith_exponent), description: 'Exposant du critere Smith',
}] : []),
...(props.formulaConfig.techcomm_exponent !== null ? [{
label: 'TechComm', code: 'T', value: String(props.formulaConfig.techcomm_exponent), description: 'Exposant du critere TechComm',
}] : []),
])
</script>
<template>
<div class="space-y-4">
<!-- Main formula -->
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg overflow-x-auto">
<div v-html="mainFormulaHtml" class="text-center" />
</div>
<!-- Smith criterion -->
<div v-if="smithFormulaHtml" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">Critere Smith</p>
<div v-html="smithFormulaHtml" class="text-center" />
</div>
<!-- TechComm criterion -->
<div v-if="techcommFormulaHtml" class="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg overflow-x-auto">
<p class="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">Critere TechComm</p>
<div v-html="techcommFormulaHtml" class="text-center" />
</div>
<!-- Parameters grid -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div
v-for="param in parameters"
:key="param.code"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center gap-2 mb-1">
<span class="font-mono font-bold text-primary text-sm">{{ param.code }}</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ param.label }}</span>
</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ param.value }}</div>
<p v-if="showExplain" class="text-xs text-gray-500 mt-1">{{ param.description }}</p>
</div>
</div>
<!-- Explanation toggle -->
<div class="flex justify-end">
<UButton
variant="ghost"
color="neutral"
size="xs"
:icon="showExplain ? 'i-lucide-eye-off' : 'i-lucide-eye'"
@click="showExplain = !showExplain"
>
{{ showExplain ? 'Masquer les explications' : 'Afficher les explications' }}
</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* Visual gauge showing votes vs threshold.
*
* Displays a horizontal progress bar with green (pour) and red (contre) fills,
* a vertical threshold marker, participation statistics, and a pass/fail badge.
*/
const props = defineProps<{
votesFor: number
votesAgainst: number
threshold: number
wotSize: number
}>()
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
/** Percentage of "pour" votes relative to total votes. */
const forPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesFor / totalVotes.value) * 100
})
/** Percentage of "contre" votes relative to total votes. */
const againstPct = computed(() => {
if (totalVotes.value === 0) return 0
return (props.votesAgainst / totalVotes.value) * 100
})
/** Position of the threshold marker as a percentage of total votes. */
const thresholdPosition = computed(() => {
if (totalVotes.value === 0) return 50
// Threshold as a percentage of total votes
const pct = (props.threshold / totalVotes.value) * 100
return Math.min(pct, 100)
})
/** Whether the vote passes (votes_for >= threshold). */
const isPassing = computed(() => props.votesFor >= props.threshold)
/** Participation rate. */
const participationRate = computed(() => {
if (props.wotSize === 0) return 0
return (totalVotes.value / props.wotSize) * 100
})
</script>
<template>
<div class="space-y-3">
<!-- Progress bar -->
<div class="relative h-8 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<!-- Green fill (pour) -->
<div
class="absolute inset-y-0 left-0 bg-green-500 transition-all duration-500"
:style="{ width: `${forPct}%` }"
/>
<!-- Red fill (contre) -->
<div
class="absolute inset-y-0 bg-red-500 transition-all duration-500"
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
/>
<!-- Threshold marker -->
<div
v-if="totalVotes > 0"
class="absolute inset-y-0 w-0.5 bg-yellow-400 z-10"
:style="{ left: `${thresholdPosition}%` }"
>
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-yellow-600 dark:text-yellow-400 whitespace-nowrap">
Seuil
</div>
</div>
<!-- Percentage labels inside the bar -->
<div class="absolute inset-0 flex items-center px-3 text-xs font-bold text-white">
<span v-if="forPct > 10">{{ forPct.toFixed(1) }}%</span>
<span class="flex-1" />
<span v-if="againstPct > 10">{{ againstPct.toFixed(1) }}%</span>
</div>
</div>
<!-- Counts and threshold text -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<span class="text-green-600 dark:text-green-400 font-medium">
{{ votesFor }} pour
</span>
<span class="text-red-600 dark:text-red-400 font-medium">
{{ votesAgainst }} contre
</span>
</div>
<div class="font-medium" :class="isPassing ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'">
{{ votesFor }} / {{ threshold }} requis
</div>
</div>
<!-- Participation rate -->
<div class="flex items-center justify-between text-xs text-gray-500">
<span>
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} sur {{ wotSize }} membres
({{ participationRate.toFixed(2) }}%)
</span>
<!-- Pass/fail badge -->
<UBadge
:color="isPassing ? 'success' : 'error'"
variant="subtle"
size="xs"
>
{{ isPassing ? 'Adopte' : 'Non adopte' }}
</UBadge>
</div>
</div>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
/**
* Binary vote component: Pour / Contre.
*
* Displays two large buttons for binary voting with confirmation modal.
* Integrates with the votes store and auth store for submission and access control.
*/
const props = defineProps<{
sessionId: string
disabled?: boolean
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const submitting = ref(false)
const pendingVote = ref<'pour' | 'contre' | null>(null)
const showConfirm = ref(false)
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
})
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
})
function requestVote(value: 'pour' | 'contre') {
if (isDisabled.value) return
pendingVote.value = value
showConfirm.value = true
}
async function confirmVote() {
if (!pendingVote.value) return
showConfirm.value = false
submitting.value = true
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: pendingVote.value,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
pendingVote.value = null
}
}
function cancelVote() {
showConfirm.value = false
pendingVote.value = null
}
const confirmLabel = computed(() => {
return pendingVote.value === 'pour'
? 'Confirmer le vote POUR'
: 'Confirmer le vote CONTRE'
})
</script>
<template>
<div class="space-y-4">
<!-- Vote buttons -->
<div class="flex gap-4">
<UButton
size="xl"
:color="userVote?.vote_value === 'pour' ? 'success' : 'neutral'"
:variant="userVote?.vote_value === 'pour' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'pour'"
icon="i-lucide-thumbs-up"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('pour')"
>
Pour
</UButton>
<UButton
size="xl"
:color="userVote?.vote_value === 'contre' ? 'error' : 'neutral'"
:variant="userVote?.vote_value === 'contre' ? 'solid' : 'outline'"
:disabled="isDisabled"
:loading="submitting && pendingVote === 'contre'"
icon="i-lucide-thumbs-down"
class="flex-1 justify-center py-6 text-lg"
@click="requestVote('contre')"
>
Contre
</UButton>
</div>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ userVote.vote_value === 'pour' ? 'Pour' : 'Contre' }}
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter
<strong :class="pendingVote === 'pour' ? 'text-green-600' : 'text-red-600'">
{{ pendingVote === 'pour' ? 'POUR' : 'CONTRE' }}
</strong>.
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton
:color="pendingVote === 'pour' ? 'success' : 'error'"
@click="confirmVote"
>
{{ confirmLabel }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* Vote history list for a session.
*
* Displays a table of all votes cast in a session, sorted by date descending.
* For nuanced votes, shows the level label and color. Shows Smith/TechComm badges.
*/
import type { Vote } from '~/stores/votes'
const props = defineProps<{
votes: Vote[]
}>()
/** Nuanced level labels matching VoteNuanced component. */
const nuancedLabels: Record<number, { label: string; color: string }> = {
0: { label: 'CONTRE', color: 'error' },
1: { label: 'PAS DU TOUT D\'ACCORD', color: 'warning' },
2: { label: 'PAS D\'ACCORD', color: 'warning' },
3: { label: 'NEUTRE', color: 'neutral' },
4: { label: 'D\'ACCORD', color: 'success' },
5: { label: 'TOUT A FAIT D\'ACCORD', color: 'success' },
}
/** Sorted votes by date descending. */
const sortedVotes = computed(() => {
return [...props.votes].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
function truncateAddress(address: string): string {
if (address.length <= 16) return address
return `${address.slice(0, 8)}...${address.slice(-6)}`
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function voteLabel(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.label ?? `Niveau ${vote.nuanced_level}`
}
return vote.vote_value === 'pour' ? 'Pour' : 'Contre'
}
function voteColor(vote: Vote): string {
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
return nuancedLabels[vote.nuanced_level]?.color ?? 'neutral'
}
return vote.vote_value === 'pour' ? 'success' : 'error'
}
</script>
<template>
<div>
<div v-if="sortedVotes.length === 0" class="text-center py-8">
<UIcon name="i-lucide-vote" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun vote enregistre</p>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votant</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Vote</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Statut</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="vote in sortedVotes"
:key="vote.id"
class="border-b border-gray-100 dark:border-gray-800"
>
<!-- Voter address -->
<td class="px-4 py-3">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
{{ truncateAddress(vote.voter_id) }}
</span>
</td>
<!-- Vote value -->
<td class="px-4 py-3">
<UBadge :color="(voteColor(vote) as any)" variant="subtle" size="xs">
{{ voteLabel(vote) }}
</UBadge>
</td>
<!-- Smith / TechComm badges -->
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<UBadge v-if="vote.voter_is_smith" color="info" variant="subtle" size="xs">
Smith
</UBadge>
<UBadge v-if="vote.voter_is_techcomm" color="purple" variant="subtle" size="xs">
TechComm
</UBadge>
<span v-if="!vote.voter_is_smith && !vote.voter_is_techcomm" class="text-xs text-gray-400">
Membre
</span>
</div>
</td>
<!-- Date -->
<td class="px-4 py-3 text-xs text-gray-500">
{{ formatDate(vote.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
/**
* 6-level nuanced vote component.
*
* Displays 6 vote levels from CONTRE (0) to TOUT A FAIT D'ACCORD (5),
* each with a distinctive color. Negative votes (0-2) optionally include
* a comment textarea.
*/
const props = defineProps<{
sessionId: string
disabled?: boolean
}>()
const auth = useAuthStore()
const votes = useVotesStore()
const submitting = ref(false)
const selectedLevel = ref<number | null>(null)
const comment = ref('')
const showConfirm = ref(false)
interface NuancedLevel {
level: number
label: string
color: string
bgClass: string
textClass: string
ringClass: string
}
const levels: NuancedLevel[] = [
{ level: 0, label: 'CONTRE', color: 'red', bgClass: 'bg-red-500', textClass: 'text-red-600 dark:text-red-400', ringClass: 'ring-red-500' },
{ level: 1, label: 'PAS DU TOUT D\'ACCORD', color: 'orange-red', bgClass: 'bg-orange-600', textClass: 'text-orange-700 dark:text-orange-400', ringClass: 'ring-orange-600' },
{ level: 2, label: 'PAS D\'ACCORD', color: 'orange', bgClass: 'bg-orange-400', textClass: 'text-orange-600 dark:text-orange-300', ringClass: 'ring-orange-400' },
{ level: 3, label: 'NEUTRE', color: 'gray', bgClass: 'bg-gray-400', textClass: 'text-gray-600 dark:text-gray-400', ringClass: 'ring-gray-400' },
{ level: 4, label: 'D\'ACCORD', color: 'light-green', bgClass: 'bg-green-400', textClass: 'text-green-600 dark:text-green-400', ringClass: 'ring-green-400' },
{ level: 5, label: 'TOUT A FAIT D\'ACCORD', color: 'green', bgClass: 'bg-green-600', textClass: 'text-green-700 dark:text-green-300', ringClass: 'ring-green-600' },
]
/** Check if the current user has already voted in this session. */
const userVote = computed(() => {
if (!auth.identity) return null
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
})
/** Initialize selected level from existing vote. */
watchEffect(() => {
if (userVote.value?.nuanced_level !== undefined && userVote.value?.nuanced_level !== null) {
selectedLevel.value = userVote.value.nuanced_level
}
})
const isDisabled = computed(() => {
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
})
/** Whether the comment field should be shown (negative votes). */
const showComment = computed(() => {
return selectedLevel.value !== null && selectedLevel.value <= 2
})
function selectLevel(level: number) {
if (isDisabled.value) return
selectedLevel.value = level
showConfirm.value = true
}
async function confirmVote() {
if (selectedLevel.value === null) return
showConfirm.value = false
submitting.value = true
const voteValue = selectedLevel.value >= 3 ? 'pour' : 'contre'
try {
await votes.submitVote({
session_id: props.sessionId,
vote_value: voteValue,
nuanced_level: selectedLevel.value,
comment: showComment.value && comment.value.trim() ? comment.value.trim() : null,
signature: 'pending',
signed_payload: 'pending',
})
} finally {
submitting.value = false
}
}
function cancelVote() {
showConfirm.value = false
}
function getLevelLabel(level: number): string {
return levels.find(l => l.level === level)?.label ?? ''
}
</script>
<template>
<div class="space-y-4">
<!-- Level buttons -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<button
v-for="lvl in levels"
:key="lvl.level"
:disabled="isDisabled"
class="relative flex flex-col items-center p-4 rounded-lg border-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:class="[
selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level
? `ring-3 ${lvl.ringClass} border-transparent`
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
]"
@click="selectLevel(lvl.level)"
>
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
:class="lvl.bgClass"
>
{{ lvl.level }}
</div>
<span class="text-xs font-medium text-center leading-tight" :class="lvl.textClass">
{{ lvl.label }}
</span>
<!-- Selected indicator -->
<div
v-if="selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level"
class="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-xs"
:class="lvl.bgClass"
>
<UIcon name="i-lucide-check" class="w-3 h-3" />
</div>
</button>
</div>
<!-- Comment for negative votes -->
<div v-if="showComment" class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Commentaire (optionnel pour les votes negatifs)
</label>
<UTextarea
v-model="comment"
placeholder="Expliquez votre position..."
:rows="3"
:disabled="isDisabled"
/>
</div>
<!-- Status messages -->
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
Connectez-vous pour voter
</div>
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
Cette session de vote est fermee
</div>
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
Vous avez vote : {{ getLevelLabel(userVote.nuanced_level ?? 0) }}
</div>
<!-- Error display -->
<div v-if="votes.error" class="text-sm text-red-500 text-center">
{{ votes.error }}
</div>
<!-- Confirmation modal -->
<UModal v-model:open="showConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Confirmation du vote
</h3>
<p class="text-gray-600 dark:text-gray-400">
Vous etes sur le point de voter :
<strong>{{ getLevelLabel(selectedLevel ?? 0) }}</strong> (niveau {{ selectedLevel }}).
Cette action est definitive.
</p>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="cancelVote">
Annuler
</UButton>
<UButton color="primary" :loading="submitting" @click="confirmVote">
Confirmer le vote
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -0,0 +1,98 @@
/**
* Composable for real-time vote formula computation.
*
* Re-exports and wraps the threshold utility functions for reactive use
* in Vue components. Provides convenient methods for threshold calculations,
* inertia factor, required ratio, and adoption checks.
*/
import { wotThreshold, smithThreshold, techcommThreshold } from '~/utils/threshold'
export interface FormulaParams {
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
}
export function useVoteFormula() {
/**
* Compute the WoT threshold for a given set of parameters.
*/
function computeThreshold(wotSize: number, totalVotes: number, params: FormulaParams): number {
return wotThreshold(
wotSize,
totalVotes,
params.majority_pct,
params.base_exponent,
params.gradient_exponent,
params.constant_base,
)
}
/**
* Compute the inertia factor: 1 - (T/W)^G
* Ranges from ~1 (low participation) to ~0 (full participation).
*/
function computeInertiaFactor(
totalVotes: number,
wotSize: number,
gradientExponent: number,
): number {
if (wotSize <= 0 || totalVotes <= 0) return 1.0
const participationRatio = totalVotes / wotSize
return 1.0 - Math.pow(participationRatio, gradientExponent)
}
/**
* Compute the required ratio of "pour" votes at a given participation level.
* requiredRatio = M + (1 - M) * inertiaFactor
*/
function computeRequiredRatio(
totalVotes: number,
wotSize: number,
majorityPct: number,
gradientExponent: number,
): number {
const M = majorityPct / 100
const inertia = computeInertiaFactor(totalVotes, wotSize, gradientExponent)
return M + (1.0 - M) * inertia
}
/**
* Check whether a vote is adopted given all criteria.
* A vote is adopted when it passes the WoT threshold AND
* any applicable Smith/TechComm criteria.
*/
function isAdopted(
votesFor: number,
threshold: number,
smithVotesFor?: number,
smithThresholdVal?: number,
techcommVotesFor?: number,
techcommThresholdVal?: number,
): boolean {
// Main WoT criterion
if (votesFor < threshold) return false
// Smith criterion (if applicable)
if (smithThresholdVal !== undefined && smithThresholdVal > 0) {
if ((smithVotesFor ?? 0) < smithThresholdVal) return false
}
// TechComm criterion (if applicable)
if (techcommThresholdVal !== undefined && techcommThresholdVal > 0) {
if ((techcommVotesFor ?? 0) < techcommThresholdVal) return false
}
return true
}
return {
computeThreshold,
computeInertiaFactor,
computeRequiredRatio,
isAdopted,
smithThreshold,
techcommThreshold,
}
}

View File

@@ -0,0 +1,100 @@
/**
* Composable for WebSocket connectivity to receive live vote updates.
*
* Connects to the backend WS endpoint and allows subscribing to
* individual vote session channels for real-time tally updates.
*/
export function useWebSocket() {
const config = useRuntimeConfig()
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const connected = ref(false)
const lastMessage = ref<any>(null)
/**
* Open a WebSocket connection to the backend live endpoint.
*/
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return
}
const wsUrl = config.public.apiBase
.replace(/^http/, 'ws')
.replace(/\/api\/v1$/, '/api/v1/ws/live')
ws = new WebSocket(wsUrl)
ws.onopen = () => {
connected.value = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
ws.onclose = () => {
connected.value = false
reconnect()
}
ws.onerror = () => {
connected.value = false
}
ws.onmessage = (event: MessageEvent) => {
try {
lastMessage.value = JSON.parse(event.data)
} catch {
lastMessage.value = event.data
}
}
}
/**
* Subscribe to real-time updates for a vote session.
*/
function subscribe(sessionId: string) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'subscribe', session_id: sessionId }))
}
}
/**
* Unsubscribe from a vote session's updates.
*/
function unsubscribe(sessionId: string) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId }))
}
}
/**
* Gracefully close the WebSocket connection.
*/
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
connected.value = false
}
/**
* Schedule a reconnection attempt after a delay.
*/
function reconnect() {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, 3000)
}
return { connected, lastMessage, connect, subscribe, unsubscribe, disconnect }
}

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
/**
* Protocol detail page.
*
* Displays full protocol information including name, type, description,
* mode params, formula config, and links to the formula simulator.
*/
const route = useRoute()
const protocols = useProtocolsStore()
const votes = useVotesStore()
const protocolId = computed(() => route.params.id as string)
onMounted(async () => {
await protocols.fetchProtocolById(protocolId.value)
})
const protocol = computed(() => protocols.currentProtocol)
const voteTypeLabel = (voteType: string) => {
switch (voteType) {
case 'binary': return 'Binaire'
case 'nuanced': return 'Nuance'
default: return voteType
}
}
const voteTypeColor = (voteType: string) => {
switch (voteType) {
case 'binary': return 'primary'
case 'nuanced': return 'info'
default: return 'neutral'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
/** Build simulator URL with prefilled params. */
const simulatorLink = computed(() => {
if (!protocol.value?.mode_params) return '/protocols/formulas'
return `/protocols/formulas`
})
</script>
<template>
<div class="space-y-8">
<!-- Header with back link -->
<div class="flex items-center gap-3">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Detail du protocole
</h1>
</div>
<!-- Loading -->
<template v-if="protocols.loading">
<div class="space-y-3">
<USkeleton class="h-12 w-3/4" />
<USkeleton class="h-6 w-1/2" />
<USkeleton class="h-48 w-full" />
</div>
</template>
<!-- Error -->
<template v-else-if="protocols.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ protocols.error }}</p>
</div>
</UCard>
</template>
<!-- Protocol detail -->
<template v-else-if="protocol">
<!-- Protocol header card -->
<UCard>
<div class="space-y-4">
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">
{{ protocol.name }}
</h2>
<p v-if="protocol.description" class="text-gray-600 dark:text-gray-400 mt-1">
{{ protocol.description }}
</p>
<p class="text-xs text-gray-500 mt-2">
Cree le {{ formatDate(protocol.created_at) }}
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle">
Meta-gouverne
</UBadge>
</div>
</div>
</div>
</UCard>
<!-- Mode params -->
<UCard v-if="protocol.mode_params">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres du mode
</h3>
</template>
<ModeParamsDisplay :mode-params="protocol.mode_params" />
</UCard>
<!-- Formula config -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Configuration de la formule : {{ protocol.formula_config.name }}
</h3>
<NuxtLink :to="simulatorLink">
<UButton variant="outline" size="sm" icon="i-lucide-calculator">
Simuler
</UButton>
</NuxtLink>
</div>
</template>
<FormulaDisplay :formula-config="protocol.formula_config" />
</UCard>
<!-- Meta-governance info -->
<UCard v-if="protocol.is_meta_governed">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-shield" class="text-2xl text-amber-500" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
Protocole meta-gouverne
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Les modifications de ce protocole sont soumises au vote selon ses propres regles.
</p>
</div>
</div>
</UCard>
<!-- Related vote sessions -->
<UCard v-if="votes.sessions && votes.sessions.length > 0">
<template #header>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Sessions de vote utilisant ce protocole
</h3>
</template>
<div class="space-y-2">
<div
v-for="session in votes.sessions"
:key="session.id"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400">
{{ session.id.slice(0, 8) }}...
</span>
<StatusBadge :status="session.status" type="vote" class="ml-2" />
</div>
<div class="text-sm text-gray-500">
{{ session.votes_total }} votes
</div>
</div>
</div>
</UCard>
</template>
</div>
</template>

View File

@@ -0,0 +1,304 @@
<script setup lang="ts">
/**
* Formula simulator page.
*
* Allows interactive adjustment of formula parameters with live threshold
* computation, a visual gauge, and a table of thresholds at various
* participation levels.
*/
import type { FormulaConfig } from '~/stores/protocols'
import { encodeModeParams } from '~/utils/mode-params'
const { computeThreshold, computeRequiredRatio, computeInertiaFactor } = useVoteFormula()
/** Default formula config for the simulator. */
const formulaConfig = ref<FormulaConfig>({
id: 'simulator',
name: 'Simulateur',
description: null,
duration_days: 30,
majority_pct: 50,
base_exponent: 0.1,
gradient_exponent: 0.2,
constant_base: 0,
smith_exponent: null,
techcomm_exponent: null,
nuanced_min_participants: null,
nuanced_threshold_pct: null,
created_at: new Date().toISOString(),
})
/** Simulation inputs. */
const wotSize = ref(7224)
const simulatedVotes = ref(120)
const simulatedFor = ref(97)
/** Computed threshold for current params. */
const threshold = computed(() => {
try {
return computeThreshold(wotSize.value, simulatedVotes.value, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
} catch {
return 0
}
})
/** Computed required ratio. */
const requiredRatio = computed(() => {
return computeRequiredRatio(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
})
/** Computed inertia factor. */
const inertiaFactor = computed(() => {
return computeInertiaFactor(
simulatedVotes.value,
wotSize.value,
formulaConfig.value.gradient_exponent,
)
})
/** Simulated votes against. */
const simulatedAgainst = computed(() => {
return Math.max(0, simulatedVotes.value - simulatedFor.value)
})
/** Mode params string for current config. */
const modeParamsString = computed(() => {
try {
return encodeModeParams({
duration_days: formulaConfig.value.duration_days,
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
smith_exponent: formulaConfig.value.smith_exponent,
techcomm_exponent: formulaConfig.value.techcomm_exponent,
})
} catch {
return ''
}
})
/** Table of thresholds at various participation levels. */
const participationLevels = [10, 50, 100, 200, 500, 1000, 3000, 5000, 7000]
const thresholdTable = computed(() => {
return participationLevels
.filter(n => n <= wotSize.value)
.map(totalVotes => {
let t: number
let ratio: number
try {
t = computeThreshold(wotSize.value, totalVotes, {
majority_pct: formulaConfig.value.majority_pct,
base_exponent: formulaConfig.value.base_exponent,
gradient_exponent: formulaConfig.value.gradient_exponent,
constant_base: formulaConfig.value.constant_base,
})
ratio = computeRequiredRatio(
totalVotes,
wotSize.value,
formulaConfig.value.majority_pct,
formulaConfig.value.gradient_exponent,
)
} catch {
t = 0
ratio = 0
}
const participation = ((totalVotes / wotSize.value) * 100).toFixed(2)
return {
totalVotes,
threshold: t,
ratio: (ratio * 100).toFixed(1),
participation,
}
})
})
/** Keep simulatedFor within bounds when simulatedVotes changes. */
watch(simulatedVotes, (newTotal) => {
if (simulatedFor.value > newTotal) {
simulatedFor.value = newTotal
}
})
</script>
<template>
<div class="space-y-8">
<!-- Header -->
<div>
<div class="flex items-center gap-3 mb-2">
<NuxtLink to="/protocols" class="text-gray-400 hover:text-gray-600">
<UIcon name="i-lucide-arrow-left" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Simulateur de formule de seuil
</h1>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Ajustez les parametres de la formule et observez le seuil calcule en temps reel.
</p>
</div>
<!-- Current mode params -->
<UCard v-if="modeParamsString">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mode params :</span>
<ModeParamsDisplay :mode-params="modeParamsString" />
</div>
</UCard>
<!-- Formula editor -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Parametres de la formule
</h2>
</template>
<FormulaEditor v-model="formulaConfig" />
</UCard>
<!-- Simulation inputs -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Simulation
</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- WoT size -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Taille du corpus WoT (W)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ wotSize }}</span>
</div>
<UInput v-model.number="wotSize" type="number" :min="1" :max="100000" />
</div>
<!-- Total votes -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes totaux (T)
</label>
<span class="text-sm font-mono font-bold text-primary">{{ simulatedVotes }}</span>
</div>
<URange v-model="simulatedVotes" :min="0" :max="wotSize" :step="1" />
</div>
<!-- Votes for -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Votes pour
</label>
<span class="text-sm font-mono font-bold text-green-600">{{ simulatedFor }}</span>
</div>
<URange v-model="simulatedFor" :min="0" :max="simulatedVotes" :step="1" />
</div>
</div>
</UCard>
<!-- Results -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Resultat
</h2>
</template>
<div class="space-y-6">
<!-- Threshold gauge -->
<ThresholdGauge
:votes-for="simulatedFor"
:votes-against="simulatedAgainst"
:threshold="threshold"
:wot-size="wotSize"
/>
<!-- Key metrics -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Seuil requis</p>
<p class="text-2xl font-bold text-primary">{{ threshold }}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Ratio requis</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ (requiredRatio * 100).toFixed(1) }}%
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Facteur d'inertie</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ inertiaFactor.toFixed(4) }}
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">Participation</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ ((simulatedVotes / wotSize) * 100).toFixed(2) }}%
</p>
</div>
</div>
</div>
</UCard>
<!-- Formula display -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Formule
</h2>
</template>
<FormulaDisplay :formula-config="formulaConfig" show-explanation />
</UCard>
<!-- Threshold table -->
<UCard>
<template #header>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Seuils par niveau de participation
</h2>
</template>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Votes totaux (T)</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Participation</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Ratio requis</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Seuil (votes pour)</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in thresholdTable"
:key="row.totalVotes"
class="border-b border-gray-100 dark:border-gray-800"
:class="row.totalVotes === simulatedVotes ? 'bg-primary-50 dark:bg-primary-900/20' : ''"
>
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">{{ row.totalVotes }}</td>
<td class="px-4 py-3 text-gray-600">{{ row.participation }}%</td>
<td class="px-4 py-3 text-gray-600">{{ row.ratio }}%</td>
<td class="px-4 py-3 font-mono font-bold text-primary">{{ row.threshold }}</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</div>
</template>

View File

@@ -1,5 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* Protocols index page.
*
* Lists all voting protocols with ModeParamsDisplay component,
* links to protocol detail pages, and provides creation modal
* and simulator link.
*/
const protocols = useProtocolsStore() const protocols = useProtocolsStore()
const auth = useAuthStore()
const showCreateModal = ref(false)
const creating = ref(false)
/** Creation form state. */
const newProtocol = reactive({
name: '',
description: '',
vote_type: 'binary',
formula_config_id: '',
})
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
@@ -24,14 +43,17 @@ const voteTypeColor = (voteType: string) => {
} }
} }
function formatModeParamsDisplay(modeParams: string | null): string { const voteTypeOptions = [
if (!modeParams) return '-' { label: 'Binaire (Pour/Contre)', value: 'binary' },
try { { label: 'Nuance (6 niveaux)', value: 'nuanced' },
return formatModeParams(modeParams) ]
} catch {
return modeParams const formulaOptions = computed(() => {
} return protocols.formulas.map(f => ({
} label: f.name,
value: f.id,
}))
})
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', { return new Date(dateStr).toLocaleDateString('fr-FR', {
@@ -40,18 +62,61 @@ function formatDate(dateStr: string): string {
year: 'numeric', year: 'numeric',
}) })
} }
function openCreateModal() {
newProtocol.name = ''
newProtocol.description = ''
newProtocol.vote_type = 'binary'
newProtocol.formula_config_id = ''
showCreateModal.value = true
}
async function createProtocol() {
if (!newProtocol.name.trim() || !newProtocol.formula_config_id) return
creating.value = true
try {
await protocols.createProtocol({
name: newProtocol.name.trim(),
description: newProtocol.description.trim() || null,
vote_type: newProtocol.vote_type,
formula_config_id: newProtocol.formula_config_id,
})
showCreateModal.value = false
await protocols.fetchProtocols()
} finally {
creating.value = false
}
}
</script> </script>
<template> <template>
<div class="space-y-8"> <div class="space-y-8">
<!-- Header --> <!-- Header -->
<div> <div class="flex items-start justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> <div>
Protocoles de vote <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
</h1> Protocoles de vote
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> </h1>
Configuration des protocoles de vote et formules de seuil WoT <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
</p> Configuration des protocoles de vote et formules de seuil WoT
</p>
</div>
<div class="flex items-center gap-3">
<NuxtLink to="/protocols/formulas">
<UButton variant="outline" icon="i-lucide-calculator" size="sm">
Simulateur de formules
</UButton>
</NuxtLink>
<UButton
v-if="auth.isAuthenticated"
icon="i-lucide-plus"
size="sm"
@click="openCreateModal"
>
Nouveau protocole
</UButton>
</div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
@@ -88,85 +153,86 @@ function formatDate(dateStr: string): string {
</div> </div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<UCard <NuxtLink
v-for="protocol in protocols.protocols" v-for="protocol in protocols.protocols"
:key="protocol.id" :key="protocol.id"
:to="`/protocols/${protocol.id}`"
class="block"
> >
<div class="space-y-4"> <UCard class="hover:ring-2 hover:ring-primary-300 dark:hover:ring-primary-700 transition-all cursor-pointer">
<!-- Protocol header --> <div class="space-y-4">
<div class="flex items-start justify-between"> <!-- Protocol header -->
<div> <div class="flex items-start justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white"> <div>
{{ protocol.name }} <h3 class="font-semibold text-gray-900 dark:text-white">
</h3> {{ protocol.name }}
<p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5"> </h3>
{{ protocol.description }} <p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5">
</p> {{ protocol.description }}
</p>
</div>
<div class="flex items-center gap-2">
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle" size="xs">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
Meta-gouverne
</UBadge>
</div>
</div> </div>
<div class="flex items-center gap-2">
<UBadge :color="voteTypeColor(protocol.vote_type)" variant="subtle" size="xs">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
Meta-gouverne
</UBadge>
</div>
</div>
<!-- Mode params --> <!-- Mode params with component -->
<div v-if="protocol.mode_params" class="p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs"> <div v-if="protocol.mode_params">
<div class="flex items-center gap-2 mb-1"> <ModeParamsDisplay :mode-params="protocol.mode_params" />
<span class="font-mono font-bold text-primary">{{ protocol.mode_params }}</span>
</div> </div>
<p class="text-gray-500">{{ formatModeParamsDisplay(protocol.mode_params) }}</p>
</div>
<!-- Formula config summary --> <!-- Formula config summary -->
<div class="border-t border-gray-100 dark:border-gray-800 pt-3"> <div class="border-t border-gray-100 dark:border-gray-800 pt-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"> <h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Formule : {{ protocol.formula_config.name }} Formule : {{ protocol.formula_config.name }}
</h4> </h4>
<div class="grid grid-cols-3 gap-2 text-xs"> <div class="grid grid-cols-3 gap-2 text-xs">
<div> <div>
<span class="text-gray-400 block">Duree</span> <span class="text-gray-400 block">Duree</span>
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.duration_days }}j {{ protocol.formula_config.duration_days }}j
</span> </span>
</div> </div>
<div> <div>
<span class="text-gray-400 block">Majorite</span> <span class="text-gray-400 block">Majorite</span>
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.majority_pct }}% {{ protocol.formula_config.majority_pct }}%
</span> </span>
</div> </div>
<div> <div>
<span class="text-gray-400 block">Base</span> <span class="text-gray-400 block">Base</span>
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.base_exponent }} {{ protocol.formula_config.base_exponent }}
</span> </span>
</div> </div>
<div> <div>
<span class="text-gray-400 block">Gradient</span> <span class="text-gray-400 block">Gradient</span>
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.gradient_exponent }} {{ protocol.formula_config.gradient_exponent }}
</span> </span>
</div> </div>
<div v-if="protocol.formula_config.smith_exponent !== null"> <div v-if="protocol.formula_config.smith_exponent !== null">
<span class="text-gray-400 block">Smith</span> <span class="text-gray-400 block">Smith</span>
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.smith_exponent }} {{ protocol.formula_config.smith_exponent }}
</span> </span>
</div> </div>
<div v-if="protocol.formula_config.techcomm_exponent !== null"> <div v-if="protocol.formula_config.techcomm_exponent !== null">
<span class="text-gray-400 block">TechComm</span> <span class="text-gray-400 block">TechComm</span>
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.techcomm_exponent }} {{ protocol.formula_config.techcomm_exponent }}
</span> </span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </UCard>
</UCard> </NuxtLink>
</div> </div>
</div> </div>
@@ -253,5 +319,68 @@ function formatDate(dateStr: string): string {
</div> </div>
</UCard> </UCard>
</template> </template>
<!-- Create protocol modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<div class="p-6 space-y-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Nouveau protocole de vote
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Nom du protocole
</label>
<UInput v-model="newProtocol.name" placeholder="Ex: Vote standard G1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Description (optionnel)
</label>
<UTextarea v-model="newProtocol.description" placeholder="Description du protocole..." :rows="2" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Type de vote
</label>
<USelect
v-model="newProtocol.vote_type"
:items="voteTypeOptions"
value-key="value"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Configuration de formule
</label>
<USelect
v-model="newProtocol.formula_config_id"
:items="formulaOptions"
placeholder="Selectionnez une formule..."
value-key="value"
/>
</div>
</div>
<div class="flex justify-end gap-3">
<UButton variant="ghost" color="neutral" @click="showCreateModal = false">
Annuler
</UButton>
<UButton
:loading="creating"
:disabled="!newProtocol.name.trim() || !newProtocol.formula_config_id"
@click="createProtocol"
>
Creer le protocole
</UButton>
</div>
</div>
</template>
</UModal>
</div> </div>
</template> </template>

View File

@@ -32,9 +32,52 @@ export interface VotingProtocol {
formula_config: FormulaConfig formula_config: FormulaConfig
} }
export interface ProtocolCreate {
name: string
description: string | null
vote_type: string
formula_config_id: string
}
export interface FormulaCreate {
name: string
description?: string | null
duration_days: number
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
smith_exponent?: number | null
techcomm_exponent?: number | null
nuanced_min_participants?: number | null
nuanced_threshold_pct?: number | null
}
export interface SimulateParams {
wot_size: number
total_votes: number
majority_pct: number
base_exponent: number
gradient_exponent: number
constant_base: number
smith_wot_size?: number
smith_exponent?: number
techcomm_size?: number
techcomm_exponent?: number
}
export interface SimulateResult {
threshold: number
smith_threshold: number | null
techcomm_threshold: number | null
inertia_factor: number
required_ratio: number
}
interface ProtocolsState { interface ProtocolsState {
protocols: VotingProtocol[] protocols: VotingProtocol[]
formulas: FormulaConfig[] formulas: FormulaConfig[]
currentProtocol: VotingProtocol | null
loading: boolean loading: boolean
error: string | null error: string | null
} }
@@ -43,6 +86,7 @@ export const useProtocolsStore = defineStore('protocols', {
state: (): ProtocolsState => ({ state: (): ProtocolsState => ({
protocols: [], protocols: [],
formulas: [], formulas: [],
currentProtocol: null,
loading: false, loading: false,
error: null, error: null,
}), }),
@@ -96,5 +140,89 @@ export const useProtocolsStore = defineStore('protocols', {
this.loading = false this.loading = false
} }
}, },
/**
* Fetch a single protocol by ID.
*/
async fetchProtocolById(id: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.currentProtocol = await $api<VotingProtocol>(`/protocols/${id}`)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Protocole introuvable'
} finally {
this.loading = false
}
},
/**
* Create a new voting protocol.
*/
async createProtocol(data: ProtocolCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const protocol = await $api<VotingProtocol>('/protocols/', {
method: 'POST',
body: data,
})
this.protocols.push(protocol)
return protocol
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du protocole'
throw err
} finally {
this.loading = false
}
},
/**
* Create a new formula configuration.
*/
async createFormula(data: FormulaCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const formula = await $api<FormulaConfig>('/protocols/formulas', {
method: 'POST',
body: data,
})
this.formulas.push(formula)
return formula
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la formule'
throw err
} finally {
this.loading = false
}
},
/**
* Simulate formula computation on the backend.
*/
async simulate(params: SimulateParams) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
return await $api<SimulateResult>('/protocols/simulate', {
method: 'POST',
body: params,
})
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la simulation'
throw err
} finally {
this.loading = false
}
},
}, },
}) })

View File

@@ -62,6 +62,17 @@ export interface VoteResult {
techcomm_pass: boolean techcomm_pass: boolean
} }
export interface ThresholdDetails {
wot_threshold: number
smith_threshold: number | null
techcomm_threshold: number | null
wot_pass: boolean
smith_pass: boolean | null
techcomm_pass: boolean | null
inertia_factor: number
required_ratio: number
}
export interface VoteCreate { export interface VoteCreate {
session_id: string session_id: string
vote_value: string vote_value: string
@@ -71,10 +82,27 @@ export interface VoteCreate {
signed_payload: string signed_payload: string
} }
export interface VoteSessionCreate {
decision_id?: string | null
item_version_id?: string | null
voting_protocol_id: string
wot_size?: number
smith_size?: number
techcomm_size?: number
}
export interface SessionFilters {
status?: string
voting_protocol_id?: string
decision_id?: string
}
interface VotesState { interface VotesState {
currentSession: VoteSession | null currentSession: VoteSession | null
votes: Vote[] votes: Vote[]
result: VoteResult | null result: VoteResult | null
thresholdDetails: ThresholdDetails | null
sessions: VoteSession[]
loading: boolean loading: boolean
error: string | null error: string | null
} }
@@ -84,6 +112,8 @@ export const useVotesStore = defineStore('votes', {
currentSession: null, currentSession: null,
votes: [], votes: [],
result: null, result: null,
thresholdDetails: null,
sessions: [],
loading: false, loading: false,
error: null, error: null,
}), }),
@@ -166,6 +196,94 @@ export const useVotesStore = defineStore('votes', {
} }
}, },
/**
* Fetch threshold details for a session.
*/
async fetchThresholdDetails(sessionId: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.thresholdDetails = await $api<ThresholdDetails>(
`/votes/sessions/${sessionId}/threshold`,
)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des details du seuil'
} finally {
this.loading = false
}
},
/**
* Close a vote session.
*/
async closeSession(sessionId: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const session = await $api<VoteSession>(`/votes/sessions/${sessionId}/close`, {
method: 'POST',
})
this.currentSession = session
// Refresh result after closing
this.result = await $api<VoteResult>(`/votes/sessions/${sessionId}/result`)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la fermeture de la session'
throw err
} finally {
this.loading = false
}
},
/**
* Fetch a list of vote sessions with optional filters.
*/
async fetchSessions(filters?: SessionFilters) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const query: Record<string, string> = {}
if (filters?.status) query.status = filters.status
if (filters?.voting_protocol_id) query.voting_protocol_id = filters.voting_protocol_id
if (filters?.decision_id) query.decision_id = filters.decision_id
this.sessions = await $api<VoteSession[]>('/votes/sessions', { query })
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des sessions'
} finally {
this.loading = false
}
},
/**
* Create a new vote session.
*/
async createSession(data: VoteSessionCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const session = await $api<VoteSession>('/votes/sessions', {
method: 'POST',
body: data,
})
this.sessions.push(session)
return session
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la session'
throw err
} finally {
this.loading = false
}
},
/** /**
* Clear the current session state. * Clear the current session state.
*/ */
@@ -173,6 +291,7 @@ export const useVotesStore = defineStore('votes', {
this.currentSession = null this.currentSession = null
this.votes = [] this.votes = []
this.result = null this.result = null
this.thresholdDetails = null
}, },
}, },
}) })

View File

@@ -18,6 +18,12 @@ export default defineNuxtConfig({
{ name: 'description', content: 'Plateforme de decisions collectives pour la communaute Duniter/G1' }, { name: 'description', content: 'Plateforme de decisions collectives pour la communaute Duniter/G1' },
], ],
title: 'Glibredecision', title: 'Glibredecision',
link: [
{ rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css' },
],
script: [
{ src: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js', defer: true },
],
}, },
}, },
runtimeConfig: { runtimeConfig: {