v3 : quatrième relecture — coquille en liste blanche, mesurée contre le texte voté

- Une coquille ne touche que des lettres latines, des espaces et la ponctuation
  ordinaire ; tout le reste est figé : chiffres de toute écriture, signes,
  relations, parenthèses, sosies (U+201A, U+2044, pleine chasse), marques
  superposées (U+0336, U+0338), autres alphabets, rayure Markdown, contenu entre
  accents graves, séparateurs et tirets collés à un chiffre.
- Adresses (domaines, liens, courriels) comparées à l'identique.
- Glissement de clavier au cœur du mot seulement (ni les deux premières ni les
  deux dernières lettres : préfixes, nombre, genre, temps, mode) ; mots de
  position ajoutés (dessus/dessous, avant/après…).
- Chaque correction est mesurée contre le texte voté (dernière révision initiale
  ou de fond) : les glissements ne s'additionnent plus ; le texte soumis d'un
  brouillon devient sa révision initiale.
- Déploiement : « previous » = l'image du conteneur en service, pas la dernière
  construite ; migrations inconnues tolérées pour qu'une image précédente démarre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 05:58:36 +02:00
co-authored by Claude Opus 5.5
parent 9203fd2501
commit 1eac6f856a
8 changed files with 236 additions and 206 deletions
+127 -182
View File
@@ -34,154 +34,37 @@ const NEGATIONS: &[&str] = &[
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada",
];
/// Words that carry a quantity or a scope (folded); changing one is never a typo.
/// Words that carry a quantity, a scope or a relation (folded); changing one is
/// never a typo.
#[rustfmt::skip]
const QUANTITIES: &[&str] = &[
// fr
"un",
"une",
"deux",
"trois",
"quatre",
"cinq",
"six",
"sept",
"huit",
"neuf",
"dix",
"onze",
"douze",
"treize",
"quatorze",
"quinze",
"seize",
"vingt",
"trente",
"quarante",
"cinquante",
"soixante",
"septante",
"huitante",
"octante",
"nonante",
"cent",
"cents",
"mille",
"million",
"millions",
"milliard",
"milliards",
"premier",
"premiere",
"premiers",
"premieres",
"second",
"seconde",
"deuxieme",
"troisieme",
"dernier",
"derniere",
"double",
"triple",
"moitie",
"tiers",
"quart",
"moins",
"minimum",
"maximum",
"majorite",
"minorite",
"unanimite",
"tous",
"toutes",
"tout",
"toute",
"chaque",
"seul",
"seule",
"seuls",
"seules", // en
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"twenty",
"thirty",
"forty",
"fifty",
"sixty",
"hundred",
"thousand",
"million",
"first",
"second",
"third",
"last",
"half",
"twice",
"less",
"more",
"least",
"most",
"majority",
"minority",
"all",
"every",
"each",
"only", // es
"uno",
"dos",
"tres",
"cuatro",
"cinco",
"seis",
"siete",
"ocho",
"nueve",
"diez",
"once",
"doce",
"veinte",
"treinta",
"cuarenta",
"cincuenta",
"cien",
"ciento",
"mil",
"millon",
"primero",
"primera",
"segundo",
"segunda",
"ultimo",
"ultima",
"mitad",
"doble",
"menos",
"mas",
"minimo",
"maximo",
"mayoria",
"minoria",
"todos",
"todas",
"todo",
"toda",
"cada",
"solo",
"sola",
"un", "une", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize",
"quatorze", "quinze", "seize", "vingt", "trente", "quarante", "cinquante", "soixante", "septante", "huitante",
"octante", "nonante", "cent", "cents", "mille", "million", "millions", "milliard", "milliards", "premier",
"premiere", "premiers", "premieres", "second", "seconde", "deuxieme", "troisieme", "dernier", "derniere", "double",
"triple", "moitie", "tiers", "quart", "moins", "minimum", "maximum", "majorite", "minorite", "unanimite", "tous",
"toutes", "tout", "toute", "chaque", "seul", "seule", "seuls", "seules", "dessus", "dessous", "superieur",
"superieure", "inferieur", "inferieure", "avant", "apres", "sous", "entre", "hors", "sauf", "inclus", "exclus",
// en
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "twenty",
"thirty", "forty", "fifty", "sixty", "hundred", "thousand", "million", "first", "second", "third", "last", "half",
"twice", "less", "more", "least", "most", "majority", "minority", "all", "every", "each", "only", "above", "below",
"over", "under", "before", "after", "within", "except",
// es
"uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "veinte",
"treinta", "cuarenta", "cincuenta", "cien", "ciento", "mil", "millon", "primero", "primera", "segundo", "segunda",
"ultimo", "ultima", "mitad", "doble", "menos", "mas", "minimo", "maximo", "mayoria", "minoria", "todos", "todas",
"todo", "toda", "cada", "solo", "sola", "encima", "debajo", "antes", "despues", "sobre", "bajo", "excepto",
];
const MAX_TYPO_WORDS: usize = 2;
/// Shorter words have too many neighbours one letter away ("can" / "cant").
const MIN_TYPO_WORD: usize = 4;
const MIN_TYPO_WORD: usize = 5;
/// Letters at each end of a word that a slip never touches: the start carries
/// prefixes ("valide" / "invalide"), the end carries number, gender, tense and
/// mood ("certifierai" / "certifierais", "será" / "sería").
const FIXED_ENDS: usize = 2;
/// Lowercase, strip diacritics.
fn fold(s: &str) -> String {
@@ -197,15 +80,20 @@ fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
}
/// Symbols that carry a value, a relation or a grouping, wherever they stand.
const RELATIONS: &[char] = &[
'+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√', '/', '(', ')', '[',
']', '{', '}', '`',
];
/// Signs: meaningful next to a figure ("-5", "5-10").
const SIGNS: &[char] = &['-', '−', '–'];
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625").
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’'];
/// A letter of the Latin alphabet, precomposed accents included (after NFC):
/// the only letters a typo fix may touch. Any other script may hold look-alikes
/// (Cyrillic « а ») or reorder the display (Hebrew, Arabic).
fn is_latin_letter(c: char) -> bool {
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF)
}
/// Ordinary punctuation, free to change in a typo fix — except where noted below.
const PUNCTUATION: &[char] =
&['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_'];
/// Separators that become part of a number between two figures ("10 000", "10,000", "182.625").
const NUMBER_SEPARATORS: &[char] = &['.', ',', '\'', '’'];
/// Dashes that become a sign next to a figure ("-5", "5-10").
const DASHES: &[char] = &['‐', '-', '–', '—'];
/// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
@@ -216,68 +104,104 @@ pub fn has_hidden_controls(s: &str) -> bool {
)
}
/// Everything a typo fix may not change, in order. Built the other way round
/// from a list of dangerous symbols: what is kept is everything that is NOT a
/// Latin letter, a space or ordinary punctuation — figures in any script, signs,
/// relations, brackets, look-alikes (U+201A for a comma, U+2044 for a slash,
/// full-width forms), stray combining marks (U+0336, U+0338), other alphabets.
/// Spaces and punctuation count too where they belong to a number.
fn signature(s: &str) -> String {
let cs: Vec<char> = s.nfc().collect();
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
cs.iter()
.enumerate()
.filter(|&(i, &c)| {
let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1)));
if is_latin_letter(c) {
false
} else if c.is_whitespace() {
before && after
} else if NUMBER_SEPARATORS.contains(&c) && before && after {
true
} else if DASHES.contains(&c) {
before || after
} else {
!PUNCTUATION.contains(&c)
}
})
.map(|(_, &c)| c)
.collect()
}
/// What stands between backticks: formulas and identifiers, compared exactly.
fn code_spans(s: &str) -> Vec<&str> {
s.split('`').skip(1).step_by(2).collect()
}
/// A single slip of the keyboard in a word: one letter added, removed, or two
/// neighbours swapped — never at its start, where prefixes turn a meaning
/// around ("valide" / "invalide", "limitée" / "illimitée"), and never a letter
/// replaced by another ("deux" / "dix", "majorité" / "minorité").
/// Web and mail addresses, domains, paths: compared exactly ("duniter" /
/// "dunniter" is a typo of a word, not of an address).
fn addresses(s: &str) -> Vec<String> {
s.split_whitespace()
.map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|t| {
let cs: Vec<char> = t.chars().collect();
t.contains("://")
|| t.contains('@')
|| (1..cs.len().saturating_sub(1))
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
})
.map(str::to_owned)
.collect()
}
/// A single slip of the keyboard inside a word: one letter added, removed, or
/// two neighbours swapped — never at either end (see `FIXED_ENDS`), and never a
/// letter replaced by another ("deux" / "dix", "majorité" / "minorité").
fn one_slip(x: &str, y: &str) -> bool {
let (a, b): (Vec<char>, Vec<char>) = (x.chars().collect(), y.chars().collect());
if a.len().min(b.len()) < MIN_TYPO_WORD || a[..2] != b[..2] {
if a.len().min(b.len()) < MIN_TYPO_WORD {
return false;
}
let inside = |i: usize, len: usize| i >= FIXED_ENDS && i + FIXED_ENDS < len;
match a.len().abs_diff(b.len()) {
1 => {
let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
(2..long.len()).any(|i| {
(0..long.len()).any(|i| {
let mut l = long.clone();
l.remove(i);
&l == short
inside(i, long.len()) && &l == short
})
}
0 => {
let at: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
at.len() == 2 && at[1] == at[0] + 1 && a[at[0]] == b[at[1]] && a[at[1]] == b[at[0]]
at.len() == 2
&& at[1] == at[0] + 1
&& a[at[0]] == b[at[1]]
&& a[at[1]] == b[at[0]]
&& inside(at[0], a.len())
&& inside(at[1], a.len())
}
_ => false,
}
}
/// What the text says in figures and symbols, as written. Two texts with the
/// same letters but a different figure, sign, separator inside a number or
/// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not
/// say the same thing.
fn figures(s: &str) -> String {
let cs: Vec<char> = s.chars().collect();
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
cs.iter()
.enumerate()
.filter(|&(i, c)| {
let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1)));
c.is_numeric()
|| RELATIONS.contains(c)
|| (SIGNS.contains(c) && (before || after))
|| (SEPARATORS.contains(c) && before && after)
})
.map(|(_, c)| *c)
.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()
}
/// Classify `new` against `old`. For a text under vote, `old` must be the text
/// that was **voted** (its last initial or substantive revision), not the
/// current one: typo fixes are measured together, so slips never add up.
pub fn classify(old: &str, new: &str) -> EditKind {
if old == new {
return EditKind::Unchanged;
}
// A figure, a sign or a relation is never a typo: it changes what the text says.
if figures(old) != figures(new) || code_spans(old) != code_spans(new) {
if has_hidden_controls(new)
|| signature(old) != signature(new)
|| code_spans(old) != code_spans(new)
|| addresses(old) != addresses(new)
{
return EditKind::Substantive;
}
if skeleton(old) == skeleton(new) {
@@ -468,6 +392,26 @@ mod tests {
("M(n)/N(n)", "M(n)N(n)"),
("DU(n) - c", "DU(n) / c"),
("la formule `a - b`", "la formule `a * b`"),
// Fourth independent review: look-alikes, overlays, addresses, endings.
("au moins 50 voix", "au moins 5\u{201a}0 voix"),
("**12 mois**", "**1\u{201a}2 mois**"),
("15 jours", "1\u{2044}5 jours"),
("2 ans", "\u{ff0d}2 ans"),
(">= 5", "\u{226f}= 5"),
("a = b", "a \u{2260} b"),
("a = b", "a =\u{0338} b"),
("ne jamais certifier", "ne j\u{0336}a\u{0336}m\u{0336}a\u{0336}i\u{0336}s\u{0336} certifier"),
("la clé est vérifiée", "la clé est ~~vérifiée~~"),
("une personne connue", "une personne conn\u{05d5}ue"),
("une personne connue", "une personne c\u{043e}nnue"),
(
"https://git.duniter.org/documents/g1_monetary_license",
"https://git.dunniter.org/documents/g1_monetary_license",
),
("au-dessus de 50 %", "au-dessous de 50 %"),
("je certifierai", "je certifierais"),
("será válido", "sería válido"),
("they must certify", "they mustn certify"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
@@ -482,7 +426,8 @@ mod tests {
#[test]
fn misspelt_words() {
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
// The end of a word carries number and tense: a plural fixed is a revision.
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Substantive);
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
assert_eq!(classify("un acceuil chaleureux", "un accueil chaleureux"), EditKind::Typographic);
// A letter replaced by another is not a slip: the author revises.
+5 -1
View File
@@ -61,7 +61,11 @@ pub async fn open_db(url: &str) -> anyhow::Result<SqlitePool> {
.busy_timeout(Duration::from_secs(5));
let max = if url.contains(":memory:") { 1 } else { 8 };
let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
// A previous image (rollback) must still start on a database a newer one has
// migrated: migrations are additive, the unknown ones are tolerated.
let mut migrator = sqlx::migrate!("./migrations");
migrator.set_ignore_missing(true);
migrator.run(&pool).await?;
Ok(pool)
}
+34 -1
View File
@@ -71,6 +71,18 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
Ok(())
}
/// The text the votes were cast on: its last initial or substantive revision.
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
ORDER BY id DESC LIMIT 1",
)
.bind(&p.id)
.fetch_optional(&state.db)
.await?;
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
}
async fn record_revision(
state: &AppState,
proposal_id: &str,
@@ -302,7 +314,18 @@ pub async fn edit(
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("closed"));
}
let kind = edit::classify(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}"));
// Measured against the text that was voted, not the current one: successive
// typo fixes are judged together and never add up to a change of meaning.
let next = format!("{title}\n{body}");
let kind = if next == format!("{}\n{}", p.title, p.body) {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
match edit::classify(&format!("{voted_title}\n{voted_body}"), &next) {
EditKind::Unchanged => EditKind::Typographic, // back to the voted text
k => k,
}
};
match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
EditKind::Typographic => {
@@ -379,6 +402,16 @@ pub async fn submit(
.bind(&id)
.execute(&state.db)
.await?;
// A draft is edited freely: the text submitted is the one votes will be cast on.
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
.bind(&p.title)
.bind(&p.body)
.bind(&id)
.execute(&state.db)
.await?;
if initial.rows_affected() == 0 {
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
}
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "open", "entered": entered })))
}
+28
View File
@@ -251,6 +251,34 @@ async fn editing_is_not_voting() {
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
// Typo fixes are measured against the voted text: slips never add up.
let patch = |body: String| json!({ "title": e2["title"], "body": body });
let (_, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
.await;
assert_eq!(v["edit"], "typo", "one slip is a typo");
let (s, v) = b
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
.await;
assert_eq!(
(s, v["error"].as_str()),
(StatusCode::CONFLICT, Some("substantive_in_force")),
"a second slip on top is not"
);
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
assert_eq!(v["edit"], "typo", "back to the voted text");
// An address is compared exactly.
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
let x11_body = x11["body"].as_str().expect("body");
assert!(x11_body.contains("git.duniter.org"));
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{}", x11["id"].as_str().expect("id")),
Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
// A member proposal: typo keeps votes, substance asks to reconfirm.
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();