v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Decision qualification engine.
|
||||
|
||||
Pure functions — no database, no I/O.
|
||||
Takes a QualificationInput + QualificationConfig and returns a QualificationResult.
|
||||
|
||||
LLM integration (suggest_modalities_from_context) is stubbed pending local Qwen deployment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DecisionType(str, Enum):
|
||||
INDIVIDUAL = "individual"
|
||||
COLLECTIVE = "collective"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration (thresholds — stored as QualificationProtocol in DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualificationConfig:
|
||||
"""Configurable thresholds for the qualification engine.
|
||||
|
||||
Seeded as a QualificationProtocol record so they can be adjusted
|
||||
through the admin interface without code changes.
|
||||
|
||||
small_group_max: affected_count <= this → individual recommended, collective available
|
||||
collective_wot_min: affected_count > this → WoT formula applicable (still recommended, not required)
|
||||
|
||||
affected_count must be >= 2 — decisions affecting only the author
|
||||
have no place in this tool.
|
||||
"""
|
||||
small_group_max: int = 5
|
||||
collective_wot_min: int = 50
|
||||
|
||||
default_modalities: list[str] = field(default_factory=lambda: [
|
||||
"vote_wot",
|
||||
"vote_smith",
|
||||
"consultation_avis",
|
||||
"election",
|
||||
])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input / Output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualificationInput:
|
||||
within_mandate: bool = False
|
||||
affected_count: int | None = None # must be >= 2 when within_mandate=False
|
||||
is_structural: bool = False
|
||||
context_description: str | None = None # reserved for LLM suggestion
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualificationResult:
|
||||
decision_type: DecisionType
|
||||
process: str
|
||||
recommended_modalities: list[str]
|
||||
recommend_onchain: bool
|
||||
onchain_reason: str | None
|
||||
confidence: str # "required" | "recommended" | "optional"
|
||||
collective_available: bool
|
||||
record_in_observatory: bool # True → decision must be logged in Observatoire
|
||||
reasons: list[str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM stub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def suggest_modalities_from_context(
|
||||
context: str,
|
||||
config: QualificationConfig,
|
||||
) -> list[str]:
|
||||
"""Suggest voting modalities based on a natural-language context description.
|
||||
|
||||
Stub — returns empty list until local Qwen (qwen3.6, MacStudio) is integrated.
|
||||
When implemented, will call the LLM API and return an ordered subset of
|
||||
config.default_modalities ranked by contextual relevance.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def qualify(inp: QualificationInput, config: QualificationConfig) -> QualificationResult:
|
||||
"""Qualify a decision and recommend a type, process, and modalities.
|
||||
|
||||
Rules (in priority order):
|
||||
R1/R2 within_mandate → individual + consultation_avis, no vote modalities,
|
||||
decision must be recorded in Observatoire des décisions
|
||||
R4 2 ≤ affected_count ≤ small_group_max → individual recommended, collective available
|
||||
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommended
|
||||
R6 affected_count > collective_wot_min → collective recommended (WoT formula applicable)
|
||||
R7/R8 is_structural → recommend_onchain with reason
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
|
||||
# ── R1/R2: mandate scope overrides everything ───────────────────────────
|
||||
if inp.within_mandate:
|
||||
reasons.append("Décision dans le périmètre d'un mandat existant.")
|
||||
return QualificationResult(
|
||||
decision_type=DecisionType.INDIVIDUAL,
|
||||
process="consultation_avis",
|
||||
recommended_modalities=[],
|
||||
recommend_onchain=_onchain(inp, reasons),
|
||||
onchain_reason=_onchain_reason(inp),
|
||||
confidence="required",
|
||||
collective_available=False,
|
||||
record_in_observatory=True,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
count = inp.affected_count if inp.affected_count is not None else 2
|
||||
|
||||
# ── R4: small group → individual recommended, collective available ───────
|
||||
if count <= config.small_group_max:
|
||||
reasons.append(
|
||||
f"{count} personnes concernées : décision individuelle recommandée, "
|
||||
"vote collectif possible."
|
||||
)
|
||||
return QualificationResult(
|
||||
decision_type=DecisionType.INDIVIDUAL,
|
||||
process="personal",
|
||||
recommended_modalities=[],
|
||||
recommend_onchain=_onchain(inp, reasons),
|
||||
onchain_reason=_onchain_reason(inp),
|
||||
confidence="recommended",
|
||||
collective_available=True,
|
||||
record_in_observatory=False,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
# ── R5/R6: medium or large group → collective ────────────────────────────
|
||||
modalities = _collect_modalities(inp, config)
|
||||
|
||||
if count <= config.collective_wot_min:
|
||||
reasons.append(f"{count} personnes concernées : vote collectif recommandé.")
|
||||
confidence = "recommended"
|
||||
else:
|
||||
reasons.append(
|
||||
f"{count} personnes concernées : vote collectif recommandé "
|
||||
"(formule WoT applicable à cette échelle)."
|
||||
)
|
||||
confidence = "recommended"
|
||||
if "vote_wot" not in modalities:
|
||||
modalities = ["vote_wot"] + modalities
|
||||
|
||||
return QualificationResult(
|
||||
decision_type=DecisionType.COLLECTIVE,
|
||||
process="vote_collective",
|
||||
recommended_modalities=modalities,
|
||||
recommend_onchain=_onchain(inp, reasons),
|
||||
onchain_reason=_onchain_reason(inp),
|
||||
confidence=confidence,
|
||||
collective_available=True,
|
||||
record_in_observatory=False,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _onchain(inp: QualificationInput, reasons: list[str]) -> bool:
|
||||
if inp.is_structural:
|
||||
reasons.append(
|
||||
"Décision structurante : gravure on-chain recommandée "
|
||||
"(a force de loi ou déclenche une action machine)."
|
||||
)
|
||||
return inp.is_structural
|
||||
|
||||
|
||||
def _onchain_reason(inp: QualificationInput) -> str | None:
|
||||
if not inp.is_structural:
|
||||
return None
|
||||
return (
|
||||
"Cette décision est structurante : elle a valeur de loi au sein de la "
|
||||
"communauté ou déclenche une action machine (ex : runtime upgrade). "
|
||||
"La gravure on-chain (IPFS + system.remark) garantit son immuabilité "
|
||||
"et sa vérifiabilité publique."
|
||||
)
|
||||
|
||||
|
||||
def _collect_modalities(inp: QualificationInput, config: QualificationConfig) -> list[str]:
|
||||
"""Combine default modalities with any LLM suggestions (stub for now)."""
|
||||
llm_suggestions = []
|
||||
if inp.context_description:
|
||||
llm_suggestions = suggest_modalities_from_context(inp.context_description, config)
|
||||
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for m in llm_suggestions + config.default_modalities:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
result.append(m)
|
||||
return result
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user