Sprint 1 : scaffolding complet de Glibredecision

Plateforme de decisions collectives pour Duniter/G1.
Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services,
moteur de vote avec formule d'inertie WoT/Smith/TechComm).
Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores).
Infrastructure Docker + Woodpecker CI + Traefik.
Documentation technique et utilisateur (15 fichiers).
Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote.
30 tests unitaires (formules, mode params, vote nuance) -- tous verts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 12:46:11 +01:00
commit 25437f24e3
100 changed files with 10236 additions and 0 deletions

View File

View File

@@ -0,0 +1,107 @@
"""Parse mode-parameter strings into structured dicts.
A mode-params string encodes voting formula parameters in a compact format.
Example: ``"D30M50B.1G.2T.1"``
Supported codes:
D = duration_days (int)
M = majority_pct (int, 0-100)
B = base_exponent (float)
G = gradient_exponent (float)
C = constant_base (float)
S = smith_exponent (float)
T = techcomm_exponent (float)
N = ratio_multiplier (float)
R = ratio_mode (bool, 0 or 1)
Values may start with a dot for decimals < 1, e.g. ``B.1`` means base_exponent=0.1.
"""
from __future__ import annotations
import re
# Ordered list of recognised codes and their target keys + types
_CODES: dict[str, tuple[str, type]] = {
"D": ("duration_days", int),
"M": ("majority_pct", int),
"B": ("base_exponent", float),
"G": ("gradient_exponent", float),
"C": ("constant_base", float),
"S": ("smith_exponent", float),
"T": ("techcomm_exponent", float),
"N": ("ratio_multiplier", float),
"R": ("is_ratio_mode", bool),
}
# Regex: a single uppercase letter followed by a numeric value (int or float,
# possibly starting with '.' for values like .1 meaning 0.1)
_PARAM_RE = re.compile(r"([A-Z])(\d*\.?\d+)")
def parse_mode_params(params_str: str) -> dict:
"""Parse a mode-params string into a parameter dict.
Parameters
----------
params_str:
Compact parameter string, e.g. ``"D30M50B.1G.2T.1"``.
Returns
-------
dict
Keys present depend on codes found in the string. Defaults are
applied for any code not present::
{
"duration_days": 30,
"majority_pct": 50,
"base_exponent": 0.1,
"gradient_exponent": 0.2,
"constant_base": 0.0,
"smith_exponent": None,
"techcomm_exponent": None,
"ratio_multiplier": None,
"is_ratio_mode": False,
}
Raises
------
ValueError
If an unrecognised code letter is found.
"""
defaults: dict = {
"duration_days": 30,
"majority_pct": 50,
"base_exponent": 0.1,
"gradient_exponent": 0.2,
"constant_base": 0.0,
"smith_exponent": None,
"techcomm_exponent": None,
"ratio_multiplier": None,
"is_ratio_mode": False,
}
if not params_str or not params_str.strip():
return dict(defaults)
result = dict(defaults)
for match in _PARAM_RE.finditer(params_str):
code = match.group(1)
raw_value = match.group(2)
if code not in _CODES:
raise ValueError(f"Code de parametre inconnu : '{code}'")
key, target_type = _CODES[code]
if target_type is int:
result[key] = int(float(raw_value))
elif target_type is float:
result[key] = float(raw_value)
elif target_type is bool:
result[key] = float(raw_value) != 0.0
return result

View File

@@ -0,0 +1,95 @@
"""Six-level nuanced vote evaluation.
Levels:
0 - CONTRE
1 - PAS DU TOUT
2 - PAS D'ACCORD
3 - NEUTRE
4 - D'ACCORD
5 - TOUT A FAIT
Adoption rule:
The sum of votes at levels 3 + 4 + 5 must be >= threshold_pct% of total votes.
A minimum number of participants is also required.
"""
from __future__ import annotations
LEVEL_LABELS: dict[int, str] = {
0: "CONTRE",
1: "PAS DU TOUT",
2: "PAS D'ACCORD",
3: "NEUTRE",
4: "D'ACCORD",
5: "TOUT A FAIT",
}
NUM_LEVELS = 6
def evaluate_nuanced(
votes: list[int],
threshold_pct: int = 80,
min_participants: int = 59,
) -> dict:
"""Evaluate a nuanced vote from a list of individual vote levels.
Parameters
----------
votes:
List of vote levels (each 0-5). One entry per voter.
threshold_pct:
Minimum percentage of positive votes (levels 3-5) out of total
for adoption.
min_participants:
Minimum number of participants required for validity.
Returns
-------
dict
{
"total": int,
"per_level_counts": {0: int, 1: int, ..., 5: int},
"positive_count": int, # levels 3 + 4 + 5
"positive_pct": float, # 0.0 - 100.0
"threshold_met": bool,
"min_participants_met": bool,
"adopted": bool,
}
Raises
------
ValueError
If any vote value is outside the 0-5 range.
"""
# Validate vote levels
for v in votes:
if v < 0 or v > 5:
raise ValueError(
f"Niveau de vote invalide : {v}. Les niveaux valides sont 0-5."
)
total = len(votes)
per_level_counts: dict[int, int] = {level: 0 for level in range(NUM_LEVELS)}
for v in votes:
per_level_counts[v] += 1
# Positive = levels 3 (NEUTRE), 4 (D'ACCORD), 5 (TOUT A FAIT)
positive_count = per_level_counts[3] + per_level_counts[4] + per_level_counts[5]
positive_pct = (positive_count / total * 100.0) if total > 0 else 0.0
threshold_met = positive_pct >= threshold_pct
min_participants_met = total >= min_participants
adopted = threshold_met and min_participants_met
return {
"total": total,
"per_level_counts": per_level_counts,
"positive_count": positive_count,
"positive_pct": round(positive_pct, 2),
"threshold_met": threshold_met,
"min_participants_met": min_participants_met,
"adopted": adopted,
}

