v3 : troisième relecture — une coquille n'inverse plus un sens ; retour à la version précédente
- Mot mal tapé = un seul glissement (une lettre ajoutée, retirée, ou deux voisines inversées), jamais au début du mot (préfixes : valide/invalide, limitée/illimitée), jamais une lettre remplacée (deux/dix, majorité/minorité), jamais sur un mot de négation ou de quantité, mots de 4 lettres au moins. - Parenthèses, crochets, « / » et tout ce qui est entre accents graves comptent comme le sens (ceil(N^(1/5)) → ceil(N^1/5) n'est plus une coquille). - Caractères invisibles (inversion du sens d'écriture, largeur nulle, BOM) refusés dans les textes, titres, traductions et motifs de signalement. - Déploiement : la version en service est gardée (image « previous ») ; un témoin marque le déploiement en cours ; « retablir » relance la maquette au premier déploiement, la version précédente ensuite ; la liste des conteneurs retirés n'est plus vidée par un pipeline annulé. - Tests : 59 Rust (dont 14 scénarios d'API, avec deux → dix sur E2 en vigueur et l'inversion bidi), e2e 12 / 12. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+245
-17
@@ -4,9 +4,11 @@
|
||||
//! tells two kinds of corrections apart:
|
||||
//!
|
||||
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
|
||||
//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and
|
||||
//! never a figure, a sign, a separator inside a number or a relation
|
||||
//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff.
|
||||
//! 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.
|
||||
//! - **substantive**: anything else. It opens a new revision and every vote
|
||||
//! cast on the previous one waits for its author to reconfirm it.
|
||||
//!
|
||||
@@ -32,8 +34,154 @@ 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.
|
||||
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",
|
||||
];
|
||||
|
||||
const MAX_TYPO_WORDS: usize = 2;
|
||||
const MAX_TYPO_DISTANCE: usize = 2;
|
||||
/// Shorter words have too many neighbours one letter away ("can" / "cant").
|
||||
const MIN_TYPO_WORD: usize = 4;
|
||||
|
||||
/// Lowercase, strip diacritics.
|
||||
fn fold(s: &str) -> String {
|
||||
@@ -49,14 +197,56 @@ fn skeleton(s: &str) -> String {
|
||||
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
}
|
||||
|
||||
/// Symbols that carry a value or a relation, wherever they stand.
|
||||
const RELATIONS: &[char] =
|
||||
&['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√'];
|
||||
/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10").
|
||||
const SIGNS: &[char] = &['-', '−', '–', '/'];
|
||||
/// 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}', '\'', '’'];
|
||||
|
||||
/// Invisible format characters (zero-width, bidirectional overrides and
|
||||
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
|
||||
/// Source"). A text of reference never needs them; they are refused.
|
||||
pub fn has_hidden_controls(s: &str) -> bool {
|
||||
s.chars().any(
|
||||
|c| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0x061C | 0xFEFF),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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é").
|
||||
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] {
|
||||
return false;
|
||||
}
|
||||
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| {
|
||||
let mut l = long.clone();
|
||||
l.remove(i);
|
||||
&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]]
|
||||
}
|
||||
_ => 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
|
||||
@@ -87,7 +277,7 @@ pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
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) {
|
||||
if figures(old) != figures(new) || code_spans(old) != code_spans(new) {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
@@ -104,12 +294,10 @@ pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
}
|
||||
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;
|
||||
|| x.chars().any(char::is_numeric)
|
||||
|| y.chars().any(char::is_numeric)
|
||||
|| [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str()))
|
||||
|| !one_slip(x, y);
|
||||
if touches_meaning {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
@@ -251,14 +439,54 @@ mod tests {
|
||||
// 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("1. premier point", "1) premier point"), 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_turned_around_is_never_a_typo() {
|
||||
// Third independent review, 2026-10-21: one or two letters that invert a meaning.
|
||||
for (old, new) in [
|
||||
("ces deux présents engagements", "ces dix présents engagements"),
|
||||
("Nouveau compte valide si", "Nouveau compte invalide si"),
|
||||
("100 certifications possibles", "100 certifications impossibles"),
|
||||
("à la majorité des voix", "à la minorité des voix"),
|
||||
("un minimum de garanties", "un maximum de garanties"),
|
||||
("les membres inclus", "les membres exclus"),
|
||||
("une personne majeure", "une personne mineure"),
|
||||
("une durée limitée", "une durée illimitée"),
|
||||
("un mandat révocable", "un mandat irrévocable"),
|
||||
("des comptes indépendants", "des comptes dépendants"),
|
||||
("pendant six mois", "pendant dix mois"),
|
||||
("sept certifications", "cent certifications"),
|
||||
("seize jours", "treize jours"),
|
||||
("five members", "nine members"),
|
||||
("you can certify", "you cant certify"),
|
||||
("Y = ceil(N^(1/5))", "Y = ceil(N^1/5)"),
|
||||
("M(n)/N(n)", "M(n)N(n)"),
|
||||
("DU(n) - c", "DU(n) / c"),
|
||||
("la formule `a - b`", "la formule `a * b`"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_controls_are_spotted() {
|
||||
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
|
||||
assert!(has_hidden_controls("5\u{200b}0"));
|
||||
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspelt_words() {
|
||||
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic);
|
||||
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.
|
||||
assert_eq!(classify("une certifixation", "une certification"), EditKind::Substantive);
|
||||
// « personne » is also a negation (ne … personne): conservative.
|
||||
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user