v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
//! Rule-based editing assistant — no AI yet (Yvv, 2026-10-21: « D'abord des
|
||||
//! règles simples, ensuite on mettra de l'IA, mais locale »).
|
||||
//!
|
||||
//! It never decides. It says, while the author writes:
|
||||
//! - « this text seems to carry several decisions — split it? » with the parts;
|
||||
//! - « this text is close to <unit> — make it a variant instead? »;
|
||||
//! - « this text is long ».
|
||||
//!
|
||||
//! The local AI planned later plugs in behind the same `Hint` type.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Hint {
|
||||
/// Several decisions in one text: the suggested parts, in order.
|
||||
Split { parts: Vec<String> },
|
||||
/// Close to an existing unit or proposal.
|
||||
Similar { target: String, score: f64 },
|
||||
/// Longer than a unit usually is.
|
||||
Long { chars: usize },
|
||||
}
|
||||
|
||||
pub const LONG_UNIT_CHARS: usize = 700;
|
||||
pub const SIMILARITY_HINT: f64 = 0.45;
|
||||
|
||||
/// Markers of a commitment or a rule, folded (lowercase, no accents).
|
||||
const DECISION_MARKERS: &[&str] = &[
|
||||
"je m'engage",
|
||||
"je m engage",
|
||||
"j'ai ",
|
||||
"je suis ",
|
||||
"je me suis",
|
||||
"je verifie",
|
||||
"je ne ",
|
||||
"doit ",
|
||||
"doivent ",
|
||||
"s'engage",
|
||||
"est tenu",
|
||||
"est interdit",
|
||||
"obligatoire",
|
||||
"il est interdit",
|
||||
"verifiez",
|
||||
"ne certifiez",
|
||||
"contactez",
|
||||
"assurez",
|
||||
"i commit",
|
||||
"i have ",
|
||||
"must ",
|
||||
"shall ",
|
||||
"me comprometo",
|
||||
"debe ",
|
||||
];
|
||||
|
||||
const STOPWORDS: &[&str] = &[
|
||||
"le", "la", "les", "un", "une", "des", "de", "du", "d", "l", "et", "ou", "a", "au", "aux", "en", "que", "qui",
|
||||
"je", "j", "me", "m", "se", "s", "ce", "cet", "cette", "ces", "par", "pour", "sur", "dans", "avec", "est", "sont",
|
||||
"the", "of", "and", "to", "in", "is", "el", "los", "las", "y", "que", "con", "por", "para",
|
||||
];
|
||||
|
||||
fn fold(s: &str) -> String {
|
||||
s.nfd()
|
||||
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
|
||||
.flat_map(char::to_lowercase)
|
||||
.map(|c| if c == '’' { '\'' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_decision(segment: &str) -> bool {
|
||||
let f = fold(segment);
|
||||
DECISION_MARKERS.iter().any(|m| f.contains(m))
|
||||
}
|
||||
|
||||
/// Paragraphs, list items: the natural cut lines of a modular text.
|
||||
fn segments(text: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
let flush = |current: &mut String, out: &mut Vec<String>| {
|
||||
let t = current.trim();
|
||||
if !t.is_empty() {
|
||||
out.push(t.to_owned());
|
||||
}
|
||||
current.clear();
|
||||
};
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
let is_item = trimmed.starts_with("- ")
|
||||
|| trimmed.starts_with("* ")
|
||||
|| trimmed
|
||||
.split_once(['.', ')'])
|
||||
.is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
|
||||
if trimmed.is_empty() || is_item {
|
||||
flush(&mut current, &mut out);
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
if !current.is_empty() {
|
||||
current.push('\n');
|
||||
}
|
||||
current.push_str(line);
|
||||
}
|
||||
}
|
||||
flush(&mut current, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Sentences of a single paragraph (split after . ! ? followed by a space).
|
||||
fn sentences(paragraph: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = paragraph.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
current.push(c);
|
||||
if matches!(c, '.' | '!' | '?') && chars.peek().is_some_and(|n| n.is_whitespace()) {
|
||||
out.push(current.trim().to_owned());
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if !current.trim().is_empty() {
|
||||
out.push(current.trim().to_owned());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parts to propose separately, or `None` when the text holds one decision.
|
||||
pub fn split_suggestion(text: &str) -> Option<Vec<String>> {
|
||||
let segs = segments(text);
|
||||
if segs.len() >= 2 && segs.iter().filter(|s| is_decision(s)).count() >= 2 {
|
||||
return Some(segs);
|
||||
}
|
||||
if segs.len() == 1 {
|
||||
let sents = sentences(&segs[0]);
|
||||
let committing = sents.iter().filter(|s| is_decision(s)).count();
|
||||
if sents.len() >= 2 && committing >= 2 {
|
||||
return Some(sents);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn content_words(text: &str) -> Vec<String> {
|
||||
fold(text)
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.chars().count() > 2 && !STOPWORDS.contains(w))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Jaccard similarity over content words and their bigrams.
|
||||
pub fn similarity(a: &str, b: &str) -> f64 {
|
||||
use std::collections::BTreeSet;
|
||||
let grams = |t: &str| -> BTreeSet<String> {
|
||||
let w = content_words(t);
|
||||
let mut set: BTreeSet<String> = w.iter().cloned().collect();
|
||||
set.extend(w.windows(2).map(|p| format!("{} {}", p[0], p[1])));
|
||||
set
|
||||
};
|
||||
let (x, y) = (grams(a), grams(b));
|
||||
if x.is_empty() || y.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let inter = x.intersection(&y).count() as f64;
|
||||
let union = x.union(&y).count() as f64;
|
||||
inter / union
|
||||
}
|
||||
|
||||
/// All hints for a draft, given the texts it may collide with (`(id, text)`).
|
||||
pub fn hints(text: &str, others: &[(String, String)]) -> Vec<Hint> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(parts) = split_suggestion(text) {
|
||||
out.push(Hint::Split { parts });
|
||||
}
|
||||
let mut close: Vec<(String, f64)> =
|
||||
others.iter().map(|(id, t)| (id.clone(), similarity(text, t))).filter(|(_, s)| *s >= SIMILARITY_HINT).collect();
|
||||
close.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
out.extend(close.into_iter().take(3).map(|(target, score)| Hint::Similar { target, score }));
|
||||
let chars = text.chars().count();
|
||||
if chars > LONG_UNIT_CHARS {
|
||||
out.push(Hint::Long { chars });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn two_commitments_in_one_text_are_flagged() {
|
||||
let text = "Je m'engage sur l'honneur à n'avoir qu'un seul compte.\n\nJe m'engage à ne certifier que des personnes qui respectent cet engagement.";
|
||||
let parts = split_suggestion(text).expect("two decisions");
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts[0].starts_with("Je m'engage sur"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_commitments_in_one_paragraph() {
|
||||
let text = "Je m'engage à vérifier la clé. Je m'engage aussi à rester joignable.";
|
||||
assert_eq!(split_suggestion(text).map(|p| p.len()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_of_rules_is_flagged_but_one_explained_commitment_is_not() {
|
||||
let list = "- Vérifiez la clé avec la personne.\n- Contactez les certifieurs existants.";
|
||||
assert_eq!(split_suggestion(list).map(|p| p.len()), Some(2));
|
||||
let single = "Je m'engage sur l'honneur à n'avoir qu'un seul compte. C'est le socle de la toile.";
|
||||
assert_eq!(split_suggestion(single), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_like_text_is_not_split() {
|
||||
let text = "Le présent acte définit les obligations réciproques des membres. Cet acte est l'unique relation contractuelle de notre toile.";
|
||||
assert_eq!(split_suggestion(text), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_finds_a_variant() {
|
||||
let current = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.";
|
||||
let variant = "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.";
|
||||
let other = "J'ai vérifié avec la personne certifiée qu'elle a généré son document de révocation.";
|
||||
assert!(similarity(current, variant) >= SIMILARITY_HINT);
|
||||
assert!(similarity(current, other) < 0.2);
|
||||
let h = hints(variant, &[("E1".into(), current.into()), ("E6".into(), other.into())]);
|
||||
assert!(matches!(&h[..], [Hint::Similar { target, .. }] if target == "E1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_text() {
|
||||
let text = "mot ".repeat(200);
|
||||
assert!(hints(&text, &[]).iter().any(|h| matches!(h, Hint::Long { .. })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Composition of a modular document under permanent vote.
|
||||
//!
|
||||
//! A document is an ordered list of **units** (preamble, articles, headings).
|
||||
//! A unit is a slot: it may hold one version in force, or none yet. Every
|
||||
//! **proposal** targets exactly one unit — one proposal, one decision — and is
|
||||
//! one of: a text for the unit (new or replacing), its removal, or a change of
|
||||
//! its inertia. Proposals on the same unit and the same dimension (content or
|
||||
//! inertia) are rivals: at most one of them enters into force at a time.
|
||||
//!
|
||||
//! A rival replaces the version in force only if it reached its threshold
|
||||
//! **and** holds a wider margin than the version in force (for − required).
|
||||
//! The displaced version goes back among the proposals with its votes; it can
|
||||
//! only return by widening its margin again — no oscillation. A seed a minima
|
||||
//! (in force without any vote) has the margin of zero votes, so the first
|
||||
//! rival that reaches its threshold replaces it.
|
||||
//!
|
||||
//! This module answers three questions, purely:
|
||||
//! - which proposals enter into force now (`adoptions`);
|
||||
//! - what the document would read if the well-rated, or all, proposals passed
|
||||
//! (`preview`);
|
||||
//! - which formula a proposal is judged by (`params_for`).
|
||||
|
||||
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct VersionText {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// Votes still cast on the version in force (permanent vote).
|
||||
#[serde(default)]
|
||||
pub tally: Tally,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnitState {
|
||||
pub id: String,
|
||||
pub position: f64,
|
||||
pub inertia: InertiaPreset,
|
||||
pub in_force: Option<VersionText>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "inertia", rename_all = "lowercase")]
|
||||
pub enum ProposalKind {
|
||||
/// A text for the unit: an addition if the unit is empty, a replacement otherwise.
|
||||
Text,
|
||||
/// Remove the unit from the text in force.
|
||||
Remove,
|
||||
/// Change the inertia of the unit.
|
||||
Inertia(InertiaPreset),
|
||||
}
|
||||
|
||||
impl ProposalKind {
|
||||
fn is_content(self) -> bool {
|
||||
!matches!(self, Self::Inertia(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProposalState {
|
||||
pub id: String,
|
||||
pub unit_id: String,
|
||||
pub kind: ProposalKind,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub tally: Tally,
|
||||
/// Sortable submission stamp (RFC 3339): the earlier wins a perfect tie.
|
||||
pub submitted_at: String,
|
||||
/// An active contradiction signal forbids entering into force.
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
/// A change of inertia is voted one level above the unit (« inertie + »).
|
||||
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams {
|
||||
let preset = match kind {
|
||||
ProposalKind::Inertia(_) => unit.inertia.raised(),
|
||||
ProposalKind::Text | ProposalKind::Remove => unit.inertia,
|
||||
};
|
||||
FormulaParams::for_inertia(preset, floor)
|
||||
}
|
||||
|
||||
/// A proposal that changes nothing cannot enter into force.
|
||||
fn is_effective(unit: &UnitState, p: &ProposalState) -> bool {
|
||||
match p.kind {
|
||||
ProposalKind::Text => unit.in_force.as_ref().is_none_or(|v| v.title != p.title || v.body != p.body),
|
||||
ProposalKind::Remove => unit.in_force.is_some(),
|
||||
ProposalKind::Inertia(target) => target != unit.inertia,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Scored<'a> {
|
||||
proposal: &'a ProposalState,
|
||||
margin: i64,
|
||||
reached: bool,
|
||||
well_rated: bool,
|
||||
}
|
||||
|
||||
fn score<'a>(
|
||||
unit: &UnitState,
|
||||
p: &'a ProposalState,
|
||||
electorate: u32,
|
||||
floor: f64,
|
||||
) -> Result<Scored<'a>, ThresholdError> {
|
||||
let e = evaluate(p.tally, electorate, ¶ms_for(unit, p.kind, floor))?;
|
||||
Ok(Scored {
|
||||
proposal: p,
|
||||
margin: i64::from(p.tally.for_votes) - i64::from(e.required),
|
||||
reached: e.reached,
|
||||
well_rated: e.well_rated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Best of rivals: widest margin, then most *for* votes, then earliest.
|
||||
fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
|
||||
rivals.sort_by(|a, b| {
|
||||
b.margin
|
||||
.cmp(&a.margin)
|
||||
.then(b.proposal.tally.for_votes.cmp(&a.proposal.tally.for_votes))
|
||||
.then(a.proposal.submitted_at.cmp(&b.proposal.submitted_at))
|
||||
.then(a.proposal.id.cmp(&b.proposal.id))
|
||||
});
|
||||
rivals.first().map(|s| s.proposal)
|
||||
}
|
||||
|
||||
/// Margin of the version in force, judged like a text proposal on its unit.
|
||||
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> {
|
||||
let Some(v) = &unit.in_force else { return Ok(None) };
|
||||
let e = evaluate(v.tally, electorate, ¶ms_for(unit, ProposalKind::Text, floor))?;
|
||||
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
|
||||
}
|
||||
|
||||
/// The proposals that enter into force now: per unit and dimension, the best
|
||||
/// of those that reached their threshold, are effective and not blocked — and,
|
||||
/// for content, hold a wider margin than the version in force.
|
||||
pub fn adoptions(
|
||||
units: &[UnitState],
|
||||
proposals: &[ProposalState],
|
||||
electorate: u32,
|
||||
floor: f64,
|
||||
) -> Result<Vec<String>, ThresholdError> {
|
||||
let mut out = Vec::new();
|
||||
for unit in units {
|
||||
let incumbent = in_force_margin(unit, electorate, floor)?;
|
||||
for content in [true, false] {
|
||||
let mut rivals = Vec::new();
|
||||
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
|
||||
if p.blocked || !is_effective(unit, p) {
|
||||
continue;
|
||||
}
|
||||
let s = score(unit, p, electorate, floor)?;
|
||||
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
|
||||
if s.reached && beats_incumbent {
|
||||
rivals.push(s);
|
||||
}
|
||||
}
|
||||
if let Some(winner) = best(rivals) {
|
||||
out.push(winner.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PreviewMode {
|
||||
/// Only the in-force text.
|
||||
InForce,
|
||||
/// As if every well-rated proposal passed (« si les bien cotées passent »).
|
||||
WellRated,
|
||||
/// As if every proposal passed, whatever its rating (« si tout passe »).
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PreviewMark {
|
||||
Unchanged,
|
||||
Added,
|
||||
Replaced,
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewUnit {
|
||||
pub unit_id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub mark: PreviewMark,
|
||||
/// The proposal that would bring the change.
|
||||
pub proposal_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The document as it would read. Removed units are kept with their mark so
|
||||
/// the screen can show them struck through.
|
||||
pub fn preview(
|
||||
units: &[UnitState],
|
||||
proposals: &[ProposalState],
|
||||
electorate: u32,
|
||||
floor: f64,
|
||||
mode: PreviewMode,
|
||||
) -> Result<Vec<PreviewUnit>, ThresholdError> {
|
||||
let mut ordered: Vec<&UnitState> = units.iter().collect();
|
||||
ordered.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(&b.id)));
|
||||
let mut out = Vec::new();
|
||||
for unit in ordered {
|
||||
let chosen = if mode == PreviewMode::InForce {
|
||||
None
|
||||
} else {
|
||||
let mut rivals = Vec::new();
|
||||
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content()) {
|
||||
if !is_effective(unit, p) {
|
||||
continue;
|
||||
}
|
||||
let s = score(unit, p, electorate, floor)?;
|
||||
if mode == PreviewMode::All || s.well_rated {
|
||||
rivals.push(s);
|
||||
}
|
||||
}
|
||||
if mode == PreviewMode::All {
|
||||
// Whatever its rating: the most supported, then the earliest.
|
||||
for r in &mut rivals {
|
||||
r.margin = 0;
|
||||
}
|
||||
}
|
||||
best(rivals)
|
||||
};
|
||||
match (chosen, &unit.in_force) {
|
||||
(Some(p), current) => {
|
||||
let mark = match (p.kind, current) {
|
||||
(ProposalKind::Remove, _) => PreviewMark::Removed,
|
||||
(_, Some(_)) => PreviewMark::Replaced,
|
||||
(_, None) => PreviewMark::Added,
|
||||
};
|
||||
let (title, body) = match (p.kind, current) {
|
||||
(ProposalKind::Remove, Some(v)) => (v.title.clone(), v.body.clone()),
|
||||
_ => (p.title.clone(), p.body.clone()),
|
||||
};
|
||||
out.push(PreviewUnit { unit_id: unit.id.clone(), title, body, mark, proposal_id: Some(p.id.clone()) });
|
||||
}
|
||||
(None, Some(v)) => out.push(PreviewUnit {
|
||||
unit_id: unit.id.clone(),
|
||||
title: v.title.clone(),
|
||||
body: v.body.clone(),
|
||||
mark: PreviewMark::Unchanged,
|
||||
proposal_id: None,
|
||||
}),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit(id: &str, position: f64, in_force: Option<&str>) -> UnitState {
|
||||
UnitState {
|
||||
id: id.into(),
|
||||
position,
|
||||
inertia: InertiaPreset::Standard,
|
||||
in_force: in_force.map(|b| VersionText {
|
||||
id: format!("{id}-v"),
|
||||
title: id.into(),
|
||||
body: b.into(),
|
||||
tally: Tally::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn prop(id: &str, unit_id: &str, kind: ProposalKind, body: &str, for_votes: u32, against: u32) -> ProposalState {
|
||||
ProposalState {
|
||||
id: id.into(),
|
||||
unit_id: unit_id.into(),
|
||||
kind,
|
||||
title: unit_id.into(),
|
||||
body: body.into(),
|
||||
tally: Tally { for_votes, against },
|
||||
submitted_at: format!("2026-10-21T00:00:0{}Z", id.len() % 10),
|
||||
blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
const W: u32 = 5745;
|
||||
const C: f64 = 50.0;
|
||||
|
||||
#[test]
|
||||
fn nothing_passes_at_zero_votes() {
|
||||
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
|
||||
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
|
||||
assert!(adoptions(&units, &props, W, C).expect("valid").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_floor_holds_then_releases() {
|
||||
let units = [unit("E3", 7.0, None)];
|
||||
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
|
||||
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty());
|
||||
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
|
||||
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rivals_on_one_unit_yield_one_winner() {
|
||||
let units = [unit("E3", 7.0, None)];
|
||||
let props = [
|
||||
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
|
||||
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
|
||||
];
|
||||
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inertia_and_content_are_independent_dimensions() {
|
||||
let units = [unit("E1", 4.0, Some("unicité"))];
|
||||
let props = [
|
||||
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
|
||||
prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0),
|
||||
];
|
||||
let won = adoptions(&units, &props, W, C).expect("valid");
|
||||
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inertia_change_is_voted_one_level_up() {
|
||||
let u = unit("E1", 4.0, Some("x"));
|
||||
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C);
|
||||
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct());
|
||||
assert_eq!(p.gradient, InertiaPreset::High.gradient());
|
||||
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_or_ineffective_never_pass() {
|
||||
let units = [unit("E1", 4.0, Some("unicité"))];
|
||||
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
|
||||
blocked.blocked = true;
|
||||
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
|
||||
let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0);
|
||||
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rival_must_beat_the_version_in_force() {
|
||||
let mut units = [unit("E1", 4.0, Some("unicité"))];
|
||||
// The seed a minima has no vote: the first rival that reaches its threshold wins.
|
||||
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
|
||||
assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]);
|
||||
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits.
|
||||
if let Some(v) = units[0].in_force.as_mut() {
|
||||
v.tally = Tally { for_votes: 60, against: 0 };
|
||||
}
|
||||
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
|
||||
assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty());
|
||||
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
|
||||
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]);
|
||||
// The same rule holds for a removal; inertia does not compete with the text.
|
||||
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
|
||||
assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty());
|
||||
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)];
|
||||
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews() {
|
||||
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None), unit("E1", 4.0, Some("unicité"))];
|
||||
let props = [
|
||||
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
|
||||
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
|
||||
];
|
||||
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid");
|
||||
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
|
||||
|
||||
let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid");
|
||||
assert_eq!(
|
||||
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
|
||||
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
|
||||
);
|
||||
|
||||
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
|
||||
assert_eq!(
|
||||
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
|
||||
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_mode_prefers_the_most_supported_rival() {
|
||||
let units = [unit("E3", 7.0, None)];
|
||||
let props = [
|
||||
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
|
||||
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
|
||||
];
|
||||
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
|
||||
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Edit classification — a typo fix is not a decision.
|
||||
//!
|
||||
//! Once a proposal is under vote, its author may still correct it. The engine
|
||||
//! tells two kinds of corrections apart:
|
||||
//!
|
||||
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no number, no negation
|
||||
//! touched). The votes stay; the correction is shown with its diff.
|
||||
//! - **substantive**: anything else. It opens a new revision and every vote
|
||||
//! cast on the previous one waits for its author to reconfirm it.
|
||||
//!
|
||||
//! The rule is deliberately conservative: when in doubt, it is substantive.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EditKind {
|
||||
Unchanged,
|
||||
Typographic,
|
||||
Substantive,
|
||||
}
|
||||
|
||||
/// Words whose presence flips a meaning; touching one is never a typo.
|
||||
const NEGATIONS: &[&str] = &[
|
||||
// fr
|
||||
"ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne",
|
||||
// en
|
||||
"not", "no", "never", "without", "none", "nor", // es
|
||||
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada",
|
||||
];
|
||||
|
||||
const MAX_TYPO_WORDS: usize = 2;
|
||||
const MAX_TYPO_DISTANCE: usize = 2;
|
||||
|
||||
/// Lowercase, strip diacritics.
|
||||
fn fold(s: &str) -> String {
|
||||
s.nfd().filter(|c| !is_combining(*c)).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
fn is_combining(c: char) -> bool {
|
||||
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F)
|
||||
}
|
||||
|
||||
/// Letters and digits only, folded: what survives every typographic change.
|
||||
fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// Folded words (runs of letters/digits).
|
||||
fn words(s: &str) -> Vec<String> {
|
||||
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
if old == new {
|
||||
return EditKind::Unchanged;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
return EditKind::Typographic;
|
||||
}
|
||||
let (a, b) = (words(old), words(new));
|
||||
if a.len() != b.len() {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
let mut changed = 0;
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
if x == y {
|
||||
continue;
|
||||
}
|
||||
changed += 1;
|
||||
let touches_meaning = changed > MAX_TYPO_WORDS
|
||||
|| x.chars().any(|c| c.is_ascii_digit())
|
||||
|| y.chars().any(|c| c.is_ascii_digit())
|
||||
|| NEGATIONS.contains(&x.as_str())
|
||||
|| NEGATIONS.contains(&y.as_str())
|
||||
|| x.chars().count().min(y.chars().count()) < 3
|
||||
|| levenshtein(x, y) > MAX_TYPO_DISTANCE;
|
||||
if touches_meaning {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
}
|
||||
EditKind::Typographic
|
||||
}
|
||||
|
||||
pub fn levenshtein(a: &str, b: &str) -> usize {
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0; b.len() + 1];
|
||||
for (i, ca) in a.chars().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let cost = usize::from(ca != *cb);
|
||||
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DiffOp {
|
||||
Same,
|
||||
Added,
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DiffPart {
|
||||
pub op: DiffOp,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Tokens that keep every character: words, and the runs between them.
|
||||
fn tokens(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut in_word: Option<bool> = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
let w = c.is_alphanumeric();
|
||||
if in_word.is_some_and(|prev| prev != w) {
|
||||
out.push(&s[start..i]);
|
||||
start = i;
|
||||
}
|
||||
in_word = Some(w);
|
||||
}
|
||||
if start < s.len() {
|
||||
out.push(&s[start..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const MAX_DIFF_TOKENS: usize = 4000;
|
||||
|
||||
/// Word-level diff for display. Concatenating `Same` + `Added` gives `new`,
|
||||
/// `Same` + `Removed` gives `old`.
|
||||
pub fn diff(old: &str, new: &str) -> Vec<DiffPart> {
|
||||
let (a, b) = (tokens(old), tokens(new));
|
||||
if a.len() * b.len() > MAX_DIFF_TOKENS * MAX_DIFF_TOKENS / 4 {
|
||||
return vec![
|
||||
DiffPart { op: DiffOp::Removed, text: old.to_owned() },
|
||||
DiffPart { op: DiffOp::Added, text: new.to_owned() },
|
||||
];
|
||||
}
|
||||
// LCS table, suffix form.
|
||||
let (n, m) = (a.len(), b.len());
|
||||
let mut lcs = vec![vec![0u32; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
lcs[i][j] = if a[i] == b[j] { lcs[i + 1][j + 1] + 1 } else { lcs[i + 1][j].max(lcs[i][j + 1]) };
|
||||
}
|
||||
}
|
||||
let mut parts: Vec<DiffPart> = Vec::new();
|
||||
let mut push = |op: DiffOp, text: &str| match parts.last_mut() {
|
||||
Some(last) if last.op == op => last.text.push_str(text),
|
||||
_ => parts.push(DiffPart { op, text: text.to_owned() }),
|
||||
};
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < n && j < m {
|
||||
if a[i] == b[j] {
|
||||
push(DiffOp::Same, a[i]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if lcs[i + 1][j] >= lcs[i][j + 1] {
|
||||
push(DiffOp::Removed, a[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
push(DiffOp::Added, b[j]);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
for t in &a[i..] {
|
||||
push(DiffOp::Removed, t);
|
||||
}
|
||||
for t in &b[j..] {
|
||||
push(DiffOp::Added, t);
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_glued_word_of_the_seed_is_a_typo() {
|
||||
let old = "Toute Certification doit s'accompagner de latransmission de ce document";
|
||||
let new = "Toute Certification doit s'accompagner de la transmission de ce document";
|
||||
assert_eq!(classify(old, new), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accents_case_punctuation_emphasis() {
|
||||
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier !"), EditKind::Typographic);
|
||||
assert_eq!(classify("un **être humain**", "un être humain"), EditKind::Typographic);
|
||||
assert_eq!(classify("same", "same"), EditKind::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspelt_words() {
|
||||
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
|
||||
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
|
||||
// « personne » is also a negation (ne … personne): conservative.
|
||||
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meaning_changes() {
|
||||
// A number.
|
||||
assert_eq!(classify("tous les 5 jours", "tous les 6 jours"), EditKind::Substantive);
|
||||
// A negation, even one letter apart.
|
||||
assert_eq!(classify("je peux certifier", "je ne peux certifier"), EditKind::Substantive);
|
||||
assert_eq!(classify("plus de deux", "pas de deux"), EditKind::Substantive);
|
||||
// A different word.
|
||||
assert_eq!(classify("au moins deux canaux", "au moins trois canaux"), EditKind::Substantive);
|
||||
// Three fixes are no longer a typo.
|
||||
assert_eq!(classify("aaaa bbbb cccc", "aaab bbbc cccd"), EditKind::Substantive);
|
||||
// A word added.
|
||||
assert_eq!(classify("en visio", "en visio directe"), EditKind::Substantive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_reconstructs_both_sides() {
|
||||
let old = "J'ai rencontré la personne physiquement (préférable).";
|
||||
let new = "J'ai rencontré la personne en visio (préférable).";
|
||||
let parts = diff(old, new);
|
||||
let rebuild = |keep: DiffOp| -> String {
|
||||
parts.iter().filter(|p| p.op == DiffOp::Same || p.op == keep).map(|p| p.text.as_str()).collect()
|
||||
};
|
||||
assert_eq!(rebuild(DiffOp::Removed), old);
|
||||
assert_eq!(rebuild(DiffOp::Added), new);
|
||||
assert!(parts.iter().any(|p| p.op == DiffOp::Removed && p.text.contains("physiquement")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levenshtein_basics() {
|
||||
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
||||
assert_eq!(levenshtein("", "abc"), 3);
|
||||
assert_eq!(levenshtein("été", "ete"), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Pure engines of libreDecision — no IO, no clock, no randomness.
|
||||
//!
|
||||
//! Everything that decides an outcome lives here so it can be tested in
|
||||
//! isolation and ported verbatim: the inertial threshold (parity with the
|
||||
//! Python/TypeScript reference of the v2 maquette), the edit classifier that
|
||||
//! separates a typo fix from a change of meaning, the rule-based editing
|
||||
//! assistant, and the composition of a document under permanent vote.
|
||||
|
||||
pub mod assist;
|
||||
pub mod compose;
|
||||
pub mod edit;
|
||||
pub mod threshold;
|
||||
|
||||
pub use threshold::{FormulaParams, InertiaPreset, Tally};
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula.
|
||||
//!
|
||||
//! ```text
|
||||
//! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C)
|
||||
//! ```
|
||||
//!
|
||||
//! R is the minimum number of *for* votes; a proposal is adopted when
|
||||
//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for +
|
||||
//! against). At low participation the formula asks for near-unanimity, at full
|
||||
//! participation the simple majority M suffices. C is the minimal number of
|
||||
//! *for* votes below which nothing passes — the guard of a permanent vote
|
||||
//! (Yvv, 2026-10-21: « Nombre minimal de votants »).
|
||||
//!
|
||||
//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the
|
||||
//! Python reference, with one deliberate guard — the participation ratio is
|
||||
//! capped at 1, so a stale W can never lower the bar below M.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The four inertia levels of a modular text. Wiring of the v2 maquette:
|
||||
/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InertiaPreset {
|
||||
Low,
|
||||
Standard,
|
||||
High,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl InertiaPreset {
|
||||
pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max];
|
||||
|
||||
pub fn majority_pct(self) -> f64 {
|
||||
match self {
|
||||
Self::Low | Self::Standard => 50.0,
|
||||
Self::High => 60.0,
|
||||
Self::Max => 66.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gradient(self) -> f64 {
|
||||
match self {
|
||||
Self::Low => 0.1,
|
||||
Self::Standard => 0.2,
|
||||
Self::High => 0.4,
|
||||
Self::Max => 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
/// « Inertie + » : one level up, capped at max. A change of inertia is
|
||||
/// itself voted at the raised level of the unit it touches.
|
||||
pub fn raised(self) -> Self {
|
||||
match self {
|
||||
Self::Low => Self::Standard,
|
||||
Self::Standard => Self::High,
|
||||
Self::High | Self::Max => Self::Max,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Standard => "standard",
|
||||
Self::High => "high",
|
||||
Self::Max => "max",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| p.as_str() == raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// The letters of the formula, named.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FormulaParams {
|
||||
/// M, in percent (0–100).
|
||||
pub majority_pct: f64,
|
||||
/// G.
|
||||
pub gradient: f64,
|
||||
/// B.
|
||||
pub base: f64,
|
||||
/// C — minimal number of *for* votes.
|
||||
pub constant: f64,
|
||||
}
|
||||
|
||||
impl FormulaParams {
|
||||
pub const DEFAULT_BASE: f64 = 0.1;
|
||||
|
||||
pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self {
|
||||
Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ThresholdError {
|
||||
EmptyElectorate,
|
||||
MajorityOutOfRange,
|
||||
}
|
||||
|
||||
/// The required ratio of *for* votes among the votes cast beyond C.
|
||||
pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 {
|
||||
let m = params.majority_pct / 100.0;
|
||||
let participation = (f64::from(cast) / f64::from(electorate.max(1))).min(1.0);
|
||||
m + (1.0 - m) * (1.0 - participation.powf(params.gradient))
|
||||
}
|
||||
|
||||
/// ⌈R⌉ — the minimum number of *for* votes for adoption.
|
||||
pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
|
||||
if electorate == 0 {
|
||||
return Err(ThresholdError::EmptyElectorate);
|
||||
}
|
||||
if !(0.0..=100.0).contains(¶ms.majority_pct) {
|
||||
return Err(ThresholdError::MajorityOutOfRange);
|
||||
}
|
||||
let c = params.constant;
|
||||
let offset = params.base.powf(f64::from(electorate));
|
||||
let raw = if cast == 0 {
|
||||
c + offset
|
||||
} else {
|
||||
c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)
|
||||
};
|
||||
// `raw` is finite and non-negative for any valid input; saturate rather than wrap.
|
||||
Ok(clamp_ceil(raw))
|
||||
}
|
||||
|
||||
fn clamp_ceil(raw: f64) -> u32 {
|
||||
let ceiled = raw.ceil();
|
||||
if ceiled <= 0.0 {
|
||||
0
|
||||
} else if ceiled >= f64::from(u32::MAX) {
|
||||
u32::MAX
|
||||
} else {
|
||||
// Exact: `ceiled` is an integer in (0, u32::MAX).
|
||||
ceiled as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// Votes counted on one proposal.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tally {
|
||||
#[serde(rename = "for")]
|
||||
pub for_votes: u32,
|
||||
pub against: u32,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
pub fn cast(&self) -> u32 {
|
||||
self.for_votes.saturating_add(self.against)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a proposal stands, in words a screen can say.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Evaluation {
|
||||
pub cast: u32,
|
||||
/// ⌈R⌉ at the current participation.
|
||||
pub required: u32,
|
||||
/// `for ≥ ⌈R⌉` with at least one vote cast.
|
||||
pub reached: bool,
|
||||
/// *for* votes still missing (0 once reached).
|
||||
pub missing: u32,
|
||||
/// The majority the inertia asks for at this participation.
|
||||
pub required_ratio: f64,
|
||||
/// « Bien cotée » : it holds the majority its inertia asks for, it only
|
||||
/// lacks voters (C ignored). Used by the "if the well-rated pass" preview.
|
||||
pub well_rated: bool,
|
||||
}
|
||||
|
||||
pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> {
|
||||
let cast = tally.cast();
|
||||
let required = wot_threshold(electorate, cast, params)?;
|
||||
let without_floor = FormulaParams { constant: 0.0, ..*params };
|
||||
let required_without_floor = wot_threshold(electorate, cast, &without_floor)?;
|
||||
let reached = cast > 0 && tally.for_votes >= required;
|
||||
Ok(Evaluation {
|
||||
cast,
|
||||
required,
|
||||
reached,
|
||||
missing: required.saturating_sub(tally.for_votes),
|
||||
required_ratio: required_ratio(electorate, cast, params),
|
||||
well_rated: cast > 0 && tally.for_votes >= required_without_floor,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn g1vote(m: f64, g: f64) -> FormulaParams {
|
||||
FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_case_of_the_smith_vote() {
|
||||
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94.
|
||||
let p = g1vote(50.0, 0.2);
|
||||
assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
|
||||
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
|
||||
assert!(e.reached);
|
||||
assert_eq!(e.missing, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_with_the_typescript_reference() {
|
||||
// Values computed by maquette/frontend/app/engine/threshold.ts.
|
||||
let p = g1vote(50.0, 0.2);
|
||||
assert_eq!(wot_threshold(7224, 0, &p), Ok(0));
|
||||
assert_eq!(wot_threshold(7224, 1, &p), Ok(1));
|
||||
assert_eq!(wot_threshold(100, 100, &p), Ok(50));
|
||||
assert_eq!(wot_threshold(5, 5, &p), Ok(3));
|
||||
let high = g1vote(60.0, 0.4);
|
||||
assert_eq!(wot_threshold(7224, 120, &high), Ok(111));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_vote_passes_without_floor_and_never_with_it() {
|
||||
// The trap of a permanent vote: a lone "for" meets the inertial bar.
|
||||
let bare = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0);
|
||||
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached);
|
||||
let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
|
||||
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid");
|
||||
assert!(!e.reached);
|
||||
assert_eq!(e.required, 50);
|
||||
assert_eq!(e.missing, 49);
|
||||
assert!(e.well_rated, "unanimous, it only lacks voters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floor_is_a_minimum_of_for_votes() {
|
||||
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
|
||||
// 50 unanimous votes reach it, 49 do not.
|
||||
assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached);
|
||||
assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached);
|
||||
// Beyond the floor the inertial majority applies to the rest.
|
||||
let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid");
|
||||
assert!(!e.reached, "near-unanimity is asked of the votes beyond C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_vote_is_never_reached() {
|
||||
let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0);
|
||||
let e = evaluate(Tally::default(), 3, &p).expect("valid");
|
||||
assert!(!e.reached);
|
||||
assert!(!e.well_rated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_group_needs_everyone() {
|
||||
// Test group of two people, C = 1: both must vote for.
|
||||
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0);
|
||||
assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
|
||||
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_electorate_cannot_lower_the_bar_below_majority() {
|
||||
let p = g1vote(50.0, 0.2);
|
||||
// 30 votes cast for an electorate believed to be 10: the reference
|
||||
// would ask 12 (ratio below M), the cap keeps the simple majority:
|
||||
// ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up.
|
||||
let r = wot_threshold(10, 30, &p).expect("valid");
|
||||
assert_eq!(r, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inertia_levels_rise_and_cap() {
|
||||
assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard);
|
||||
assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High);
|
||||
assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max);
|
||||
for p in InertiaPreset::ALL {
|
||||
assert_eq!(InertiaPreset::parse(p.as_str()), Some(p));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let p = g1vote(50.0, 0.2);
|
||||
assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate));
|
||||
let bad = g1vote(120.0, 0.2);
|
||||
assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user