v2 : conception ultracode + moteur de formules porté en TS

- Blueprint définitif (docs/dev/BLUEPRINT-V2.md + JSON) : panel 4 visions
  + 3 juges, corpus doctrinal (Mon nom est personne, Une économie du don,
  SejeteralO), 4 critiques d'intégration, 3 vérificateurs, réparations
- Corpus d'intention + lectures brutes archivés
- Moteur porté Python→TS pur : threshold (inertie WoT, Smith, ComTech),
  nuancé 6 niveaux, DSL modeParams — 45 tests vitest, parité croisée
  vérifiée (Forgeron W=7224 T=120 ⇒ 94)
- Contrat de domaine v2 (types/domain.ts) + lexique UI (lexicon.ts)
- vitest + idb-keyval en dépendances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 05:59:33 +02:00
co-authored by Claude Fable 5
parent 172eab4c7c
commit 53d8752e40
17 changed files with 5641 additions and 3 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* Pure voting-formula engine — TypeScript port of backend/app/engine/.
* Python is the reference implementation; the vitest suite in
* frontend/tests/engine/ mirrors the backend pytest suite.
*/
export { wotThreshold, smithThreshold, techcommThreshold } from './threshold'
export { nuancedResult, LEVEL_LABELS, NUM_LEVELS } from './nuanced'
export type { NuancedResult } from './nuanced'
export { parseModeParams, formatModeParams } from './modeParams'
export type { ModeParams } from './modeParams'
+163
View File
@@ -0,0 +1,163 @@
/**
* Mode-params DSL: parse and format compact parameter strings.
*
* Exact TypeScript port of backend/app/engine/mode_params.py
* (Python is the reference implementation for parsing; the Python
* module has no formatter, so formatModeParams() is defined as the
* canonical inverse of parseModeParams()).
*
* 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'
// Ordered list of recognised codes and their target keys + types
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' },
}
// Regex: a single uppercase letter followed by a numeric value (int or float,
// possibly starting with '.' for values like .1 meaning 0.1)
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 parameter object.
*
* Defaults are applied for any code not present in the string.
*
* @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
}
for (const match of paramsStr.matchAll(PARAM_RE)) {
const code = match[1]!
const rawValue = match[2]!
const entry = CODES[code]
if (!entry) {
throw new Error(`Code de parametre inconnu : '${code}'`)
}
const { key, type } = entry
if (type === 'int') {
// Python: int(float(raw)) truncates toward zero
;(result as Record<keyof ModeParams, unknown>)[key] = Math.trunc(parseFloat(rawValue))
} else if (type === 'float') {
;(result as Record<keyof ModeParams, unknown>)[key] = parseFloat(rawValue)
} else {
;(result as Record<keyof ModeParams, unknown>)[key] = parseFloat(rawValue) !== 0.0
}
}
return result
}
/**
* Format a numeric value in DSL form: 0.1 -> ".1", 1 -> "1", 2.5 -> "2.5".
*/
function formatValue(value: number): string {
const s = String(value)
return value > 0 && value < 1 ? s.replace(/^0\./, '.') : s
}
/**
* Format a parameter object back into a canonical mode-params string.
*
* Canonical form matches the strings used across the project
* (e.g. "D30M50B.1G.2S.1"): D, M, B, G always present in that order,
* then C when non-zero, then S/T/N when set, then R1 when ratio mode.
* Round-trip: parseModeParams(formatModeParams(p)) is identical to p.
*
* @param params - Parameters to encode (missing keys take defaults)
* @returns Compact string, e.g. "D30M50B.1G.2"
*/
export function formatModeParams(params: Partial<ModeParams> = {}): string {
const p = getDefaults()
for (const key of Object.keys(p) as Array<keyof ModeParams>) {
const value = params[key]
if (value !== undefined) {
;(p[key] as ModeParams[typeof key]) = value
}
}
const parts: string[] = [
`D${formatValue(p.duration_days)}`,
`M${formatValue(p.majority_pct)}`,
`B${formatValue(p.base_exponent)}`,
`G${formatValue(p.gradient_exponent)}`,
]
if (p.constant_base !== 0) {
parts.push(`C${formatValue(p.constant_base)}`)
}
if (p.smith_exponent !== null) {
parts.push(`S${formatValue(p.smith_exponent)}`)
}
if (p.techcomm_exponent !== null) {
parts.push(`T${formatValue(p.techcomm_exponent)}`)
}
if (p.ratio_multiplier !== null) {
parts.push(`N${formatValue(p.ratio_multiplier)}`)
}
if (p.is_ratio_mode) {
parts.push('R1')
}
return parts.join('')
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Six-level nuanced vote evaluation.
*
* Exact TypeScript port of backend/app/engine/nuanced_vote.py
* (Python is the reference implementation).
*
* 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 >= thresholdPct% of total votes.
* A minimum number of participants is also required.
*/
export const LEVEL_LABELS: Record<number, string> = {
0: 'CONTRE',
1: 'PAS DU TOUT',
2: "PAS D'ACCORD",
3: 'NEUTRE',
4: "D'ACCORD",
5: 'TOUT A FAIT',
}
export const NUM_LEVELS = 6
/** Result shape mirrors the Python dict returned by evaluate_nuanced(). */
export interface NuancedResult {
total: number
per_level_counts: Record<number, number>
positive_count: number
positive_pct: number
threshold_met: boolean
min_participants_met: boolean
adopted: boolean
}
/**
* Round to 2 decimals with ties-to-even, mirroring Python's round(x, 2).
*/
function round2(value: number): number {
const scaled = value * 100
const floor = Math.floor(scaled)
const diff = scaled - floor
let rounded: number
if (diff > 0.5) {
rounded = floor + 1
} else if (diff < 0.5) {
rounded = floor
} else {
rounded = floor % 2 === 0 ? floor : floor + 1
}
return rounded / 100
}
/**
* Evaluate a nuanced vote from a list of individual vote levels.
*
* @param votes - List of vote levels (each 0-5), one entry per voter
* @param thresholdPct - Minimum percentage of positive votes (levels 3-5) for adoption
* @param minParticipants - Minimum number of participants required for validity
* @returns Aggregated result (counts, percentage, adoption flags)
* @throws Error if any vote value is outside the 0-5 integer range
*/
export function nuancedResult(
votes: number[],
thresholdPct: number = 80,
minParticipants: number = 59,
): NuancedResult {
// Validate vote levels (integer check added: JS has no KeyError safety net)
for (const v of votes) {
if (!Number.isInteger(v) || v < 0 || v > 5) {
throw new Error(`Niveau de vote invalide : ${v}. Les niveaux valides sont 0-5.`)
}
}
const total = votes.length
const perLevelCounts: Record<number, number> = {}
for (let level = 0; level < NUM_LEVELS; level++) {
perLevelCounts[level] = 0
}
for (const v of votes) {
perLevelCounts[v] = (perLevelCounts[v] ?? 0) + 1
}
// Positive = levels 3 (NEUTRE), 4 (D'ACCORD), 5 (TOUT A FAIT)
const positiveCount = (perLevelCounts[3] ?? 0) + (perLevelCounts[4] ?? 0) + (perLevelCounts[5] ?? 0)
const positivePct = total > 0 ? (positiveCount / total) * 100.0 : 0.0
const thresholdMet = positivePct >= thresholdPct
const minParticipantsMet = total >= minParticipants
const adopted = thresholdMet && minParticipantsMet
return {
total,
per_level_counts: perLevelCounts,
positive_count: positiveCount,
positive_pct: round2(positivePct),
threshold_met: thresholdMet,
min_participants_met: minParticipantsMet,
adopted,
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* WoT members threshold formula for binary votes.
*
* Exact TypeScript port of backend/app/engine/threshold.py,
* smith_threshold.py and techcomm_threshold.py (Python is the
* reference implementation — same guards, same ceil rounding).
*
* Core formula:
* Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C)
*
* Where:
* C = constantBase
* B = baseExponent
* W = wotSize (corpus of eligible voters)
* T = totalVotes (for + against)
* M = majorityRatio (majorityPct / 100)
* G = gradientExponent
*
* Inertia behaviour:
* - Low participation (T << W) -> near-unanimity required
* - High participation (T -> W) -> simple majority M suffices
*
* Reference test case:
* wotSize=7224, votesFor=97, votesAgainst=23 (total=120)
* params M50 B.1 G.2 => threshold=94, adopted (97 >= 94)
*/
/**
* Compute the minimum number of *for* votes required for adoption.
*
* A vote passes when `votesFor >= wotThreshold(...)`.
*
* @param wotSize - Size of the eligible voter corpus (WoT members)
* @param totalVotes - Number of votes cast (for + against)
* @param majorityPct - Majority percentage (0-100). 50 = simple majority at full participation
* @param baseExponent - B in the formula; B^W is a vanishingly small offset when W is large
* @param gradientExponent - G controls how fast the super-majority decays toward M
* @param constantBase - C, a fixed additive floor on the threshold
* @returns The ceiling of the computed threshold
*/
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 C = constantBase
const B = baseExponent
const W = wotSize
const T = totalVotes
const M = majorityPct / 100.0
const G = gradientExponent
// Guard: if no votes, threshold is at least ceil(C + B^W)
if (T === 0) {
return Math.ceil(C + B ** W)
}
// Core formula
const participationRatio = T / W
const inertiaFactor = 1.0 - participationRatio ** G
const requiredRatio = M + (1.0 - M) * inertiaFactor
const result = C + B ** W + requiredRatio * Math.max(0.0, T - C)
return Math.ceil(result)
}
/**
* Smith sub-WoT threshold criterion.
*
* Formula: ceil(smithWotSize ^ S)
*
* @param smithWotSize - Number of active Smith members (forgerons)
* @param exponent - S in the formula
* @returns Minimum Smith votes required
*/
export function smithThreshold(smithWotSize: number, exponent: number = 0.1): number {
if (smithWotSize <= 0) {
throw new Error('smithWotSize doit etre strictement positif')
}
return Math.ceil(smithWotSize ** exponent)
}
/**
* Technical Committee threshold criterion.
*
* Formula: ceil(cotecSize ^ T)
*
* @param cotecSize - Number of Technical Committee members
* @param exponent - T in the formula
* @returns Minimum TechComm votes required
*/
export function techcommThreshold(cotecSize: number, exponent: number = 0.1): number {
if (cotecSize <= 0) {
throw new Error('cotecSize doit etre strictement positif')
}
return Math.ceil(cotecSize ** exponent)
}