forked from yvv/decision
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:
@@ -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'
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user