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)
}
+251
View File
@@ -0,0 +1,251 @@
// ─────────────────────────────────────────────────────────────
// libreDecision v2 — lexicon: SINGLE source of French UI labels.
// Tone rules (BLUEPRINT-V2.md):
// 1. tutoyer la personne dans ses gestes ;
// 2. actes collectifs à l'infinitif (« Adopter notre Pacte ») ;
// 3. sujet grammatical des phrases générées = les personnes ou le collectif,
// JAMAIS la formule ou le système.
// The anti-lexicon vitest test (tests/lexicon.spec.ts) enforces FORBIDDEN_UI_TERMS
// over every UI-visible string (lexicon + .vue templates).
// ─────────────────────────────────────────────────────────────
import type {
DecisionRoute,
DecisionStatus,
InertiaPreset,
NominationMethod,
NuancedValue,
VoteMethod,
Weight,
Reversibility,
} from '~/types/domain'
/** Words that must NEVER appear in UI-visible text (case-insensitive, accent-aware).
* They may exist as code identifiers — the test only scans UI strings. */
export const FORBIDDEN_UI_TERMS = [
'déléguer',
'délégation',
'délégué',
'dette',
'retard',
'en souffrance',
'corpus',
'snapshot',
'verdict',
'triage',
'oligarchie',
'peuple adulte',
] as const
// ── Routes — first person: the path is a gesture, not a category ──
export const ROUTE_LABELS: Record<DecisionRoute, string> = {
solo: 'Je décide',
mandate: 'Je décide, sous mandat',
transmit: 'Je transmets',
advice: "J'écoute, puis je décide",
collective: 'Nous décidons',
record: 'Je le consigne',
}
/** Short pill forms (registry filters). */
export const ROUTE_SHORT: Record<DecisionRoute, string> = {
solo: 'seul',
mandate: 'sous mandat',
transmit: 'transmis',
advice: 'sur avis',
collective: 'ensemble',
record: 'consigné',
}
export const ROUTE_ICONS: Record<DecisionRoute, string> = {
solo: 'i-lucide-zap',
mandate: 'i-lucide-key-round',
transmit: 'i-lucide-send',
advice: 'i-lucide-message-circle',
collective: 'i-lucide-users',
record: 'i-lucide-notebook-pen',
}
export const ICONS = {
urgent: 'i-lucide-siren',
alreadyDecided: 'i-lucide-book-open-check',
recurrence: 'i-lucide-repeat',
parametric: 'i-lucide-sliders-horizontal',
crystallize: 'i-lucide-stamp',
} as const
// ── Decision states — plain French pills ──
export const STATUS_LABELS: Record<DecisionStatus, string> = {
draft: 'préparation',
advice: 'avis',
objection: 'objection',
framing: 'formulation',
voting: 'vote',
adopted: 'en vigueur',
rejected: 'rejeté',
revoked: 'révoqué',
transmitted: 'transmis',
closed: 'clos',
}
/** Session display state (frozen lives on VoteSession, shown as a decision badge). */
export const FROZEN_LABEL = 'figé'
export const FROZEN_BANNER = 'Votes figés — en attente de cristallisation'
// ── Weight & reversibility — the tunnel chips ──
export const WEIGHT_LABELS: Record<Weight, string> = {
light: 'rien de lourd',
binding: 'du temps ou des ressources',
structural: 'la structure, les règles, du long terme',
}
export const REVERSIBILITY_LABELS: Record<Reversibility, string> = {
easy: 'facilement réversible',
costly: 'coûteux à défaire',
irreversible: 'irréversible',
}
// ── Nuanced scale — never « CONTRE » ──
export const NUANCED_LABELS: Record<NuancedValue, string> = {
0: 'Pas du tout',
1: 'Plutôt non',
2: 'Réservé',
3: 'Plutôt oui',
4: 'Oui',
5: 'Tout à fait',
}
// ── Vote methods ──
export const METHOD_LABELS: Record<VoteMethod, string> = {
consent: 'Consentement',
binary: 'Pour / contre',
nuanced: 'Vote nuancé',
parametric: 'Réglage collectif',
election: 'Élection',
}
export const BINARY_COST = 'deux camps, un perdant'
export const BINARY_DESCRIPTION = "l'outil de dernier recours des très grands corps"
// ── The review — l'épreuve du réel ──
export const REVIEW_TITLE = "L'épreuve du réel"
export const REVIEW_QUESTION = 'Le réel a-t-il suivi ?'
export const REVIEW_VERDICTS = {
confirmed: 'Ça tient',
revise: 'À revoir',
revoke: 'À révoquer',
} as const
// ── Windows, assent, perimeter ──
export const WINDOW_OK = 'Ça me va'
export const WINDOW_OBJECT = "J'objecte"
export const ASSENT_MISSING = 'Il manque un accord explicite'
export const BOUNDARY_SUSPENDED = 'compte à rebours suspendu — frontière contestée'
export const CONCERN_FIRST = 'Concerné·e en premier lieu'
export const CONCERN_SECOND = "Concerné·e en second lieu — s'est déclaré·e"
export const CONCERN_ME = 'Ça me concerne'
export const PERIMETER_OVERFLOW = "Le périmètre déborde — élargis d'un cran"
export const SCOPE_WIDEN = "Élargir d'un cran"
export const SCOPE_KEEP = 'Maintenir — en motivant publiquement'
// ── Parametric — le réglage collectif ──
export const PARAMETRIC_ALT = 'C\'est un réglage (montant, taux, répartition) — décidez au curseur'
export const PARAM_CONSTRAINT_LABEL = 'contrainte'
export const PARAM_DERIVED_LABEL = 'calculé'
export const IMPACT_CARD_TITLE = 'Pour moi'
export const IMPACT_DISCLAIMER = 'estimation sur données déclarées'
export const EXPLORE_LABEL = 'Explorer'
export const EXPLORE_DISCLAIMER = 'exploration — ne compte pas'
export const CRYSTALLIZE_ACTION = 'Cristalliser'
export const CRYSTALLIZE_CARD = 'Les votes sont figés — cristallise la médiane'
export const MEDIAN_EXPLANATION =
'Le collectif retient la médiane de chaque curseur — robuste aux extrêmes, '
+ "et c'est une position que chacun aurait pu proposer."
export const BIMODAL_BANNER = (paramLabel: string) =>
`Deux positions distinctes se dessinent sur ${paramLabel} — scinder la question ou changer de référentiel ?`
export const CRYSTALLIZE_CHOICES = ['Cristalliser quand même', 'Scinder', 'Reformuler'] as const
// ── Record & observatory ──
export const RECORD_ALT = 'Déjà tranché — je le consigne'
export const RECORD_HOW = "Comment ça s'est décidé ?"
export const OBSERVATORY_TITLE = "L'Observatoire"
export const OBSERVATORY_SUBTITLE = 'comment nous décidons, dans les faits'
export const REVOKED_SECTION = "Révoquées — ce qu'on en a appris"
export const MATURATION_CARD = 'Cette pratique est mûre — protocolise-la en quelques phrases'
// ── Élection ──
export const BLANK_VOTE = 'Vote blanc'
export const ELECTION_RULE =
"La personne la plus désignée est nommée ; en cas d'égalité, vous départagez — jamais l'outil."
// ── Urgence ──
export const URGENT_TOGGLE = "C'est urgent"
export const URGENT_BADGE = 'Décidé en urgence — le collectif ratifie'
export const URGENT_REFUSED = "Irréversible : l'urgence ne peut pas contourner le collectif."
// ── Fil / Aujourd'hui ──
export const FEED_HEADER = 'À toi de décider'
export const CAPTURE_PLACEHOLDER = "Qu'est-ce que tu décides ?"
export const DOSSIER_COMPLETE_CARD = 'Le dossier est complet — clore et publier la cartographie de clôture'
// ── Fiche / tunnel ──
export const PATH_CARD_TITLE = 'Le chemin'
export const CHOOSE_OTHERWISE = 'Je choisis autrement'
export const WHY_DISCLOSURE = 'Pourquoi ?'
export const ENGAGES_LABEL = 'Ce que ça engage'
export const BASELINE_PREFIX = "Aujourd'hui :"
export const BASELINE_ARROW = "Aujourd'hui → Proposé"
export const INSTRUCT_BLOCK = "S'instruire"
export const REOPEN_HANDLE = 'Remettre en question'
export const ADOPTED_STAMP = 'Décidé'
export const ADOPTED_TOAST = "C'est décidé — et révisable, comme tout ici."
export const VOTE_PRIVACY = "Ton vote est remplaçable — ton historique n'est visible que de toi."
export const WHO_VOTES = (n: number, date: string) =>
`Qui vote : ${n} personne${n > 1 ? 's' : ''} — liste arrêtée le ${date}`
// ── Mandats ──
export const MANDATES_TITLE = 'Mandats — les pouvoirs confiés'
export const MANDATE_EXERCISE = 'Exercice du mandat'
export const MANDATE_SPOTLIGHT = 'Feux de la rampe'
export const MANDATE_SPOTLIGHT_SUB = 'toutes les décisions prises sous ce mandat — contestables'
export const MANDATE_CLAIM = 'Réclamer un mandat'
export const MANDATE_REVOKE = 'Demander la révocation'
export const NOMINATION_LABELS: Record<NominationMethod, string> = {
'ratified-self': 'Auto-désignation ratifiée',
'election-no-candidate': 'Élection sans candidat',
'nuanced-vote': 'Vote nuancé',
consent: 'Consentement',
draw: 'Tirage au sort',
rotation: 'Rotation',
}
// ── Textes / Pacte ──
export const PACT_BADGE = 'les règles du jeu'
export const PACT_SUBTITLE = 'Notre contrat social — sacralisé, jamais immuable'
export const INERTIA_LABELS: Record<InertiaPreset, string> = {
low: 'inertie faible',
standard: 'inertie standard',
high: 'inertie haute',
max: 'inertie maximale',
}
export const FIRST_DECISION = 'Adopter notre Pacte'
export const CREATE_SIGNATURE = 'Osons, nous verrons'
export const OPENING_DREAM = "Une démocratie qui n'a jamais existé — à votre échelle."
export const CLOSING_LINE = 'La ville change, le puits demeure.'
// ── Preuve / gravure / données ──
export const PROOF_LOCAL = 'empreinte locale — démo'
export const SECRET_DISPLAY = "secret d'affichage — démo"
export const LINEAGE_PREFIX = 'essaimé de'
export const WORKSHOP_MODE = 'mode atelier'
export const WORKSHOP_RECORDED = (by: string, forWhom: string, date: string) =>
`saisi par ${by} pour ${forWhom} — atelier du ${date}`
export const ATTRIBUTES_HINT =
'servent uniquement ta carte Pour moi, jamais montrés aux autres'
export const SYNC_NOTE =
'Un serveur pourra se brancher plus tard, tes données ne changent pas de forme.'
export const NO_PROTOCOL_BANNER = 'Aucun protocole — crée-le ou décide sur avis'
// ── Génération des phrases de seuil (sujet = le collectif, jamais la formule) ──
export const thresholdSentence = (W: number, T: number, threshold: number) =>
`${T.toLocaleString('fr-FR')} votant${T > 1 ? 's' : ''} sur ${W.toLocaleString('fr-FR')} : `
+ `à si faible participation, il faut presque l'unanimité — ${threshold.toLocaleString('fr-FR')} pour. `
+ 'Plus de monde vote, plus le seuil descend.'
+521
View File
@@ -0,0 +1,521 @@
// ─────────────────────────────────────────────────────────────
// libreDecision v2 — domain core (BLUEPRINT-V2.md, docs/dev/)
// Local-first, sync-ready. English identifiers, French UI via app/lexicon.ts.
// INVARIANT 1: THE DECISION IS THE PIVOT — mandates, clauses, revocations,
// ratifications and dossier elements are chained specializations of it.
// INVARIANT 2: that unification is an architectural fact, NEVER UI vocabulary.
// INVARIANT 3: no aggregate/score on a person, anywhere — we evaluate things.
// INVARIANT 4: no computation ever produces adopted content without a dated
// human gesture (parametric crystallization, election tie-break, dossier close).
// ─────────────────────────────────────────────────────────────
export type Id = string // UUIDv7 — time-sortable, sync-ready
export type ISODate = string // ISO 8601 UTC
export type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
// Every entity: collective scoping + sync clock + soft delete.
// Sync-ready rules: never a physical delete (archivedAt);
// Vote/Advice/Objection/Assent append-only (trivial merge); the rest LWW by updatedAt.
export interface Entity {
id: Id
collectiveId: Id
createdAt: ISODate
updatedAt: ISODate
archivedAt?: ISODate
}
// ── The tenant ───────────────────────────────────────────────
export type CollectiveTemplate =
| 'blank' // « Page blanche — observatoire d'abord » : one-clause Pact + A1, Consent protocol ONLY
| 'informal'
| 'association'
| 'cooperative'
| 'commune'
| 'free-currency' // Ğ1 heritage — ONLY template where protocolByRange.large = inertial binary
| 'symmetric' // « Institution symétrique » — the founding militant gesture (MNP)
export interface Collective {
id: Id
slug: string
name: string
color: string // hex accent
icon: string // i-lucide-*
template: CollectiveTemplate
isTransparent: boolean
pactDocId: Id // TextDoc role='pact' — settings AS a voted document
rootCircleId: Id
lineage?: {
// seeding trail: set automatically when importing a bundle from another collective
sourceSlug: string
exportedAt: ISODate
sha256: string
} // NO parentCollectiveId — federation is never a table
createdAt: ISODate
updatedAt: ISODate
}
// ── The people ───────────────────────────────────────────────
export interface Person extends Entity {
displayName: string
isMe: boolean // local profile (v2 single-seat; « mode atelier » for in-person capture)
attributes?: Record<string, number> // SELF-declared attributes (free keys: 'heures/mois'…),
// edited in /donnees — feed ONLY the « Pour moi » card;
// never shown to others, never aggregated (invariant 3)
duniterAddress?: string // SS58 — reserved for Duniter auth (out of v2 scope)
wotStatus?: 'member' | 'smith'
}
export type CircleKind = 'place' | 'theme' | 'team'
export interface Circle extends Entity {
name: string
purpose: string
kind?: CircleKind // lieu / thème / équipe — iconography, grouping, suggestion order
// ONLY; never a right; non-blocking hint when a 'team' exceeds 12
memberIds: Id[] // v2: nominative list = the ONLY membership rule (auditable)
parentCircleId?: Id // nesting (couches d'oignon) — root = rootCircle
domains: string[] // tags — SUGGESTION/discovery only, never a voting right
}
// Corpus of a decision = COMPUTABLE union: members of scoped circles
// named persons holders of mandates whose domain intersects.
// Every inclusion keeps its reason (Concern.reason) — auditable in one tap.
// ── THE DECISION (pivot) ─────────────────────────────────────
export type Reversibility = 'easy' | 'costly' | 'irreversible'
export type Weight = 'light' | 'binding' | 'structural'
export type DecisionRoute =
| 'solo' // « Je décide » (optional trace outside mandate)
| 'mandate' // « Je décide, sous mandat » (mandatory trace + objection window)
| 'transmit' // « Je transmets » — someone else's mandate covers
| 'advice' // « J'écoute, puis je décide »
| 'collective' // « Nous décidons » (protocol modality)
| 'record' // « Déjà tranché — je le consigne » : the observatory in one gesture, adopted immediately
export type DecisionStatus =
| 'draft'
| 'advice'
| 'objection'
| 'framing'
| 'voting'
| 'adopted'
| 'rejected'
| 'revoked'
| 'transmitted'
| 'closed'
export type TriageRule = 'R-U' | 'R0a' | 'R0b' | 'R0c' | 'R2' | 'R3' | 'R4' | 'R5' | 'R6'
// The matter — « il n'est pas consulté, il est instruit »
export interface Effect {
label: string // sought effect, one line
target?: string // optional measurable target (« ≤ 400 € », « +5 membres »)
measured?: { note: string; at: ISODate; byId: Id } // observed — filled by measurerIds at review time
}
export interface Brief {
context?: string
sources?: { label: string; url: string }[]
symptomsVsCauses?: string // doctrinal qualification of the subject
effects: Effect[] // GUARD (state.ts, opening of a COLLECTIVE session only):
// required when weight ∈ Pact 'triage.requireEffects' ('binding' covers binding+structural);
// structural under guard ⇒ ≥1 effect with target. NEVER for weight 'light',
// NEVER in the tunnel, NEVER for solo/record.
}
// The linked resource decision — « chaque décision implique une allocation »
export interface Resources {
note: string // « Ce que ça engage » in one sentence
amount?: number
unit?: 'heures' | '€' | 'DU' | string // oil/water: units NEVER converted nor summed together
}
// The collective tuning — « raisonner curseur / table de mixage »
export type ParamKind = 'slider' | 'share' // 'curve' reserved for v3 (full SejeteralO)
export interface ParamDef {
key: string
label: string // MANDATORY business label — never raw a, b, c
kind: ParamKind
min: number
max: number
step: number
unit?: string
baseline?: number // status quo — hollow marker always displayed
derived?: boolean // resolved by the constraint, never voted — labelled « calculé »
}
export interface ParamSpec {
params: ParamDef[] // ≤ 7 (SejeteralO lesson: small manipulable space)
constraint: 'none' | 'sum100' // sum100: 'share' params total 100; EXACTLY ONE 'derived'
// share required (validated at spec creation) — the absorption variable, resolved
// linearly (100 Σ others)
impactAttrKey?: string // key of Person.attributes → « Pour moi » card ('linear-share')
}
export type ChainKind = 'ratification' | 'revision' | 'revocation' | 'element'
export interface Decision extends Entity {
authorId: Id
title: string // THE capture sentence — only mandatory field
body?: string // progressive disclosure
brief?: Brief // matter — see opening guard above
baselineNote?: string // « Aujourd'hui : … » — suggested on advice/collective routes, never solo
resources?: Resources // SUGGESTED-unfolded in the tunnel (never blocking); REQUIRED by the
// window/session opening guard (state.ts) when weight != 'light' outside solo
tags: string[] // discovery + recurrence — NEVER a corpus
// — triage verdict (pre-filled by the engine, adjustable) —
reversibility: Reversibility
weight: Weight
urgent: boolean
scope: {
selfOnly: boolean
circleIds: Id[] // floor: widening free, NEVER narrowing below the computation
personIds: Id[]
}
route: DecisionRoute
triageRule: TriageRule // relegated to the « pourquoi ? » disclosure, PV, journal — never on the card
routeOverridden: boolean
overrideNote?: string // MANDATORY when lightening (derogation asymmetry)
scopeKeptNote?: string // influx reached + perimeter kept ⇒ mandatory PUBLIC motivated note
// — chained specializations (the generative folding) —
underMandateId?: Id // decided UNDER this mandate → the mandate's « feux de la rampe »
createsMandate?: MandateDraft
amendsClauseId?: Id
protocolId?: Id // resolved by the Pact, adjustable by weighting up
paramSpec?: ParamSpec // when parametric modality (« Réglage collectif »)
parentDecisionId?: Id
chainKind?: ChainKind
// 'element': micro-decision of a split dossier (« décision incrémentielle »).
// Parent of elements: framing→closed transition guarded by
// « all element children terminal » — steward GESTURE (Fil card), never automatic;
// the closing cartography becomes the dossier's PV.
decidedHow?: string // route 'record': « comment ça s'est décidé », one sentence
// — time & windows —
status: DecisionStatus
windowEndsAt?: ISODate
windowSuspendedAt?: ISODate // boundary objection → countdown SUSPENDED (visible)
decidedAt?: ISODate
review?: Review // mandatory when irreversible || structural
sunsetAt?: ISODate
stewardIds: Id[] // stewards — REALIZE (and crystallize parametric, close dossiers)
measurerIds: Id[] // measurers — OBSERVE (fill Effect.measured): the doctrinal pair
// — proof & visibility —
visibility: 'private' | 'scope' | 'collective' // anti-surveillance: trace owned outside mandate
engraving?: Engraving
}
export interface Review {
dueAt: ISODate
verdict?: 'confirmed' | 'revise' | 'revoke' // UI: « Ça tient / À revoir / À révoquer »
note?: string
decidedAt?: ISODate
}
export interface Engraving {
sha256: string
engravedAt: ISODate
proofLevel: 'local' // v2: ONLY 'local' — « empreinte locale — démo »
ipfsCid?: string // reserved v3 — never displayed until real
chainRef?: string
}
// ── Inclusion & windows ──────────────────────────────────────
export interface Concern extends Entity {
decisionId: Id
personId: Id
origin: 'computed' | 'declared' // UI: « concerné·e en premier lieu » / « en second lieu »
reason: string // « membre du cercle Forgerons » — shown on tap
declaredNote?: string
beforeSnapshot: boolean // before the list is arrested ⇒ vote; after ⇒ consultative voice
priority?: 0 | 1 | 2 | 3 // « pondère tes enjeux » — per element of a dossier;
// feeds the closing cartography, never the voting right
}
export interface Assent extends Entity {
// « Ça me va » STORED — the explicit agreement of objection windows
decisionId: Id
personId: Id
recordedById?: Id // mode atelier
}
// OBJECTION WINDOW RULE (state.ts): at deadline with no open objection —
// easy ⇒ adopted (silence counts as agreement); costly/irreversible ⇒ adopted ONLY if
// ≥1 Assent from a concerned person ≠ author, otherwise EXTENSION by one notch
// (+objectionWindowHours, Fil reminder) — never adoption by pure silence outside easy.
export interface Objection extends Entity {
decisionId: Id
personId: Id
kind: 'content' | 'boundary' // boundary SUSPENDS the window — the frontier precedes substance
argument: string
status: 'open' | 'withdrawn' | 'integrated' | 'escalated'
resolutionNote?: string
recordedById?: Id // mode atelier: « saisi par X pour Y — atelier du <date> »
}
export interface Advice extends Entity {
decisionId: Id
personId: Id
position: 'favorable' | 'reserved' | 'unfavorable'
note?: string
recordedById?: Id // mode atelier
}
// ── The power ────────────────────────────────────────────────
export type NominationMethod =
| 'ratified-self'
| 'election-no-candidate'
| 'nuanced-vote'
| 'consent'
| 'draw'
| 'rotation'
export interface Mandate extends Entity {
title: string
holderId: Id
originDecisionId: Id // the mandate IS born of a decision — clickable
domain: { circleIds: Id[]; tags: string[] }
startsAt: ISODate
endsAt: ISODate // always bounded
electorCircleId: Id // election corpus = revocation corpus
nominationMethod: NominationMethod
reports: MandateReport[]
status: 'proposed' | 'active' | 'expired' | 'revoked'
// NO aggregate/gauge: the sheet shows counted facts (« Exercice du mandat »),
// never synthesized; the public stream = « Feux de la rampe »
}
export interface MandateReport {
dueAt: ISODate
deliveredAt?: ISODate
content?: string
}
export interface MandateDraft {
title: string
domainCircleIds: Id[]
domainTags: string[]
durationDays: number
reportEveryDays?: number
}
// ── The texts ────────────────────────────────────────────────
export interface TextDoc extends Entity {
slug: string
title: string
role: 'pact' | 'reference'
description: string // seeded Pact: « Notre contrat social — sacralisé, jamais immuable »
provenance?: Provenance
}
export interface Provenance {
sources: { title: string; url: string; date?: string; version?: string }[]
voteRecord?: {
url: string
modeParams: string
period: string
result: {
for: number
against: number
invalid?: number
wotSize: number
thresholdRequired: number
status: string
}
}
contributors?: string[]
notes?: string // typos of voted texts: FLAGGED, never fixed without a vote
}
export type InertiaPreset = 'low' | 'standard' | 'high' | 'max'
// REAL wiring: low={M:50,G:0.1} standard={M:50,G:0.2} high={M:60,G:0.4} max={M:66,G:0.6} (B=0.1, C=0)
export interface Clause extends Entity {
docId: Id
section: string
position: number
code: string
title: string
inertia: InertiaPreset
currentVersionId?: Id
settingKey?: string // Pact clauses: e.g. 'triage.smallGroupMax'
}
export interface ClauseVersion extends Entity {
clauseId: Id
decisionId: Id // EVERY version is born of a decision (founding seeded ones included)
versionLabel: string
content: string
settingValue?: Json
status: 'current' | 'proposed' | 'superseded' | 'rejected'
adoptedAt?: ISODate
}
// ── Modalities & vote ────────────────────────────────────────
export type VoteMethod = 'consent' | 'binary' | 'nuanced' | 'parametric' | 'election'
// UI: consentement · pour/contre (last-resort tool) · nuancé ·
// « Réglage collectif » (au curseur) · élection
export interface Protocol extends Entity {
name: string
method: VoteMethod
description: string
durationDays: number
ballot: 'open' | 'secret' // decided by the Pact — default 'open' (feux de la rampe).
// v2 single-seat: « secret d'affichage — démo » (aggregate tallies and anonymized
// comments only); real crypto arrives with sync (v3)
formula: FormulaParams
modeParams: string // DERIVED display DSL: 'D30M50B.1G.2S.1' — never hand-edited
pactClauseId?: Id // referenced by the Pact ⇒ changing it = amending the Pact
}
export interface FormulaParams {
majorityPct: number // M
baseExponent: number // B (default 0.1)
gradientExponent: number // G — the inertia
constantBase: number // C (default 0)
smithExponent?: number
techcommExponent?: number
nuancedMinParticipants?: number
nuancedThresholdPct?: number
parametricMinParticipants?: number // collective-tuning quorum — not reached ⇒ rejected at the gesture
electionMinParticipants?: number // election quorum — not reached ⇒ rejected
tieBreak?: 'runoff' | 'draw' // election tie-break DECIDED IN ADVANCE by the Pact
// (default 'runoff': chained runoff session among tied; 'draw': draw among tied —
// never chosen by the engine)
}
export interface VoteSession extends Entity {
decisionId: Id
protocolId: Id
corpusPersonIds: Id[] // SNAPSHOT at opening — UI: « liste arrêtée le … »
corpusSize: number // W — never 0 again (letter reserved to the Atelier)
opensAt: ISODate
closesAt: ISODate
status: 'open' | 'frozen' | 'closed'
// 'frozen': PARAMETRIC only — at closesAt votes are frozen, the frozen median is shown,
// and the session WAITS for the steward's human gesture (Fil reminder if late).
crystallizedById?: Id // the « Cristalliser » gesture: who, when — dated and signed
crystallizedAt?: ISODate
outcome?: 'adopted' | 'rejected' | 'tie'
// 'tie': tied election — the tool NEVER breaks a tie: chained runoff session
// (restricted to tied) or draw IF the protocol planned it (tieBreak).
// Consent/nuanced/binary: open→closed automatic at closesAt (the engine only observes a
// threshold over human-written content). Parametric: open→frozen→closed (gesture).
// INVARIANT: tallies, histograms AND medians ALWAYS recomputed from Vote[] — never denormalized.
}
export type NuancedValue = 0 | 1 | 2 | 3 | 4 | 5
export interface Vote extends Entity {
sessionId: Id
voterId: Id
value?: 'for' | 'against' | NuancedValue
// nuancé UI: 0 Pas du tout · 1 Plutôt non · 2 Réservé · 3 Plutôt oui · 4 Oui · 5 Tout à fait
values?: number[] // parametric — order of paramSpec.params EXCLUDING derived
choicePersonId?: Id // election — designation; a vote without choicePersonId = BLANK
// (counts for participation, not designation)
// INVARIANT: EXACTLY ONE of value / values / choicePersonId per method (validated by the
// store) — except an election blank (none of the three, method 'election' only)
comment?: string // MANDATORY when negative (against, 0, 1)
supersedesVoteId?: Id // audited re-vote — PRIVACY: the chain is NEVER displayed publicly
// under a voter's name; visible to its AUTHOR only; even with ballot 'open', only the
// LAST active vote appears under a name, aggregate tallies recomputed
// (« pardonner les positions »)
recordedById?: Id // mode atelier
}
// ── Resolved Pact settings (pure function — NOT a settings table) ──
export interface CollectiveSettings {
// resolveSettings(pactClauses, versions)
triage: {
smallGroupMax: number // default 5
collectiveMin: number // default 50
consentMax: number // default 7
objectionWindowHours: number // default 48
adviceWindowHours: number // default 72
framingDays: number // default 14 — « s'instruire et formuler des contre-propositions »
concernEscalateRatio: number // default 0.5 — reached ⇒ MANDATORY handling (state.ts guard)
recurrenceThreshold: number // default 3
reviewDelayDays: number // default 90
requireEffects: 'none' | 'structural' | 'binding'
// key 'triage.requireEffects' — governs the matter guard of COLLECTIVE sessions:
// 'binding' = binding AND structural (default of structured templates);
// 'structural' = structural only; 'none' seeded in blank and informal.
// Under guard, structural ⇒ ≥1 effect with measurable target.
}
protocolByRange: {
consent: Id // MANDATORY — invariant: every collective has a Consent protocol
// (every template seeds it, import validates it)
nuanced?: Id
large?: Id // ex-inertialBinary: the name no longer presumes the method.
// Standard templates → nuanced; free-currency → inertial binary
parametric?: Id // « Réglage collectif »
election?: Id
clauseByInertia?: Record<InertiaPreset, Id> // the Ğ1 heritage lives here, intact
}
// SPECIFIED FALLBACK (resolveSettings): any unresolved optional key falls back to consent;
// consent unfindable (corrupted bundle) ⇒ triage routes 'advice' with the banner
// « Aucun protocole — crée-le ou décide sur avis » — NEVER a crash.
}
// ── Triage engine contracts ──────────────────────────────────
export interface TriageInput {
title: string
tags: string[]
scope: Decision['scope']
reversibility: Reversibility
weight: Weight
urgent: boolean
amendsClauseId?: Id
}
export interface TriageContext {
// assembled by the store, consumed pure — testable without I/O
myActiveMandates: Mandate[]
otherActiveMandates: Mandate[]
matchingClauses: Clause[] // Q0 index « déjà décidé ? » — the SAME index as Cmd+K
similarRecentCount: number // adopted decisions sharing ≥2 tags over 90 d
similarRecordedCount: number // 'record' entries sharing ≥2 tags — maturation
computedConcernedIds: Id[]
}
export interface Verdict {
route: DecisionRoute
rule: TriageRule
explanation: string // ONE French sentence — displayed ALONE, without rule code
protocolId?: Id
windowHours?: number
framingDays?: number
reviewRequired: boolean
engravingSuggested: boolean
conservatoryChain?: boolean
parametricHint?: boolean // number/%/amount detected ⇒ « C'est un réglage » put forward
alternatives: { route: DecisionRoute; label: string; cost: string }[]
// always present: « C'est un réglage — décidez au curseur » (parametric);
// « Déjà tranché — je le consigne » (record); binary with its cost (« deux camps, un perdant »)
suggestion?: { kind: 'claim-mandate' | 'create-rule' | 'protocolize'; prefill: Json }
}
// ── Local persistence & future sync ──────────────────────────
export interface Bundle {
// export/import AND seeds — the SAME code path
schemaVersion: 2
exportedAt: ISODate
collective: Collective
people: Person[]
circles: Circle[]
decisions: Decision[]
concerns: Concern[]
objections: Objection[]
advices: Advice[]
assents: Assent[]
mandates: Mandate[]
docs: TextDoc[]
clauses: Clause[]
versions: ClauseVersion[]
protocols: Protocol[]
sessions: VoteSession[]
votes: Vote[]
}
// data/persistence.ts: IndexedDB (idb-keyval), key `ld2:<collectiveId>`, 500 ms debounce.
// importBundle() validates protocolByRange.consent + sets Collective.lineage for foreign bundles.
// Sync-ready: UUIDv7, updatedAt (LWW), archivedAt, Vote/Advice/Objection/Assent append-only.
// Pinia store contract unchanged — the future FastAPI adapter changes NO screen.
// Contract notes: ballot 'secret' must become cryptographic server-side (v3);
// the privacy of supersedesVoteId chains (author-visible only) is a contract invariant.
+325 -1
View File
@@ -17,6 +17,7 @@
"@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1",
"@yvv/nuxt-base": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0",
"idb-keyval": "^6.3.0",
"nuxt": "^4.3.1",
"pinia": "^3.0.2",
"vue": "^3.5.28",
@@ -25,7 +26,8 @@
"devDependencies": {
"@iconify-json/lucide": "^1.2.91",
"typescript": "^5.9.3",
"unocss": "^66.6.0"
"unocss": "^66.6.0",
"vitest": "^4.1.10"
}
},
"node_modules/@alloc/quick-lru": {
@@ -7767,6 +7769,17 @@
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -7776,6 +7789,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -8378,6 +8398,129 @@
"vue": "^3.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
@@ -9355,6 +9498,16 @@
"node": ">=10"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -9914,6 +10067,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/char-regex": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@@ -11254,6 +11417,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -12271,6 +12444,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb-keyval": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz",
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -17300,6 +17479,13 @@
"node": ">=20"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -17541,6 +17727,13 @@
"node": ">=20.16.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -17974,6 +18167,13 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -17999,6 +18199,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-buffer": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
@@ -19838,6 +20048,103 @@
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -20107,6 +20414,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+5 -2
View File
@@ -8,7 +8,8 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"test": "vitest run"
},
"dependencies": {
"@nuxt/content": "^3.11.2",
@@ -19,6 +20,7 @@
"@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1",
"@yvv/nuxt-base": "git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0",
"idb-keyval": "^6.3.0",
"nuxt": "^4.3.1",
"pinia": "^3.0.2",
"vue": "^3.5.28",
@@ -27,6 +29,7 @@
"devDependencies": {
"@iconify-json/lucide": "^1.2.91",
"typescript": "^5.9.3",
"unocss": "^66.6.0"
"unocss": "^66.6.0",
"vitest": "^4.1.10"
}
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Ported from backend/app/tests/test_mode_params.py — every pytest case
* exists here with the same expected values, plus tests for
* formatModeParams() (no Python counterpart: defined as the canonical
* inverse of parseModeParams, matching the seed DSL strings).
*/
import { describe, expect, it } from 'vitest'
import { formatModeParams, parseModeParams } from '../../app/engine/modeParams'
describe('parseModeParams', () => {
it('D30M50B.1G.2 => standard Licence G1 params', () => {
const result = parseModeParams('D30M50B.1G.2')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
// Optional criteria absent
expect(result.smith_exponent).toBeNull()
expect(result.techcomm_exponent).toBeNull()
})
it('D30M50B.1G.2S.1 => standard + smith_exponent=0.1', () => {
const result = parseModeParams('D30M50B.1G.2S.1')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
expect(result.smith_exponent).toBe(0.1)
expect(result.techcomm_exponent).toBeNull()
})
it('D30M50B.1G.2T.1 => standard + techcomm_exponent=0.1', () => {
const result = parseModeParams('D30M50B.1G.2T.1')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
expect(result.smith_exponent).toBeNull()
expect(result.techcomm_exponent).toBe(0.1)
})
it('D30M50B1G.5C10 => integer base, gradient=0.5, constant=10', () => {
const result = parseModeParams('D30M50B1G.5C10')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(1.0)
expect(result.gradient_exponent).toBe(0.5)
expect(result.constant_base).toBe(10.0)
})
it('empty string returns all defaults', () => {
const result = parseModeParams('')
expect(result.duration_days).toBe(30)
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
expect(result.constant_base).toBe(0.0)
expect(result.smith_exponent).toBeNull()
expect(result.techcomm_exponent).toBeNull()
expect(result.ratio_multiplier).toBeNull()
expect(result.is_ratio_mode).toBe(false)
})
it('whitespace-only string treated as empty', () => {
const result = parseModeParams(' ')
expect(result.duration_days).toBe(30)
})
it('result exposes exactly the expected keys', () => {
const result = parseModeParams('D30M50B.1G.2')
const expectedKeys = [
'duration_days',
'majority_pct',
'base_exponent',
'gradient_exponent',
'constant_base',
'smith_exponent',
'techcomm_exponent',
'ratio_multiplier',
'is_ratio_mode',
].sort()
expect(Object.keys(result).sort()).toEqual(expectedKeys)
})
it('unknown code letter throws (Python parity)', () => {
expect(() => parseModeParams('D30X5')).toThrow(/inconnu/)
})
it('D7R1N2.5 => ratio mode with multiplier (Python cross-check)', () => {
const result = parseModeParams('D7R1N2.5')
expect(result.duration_days).toBe(7)
expect(result.is_ratio_mode).toBe(true)
expect(result.ratio_multiplier).toBe(2.5)
// Untouched defaults
expect(result.majority_pct).toBe(50)
expect(result.base_exponent).toBe(0.1)
expect(result.gradient_exponent).toBe(0.2)
})
})
describe('formatModeParams', () => {
it('defaults format to the canonical standard string', () => {
expect(formatModeParams()).toBe('D30M50B.1G.2')
expect(formatModeParams({})).toBe('D30M50B.1G.2')
})
it('round-trips the seed DSL strings', () => {
for (const s of ['D30M50B.1G.2', 'D30M50B.1G.2S.1', 'D30M50B.1G.2T.1']) {
expect(formatModeParams(parseModeParams(s))).toBe(s)
}
})
it('formats integer base and constant', () => {
expect(formatModeParams(parseModeParams('D30M50B1G.5C10'))).toBe('D30M50B1G.5C10')
})
it('formats ratio mode and multiplier', () => {
expect(formatModeParams(parseModeParams('D7R1N2.5'))).toBe('D7M50B.1G.2N2.5R1')
})
it('parse(format(p)) is identical to p', () => {
for (const s of ['D30M50B.1G.2S.1', 'D30M50B1G.5C10', 'D7R1N2.5', 'D90M66B.2G.3S.2T.1']) {
const p = parseModeParams(s)
expect(parseModeParams(formatModeParams(p))).toEqual(p)
}
})
})
+118
View File
@@ -0,0 +1,118 @@
/**
* Ported from backend/app/tests/test_nuanced.py — every pytest case
* exists here with the same expected values.
*
* Levels: 0-CONTRE, 1-PAS DU TOUT, 2-PAS D'ACCORD, 3-NEUTRE, 4-D'ACCORD, 5-TOUT A FAIT
* Positive = levels 3 + 4 + 5
* Adoption requires: positive_pct >= threshold (80%) AND total >= min_participants (59).
*/
import { describe, expect, it } from 'vitest'
import { nuancedResult } from '../../app/engine/nuanced'
/** Build a votes array from level repetitions, e.g. fill([5, 20], [4, 20]). */
function fill(...groups: Array<[level: number, count: number]>): number[] {
const votes: number[] = []
for (const [level, count] of groups) {
for (let i = 0; i < count; i++) votes.push(level)
}
return votes
}
describe('nuancedResult — adoption', () => {
it('59 positive + 10 negative = 69 total => adopted (85.51% >= 80%)', () => {
const votes = fill([5, 20], [4, 20], [3, 19], [2, 5], [1, 3], [0, 2])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(69)
expect(result.positive_count).toBe(59)
expect(result.positive_pct).toBeCloseTo(85.51, 1)
// Python cross-check: round(59/69*100, 2) == 85.51
expect(result.positive_pct).toBe(85.51)
expect(result.threshold_met).toBe(true)
expect(result.min_participants_met).toBe(true)
expect(result.adopted).toBe(true)
})
it('all 59 voters at level 5 => 100% positive, adopted', () => {
const votes = fill([5, 59])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(59)
expect(result.positive_count).toBe(59)
expect(result.positive_pct).toBe(100.0)
expect(result.adopted).toBe(true)
})
})
describe('nuancedResult — rejection', () => {
it('40 positive + 30 negative = 70 total => threshold not met (57.14% < 80%)', () => {
const votes = fill([5, 15], [4, 15], [3, 10], [2, 10], [1, 10], [0, 10])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(70)
expect(result.positive_count).toBe(40)
expect(result.positive_pct).toBeCloseTo(57.14, 1)
expect(result.threshold_met).toBe(false)
expect(result.min_participants_met).toBe(true) // 70 >= 59
expect(result.adopted).toBe(false)
})
it('55 total < 59 min participants => rejected despite 90.9% positive', () => {
const votes = fill([5, 30], [4, 10], [3, 10], [1, 3], [0, 2])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(55)
expect(result.positive_count).toBe(50)
expect(result.positive_pct).toBeGreaterThan(80)
expect(result.threshold_met).toBe(true)
expect(result.min_participants_met).toBe(false)
expect(result.adopted).toBe(false)
})
})
describe('nuancedResult — edge cases', () => {
it('exactly 80% positive votes passes the threshold', () => {
// 80 positive out of 100 = exactly 80%
const votes = fill([5, 40], [4, 20], [3, 20], [2, 10], [1, 5], [0, 5])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(100)
expect(result.positive_count).toBe(80)
expect(result.positive_pct).toBe(80.0)
expect(result.threshold_met).toBe(true)
expect(result.min_participants_met).toBe(true)
expect(result.adopted).toBe(true)
})
it('79 positive out of 100 = 79% < 80% => rejected', () => {
const votes = fill([5, 39], [4, 20], [3, 20], [2, 11], [1, 5], [0, 5])
const result = nuancedResult(votes, 80, 59)
expect(result.total).toBe(100)
expect(result.positive_count).toBe(79)
expect(result.positive_pct).toBe(79.0)
expect(result.threshold_met).toBe(false)
expect(result.adopted).toBe(false)
})
it('zero votes => not adopted', () => {
const result = nuancedResult([], 80, 59)
expect(result.total).toBe(0)
expect(result.positive_count).toBe(0)
expect(result.positive_pct).toBe(0.0)
expect(result.adopted).toBe(false)
})
it('vote level outside 0-5 throws', () => {
expect(() => nuancedResult([5, 3, 6])).toThrow(/invalide/)
})
it('per-level breakdown is correct', () => {
const votes = [0, 1, 2, 3, 4, 5, 5, 4, 3]
const result = nuancedResult(votes, 50, 1)
expect(result.per_level_counts).toEqual({ 0: 1, 1: 1, 2: 1, 3: 2, 4: 2, 5: 2 })
expect(result.positive_count).toBe(6) // 2+2+2
expect(result.total).toBe(9)
})
})
+174
View File
@@ -0,0 +1,174 @@
/**
* Ported from backend/app/tests/test_threshold.py — every pytest case
* exists here with the same expected values. Exact integer expectations
* were cross-checked by executing the Python engine (backend/.venv).
*
* Real-world reference case:
* Vote Engagement Forgeron v2.0.0 (Feb 2026)
* wotSize=7224, votesFor=97, votesAgainst=23, total=120
* params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
*/
import { describe, expect, it } from 'vitest'
import { smithThreshold, techcommThreshold, wotThreshold } from '../../app/engine/threshold'
// ---------------------------------------------------------------------------
// WoT threshold: real-world vote Forgeron
// ---------------------------------------------------------------------------
describe('wotThreshold — Forgeron reference case', () => {
it('forgeron vote passes (97 for out of 120 total, wot=7224)', () => {
const threshold = wotThreshold(7224, 120, 50, 0.1, 0.2)
// With low participation (120/7224 ~ 1.66%), near-unanimity is required.
// The historical threshold was 94, and 97 >= 94.
expect(97).toBeGreaterThanOrEqual(threshold)
// The threshold should be high relative to total votes (inertia effect)
expect(threshold).toBeGreaterThan(60)
})
it('forgeron threshold value is in a reasonable range', () => {
const threshold = wotThreshold(7224, 120, 50, 0.1, 0.2)
// At ~1.66% participation, inertia should push threshold close to 78-95%
// of total votes.
expect(threshold).toBeGreaterThanOrEqual(80)
expect(threshold).toBeLessThanOrEqual(120)
})
it('forgeron threshold is exactly 94 (Python cross-check)', () => {
expect(wotThreshold(7224, 120, 50, 0.1, 0.2)).toBe(94)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: low participation
// ---------------------------------------------------------------------------
describe('wotThreshold — low participation', () => {
it('10 votes out of 7224 requires near-unanimity', () => {
const threshold = wotThreshold(7224, 10, 50, 0.1, 0.2)
// With participation ratio 10/7224 ~ 0.14%, threshold should be
// very close to totalVotes (near-unanimity).
expect(threshold).toBeGreaterThanOrEqual(9)
expect(threshold).toBeLessThanOrEqual(10)
// Python cross-check: exact value
expect(threshold).toBe(9)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: high participation
// ---------------------------------------------------------------------------
describe('wotThreshold — high participation', () => {
it('3000 votes out of 7224 approaches simple majority', () => {
const threshold = wotThreshold(7224, 3000, 50, 0.1, 0.2)
// With ~42% participation, the inertia factor diminishes.
const simpleMajority = Math.ceil(3000 * 0.5)
expect(threshold).toBeGreaterThanOrEqual(simpleMajority)
// Should be noticeably less than near-unanimity
expect(threshold).toBeLessThan(2700)
// Python cross-check: exact value
expect(threshold).toBe(1742)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: edge cases
// ---------------------------------------------------------------------------
describe('wotThreshold — edge cases', () => {
it('zero total votes => ceil(C + B^W)', () => {
const threshold = wotThreshold(7224, 0, 50, 0.1, 0.2)
// B^W = 0.1^7224 is effectively 0
const expected = Math.ceil(0.0 + 0.1 ** 7224)
expect(threshold).toBe(expected)
expect(threshold).toBe(0)
})
it('throws on wotSize = 0', () => {
expect(() => wotThreshold(0, 10)).toThrow(/wotSize/)
})
it('throws on negative totalVotes', () => {
expect(() => wotThreshold(100, -1)).toThrow(/totalVotes/)
})
it('throws on majorityPct out of range', () => {
expect(() => wotThreshold(100, 10, 150)).toThrow(/majorityPct/)
})
})
// ---------------------------------------------------------------------------
// WoT threshold: Python↔TS parity (values executed from backend/.venv)
// ---------------------------------------------------------------------------
describe('wotThreshold — Python parity cases', () => {
it('T > W (100 wot, 150 votes) => 69', () => {
expect(wotThreshold(100, 150, 50, 0.1, 0.2)).toBe(69)
})
it('T = W (full participation) => simple majority 50', () => {
expect(wotThreshold(100, 100, 50, 0.1, 0.2)).toBe(50)
})
it('constantBase C=10 on Forgeron numbers => 96', () => {
expect(wotThreshold(7224, 120, 50, 0.1, 0.2, 10.0)).toBe(96)
})
it('majorityPct 80 on Forgeron numbers => 110', () => {
expect(wotThreshold(7224, 120, 80, 0.1, 0.2)).toBe(110)
})
it('minimal corpus W=1, T=1 => 1', () => {
expect(wotThreshold(1, 1, 50, 0.1, 0.2)).toBe(1)
})
it('W=10, T=5 => 3', () => {
expect(wotThreshold(10, 5, 50, 0.1, 0.2)).toBe(3)
})
})
// ---------------------------------------------------------------------------
// Smith threshold
// ---------------------------------------------------------------------------
describe('smithThreshold — ceil(smithSize ^ S)', () => {
it('smithSize=20, exponent=0.1 => ceil(20^0.1) = 2', () => {
const result = smithThreshold(20, 0.1)
expect(result).toBe(Math.ceil(20 ** 0.1))
// 20^0.1 ~ 1.35, ceil => 2
expect(result).toBe(2)
})
it('smithSize=1 => ceil(1^0.1) = 1', () => {
expect(smithThreshold(1, 0.1)).toBe(1)
})
it('throws on smithSize = 0', () => {
expect(() => smithThreshold(0)).toThrow()
})
it('smithSize=1000, exponent=0.5 => 32 (Python cross-check)', () => {
expect(smithThreshold(1000, 0.5)).toBe(32)
})
})
// ---------------------------------------------------------------------------
// TechComm threshold
// ---------------------------------------------------------------------------
describe('techcommThreshold — ceil(cotecSize ^ T)', () => {
it('cotecSize=5, exponent=0.1 => ceil(5^0.1) = 2', () => {
const result = techcommThreshold(5, 0.1)
expect(result).toBe(Math.ceil(5 ** 0.1))
// 5^0.1 ~ 1.175, ceil => 2
expect(result).toBe(2)
})
it('cotecSize=1 => 1', () => {
expect(techcommThreshold(1, 0.1)).toBe(1)
})
it('throws on cotecSize = 0', () => {
expect(() => techcommThreshold(0)).toThrow()
})
})
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'~': fileURLToPath(new URL('./app', import.meta.url)),
'@': fileURLToPath(new URL('./app', import.meta.url)),
},
},
test: {
environment: 'node',
include: ['tests/**/*.spec.ts'],
},
})