Lot T2 : les trois phrases de F1.1, F1.2 et N1.1 écrites justes (la part du cran, la plus haute au quorum, baisse quand la participation monte ; le quorum se fixe tant que l'article ne garde la trace d'aucun vote ; le quorum n'est pas un nombre minimal de votants), dans le seed et par un amendement gardé qui suit celui de M6b

- seed_amend : 2026-11-03-f1-1-phrases, -f1-2-phrases, -n1-1-phrases ; la garde admet la révision écrite par la chaîne des amendements suivis (earlier), dans leur ordre, le dernier étant celui de M6b (F1.1 en révision 2, F1.2 et N1.1 en révision 3) ; les amendements de M6b dépassés ; le commentaire dit les deux votes qui ne laissent pas de trace (vote direct retiré dans le groupe d'essai, votes simulés après « supprime »)
- tests : base faite comme la production après M6b (fixture seed-quorum-m6b.json), appliqué une fois, refus votes et ballots, second démarrage sans rejeu, base née le 02/11 ; les phrases et leur preuve par le moteur ; le quorum de nouveau réglable après un vote direct retiré et après « supprime »
- moteur : la part exigée au quorum en production (W 5 707) : 64,4 · 73,5 · 77,4 · 85,3 · 91,9 % (la relecture disait 74 au cran 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-03 10:12:12 +01:00
co-authored by Claude Opus 5.5
parent f6c110a60c
commit a32912373d
8 changed files with 811 additions and 92 deletions
+189 -31
View File
@@ -10,9 +10,15 @@
//! Since 2026-11-03 (lot T), a text may be amended twice: F1.1, F1.2 and N1.1 say
//! the quorum by level (ADR 0013) instead of the floor C, and F1.2 and N1.1 had
//! been rewritten by the amendments of 02/11. An amendment that `follows` an
//! earlier one replaces the text that one wrote: it admits, besides the seed's
//! own revision 1, the revision 2 whose history is « initial » then « amendment »
//! and on which that earlier amendment, and it alone, was applied — nothing else.
//! earlier one replaces the text that one wrote. The same day (lot T2, after the
//! review of M6b), three phrases of those texts are written true by amendments that
//! follow lot T's: F1.1 is then at its revision 2, F1.2 and N1.1 at their revision 3.
//! So an amendment admits, besides the seed's own revision 1, the revision written
//! by the amendments it follows (`earlier`: the one it follows, the one that one
//! follows…), applied in that order, the last being the one it follows, and nothing
//! else between them: a revision n + 1 whose history is « initial » then n times
//! « amendment », on which those n amendments, and they alone, were applied
//! (`seed_amendments`), its text the one the amendment replaces (fingerprint).
//! The earlier one is then `superseded_by` it: the seed no longer gives its text,
//! so it decides nothing more, without an error (the decisions already taken stay).
//!
@@ -23,11 +29,16 @@
//! nobody has touched the article**:
//!
//! - its version in force is the seed's own, a *seed a minima* (never voted
//! into force, never confirmed), at revision 1 — or 2, after the amendment it
//! follows;
//! - nobody ever voted on it (a vote, or a line of a ballot of the buffer, pending
//! or proven, whatever became of it: ADR 0012), corrected it, edited one of its
//! translations or named it in a contradiction signal (an objection);
//! into force, never confirmed), at revision 1 — or further, written by the
//! amendments it follows;
//! - no trace of a vote is on it — a vote that counts (`votes`: a vote of the server,
//! proven by the chain, or simulated), or a line of a ballot of the buffer, pending
//! or proven, whatever became of it (ADR 0012) — and nobody corrected it, edited
//! one of its translations or named it in a contradiction signal (an objection).
//! Two votes leave no trace, and so do not keep the text: in a group without the
//! buffer (the test group), a vote of the server withdrawn (`votes` keeps the last
//! choice only); and a simulated vote once the act « supprime » took it away
//! (`simulation::discard`);
//! - its text is exactly the one the amendment replaces (fingerprint).
//!
//! Each document is decided once, at the first start that knows the
@@ -146,7 +157,7 @@ pub const AMENDMENTS: &[Amendment] = &[
to: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa",
reason: "proposition de départ réécrite : F1.1, le quorum par cran",
follows: None,
superseded_by: None,
superseded_by: Some("2026-11-03-f1-1-phrases"),
},
Amendment {
id: "2026-11-03-f1-2-quorum",
@@ -157,7 +168,7 @@ pub const AMENDMENTS: &[Amendment] = &[
to: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9",
reason: "proposition de départ réécrite : F1.2, le quorum par cran",
follows: Some("2026-11-02-f1-2-regles"),
superseded_by: None,
superseded_by: Some("2026-11-03-f1-2-phrases"),
},
Amendment {
id: "2026-11-03-n1-1-quorum",
@@ -168,6 +179,43 @@ pub const AMENDMENTS: &[Amendment] = &[
to: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48",
reason: "proposition de départ réécrite : N1.1, le quorum par cran",
follows: Some("2026-11-02-n1-1-regles"),
superseded_by: Some("2026-11-03-n1-1-phrases"),
},
// The three phrases the review of M6b left inexact (lot T2, 2026-11-03): the share of the
// level is highest at the quorum (F1.1); the author sets the quorum while the article keeps
// no trace of a vote (F1.2); the quorum is no minimum of voters (N1.1). Each replaces the
// text lot T wrote: F1.1 at its revision 2, F1.2 and N1.1 at their revision 3.
Amendment {
id: "2026-11-03-f1-1-phrases",
doc: "acte-engagement-certification",
unit: "F1.1",
version: Version::Open("1.0.0"),
from: "ca6d3c74a3e6de22ebf55eb0cdd6465dbe22d69cd86aae89ac335b960671b4fa",
to: "8375b78af825e5ce5f978442519c078010ac9ad87f5cbc9358b407aa3668c5e5",
reason: "proposition de départ réécrite : F1.1, la part du cran au-delà du quorum",
follows: Some("2026-11-03-f1-1-quorum"),
superseded_by: None,
},
Amendment {
id: "2026-11-03-f1-2-phrases",
doc: "acte-engagement-certification",
unit: "F1.2",
version: Version::Open("1.0.0"),
from: "66a9bc125c78120a76a81e8b53c427f4d2d5895581cb4ed4ade1aaab740944a9",
to: "96caf353b33805f2894593d72c5b70fce2e751039cd7f4651d8842c986729a14",
reason: "proposition de départ réécrite : F1.2, le quorum fixé tant qu'aucun vote n'a laissé de trace",
follows: Some("2026-11-03-f1-2-quorum"),
superseded_by: None,
},
Amendment {
id: "2026-11-03-n1-1-phrases",
doc: "acte-engagement-certification",
unit: "N1.1",
version: Version::Open("1.0.0"),
from: "1bdd26a63cadb4529f5347863ced83a823439f53247b6aea8e703301bab3de48",
to: "7fbedcb8750c208f8790c4f87dc21d8bd1096a27a1a0044af6b9b8f2057f2aa8",
reason: "proposition de départ réécrite : N1.1, le quorum n'est pas un minimum de votants",
follows: Some("2026-11-03-n1-1-quorum"),
superseded_by: None,
},
];
@@ -211,8 +259,24 @@ pub enum Verdict {
Refuse(&'static str),
}
/// The rule, apart from the database: any trace of a person keeps the text.
pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
/// The amendments whose text `a` replaces, oldest first: the one it follows, the one that
/// one follows, and so on, through `list`. Empty when it follows none.
pub fn earlier(list: &[Amendment], a: &Amendment) -> Vec<&'static str> {
let mut out = Vec::new();
let mut at = a.follows;
while let Some(id) = at {
if out.contains(&id) {
break;
}
out.insert(0, id);
at = list.iter().find(|e| e.id == id).and_then(|e| e.follows);
}
out
}
/// The rule, apart from the database: any trace of a person keeps the text. `earlier`:
/// the amendments `a` follows, oldest first (`earlier`).
pub fn verdict(facts: Option<&Facts>, a: &Amendment, earlier: &[&str]) -> Verdict {
let Some(f) = facts else {
return Verdict::Refuse(match a.version {
Version::InForce => "no_text_in_force",
@@ -222,9 +286,16 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
if f.fingerprint == a.to {
return Verdict::Present;
}
// The text the amendment follows: written by it, and by it alone, at revision 2.
let after_earlier = a.follows.is_some_and(|id| f.revision == 2 && f.amendments == [id]);
let history: &[&str] = if after_earlier { &["initial", "amendment"] } else { &["initial"] };
// The text the amendments it follows wrote: by them alone, in their order, the last
// being the one it follows (a base born later holds the last ones only), one revision
// each over the seed's.
let n = f.amendments.len();
let after_earlier = n > 0
&& f.revision == 1 + n as i64
&& earlier.len() >= n
&& earlier[earlier.len() - n..].iter().zip(&f.amendments).all(|(e, applied)| e == applied);
let history: Vec<&str> =
std::iter::once("initial").chain(std::iter::repeat_n("amendment", if after_earlier { n } else { 0 })).collect();
let refusal = if f.origin != "seed" {
"not_a_seed"
} else if f.entered {
@@ -264,13 +335,16 @@ pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Amendment]) -> a
let mut out = Vec::new();
for a in list {
let Some((seed, target)) = target_of(seeds, a) else { continue };
let before = earlier(list, a);
let docs: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
.bind(a.doc)
.fetch_all(&state.db)
.await?;
for (doc_id, group, publish_path) in docs {
let Some(v) = decide(state, a, seed, target, &doc_id, publish_path.is_some()).await? else { continue };
let Some(v) = decide(state, a, &before, seed, target, &doc_id, publish_path.is_some()).await? else {
continue;
};
match v {
Verdict::Apply => tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment applied"),
Verdict::Present => {
@@ -406,6 +480,7 @@ pub(crate) async fn facts(
async fn decide(
state: &AppState,
a: &Amendment,
earlier: &[&str],
seed: &SeedDoc,
target: &SeedVersion,
doc_id: &str,
@@ -450,7 +525,7 @@ async fn decide(
Some((id, status)) => facts(&mut tx, id, status).await?,
None => None,
};
let mut v = verdict(found.as_ref(), a);
let mut v = verdict(found.as_ref(), a, earlier);
let now = util::now();
let judged = found.as_ref().map_or(1, |f| f.revision);
if let (Verdict::Apply, Some((pid, status))) = (v, &examined) {
@@ -564,13 +639,13 @@ mod tests {
#[test]
fn an_untouched_starting_text_is_replaced() {
assert_eq!(verdict(Some(&intact()), &A), Verdict::Apply);
assert_eq!(verdict(Some(&intact()), &A, &[]), Verdict::Apply);
}
#[test]
fn the_new_text_already_there_is_left_as_it_is() {
let f = Facts { fingerprint: "new".into(), revision: 3, votes: 12, ..intact() };
assert_eq!(verdict(Some(&f), &A), Verdict::Present);
assert_eq!(verdict(Some(&f), &A, &[]), Verdict::Present);
}
#[test]
@@ -590,14 +665,14 @@ mod tests {
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
];
for (facts, reason) in cases {
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
assert_eq!(verdict(facts.as_ref(), &A, &[]), Verdict::Refuse(reason), "{facts:?}");
}
// An open proposal of the seed that is no longer there (withdrawn, never seeded).
let open = Amendment { version: Version::Open("1.0.0"), ..A };
assert_eq!(verdict(None, &open), Verdict::Refuse("no_open_version"));
assert_eq!(verdict(Some(&intact()), &open), Verdict::Apply);
assert_eq!(verdict(None, &open, &[]), Verdict::Refuse("no_open_version"));
assert_eq!(verdict(Some(&intact()), &open, &[]), Verdict::Apply);
// A ballot of the buffer names it, pending or proven, whatever became of it (ADR 0012).
assert_eq!(verdict(Some(&Facts { ballots: 1, ..intact() }), &A), Verdict::Refuse("ballots"));
assert_eq!(verdict(Some(&Facts { ballots: 1, ..intact() }), &A, &[]), Verdict::Refuse("ballots"));
}
/// The text an earlier amendment wrote, as production holds F1.2 and N1.1 since M5.
@@ -615,23 +690,23 @@ mod tests {
// Lot T (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2); the quorum
// texts replace them, after that amendment only.
let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A };
assert_eq!(verdict(Some(&amended_by("m5")), &second), Verdict::Apply);
assert_eq!(verdict(Some(&amended_by("m5")), &second, &["m5"]), Verdict::Apply);
// A base born with the text of M5: its revision 1, as the seed made it.
assert_eq!(verdict(Some(&intact()), &second), Verdict::Apply);
assert_eq!(verdict(Some(&intact()), &second, &["m5"]), Verdict::Apply);
// Not the amendment it follows, or none: a revision 2 of something else.
assert_eq!(verdict(Some(&amended_by("other")), &second), Verdict::Refuse("revised"));
assert_eq!(verdict(Some(&amended_by("other")), &second, &["m5"]), Verdict::Refuse("revised"));
let typo = Facts { revisions: vec!["initial".into(), "typo".into()], amendments: vec![], ..amended_by("m5") };
assert_eq!(verdict(Some(&typo), &second), Verdict::Refuse("revised"));
assert_eq!(verdict(Some(&typo), &second, &["m5"]), Verdict::Refuse("revised"));
// A history that is not « initial then amendment ».
let odd = Facts { revisions: vec!["initial".into(), "substantive".into()], ..amended_by("m5") };
assert_eq!(verdict(Some(&odd), &second), Verdict::Refuse("corrected"));
assert_eq!(verdict(Some(&odd), &second, &["m5"]), Verdict::Refuse("corrected"));
// Further on, nothing is admitted.
let third = Facts {
revision: 3,
revisions: vec!["initial".into(), "amendment".into(), "typo".into()],
..amended_by("m5")
};
assert_eq!(verdict(Some(&third), &second), Verdict::Refuse("revised"));
assert_eq!(verdict(Some(&third), &second, &["m5"]), Verdict::Refuse("revised"));
// The same traces of a person keep it.
for (f, why) in [
(Facts { votes: 1, ..amended_by("m5") }, "votes"),
@@ -641,9 +716,92 @@ mod tests {
(Facts { signals: 1, ..amended_by("m5") }, "signalled"),
(Facts { fingerprint: "other".into(), ..amended_by("m5") }, "text_differs"),
] {
assert_eq!(verdict(Some(&f), &second), Verdict::Refuse(why), "{f:?}");
assert_eq!(verdict(Some(&f), &second, &["m5"]), Verdict::Refuse(why), "{f:?}");
}
// An amendment that follows none never admits a revision 2.
assert_eq!(verdict(Some(&amended_by("m5")), &A), Verdict::Refuse("revised"));
assert_eq!(verdict(Some(&amended_by("m5")), &A, &[]), Verdict::Refuse("revised"));
}
/// The text written by `applied`, in order, one revision each over the seed's.
fn written_by(applied: &[&str]) -> Facts {
let mut revisions = vec!["initial".to_owned()];
revisions.extend(applied.iter().map(|_| "amendment".to_owned()));
Facts {
revision: 1 + applied.len() as i64,
revisions,
amendments: applied.iter().map(|a| (*a).to_owned()).collect(),
..intact()
}
}
#[test]
fn a_third_amendment_admits_the_texts_the_two_before_wrote_and_nothing_else() {
// Lot T2 (2026-11-03): F1.2 and N1.1 were amended by M5 (revision 2), then by M6b
// (revision 3); the true phrases replace the text of M6b, after it only.
let third = Amendment { version: Version::Open("1.0.0"), follows: Some("t"), ..A };
let before: &[&str] = &["m5", "t"];
// Production: M5 then M6b, each its revision.
assert_eq!(verdict(Some(&written_by(&["m5", "t"])), &third, before), Verdict::Apply);
// A base born with the text of M5 (02/11): M6b alone wrote its revision 2.
assert_eq!(verdict(Some(&written_by(&["t"])), &third, before), Verdict::Apply);
// A base born with the text of M6b: its revision 1.
assert_eq!(verdict(Some(&intact()), &third, before), Verdict::Apply);
// The last revision is not M6b's, or not M6b's alone, or out of order: refused.
for applied in [&["m5"][..], &["t", "m5"], &["m5", "other"], &["other", "t"], &["m5", "t", "t"]] {
assert_eq!(verdict(Some(&written_by(applied)), &third, before), Verdict::Refuse("revised"), "{applied:?}");
}
// A revision that no amendment wrote: a typo after M6b, a revision count off by one.
let typo = Facts {
revision: 4,
revisions: vec!["initial".into(), "amendment".into(), "amendment".into(), "typo".into()],
..written_by(&["m5", "t"])
};
assert_eq!(verdict(Some(&typo), &third, before), Verdict::Refuse("revised"));
let short = Facts { revision: 2, ..written_by(&["m5", "t"]) };
assert_eq!(verdict(Some(&short), &third, before), Verdict::Refuse("revised"));
// The history is not « initial » then twice « amendment ».
let odd = Facts {
revisions: vec!["initial".into(), "amendment".into(), "substantive".into()],
..written_by(&["m5", "t"])
};
assert_eq!(verdict(Some(&odd), &third, before), Verdict::Refuse("corrected"));
// Any trace of a person keeps the text, with its reason.
for (f, why) in [
(Facts { votes: 1, ..written_by(&["m5", "t"]) }, "votes"),
(Facts { ballots: 1, ..written_by(&["m5", "t"]) }, "ballots"),
(Facts { typos: 1, ..written_by(&["m5", "t"]) }, "corrected"),
(Facts { member_translations: 1, ..written_by(&["m5", "t"]) }, "translation_edited"),
(Facts { signals: 1, ..written_by(&["m5", "t"]) }, "signalled"),
(Facts { fingerprint: "other".into(), ..written_by(&["m5", "t"]) }, "text_differs"),
(Facts { entered: true, ..written_by(&["m5", "t"]) }, "voted_into_force"),
] {
assert_eq!(verdict(Some(&f), &third, before), Verdict::Refuse(why), "{f:?}");
}
// Lot T's own amendment, which follows M5 alone, never admits a revision 3.
let second = Amendment { version: Version::Open("1.0.0"), follows: Some("m5"), ..A };
assert_eq!(verdict(Some(&written_by(&["m5", "t"])), &second, &["m5"]), Verdict::Refuse("revised"));
}
#[test]
fn the_amendments_followed_are_read_through_the_list_oldest_first() {
let by_id = |id: &str| AMENDMENTS.iter().find(|a| a.id == id).expect("amendment");
assert_eq!(
earlier(AMENDMENTS, by_id("2026-11-03-f1-2-phrases")),
["2026-11-02-f1-2-regles", "2026-11-03-f1-2-quorum"]
);
assert_eq!(
earlier(AMENDMENTS, by_id("2026-11-03-n1-1-phrases")),
["2026-11-02-n1-1-regles", "2026-11-03-n1-1-quorum"]
);
assert_eq!(earlier(AMENDMENTS, by_id("2026-11-03-f1-1-phrases")), ["2026-11-03-f1-1-quorum"]);
assert!(earlier(AMENDMENTS, by_id("2026-10-30-i1-raison-d-etre")).is_empty());
// Each one followed wrote the text the next replaces, and is superseded by it.
for a in AMENDMENTS.iter().filter(|a| a.follows.is_some()) {
let e = by_id(a.follows.expect("follows"));
assert_eq!((e.unit, e.doc, e.to, e.superseded_by), (a.unit, a.doc, a.from, Some(a.id)), "{}", a.id);
}
// A list that loops never hangs.
let looped = [Amendment { id: "x", follows: Some("y"), ..A }, Amendment { id: "y", follows: Some("x"), ..A }];
assert_eq!(earlier(&looped, &looped[0]), ["x", "y"]);
}
}