View File

@@ -0,0 +1,31 @@
"""Smith sub-WoT threshold criterion.
The Smith criterion requires a minimum number of votes from Smith members
(forgerons) for certain decisions to be valid.
Formula: ceil(SmithWotSize ^ S)
"""
from __future__ import annotations
import math
def smith_threshold(smith_wot_size: int, exponent: float = 0.1) -> int:
"""Compute the minimum number of Smith member votes required.
Parameters
----------
smith_wot_size:
Number of active Smith members.
exponent:
S in the formula ``ceil(smith_wot_size^S)``.
Returns
-------
int
Minimum Smith votes required.
"""
if smith_wot_size <= 0:
raise ValueError("smith_wot_size doit etre strictement positif")
return math.ceil(smith_wot_size ** exponent)

View File

@@ -0,0 +1,31 @@
"""Technical Committee threshold criterion.
The TechComm criterion requires a minimum number of votes from
Technical Committee members for certain decisions.
Formula: ceil(CoTecSize ^ T)
"""
from __future__ import annotations
import math
def techcomm_threshold(cotec_size: int, exponent: float = 0.1) -> int:
"""Compute the minimum number of TechComm member votes required.
Parameters
----------
cotec_size:
Number of Technical Committee members.
exponent:
T in the formula ``ceil(cotec_size^T)``.
Returns
-------
int
Minimum TechComm votes required.
"""
if cotec_size <= 0:
raise ValueError("cotec_size doit etre strictement positif")
return math.ceil(cotec_size ** exponent)

View File

@@ -0,0 +1,85 @@
"""WoT members threshold formula for binary votes.
Core formula:
Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C)
Where:
C = constant_base
B = base_exponent
W = wot_size (corpus of eligible voters)
T = total_votes (for + against)
M = majority_ratio (majority_pct / 100)
G = gradient_exponent
Inertia behaviour:
- Low participation (T << W) -> near-unanimity required
- High participation (T -> W) -> simple majority M suffices
Reference test case:
wot_size=7224, votes_for=97, votes_against=23 (total=120)
params M50 B.1 G.2 => threshold=94, adopted (97 >= 94)
"""
from __future__ import annotations
import math
def wot_threshold(
wot_size: int,
total_votes: int,
majority_pct: int = 50,
base_exponent: float = 0.1,
gradient_exponent: float = 0.2,
constant_base: float = 0.0,
) -> int:
"""Compute the minimum number of *for* votes required for adoption.
Parameters
----------
wot_size:
Size of the eligible voter corpus (WoT members).
total_votes:
Number of votes cast (for + against).
majority_pct:
Majority percentage (0-100). 50 = simple majority at full participation.
base_exponent:
B in the formula. ``B^W`` contributes a vanishingly small offset
when W is large (0 < B < 1).
gradient_exponent:
G controls how fast the required super-majority decays toward M as
participation increases.
constant_base:
C, a fixed additive floor on the threshold.
Returns
-------
int
The ceiling of the computed threshold. A vote passes when
``votes_for >= wot_threshold(...)``.
"""
if wot_size <= 0:
raise ValueError("wot_size doit etre strictement positif")
if total_votes < 0:
raise ValueError("total_votes ne peut pas etre negatif")
if not (0 <= majority_pct <= 100):
raise ValueError("majority_pct doit etre entre 0 et 100")
C = constant_base
B = base_exponent
W = wot_size
T = total_votes
M = majority_pct / 100.0
G = gradient_exponent
# Guard: if no votes, threshold is at least ceil(C + B^W)
if T == 0:
return math.ceil(C + B ** W)
# Core formula
participation_ratio = T / W
inertia_factor = 1.0 - participation_ratio ** G
required_ratio = M + (1.0 - M) * inertia_factor
result = C + B ** W + required_ratio * max(0.0, T - C)
return math.ceil(result)