v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal

- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-08-11 09:22:19 +02:00
co-authored by Claude Fable 5
parent 53d8752e40
commit f707b5f15d
18 changed files with 10324 additions and 181 deletions
+103
View File
@@ -108,3 +108,106 @@ export function techcommThreshold(cotecSize: number, exponent: number = 0.1): nu
}
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<Id, number>
blanks: number
participants: number
}
| {
outcome: 'tie'
exAequoIds: Id[]
counts: Record<Id, number>
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<FormulaParams, 'electionMinParticipants'>,
): 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<Id, number> = {}
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 }
}