v3 : moteur des objections « redit » / « contredit » et du circuit des coquilles

- ld-engine/objection.rs : la dernière arrivée (jamais une version en vigueur)
  n'est plus votable ; un cycle à la fois (réponse de l'auteur, puis lever ou
  réitérer) ; levée seule 3 jours après la réponse ; une révision de l'autre
  côté répond aussi ; l'administrateur lève, ne réitère jamais.
- ld-engine/correction.rs : qui applique une coquille et quand (celui qui
  répond de la version l'applique, les autres attendent 3 jours au plus),
  l'auteur garde la main hors vigueur, deux coquilles en attente : la seconde
  devient caduque.
- trustin-access : answers_typo, lifts_objection, reiterates_objection,
  reads_message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 01:13:52 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit 70dbd712c8
4 changed files with 737 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
//! Who applies a typo fix, and when (Yvv, 2026-10-30, L13). What a typo is
//! does not change: `edit` (letters and spaces, judged against the voted text).
//!
//! « Corriger coquille → ping auteur pour valid → idem 3j ça passe auto, mais
//! auteur a toujours la main tant que c'est pas le texte en vote. Sinon seule
//! une coquille validée corrige le texte en vote. »
//!
//! - The person who **answers for the version** — its author; for a text
//! without author (a starting text), an administrator of trustinWallet
//! (`trustin_access::answers_typo`) — applies a fix at once (`route`).
//! - Anyone else's fix **waits**: the one who answers is told by a message,
//! validates or refuses, and **without an answer within 3 days it applies by
//! itself** (`tick`). On a text in force, only a validated fix (or one passed
//! at the deadline) corrects it.
//! - **The author keeps the hand while the version is not in force**: a fix
//! applied (validated or passed at the deadline) may still be refused — the
//! text it was written over comes back —, and a refused one validated after
//! all. Once in force, an answer is final.
//! - A fix is written only over the text it was written over (`fits`): two
//! fixes waiting on one text are each judged against the voted text; once one
//! applies, the other no longer fits and is **stale** (« recharge, puis
//! recommence »). Slips never add up.
//! - Translations stay out of this: editorial, without validation.
use serde::{Deserialize, Serialize};
/// Days the one who answers for the version has, before a fix applies by itself.
pub const ANSWER_DAYS: i64 = 3;
/// Where a fix goes when it is written.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
/// Applied at once: the writer answers for the version.
Apply,
/// Waits for the one who answers for the version, or for the deadline.
Await,
}
pub fn route(writer_answers: bool) -> Route {
if writer_answers { Route::Apply } else { Route::Await }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TypoStatus {
/// Written, waiting for an answer or the deadline.
Pending,
/// In the text.
Applied,
/// Not in the text: refused (or taken back by the author before the text was in force).
Refused,
/// The text changed before it could apply: written over a text that is no longer there.
Stale,
}
impl TypoStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Applied => "applied",
Self::Refused => "refused",
Self::Stale => "stale",
}
}
pub fn parse(raw: &str) -> Option<Self> {
[Self::Pending, Self::Applied, Self::Refused, Self::Stale].into_iter().find(|s| s.as_str() == raw)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Validate,
Refuse,
}
/// What a step does to the text of the version.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextChange {
/// Write the fix (only where `fits`).
Write,
/// Put back the text the fix was written over (only where the fix is still the text).
Restore,
Keep,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Settle {
pub status: TypoStatus,
pub text: TextChange,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TypoRefusal {
/// Already answered (or stale): nothing left to decide.
Settled,
}
/// An answer of the one who answers for the version. `in_force`: the version is in force now.
pub fn answer(status: TypoStatus, answer: Answer, in_force: bool) -> Result<Settle, TypoRefusal> {
let to = |status, text| Ok(Settle { status, text });
match (status, answer) {
(TypoStatus::Pending, Answer::Validate) => to(TypoStatus::Applied, TextChange::Write),
(TypoStatus::Pending, Answer::Refuse) => to(TypoStatus::Refused, TextChange::Keep),
// The author keeps the hand while the version is not in force.
(TypoStatus::Applied, Answer::Refuse) if !in_force => to(TypoStatus::Refused, TextChange::Restore),
(TypoStatus::Refused, Answer::Validate) if !in_force => to(TypoStatus::Applied, TextChange::Write),
_ => Err(TypoRefusal::Settled),
}
}
/// The clock (sortable stamps): a pending fix applies by itself at its deadline.
pub fn tick(status: TypoStatus, due: &str, now: &str) -> Option<Settle> {
(status == TypoStatus::Pending && due <= now)
.then_some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
}
/// A text as a fix sees it: the revision it belongs to, its title and body.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Text<'a> {
pub revision: i64,
pub title: &'a str,
pub body: &'a str,
}
/// A fix is written only over the very text it was written over: same revision
/// (no change of substance since), same title and body (no other fix since).
pub fn fits(base: Text<'_>, current: Text<'_>) -> bool {
base == current
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edit::{EditKind, classify};
#[test]
fn the_one_who_answers_applies_at_once_anyone_else_waits() {
assert_eq!(route(true), Route::Apply, "the author, or an administrator for a starting text");
assert_eq!(route(false), Route::Await);
}
#[test]
fn a_pending_fix_is_validated_or_refused() {
for in_force in [false, true] {
assert_eq!(
answer(TypoStatus::Pending, Answer::Validate, in_force),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
assert_eq!(
answer(TypoStatus::Pending, Answer::Refuse, in_force),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Keep })
);
}
}
#[test]
fn the_author_keeps_the_hand_until_the_text_is_in_force() {
// A proposal: an applied fix may be taken back, a refused one validated after all.
assert_eq!(
answer(TypoStatus::Applied, Answer::Refuse, false),
Ok(Settle { status: TypoStatus::Refused, text: TextChange::Restore })
);
assert_eq!(
answer(TypoStatus::Refused, Answer::Validate, false),
Ok(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
// In force: an answer is final.
assert_eq!(answer(TypoStatus::Applied, Answer::Refuse, true), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Validate, true), Err(TypoRefusal::Settled));
// Nothing to answer twice the same way, and never a stale fix.
assert_eq!(answer(TypoStatus::Applied, Answer::Validate, false), Err(TypoRefusal::Settled));
assert_eq!(answer(TypoStatus::Refused, Answer::Refuse, false), Err(TypoRefusal::Settled));
for a in [Answer::Validate, Answer::Refuse] {
assert_eq!(answer(TypoStatus::Stale, a, false), Err(TypoRefusal::Settled));
}
}
#[test]
fn without_an_answer_it_applies_at_the_deadline() {
let due = "2026-11-03T09:00:00Z";
assert_eq!(tick(TypoStatus::Pending, due, "2026-11-03T08:59:59Z"), None);
assert_eq!(
tick(TypoStatus::Pending, due, due),
Some(Settle { status: TypoStatus::Applied, text: TextChange::Write })
);
// A deadline passed while the server was stopped is taken at the next tick.
assert!(tick(TypoStatus::Pending, due, "2027-02-01T00:00:00Z").is_some());
for s in [TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(tick(s, due, "2027-02-01T00:00:00Z"), None, "{s:?}");
}
}
#[test]
fn two_fixes_waiting_on_one_text_the_second_goes_stale() {
let voted = Text { revision: 1, title: "Réciprocité", body: "des persones qui respectent" };
let first = Text { revision: 1, title: "Réciprocité", body: "des personnes qui respectent" };
let second = Text { revision: 1, title: "Réciprocité", body: "des persones qui repectent" };
// Each is judged against the voted text, as before: each is a typo there.
assert_eq!(classify(voted.body, first.body), EditKind::Typographic);
assert_eq!(classify(voted.body, second.body), EditKind::Typographic);
// Both were written over the voted text; the first applies.
assert!(fits(voted, voted));
// Then the text is the first fix: the second no longer fits — stale, never merged.
assert!(!fits(voted, first));
// A change of substance in between (a new revision) does the same, even back to the same words.
assert!(!fits(voted, Text { revision: 2, ..voted }));
}
#[test]
fn words_round_trip() {
for s in [TypoStatus::Pending, TypoStatus::Applied, TypoStatus::Refused, TypoStatus::Stale] {
assert_eq!(TypoStatus::parse(s.as_str()), Some(s));
}
assert_eq!(TypoStatus::parse("typo"), None);
}
}