v3 : seconde relecture — une coquille ne touche ni chiffre, ni signe, ni relation ; retour arrière du déploiement

- Correction de coquille : un chiffre, un signe, un séparateur dans un nombre ou
  une relation changés (« >= » → « <= », « + » → « - », « 182.625 » → « 182 625 »,
  « % » → « ‰ ») rendent la modification « de fond ». Testé dans le moteur et
  par l'API sur le vrai texte K1.
- Déploiement : les conteneurs retirés de la racine qui tournaient sont notés ;
  si la suite échoue, l'étape « retablir » arrête le produit et les relance.
  Le produit est recréé à chaque déploiement (--force-recreate).
- Cookies préfixés en https : __Host-ld_session, __Secure-ld_oauth (pas de
  cookie planté par un site voisin sous librodrome.org).
- syft épinglé (v1.55.0) dans les deux pipelines : ce conteneur a le socket Docker.
- Docs : limites connues assumées, groupe d'essai à deux voix, error.identity_kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 04:54:30 +02:00
co-authored by Claude Opus 5.5
parent 94907afb20
commit ac9c904e7e
11 changed files with 152 additions and 28 deletions
+51 -11
View File
@@ -4,8 +4,9 @@
//! 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.
//! 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.
//! - **substantive**: anything else. It opens a new revision and every vote
//! cast on the previous one waits for its author to reconfirm it.
//!
@@ -48,9 +49,32 @@ fn skeleton(s: &str) -> String {
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
}
/// The numbers of a text, as written: "50" and "5,0", "1 5" and "15" differ.
fn numbers(s: &str) -> Vec<&str> {
s.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).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] = &['-', '−', '–', '/'];
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625").
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’'];
/// 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).
@@ -62,8 +86,8 @@ pub fn classify(old: &str, new: &str) -> EditKind {
if old == new {
return EditKind::Unchanged;
}
// A number is never a typo: punctuation or a space inside it changes its value.
if numbers(old) != numbers(new) {
// A figure, a sign or a relation is never a typo: it changes what the text says.
if figures(old) != figures(new) {
return EditKind::Substantive;
}
if skeleton(old) == skeleton(new) {
@@ -208,11 +232,27 @@ mod tests {
}
#[test]
fn a_number_is_never_a_typo() {
// Same letters and digits, different values (independent review, 2026-10-21).
assert_eq!(classify("au moins 50 voix", "au moins 5,0 voix"), EditKind::Substantive);
assert_eq!(classify("1 5 jours", "15 jours"), EditKind::Substantive);
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
// Same letters, different meaning (independent reviews, 2026-10-21).
for (old, new) in [
("au moins 50 voix", "au moins 5,0 voix"),
("1 5 jours", "15 jours"),
("10 000 membres", "10,000 membres"),
("un écart de -5", "un écart de 5"),
("1/5 des voix", "1.5 des voix"),
("50 % des voix", "50 ‰ des voix"),
("avoir >= 5 certifications", "avoir <= 5 certifications"),
("DU(n) + c² × M(n)", "DU(n) - c² × M(n)"),
("182.625 jours", "182 625 jours"),
("c²", "c2"),
] {
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("1. premier point", "1) premier point"), EditKind::Typographic);
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
}
#[test]