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:
+127
-182
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user