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:
164
frontend/app/utils/mode-params.ts
Normal file
164
frontend/app/utils/mode-params.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* TypeScript mirror of the Python mode_params parser.
|
||||
*
|
||||
* 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 = is_ratio_mode (bool, 0 or 1)
|
||||
*
|
||||
* Values may start with a dot for decimals < 1, e.g. "B.1" means base_exponent=0.1.
|
||||
*/
|
||||
|
||||
export interface ModeParams {
|
||||
duration_days: number
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
smith_exponent: number | null
|
||||
techcomm_exponent: number | null
|
||||
ratio_multiplier: number | null
|
||||
is_ratio_mode: boolean
|
||||
}
|
||||
|
||||
type CodeType = 'int' | 'float' | 'bool'
|
||||
|
||||
const CODES: Record<string, { key: keyof ModeParams; type: CodeType }> = {
|
||||
D: { key: 'duration_days', type: 'int' },
|
||||
M: { key: 'majority_pct', type: 'int' },
|
||||
B: { key: 'base_exponent', type: 'float' },
|
||||
G: { key: 'gradient_exponent', type: 'float' },
|
||||
C: { key: 'constant_base', type: 'float' },
|
||||
S: { key: 'smith_exponent', type: 'float' },
|
||||
T: { key: 'techcomm_exponent', type: 'float' },
|
||||
N: { key: 'ratio_multiplier', type: 'float' },
|
||||
R: { key: 'is_ratio_mode', type: 'bool' },
|
||||
}
|
||||
|
||||
const PARAM_RE = /([A-Z])(\d*\.?\d+)/g
|
||||
|
||||
function getDefaults(): ModeParams {
|
||||
return {
|
||||
duration_days: 30,
|
||||
majority_pct: 50,
|
||||
base_exponent: 0.1,
|
||||
gradient_exponent: 0.2,
|
||||
constant_base: 0.0,
|
||||
smith_exponent: null,
|
||||
techcomm_exponent: null,
|
||||
ratio_multiplier: null,
|
||||
is_ratio_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a mode-params string into a structured ModeParams object.
|
||||
*
|
||||
* @param paramsStr - Compact parameter string, e.g. "D30M50B.1G.2T.1"
|
||||
* @returns Parsed parameters with defaults for codes not found
|
||||
* @throws Error if an unrecognised code letter is found
|
||||
*/
|
||||
export function parseModeParams(paramsStr: string): ModeParams {
|
||||
const result = getDefaults()
|
||||
|
||||
if (!paramsStr || !paramsStr.trim()) {
|
||||
return result
|
||||
}
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
PARAM_RE.lastIndex = 0
|
||||
|
||||
while ((match = PARAM_RE.exec(paramsStr)) !== null) {
|
||||
const code = match[1]
|
||||
const rawValue = match[2]
|
||||
|
||||
if (!(code in CODES)) {
|
||||
throw new Error(`Code de parametre inconnu : '${code}'`)
|
||||
}
|
||||
|
||||
const { key, type } = CODES[code]
|
||||
|
||||
if (type === 'int') {
|
||||
;(result as any)[key] = Math.floor(parseFloat(rawValue))
|
||||
} else if (type === 'float') {
|
||||
;(result as any)[key] = parseFloat(rawValue)
|
||||
} else if (type === 'bool') {
|
||||
;(result as any)[key] = parseFloat(rawValue) !== 0
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a ModeParams object into a compact mode-params string.
|
||||
*
|
||||
* Only includes parameters that differ from defaults.
|
||||
*
|
||||
* @param params - Parameters to encode
|
||||
* @returns Compact string, e.g. "D30M50B.1G.2"
|
||||
*/
|
||||
export function encodeModeParams(params: Partial<ModeParams>): string {
|
||||
const defaults = getDefaults()
|
||||
const parts: string[] = []
|
||||
|
||||
const codeEntries = Object.entries(CODES) as [string, { key: keyof ModeParams; type: CodeType }][]
|
||||
|
||||
for (const [code, { key, type }] of codeEntries) {
|
||||
const value = params[key]
|
||||
if (value === undefined || value === null) continue
|
||||
if (value === defaults[key]) continue
|
||||
|
||||
if (type === 'int') {
|
||||
parts.push(`${code}${value}`)
|
||||
} else if (type === 'float') {
|
||||
const numVal = value as number
|
||||
if (numVal < 1 && numVal > 0) {
|
||||
parts.push(`${code}${numVal.toString().replace(/^0/, '')}`)
|
||||
} else {
|
||||
parts.push(`${code}${numVal}`)
|
||||
}
|
||||
} else if (type === 'bool') {
|
||||
parts.push(`${code}${value ? 1 : 0}`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a mode-params string for human display.
|
||||
*
|
||||
* @param paramsStr - Compact parameter string
|
||||
* @returns Human-readable description in French
|
||||
*/
|
||||
export function formatModeParams(paramsStr: string): string {
|
||||
const params = parseModeParams(paramsStr)
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push(`Duree: ${params.duration_days} jours`)
|
||||
parts.push(`Majorite: ${params.majority_pct}%`)
|
||||
parts.push(`Base: ${params.base_exponent}`)
|
||||
parts.push(`Gradient: ${params.gradient_exponent}`)
|
||||
|
||||
if (params.constant_base > 0) {
|
||||
parts.push(`Constante: ${params.constant_base}`)
|
||||
}
|
||||
if (params.smith_exponent !== null) {
|
||||
parts.push(`Smith: ${params.smith_exponent}`)
|
||||
}
|
||||
if (params.techcomm_exponent !== null) {
|
||||
parts.push(`TechComm: ${params.techcomm_exponent}`)
|
||||
}
|
||||
|
||||
return parts.join(' | ')
|
||||
}
|
||||
84
frontend/app/utils/threshold.ts
Normal file
84
frontend/app/utils/threshold.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* TypeScript mirror of the Python WoT threshold formula.
|
||||
*
|
||||
* 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)
|
||||
*/
|
||||
export function wotThreshold(
|
||||
wotSize: number,
|
||||
totalVotes: number,
|
||||
majorityPct: number = 50,
|
||||
baseExponent: number = 0.1,
|
||||
gradientExponent: number = 0.2,
|
||||
constantBase: number = 0.0,
|
||||
): number {
|
||||
if (wotSize <= 0) {
|
||||
throw new Error('wotSize doit etre strictement positif')
|
||||
}
|
||||
if (totalVotes < 0) {
|
||||
throw new Error('totalVotes ne peut pas etre negatif')
|
||||
}
|
||||
if (majorityPct < 0 || majorityPct > 100) {
|
||||
throw new Error('majorityPct doit etre entre 0 et 100')
|
||||
}
|
||||
|
||||
const M = majorityPct / 100
|
||||
const T = totalVotes
|
||||
const W = wotSize
|
||||
const C = constantBase
|
||||
const B = baseExponent
|
||||
const G = gradientExponent
|
||||
|
||||
// Guard: if no votes, threshold is at least ceil(C + B^W)
|
||||
if (T === 0) {
|
||||
return Math.ceil(C + Math.pow(B, W))
|
||||
}
|
||||
|
||||
// Core formula
|
||||
const participationRatio = T / W
|
||||
const inertiaFactor = 1.0 - Math.pow(participationRatio, G)
|
||||
const requiredRatio = M + (1.0 - M) * inertiaFactor
|
||||
const result = C + Math.pow(B, W) + requiredRatio * Math.max(0, T - C)
|
||||
|
||||
return Math.ceil(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Smith criterion threshold.
|
||||
*
|
||||
* @param smithWotSize - Number of Smith members
|
||||
* @param smithExponent - Exponent S for the Smith criterion
|
||||
* @returns Minimum number of Smith votes required
|
||||
*/
|
||||
export function smithThreshold(smithWotSize: number, smithExponent: number): number {
|
||||
if (smithWotSize <= 0) return 0
|
||||
return Math.ceil(Math.pow(smithWotSize, smithExponent))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the TechComm criterion threshold.
|
||||
*
|
||||
* @param techcommSize - Number of TechComm members
|
||||
* @param techcommExponent - Exponent T for the TechComm criterion
|
||||
* @returns Minimum number of TechComm votes required
|
||||
*/
|
||||
export function techcommThreshold(techcommSize: number, techcommExponent: number): number {
|
||||
if (techcommSize <= 0) return 0
|
||||
return Math.ceil(Math.pow(techcommSize, techcommExponent))
|
||||
}
|
||||
Reference in New Issue
Block a user