v3 : sixième relecture — une coquille touche les lettres et les espaces, rien d'autre

- La forme du texte (tout ce qui n'est ni lettre latine ni espace, dans
  l'ordre, avec les espaces qui touchent un chiffre) ne bouge plus : ponctuation,
  emphase, signes, parenthèses passent par une proposition (un signe moins devant
  un nombre en gras, deux conditions fusionnées, une parenthèse déplacée).
  Apostrophes et guillemets de tous styles comptent pour un même signe.
- Les espaces ne font que coller ou couper des mots, jamais déplacer une
  frontière (« les avoir » → « le savoir »).
- Identifiants qui contiennent un chiffre (clés, références) comparés à
  l'identique, casse comprise, comme les adresses.
- Correction écrite seulement sur le texte jugé (révision, statut, texte) :
  sinon conflit « changed » — plus de course avec une entrée en vigueur.
- Déploiement : « previous » n'est pas repris d'un pipeline annulé.
- Interface (fr/en/es) : la note de correction dit la règle telle qu'elle est.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 06:52:04 +02:00
co-authored by Claude Opus 5.5
parent 6c0754a5c3
commit fa8e8dc1f0
9 changed files with 218 additions and 130 deletions
+97 -60
View File
@@ -3,12 +3,12 @@
//! 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 by a single slip each (one letter added, removed
//! or swapped, never at the start of the word, never on a negation or a
//! quantity) — and never a figure, a sign, a separator inside a number, a
//! relation, a bracket or anything between backticks. The votes stay; the
//! correction is shown with its diff.
//! - **typographic**: letters and spaces only — accents, case, the style of
//! apostrophes and quotes, words glued or split, or at most two misspelt
//! words fixed by a single slip each (one letter added, removed or swapped
//! in the middle of the word, never on a negation or a quantity). Never a
//! punctuation mark, a figure, a sign, an emphasis, a formula or an address.
//! 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.
//!
@@ -87,13 +87,8 @@ 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] =
&['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_'];
/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is.
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
/// Dashes become a sign when a figure follows or precedes, even across spaces ("- 5").
const DASHES: &[char] = &['‐', '-', '–', '—'];
/// Invisible format characters (zero-width, bidirectional overrides and
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
@@ -104,42 +99,43 @@ 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.
/// Anything that touches a figure counts too, spaces and punctuation included:
/// "5,0", "5,\u{202f}0", "1:5", "1…5" never read like "50" or "15".
fn signature(s: &str) -> String {
/// Apostrophes and quotation marks of every style are one and the same sign:
/// « Je m'engage » → « Je m’engage » is typography.
fn same_mark(c: char) -> char {
match c {
'’' | '‘' | 'ʼ' => '\'',
'“' | '”' | '«' | '»' => '"',
other => other,
}
}
/// The shape of a text: every run of Latin letters becomes one mark, free spaces
/// vanish, and **everything else stays as it is, in order** — punctuation,
/// figures, signs, emphasis, brackets, look-alikes, other alphabets. A typo fix
/// keeps the shape: it touches letters and spaces only (2026-10-21, after six
/// independent reviews showed that any free punctuation can carry a meaning:
/// a minus sign before a bold number, a list merged into one condition…).
fn shape(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());
// The next character that is not a free space, on either side.
let across = |i: usize, step: isize| {
let mut j = i as isize + step;
while j >= 0 && (j as usize) < cs.len() && FREE_SPACES.contains(&cs[j as usize]) {
j += step;
}
j >= 0 && is_figure(Some(j as usize))
};
cs.iter()
.enumerate()
.filter(|&(i, &c)| {
let touches_figure = is_figure(i.checked_sub(1)) || is_figure(Some(i + 1));
if is_latin_letter(c) {
false
} else if touches_figure {
true
} else if FREE_SPACES.contains(&c) {
false
} else if DASHES.contains(&c) {
across(i, -1) || across(i, 1)
} else {
!PUNCTUATION.contains(&c)
let mut out = String::new();
let mut in_word = false;
for (i, &c) in cs.iter().enumerate() {
if is_latin_letter(c) {
if !in_word {
out.push('a');
}
})
.map(|(_, &c)| c)
.collect()
in_word = true;
} else if FREE_SPACES.contains(&c) && !is_figure(i.checked_sub(1)) && !is_figure(Some(i + 1)) {
// A space between letters may come and go: « latransmission » and
// « la transmission » share a shape (segmentation is checked apart).
// Next to a figure it stays: « 1 5 » is not « 15 ».
} else {
out.push(same_mark(c));
in_word = false;
}
}
out
}
/// What stands between backticks: formulas and identifiers, compared exactly.
@@ -147,15 +143,16 @@ fn code_spans(s: &str) -> Vec<&str> {
s.split('`').skip(1).step_by(2).collect()
}
/// 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> {
/// Addresses (links, domains, mails, paths) and identifiers that hold a figure
/// (keys, fingerprints, references): compared exactly, case included.
fn exact_tokens(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('@')
|| cs.iter().any(|c| c.is_numeric()) && cs.iter().any(|c| c.is_alphabetic())
|| (1..cs.len().saturating_sub(1))
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
})
@@ -199,6 +196,24 @@ fn words(s: &str) -> Vec<String> {
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
}
/// Same letters, words only glued or split: « latransmission » → « la
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »).
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
let (mut i, mut j) = (0, 0);
while i < a.len() && j < b.len() {
if a[i] == b[j] {
(i, j) = (i + 1, j + 1);
} else if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) {
(i, j) = (i + 1, j + 2);
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) {
(i, j) = (i + 2, j + 1);
} else {
return false;
}
}
i == a.len() && j == b.len()
}
/// 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.
@@ -207,16 +222,17 @@ pub fn classify(old: &str, new: &str) -> EditKind {
return EditKind::Unchanged;
}
if has_hidden_controls(new)
|| signature(old) != signature(new)
|| shape(old) != shape(new)
|| code_spans(old) != code_spans(new)
|| addresses(old) != addresses(new)
|| exact_tokens(old) != exact_tokens(new)
{
return EditKind::Substantive;
}
if skeleton(old) == skeleton(new) {
return EditKind::Typographic;
}
let (a, b) = (words(old), words(new));
if skeleton(old) == skeleton(new) {
// Accents, case, apostrophes; words glued or split, never re-cut.
return if only_glued_or_split(&a, &b) { EditKind::Typographic } else { EditKind::Substantive };
}
if a.len() != b.len() {
return EditKind::Substantive;
}
@@ -346,12 +362,36 @@ mod tests {
}
#[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);
fn accents_case_apostrophes_quotes() {
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
assert_eq!(classify("le \"Pacte\" de la monnaie", "le « Pacte » de la Monnaie"), EditKind::Typographic);
assert_eq!(classify("same", "same"), EditKind::Unchanged);
}
#[test]
fn punctuation_and_emphasis_go_through_a_proposal() {
// Sixth independent review: free punctuation can always carry a meaning.
for (old, new) in [
("Je m'engage à vérifier.", "Je m'engage à vérifier !"),
("Je m'engage à vérifier.", "Je m'engage à vérifier ?"),
("un **être humain**", "un être humain"),
("tous les **5 jours**", "tous les -**5 jours**"),
("Seuil = C + B^W", "Seuil = C + B^-W"),
("max(0, T-C)", "max(0, TC)"),
("(Y = ceil(N))", "(Y = -ceil(N))"),
("- avoir un compte\n- être joignable", "- avoir un compte, être joignable"),
("(sauf le cas a) et le cas b", "(sauf le cas a et le cas b)"),
("libre - le texte", "libre — le texte"),
("- une liste", "* une liste"),
("clé DsEx1pS33abc", "clé dSeX1Ps33abc"),
("les avoir vérifiés", "le savoir vérifiés"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
assert_eq!(classify("toute latransmission", "toute la transmission"), EditKind::Typographic);
assert_eq!(classify("la trans mission", "la transmission"), EditKind::Typographic);
}
#[test]
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
// Same letters, different meaning (independent reviews, 2026-10-21).
@@ -369,13 +409,10 @@ mod tests {
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
// Punctuation around figures, list markers, dashes between words stay typographic.
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic);
assert_eq!(classify("50 voix pour", "50 voix Pour"), EditKind::Typographic);
// A bracket always counts, even as a list marker: conservative.
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive);
assert_eq!(classify("1. premier point", "1. premier point."), EditKind::Typographic);
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
assert_eq!(classify("1. premier point", "1. Premier point"), EditKind::Typographic);
}
#[test]