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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user