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:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
+243
View File
@@ -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);
}
}