/** * 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) } // ───────────────────────────────────────────────────────────── // Election — simple plurality (BLUEPRINT-V2.md Δ28, « Modalités » #4) // ───────────────────────────────────────────────────────────── import type { FormulaParams, Id, Vote } from '~/types/domain' /** * Outcome of an election tally. * * Discriminated union on `outcome`: * - 'elected' — a single person leads by simple plurality. * - 'tie' — several persons share the top count. The engine NEVER * breaks a tie (no randomness, no first-come): the closure * flow proposes a chained runoff among `exAequoIds`, or a * draw only if the Pact planned it (FormulaParams.tieBreak). * - 'rejected' — reason 'quorum': participants (blanks included) below * electionMinParticipants; `required` = that quorum. * reason 'no-designation': quorum reached (or absent) but * every vote is blank — nobody was designated, and an * empty tie would be meaningless; `required` still carries * the quorum (0 when none) for display purposes. */ export type ElectionOutcome = | { outcome: 'elected' winnerId: Id counts: Record blanks: number participants: number } | { outcome: 'tie' exAequoIds: Id[] counts: Record blanks: number participants: number } | { outcome: 'rejected' reason: 'quorum' | 'no-designation' participants: number required: number } /** * Tally an election by simple plurality. * * CONTRACT — the caller passes the LAST ACTIVE votes only: one vote per * voter, `supersedesVoteId` chains already resolved (the store filters * superseded votes). The engine does NOT deduplicate by voterId; * `participants` is simply `votes.length`. * * Rules (Δ28): * - A vote without `choicePersonId` is a BLANK: it counts for * participation (quorum), never for designation. * - Quorum: when `formula.electionMinParticipants` is set and * participants (blanks included) < quorum ⇒ rejected ('quorum'). * - Designation by simple PLURALITY of the non-blank votes. * - Tie at the top ⇒ 'tie' with `exAequoIds` sorted (lexicographic — * a deterministic display order, NEVER a tie-break: the engine does * not pick a winner among equals, no randomness, no first-come). * - Zero designation (all blanks) ⇒ rejected ('no-designation'). * * @param votes - Last active votes of the session (see contract above) * @param formula - Protocol formula params (only electionMinParticipants is read) * @returns The election outcome — never a tie silently broken */ export function electionResult( votes: Vote[], formula: Pick, ): ElectionOutcome { const participants = votes.length const quorum = formula.electionMinParticipants ?? 0 if (formula.electionMinParticipants !== undefined && participants < formula.electionMinParticipants) { return { outcome: 'rejected', reason: 'quorum', participants, required: formula.electionMinParticipants } } const counts: Record = {} let blanks = 0 for (const vote of votes) { if (vote.choicePersonId) { counts[vote.choicePersonId] = (counts[vote.choicePersonId] ?? 0) + 1 } else { blanks++ // blank: participation only, never designation } } const designatedIds = Object.keys(counts) if (designatedIds.length === 0) { return { outcome: 'rejected', reason: 'no-designation', participants, required: quorum } } const topCount = Math.max(...designatedIds.map(id => counts[id]!)) const leaders = designatedIds.filter(id => counts[id] === topCount) if (leaders.length === 1) { return { outcome: 'elected', winnerId: leaders[0]!, counts, blanks, participants } } return { outcome: 'tie', exAequoIds: [...leaders].sort(), counts, blanks, participants } }