Compare commits

...
15 Commits
Author SHA1 Message Date
YvvandClaude Opus 5.5 84f015ad58 Fusion du lot T2 : F1.1, F1.2 et N1.1 écrites justes (relecture de M6b) par un amendement gardé qui suit celui de M6b ; l'interface dit ce que fait la formule ; l'espace perdu de « dont N simulé » et dix autres ; fichier de relecture d'Yvv
ci/woodpecker/push/app Pipeline was successful
ci/woodpecker/push/maquette Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:22:38 +01:00
YvvandClaude Opus 5.5 770672caa3 Lot T2 : boucle complète verte sur f27ee08 (fmt, clippy, 301 tests Rust, check 0 / 0, 69 tests unitaires, build, 40 e2e) ; ligne au journal de CONTROLES.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:21:29 +01:00
YvvandClaude Opus 5.5 f27ee088f6 Lot T2 : le fichier de relecture d'Yvv (les trois phrases avant et après, leurs chiffres par le moteur, la garde, l'interface, deux points à trancher), ADR 0013 et CLAUDE.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:16:57 +01:00
YvvandClaude Opus 5.5 dc0c477f88 Lot T2 : l'interface dit ce que fait la formule (inertia.explain, formula.reading : la part demandée, la plus haute au quorum, baisse quand la participation monte, jamais « presque l'unanimité ») ; quorum.error.voted « ne change pas », plus « ne change plus » ; fr, en, es
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:13:32 +01:00
YvvandClaude Opus 5.5 a32912373d 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>
2026-11-03 10:12:12 +01:00
YvvandClaude Opus 5.5 f6c110a60c Lot T2 : l'espace perdu en tête d'un bloc Svelte (« 0 contredont 1 simulé ») ; le vote déplié rendu par svelte/server, une garde sur tous les composants (dix autres espaces collés : détails d'une version, menu) ; C34 lit la ligne entière
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:15 +01:00
YvvandClaude Opus 5.5 6a6bb0153b M7 exécuté (b2e597b en ligne, pipeline 39) : tableau et HANDOFF ; T2 lancé à 09 h 45, U2 ensuite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:45:57 +01:00
YvvandClaude Opus 5.5 3956a3ee83 Acte M7 : copies de l'intégrateur retirées (09 h 45)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:45:10 +01:00
YvvandClaude Opus 5.5 a555d4a71e Acte M7 : exécuté et vérifié en production (pipeline 39, b2e597b6), interface servie vue de l'extérieur, base identique ; copies retirées
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:44:00 +01:00
YvvandClaude Opus 5.5 82fc531d24 Tableau : M7 figé (b2e597b : ok, 09 h 28), PRÊT M7 à orch (09 h 29) ; liste d'attente de la relecture de M7 (lot U2 proposé)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:27:56 +01:00
YvvandClaude Opus 5.5 2731a91d8d Acte M7 figé sur b2e597b : relecture « b2e597b : ok » après correction du dossier, inscrite à 09 h 28 ; prémisse du témoin deploying
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:27:31 +01:00
YvvandClaude Opus 5.5 0953237f54 Acte M7 : relecture « b2e597b : à reprendre » (le dossier, pas le code) ; corrigé : l'administrateur règle le quorum des 33 articles du seed après M7 (geste d'écriture, décision d'Yvv du 02/11), captures de l'administrateur ; captures 4 et 5c reprises ; mineurs en liste d'attente
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:24:51 +01:00
YvvandClaude Opus 5.5 4c548016f9 Tableau : M7 en préparation (boucle, image, répétition, recette écran), relecture lancée à 09 h 05
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:06:23 +01:00
YvvandClaude Opus 5.5 d76d40b465 Acte M7 : relecture indépendante lancée à 09 h 05
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:05:50 +01:00
YvvandClaude Opus 5.5 e1c7f890d4 Acte M7 (lot U, le lecteur) en préparation sur b2e597b : répétition sur une copie de la production de 08 h 49 (0 écriture), recette écran de la revue d'Yvv du 02/11 (avant / après, bureau et téléphone), boucle et image ; tableau : U fusionné
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:05:15 +01:00
26 changed files with 1489 additions and 129 deletions
+3 -1
View File
@@ -70,7 +70,9 @@ crates/
# titre, ajout gardé) · seed_proposals.rs (Propositions seed, publication suspendue) ·
# quorum.rs (quorums par cran, la route, « un vote existe », l'amendement) ·
# fixtures/seed-production-2026-11-02/ (le seed que la production fait tourner :
# I1/E1/E2 en vigueur, pour rejouer les amendements sur une base comme elle)
# I1/E1/E2 en vigueur, pour rejouer les amendements sur une base comme elle) ·
# seed_quorum_texts.rs (lots T et T2 : F1.1, F1.2, N1.1 ; fixtures/seed-quorum-m6b.json,
# leurs textes depuis M6b, pour rejouer T2 sur une base comme la production)
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, aucun en vigueur ;
# I1/E1/E2 Propositions seed au cran 2 (seed::SEED_PROPOSAL_LEVEL), inertie 3 ailleurs ;
# modules en vigueur sans vote : le titre à 4, l'ordre de chaque section à 2 ;
+8 -3
View File
@@ -1,6 +1,6 @@
# HANDOFF — libreDecision
> Dernière mise à jour : 2026-11-03 à 08 h 15, intégrateur libredecision-d4 (orch : dev-63, session
> Dernière mise à jour : 2026-11-03 à 09 h 46, intégrateur libredecision-d4 (orch : dev-63, session
> racine /DEV, mandat « déploie » et « pousse » du 03/11 au 08/11). État vivant : le tableau
> `docs/chantiers/acte-certification/tableau.md`.
@@ -22,8 +22,13 @@
`actes/2026-11-03-m6b-textes-quorum.md`) : F1.1, F1.2 et N1.1 disent le quorum par cran, en fr, en,
es, dans les deux groupes. Pour Yvv : `docs/chantiers/acte-certification/textes-quorum-seed.md`
(Q13, quatre défauts appliqués) et trois phrases à reprendre (Q14).
- **En cours** : lot U (le lecteur, M7), puis lot T2 (les trois phrases, M6c). Liste d'attente de la
relecture de M6 : au tableau.
- **M7 en ligne** depuis le 03/11 à 09 h 42 (`b2e597b`, pipeline 39 ; `actes/2026-11-03-m7-lecteur.md`) :
le lecteur refait (lot U), page 2,4 fois plus courte, l'ordre en une ligne au bout de sa section avec
poignées et vote sur place, Propositions seed en tête, « 9 oui sur 12 votants pour passer », votes
simulés dits simulés. **À dire à Yvv** : « Fixer ce quorum » est visible pour lui (administrateur) sur
les 33 articles du seed tant que personne n'y a voté, et écrit en base.
- **En cours** : lot T2 (les trois phrases, M6c, lancé à 09 h 45), puis lot U2 (les mineurs de la
relecture de M7). Listes d'attente : au tableau.
## Avant le 03/11
+264
View File
@@ -0,0 +1,264 @@
# Acte — Mise en ligne M7 (le lecteur refait, lot U)
Classe : déploie
Projet : libreDecision Date : 2026-11-03
**État : EXÉCUTÉ (03/11, 09h28-09h44, par orch dev-63 sous mandat ; pipeline 39)** — figé sur `b2e597b` (`b2e597b65356cac14fbffdbf33318d620940b6f3`) le 03/11 à 09 h 28 — préparé
par l'intégrateur libredecision-d4 ; exécuté par orch (dev-63) sous son mandat (« déploie » et « pousse »,
du 03/11 au 08/11). Relecture indépendante : « b2e597b : à reprendre » (le dossier), dossier corrigé, puis
« b2e597b : ok » (prémisse 3).
## Intention
Mettre en ligne le lot U, la réponse à la revue d'Yvv du 02/11 (registre, revue en ligne de M5) :
« Améliore l'ux : la page est trop longue, trop fournie et on ne comprends pas tout de suite. » ;
l'ordonnancement « en fin de section, qui ajoute des "poignées" aux modules de la section. Et permette de
voter. » ; « distingue avec habileté les différentes nature des modules » ; pas de redondance entre
« consoles locales » et celles du bas ; « la distinction entre un déplier façon toggle et un menu qui
ouvre à droite doit être pertinente » ; « Simule le vote à tout endroit. ». Avec l'ajout du 02/11 à
06 h 26 : « combien il faut de oui pour passer et combien de non pour maintenir l'opposition ».
## Ce qui part
Version `b2e597b`, qui contient la version en ligne `bb4bdf5`. **L'interface seule** : `web/` (lecteur,
composants `OrderLine`, `Reorder`, `QuorumSetting`, `Crans`, chaînes fr, en, es, contrôles C35 à C37) ;
le reste est documentation (`CLAUDE.md`, `HANDOFF.md`, actes, ADR, `CONTROLES.md`, `UX-lecteur.md`,
tableau). `git diff bb4bdf5 b2e597b -- crates seeds docker .woodpecker .env data` → **vide** (vu par
l'intégrateur et par orch le 03/11 à 08 h 49) : le serveur est celui qui est en ligne.
## Ce que l'acte écrit dans la base de production (donnée runtime)
**Rien.** Aucune migration, aucun amendement, aucun code serveur nouveau. Au démarrage, comme à chaque
démarrage depuis M6, la file de publication suspendue reporte ses 7 lignes (`next_at`).
## Répétition
**Faite le 03/11 de 08 h 56 à 08 h 57**, avec le serveur de `b2e597b` (binaire de la boucle, copié en
`m7/ld-server-b2e597b`), sur une copie de la copie de production prise par orch à 08 h 49 min 02 (santé
`bb4bdf58`), publication vers un dépôt nu local (`m7/repetition.sh` ; journal `m7/repetition-m7.log`,
scratchpad de l'intégrateur). Deux démarrages.
- `m7/counts.sh` avant, après le premier démarrage, après le second : **identiques** (migrations 1–11,
1 identité, 0 vote, 0 bulletin, 0 signalement, propositions `inertia/withdrawn:3 order/in_force:16
text/open:70 title/in_force:2`, 113 révisions, 144 traductions, `seed_amendments` 16, quorums par cran,
I1, E1, E2 au cran 2 sans texte en vigueur, file 7 en attente et 13 faites).
- **La base entière comparée** (`.dump`, 868 lignes avant et après) : seules changent les 7 lignes de la
file en attente, par leur `next_at` (la publication suspendue les reporte à chaque démarrage, comme
depuis M6).
- Journal : « publication suspended … waiting=7 », aucun amendement, aucune ligne ERROR ni WARN ; 0 commit
vers le dépôt des textes.
## Recette écran
**Faite le 03/11 de 09 h 00 à 09 h 03**, sur deux autres copies de la copie de 08 h 49 : l'interface de
`b2e597b` (« après ») et celle de `bb4bdf5`, en ligne (« avant », construite dans une copie de travail
jetable), servies par le même serveur, connexion de développement (identités `dev:` écrites dans ces
copies seulement). Bureau 1360 × 900 et téléphone (Pixel 7), `fr-FR`. Script `m7/recette.mjs`, rapports
`m7/shots/avant-rapport.json` et `m7/shots/apres-rapport.json` : 38 étapes, 0 échec. Captures, toutes dans
`/tmp/claude-1001/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/scratchpad/m7/shots/`,
regardées une à une.
La revue d'Yvv du 02/11, ligne par ligne :
1. **« la page est trop longue, trop fournie et on ne comprends pas tout de suite »**
- Hauteur de la page d'un membre, Monnaie libre, mêmes données : **6 079 → 2 529 px** sur bureau,
**6 744 → 3 365 px** sur téléphone.
- Premier écran : avant, « Aucun article n'est en vigueur. Les propositions ci-dessous remontent… »
puis les blocs « Ordre des articles » ; après, « Aucun article n'est encore en vigueur. Commence par
les Propositions seed… », puis Raison d'être avec la marque « Proposition seed », Unicité du compte,
Réciprocité, puis « Propositions en cours » repliées par section (« Tout déplier », « Voter cette
section »).
- Captures `avant-*-membre-ml-ecran.png` et `-page.png`, `apres-*-membre-ml-ecran.png` et `-page.png`,
`geste-*-3a-propositions-repliees.png`, `geste-*-3b-propositions-depliees.png`.
2. **« la répétition de "ordonnancement" est nulle. […] en fin de section, qui ajoute des "poignées" aux
modules de la section. Et permette de voter. »**
- Avant : sept blocs « Ordre des articles · <section> » en tête (Introduction à Inertie), chacun
répétant la liste de ses articles. Après : aucun ; une ligne « Ordre de la section » au bout de chaque section.
- « Réordonner » sur Engagements techniques : 11 articles, 11 poignées, sur place (le panneau reste
fermé) ; un article monté, « Proposer cet ordre », le vote se déplie à la ligne, « Pour » → « Vote
simulé. », la ligne dit « 1 ordre proposé ».
- Captures `geste-*-5a-ligne-ordre.png`, `-5b-poignees.png`, `-5c-ordre-vote-simule.png`.
3. **« distingue avec habileté les différentes nature des modules : les textes ; l'inertie ;
l'odonnancement »**
- Texte : la carte de l'article. Inertie : les crans dessinés (cinq barres, « basse ») sur la carte, et
« Inertie · 2 / 5 · basse » dans le vote déplié. Ordre : l'icône poignée et « Ordre de la section ».
Titre du document : « Titre du document · seed a minima · 0 vote », crans « inertie 4 / 5 · haute »,
« Le vote », « Proposer un autre titre », sous le titre.
- Captures `apres-*-ecran.png`, `geste-*-6-titre.png`.
4. **« vérifie qu'il n'y a pas de redondance entre les "consoles locales" et celles du bas »**
- Vote déplié et panneau ouvert : 15 titres visibles, **0 doublon** (étape 8, bureau et téléphone).
Le panneau n'a plus d'onglets, il porte un titre : « Panier de vote », « Réglages de la formule ».
- Captures `geste-*-7a-panneau-panier.png`, `geste-*-9-panneau-reglages.png`.
5. **« la distinction entre un déplier façon toggle et un menu qui ouvre à droite doit être
pertinente »**
- La règle (`UX-lecteur.md`) : sur place, ce qui se lit et se décide sur un article (le vote, l'ordre
de sa section, le titre) ; à droite, ce qui travaille sur plusieurs articles ou demande de la place
(panier, réglages, détails, proposer). Vue : vote d'une proposition depuis la liste et vote d'un
ordre, panneau fermé (étapes 4 et 5) ; « Voter en lot » et « Réglages de la formule » ouvrent le
panneau.
6. **« Simule le vote à tout endroit. »** et le tampon (« je vois ce que j'ai fait sans attendre »)
- Monnaie libre (chaîne fermée) : sur une Proposition seed, une proposition de la liste, un ordre,
chaque « Pour » donne « Vote simulé. » ; aussitôt « Ton vote : pour · simulé », « 1 pour · 0 contre
dont 1 simulé », la note « tant qu'une décision humaine n'efface pas les votes simulés » ; la marque
« vote simulé » près de « Voter en lot ». « Vote enregistré. » : jamais vu.
- Groupe d'essai (vote sur le serveur) : le vote est enregistré, pas simulé, comme il se doit.
- Captures `geste-*-1-seed-vote-deplie.png`, `-2-seed-vote-simule.png`, `-4-proposition-vote-simule.png`,
`-11-essai-vote.png`. Les captures 4 et 5c de la première passe avaient été prises pendant la requête
(boutons grisés, « Tu n'as pas voté ») : **reprises le 03/11 à 09 h 22-09 h 23** sur une nouvelle
copie, après la réponse du serveur (« Ton vote : pour · simulé », « 1 pour · 0 contre dont 1
simulé », panneau fermé ; `m7/recette-reprise.mjs`, `shots/reprise-rapport.json`, 6 étapes, 0 échec).
La reprise de 5c porte sur l'ordre d'une autre section, **Annexes** (8 articles), pas sur
Engagements techniques.
7. **L'ajout du 02/11 à 06 h 26, « combien il faut de oui pour passer et combien de non pour maintenir
l'opposition »**
- Avant tout vote : « 9 oui sur 12 votants pour passer » (Propositions seed, cran 2), le quorum dans
l'infobulle (« Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ». ») ;
dans la liste, « 9 oui sur 12 » et « 39 oui sur 50 » (cran 3). Après un vote : « 9 oui pour passer ·
le quorum la bloque », « Il manque 8 voix pour. ». Essai : « 1 oui sur 1 votant pour passer ».
- **Le quorum de l'auteur** (corrigé après la relecture, voir prémisse 3) : la seule identité de la
production est **administratrice** (une invitation). Or l'administrateur fixe le quorum d'un texte du
seed tant que personne n'y a voté (décision d'Yvv du 02/11, registre : « L'auteur, avant le 1er vote
», un administrateur pour les textes du seed ; `trustin_access::sets_quorum`, ADR 0013). Sur les
données de production, `canSetQuorum` est donc **vrai sur les 33 articles pour l'administrateur**
(vu par le relecteur sur une copie fraîche ; 32 sur ma copie de reprise, où un vote de test venait
d'en fermer un) et faux pour un membre (étape 10). Après M7, chaque vote déplié lui montre « Quorum ·
12 votants », un champ et « Fixer ce quorum » : **un geste qui écrit** dans la base de production
(`units.quorum`, `quorum_changes`). La route est ouverte depuis M6 ; M7 la rend visible. Captures
`geste-bureau-12-admin-quorum.png`, `geste-telephone-12-admin-quorum.png` (reprise du 03/11 à 09 h 23,
le bouton jamais cliqué). L'auteur d'un nouvel article : prouvé par C37 sur une base de test.
Vu en passant, **pas du lot U, déjà en ligne** :
- « 0 contre » collé à « dont 1 simulé » (« 0 contredont 1 simulé ») : l'espace avant la marque se perd
(`VoteFold.svelte:163`, lot V, `d57326d`, en ligne depuis M6) → lot T2 ;
- la note d'inertie du vote déplié, « plus il faut de voix pour changer ce texte quand peu de gens
votent », a l'imprécision relevée sur F1.1 → lot T2 ;
- le bas du menu latéral (langues, ambiances) touche le bord droit : liste d'attente (vu par H).
- Le serveur de recette signale une fois « web of trust size: no node nor indexer answered, keeping the
last value » : pas de réseau vers les nœuds dans la recette.
## Effet attendu
- Le lecteur d'un document change pour tout le monde (les deux groupes, fr, en, es) ; les données, les
votes, les textes et la publication ne changent pas à la mise en ligne.
- **Un geste d'écriture apparaît pour l'administrateur** (Yvv) : sur chacun des 33 articles, tant que
personne n'y a voté, le réglage du quorum dans le vote déplié (« Fixer ce quorum », « Revenir au
défaut »). Il écrit `units.quorum` et `quorum_changes` quand on s'en sert. Voulu (décision d'Yvv du
02/11) ; à lui dire.
- Le dépôt des textes reste à `9e0f45a`.
Irréversibilité : **aucune** côté données ; le code se remet en arrière.
## Avant — prémisses vivantes
1. La production sert `bb4bdf5` : santé `"build":"bb4bdf58"` ; `prod/main` → `bb4bdf5`.
2. `git diff bb4bdf5 b2e597b -- crates seeds docker .woodpecker .env data` → vide.
3. La relecture indépendante de `b2e597b`, inscrite « ok » dans `~/harness/etat/relectures.json`.
**Première relecture** (sous-agent neuf, lancée à 09 h 05, rendue à 09 h 22) : « b2e597b : à reprendre
», **le dossier, pas le code** (« Le code peut partir tel quel »). Aucun bloquant.
- À reprendre : le dossier disait que personne ne règle le quorum sur les données de production ;
l'administrateur, seule identité, le peut sur les 33 articles. **Corrigé** (recette, point 7 ; Effet
attendu), captures de l'administrateur jointes.
- Ce qu'elle a vérifié de son côté : `needOf` reprend `yesNeeded` / `noNeeded` du serveur sans rien
recalculer ; réglage du quorum seulement si `canSetQuorum` ; le vote simulé jamais dit « enregistré »
(« Voter cette section » sur Conclusion : « 3 votes simulés. ») ; objections, coquilles, panier,
chaîne simulée, réglages tenus ; anciennes adresses `?onglet=` ; en et es sur téléphone sans reste de
français ni défilement ; ambiances sombres, mouvement réduit ; boucle web 0 / 0, 64 / 64, build ; e2e
38 / 40 puis 40 / 40 (au premier passage, C1 et C2 ont échoué sur leurs assertions de la page
d'accueil, au bout de 5 s, l'instantané montrant une autre page avec une session ouverte : **cause non
établie** ; `reader.spec.ts` seul 15 / 15, puis la suite complète 40 / 40) ; sa
propre répétition (`.dump` : seules les 7 dates de reprise de la file) ; le retour.
- Mineurs, **liste d'attente** (lot U2 proposé) : deux captures prises trop tôt (reprises) ; cibles de
36 px sur téléphone (« Tout déplier » `DeepList.svelte:225`, « Autres versions » `VoteFold.svelte:345`,
« Fixer ce quorum » `QuorumSetting.svelte:64`) ; « Si tout pa » coupé sur téléphone, sans indice de
défilement (`+page.svelte:525`) ; « Proposer cet ordre » éteint sans dire pourquoi
(`Reorder.svelte:196`, `order.edit.unchanged` jamais affiché) ; redites dans le vote déplié
(l'inertie deux fois, la ligne d'une Proposition seed au-dessus de son vote, l'autre version deux
fois) ; « N propositions en cours » compte les ordres ; un changement d'inertie proposé sur un ordre
ou le titre absent de « Propositions en cours » (aucun ouvert en production).
- Non vérifié par elle : la production (dont l'absence du marqueur `deploying`), la chaîne réelle, le
doigt sur un vrai téléphone, un lecteur d'écran, Firefox et Safari. Ses copies de la base de
production sont supprimées.
**Revérification du dossier corrigé** (`0953237`, même relecteur, rendue à 09 h 27) : « Le dossier dit
maintenant vrai, et le code de `b2e597b` n'a pas changé » ; la décision citée existe ; la route est
dans `bb4bdf5` (`crates/ld-server/src/lib.rs:185`) ; les captures reprises montrent ce que le dossier
en dit. Deux précisions faites (la section de la reprise 5c ; la cause de C1 et C2 non établie).
**« b2e597b : ok »**, inscrite le 03/11 à 09 h 28 (transcript
`~/.claude/projects/-home-yvv-Documents-PROD-DEV-libreDecision/d0a7108b-7f2f-484a-bcb0-de339238199d/subagents/agent-a2784cea7fe56b925.jsonl`,
dernière ligne « b2e597b : ok »).
Avant la poussée, vérifier sur sonic qu'aucun marqueur `/opt/libredecision-app/deploying` ne traîne
(sinon le pipeline ne garde pas l'image en service sous `previous`, `.woodpecker/app.yml:106-108`).
4. Boucle verte sur `b2e597b`, le 03/11 (journaux `m6/loop-m7.log`, `m7/e2e-c25.log`, `m7/e2e-all.log`) :
- de 08 h 48 à 08 h 55 : fmt, clippy `-D warnings`, 294 tests Rust, svelte-check 0 / 0, 64 vitest,
build ; e2e 39 / 40, **C25 (chaîne Ğ1 et téléphone) hors délai à 1,5 min**, la machine à une charge
de 11 à 12 (les sessions HCO) ;
- à 08 h 55, `modules.spec.ts` seul : 4 / 4, ce C25 en 7,3 s ;
- de 08 h 56 à 08 h 59, **la suite e2e complète : 40 / 40**.
Image de production construite le 03/11 de 09 h 00 à 09 h 01, sortie 0 : l'étage serveur repris du
cache (code inchangé depuis `bb4bdf5`, dont l'image a rejoué les tests Rust à 07 h 48) ; l'étage web
rejoué (svelte-check 0 / 0, 64 vitest, build). Image de contrôle retirée à 09 h 01.
5. Sur sonic, aucun témoin `/opt/libredecision-app/deploying` (`test ! -e …`) : sinon le pipeline ne
garde pas l'image en service sous `previous` (`.woodpecker/app.yml:105-108`) et le retour ne
ramènerait pas `bb4bdf5`.
## Commande exacte
```sh
cd /home/yvv/Documents/PROD/DEV/libreDecision
git push prod b2e597b65356cac14fbffdbf33318d620940b6f3:main
```
## Vérifications après
- [x] Santé vue de l'extérieur → `"build":"b2e597b6"`.
- [x] Journaux : « publication suspended … waiting=7 », aucune ligne ERROR ni WARN, aucun amendement.
- [x] La page d'un document, vue de l'extérieur, sert la nouvelle interface (le paquet JS contient
« Réordonner » et « oui pour passer »).
- [x] Sur une copie fraîche : comptes de `m6/counts.sh` identiques à la copie d'avant ; `integrity_check`
ok.
- [x] Le dépôt des textes reste à `9e0f45a`.
## Retour
`docker tag libredecision-app:previous libredecision-app:latest`, puis
`cd /opt/libredecision-app && docker compose up -d --force-recreate`. `previous` sera `bb4bdf5`, le même
serveur, qui attend aussi le premier texte pour publier : rien à couper, aucune donnée à reprendre.
## Exécution
Acte exécuté le 03/11/2026, 09h28-09h44, par orch (session dev-63, e66db8ca), sous mandat
« déploie » (inscrit du 03/11 au 08/11) ; « Pousse. Déploie. » d'Yvv du 02/11 pour les lots de sa revue,
après relecture indépendante ; consigne du 03/11 à 06h00.
- Dossier relu en entier à 09h28 ; relectures inscrites : « b2e597b : à reprendre » (09h22, le dossier)
puis « b2e597b : ok » (09h28, même relecteur, le dossier corrigé, le code inchangé) ; aucune autre
relecture de ce code en cours (PRÊT M7).
- Prémisses, 09h28:18 : santé `{"build":"bb4bdf58","ok":true}` ; `prod/main` = `bb4bdf5` ;
`git diff bb4bdf5 b2e597b -- crates seeds docker .woodpecker .env data` vide ; sur sonic, aucun témoin
`/opt/libredecision-app/deploying` ; images `latest` (08h01) et `previous` (06h51) ; pipeline 38
`success` en tête.
- Copie d'avant, 09h28:27 (`/tmp/ld-m7-avant`, 700/600) : `integrity_check` ok, `foreign_key_check`
vide ; 1 identité, 0 vote, 113 révisions, `seed_amendments` 16, file 7 en attente et 13 faites. Dépôt
des textes : `9e0f45a`.
- Poussée `git push prod b2e597b65356cac14fbffdbf33318d620940b6f3:main` à 09h28:4x, passée du premier
coup (`bb4bdf5..b2e597b`).
- Pipeline n° 39 : `running` à 09h28:54 ; santé `b2e597b6` à 09h30:38 ; `app` puis `maquette` verts ;
`success` à 09h42:01.
- Après :
- journaux : « listening … build=b2e597b6 », « publication suspended … waiting=7 », aucun amendement,
aucune ligne ERROR ni WARN ;
- redémarrage à froid à 09h42:09 : mêmes deux lignes, rien d'autre ; santé `b2e597b6` ;
- vu de l'extérieur, 09h43:40 : les paquets servis (19 nœuds, 40 morceaux, lus par `curl -4`)
contiennent « Réordonner », « oui pour passer » et « Fixer ce quorum » ;
- copie fraîche, 09h42:30 (`/tmp/ld-m7-apres`) : `counts.sh` identique à la copie d'avant, ligne à
ligne ; `integrity_check` ok, `foreign_key_check` vide ;
- dépôt des textes : `9e0f45a`, inchangé.
- `main` local avancé sur `b2e597b` (depuis `bb4bdf5`).
- Copies retirées à 09h42:46 : `/tmp/ld-m7-copie` (08h49:02, fournie à l'intégrateur),
`/tmp/ld-m7-avant`, `/tmp/ld-m7-apres`. Celles de l'intégrateur (`m7/base`, `rep`, recette) sont à
retirer par lui.
- Image de retour : `libredecision-app:previous` (`bb4bdf5`), rien à couper.
- À dire à Yvv : le réglage du quorum (« Fixer ce quorum ») est visible pour lui, administrateur, sur les
33 articles tant que personne n'y a voté ; il écrit en base quand on s'en sert.
- Copies de l'intégrateur retirées le 03/11 à 09 h 45 (scratchpad de libredecision-d4) : `m7/base`,
`m7/rep` (répétition), `m7/recette-avant`, `m7/recette-apres`, `m7/recette-reprise` (recette écran) et
les deux `.dump` ; la copie de travail jetable de `bb4bdf5` (interface d'avant). Plus aucune base dans ce
scratchpad. Restent les captures et les scripts (`m7/shots/`, aucune donnée de la base).
+30 -1
View File
@@ -248,7 +248,7 @@ pub fn quorum_bounds(electorate: u32, test_group: bool) -> (u32, u32) {
#[cfg(test)]
mod tests {
use super::*;
use crate::threshold::required_ratio;
use crate::threshold::{judged_voters, required_ratio};
const W: u32 = 7224;
/// The reference quorum of Monnaie libre (`vote_floor`): p0 = 2 × 50 / W.
@@ -312,6 +312,35 @@ mod tests {
// Ten voters, no quorum in the way: 7, 8, 9, 9 and 10 « pour », level 1 to 5.
let ten: Vec<u32> = InertiaLevel::ALL.iter().map(|k| scale.required(10, *k, 1).expect("valid")).collect();
assert_eq!(ten, [7, 8, 9, 9, 10]);
// The share asked is highest at the quorum, never near-unanimity (review of M6b, lot
// T2): 64,4 · 73,5 · 77,4 · 85,3 · 91,9 % of the quorum, level 1 to 5 — the review said
// 74 at level 2; rounded up to whole voters, 9 « pour » of 12 (75 %).
let at_quorum: Vec<f64> = InertiaLevel::ALL
.iter()
.map(|k| {
let q = scale.quorum(*k, None);
(required_ratio(w, judged_voters(0, q), &scale.params(*k, q)) * 1000.0).round() / 10.0
})
.collect();
assert_eq!(at_quorum, [64.4, 73.5, 77.4, 85.3, 91.9]);
assert_eq!((yes(2, 12, 0), scale.quorum(level(2), None)), (9, 12));
// Beyond the quorum the share only falls, down to M when everyone votes; under it,
// the voters counted are the quorum, and so is the share.
for k in InertiaLevel::ALL {
let q = scale.quorum(k, None);
let p = scale.params(k, q);
let share = |t: u32| required_ratio(w, judged_voters(t, q), &p);
let mut last = share(0);
for t in 0..=w {
let s = share(t);
assert!(s <= last, "level {}: the share never rises with the voters ({t})", k.get());
if t <= q {
assert_eq!(s, share(0), "level {}: under the quorum, the share of the quorum", k.get());
}
last = s;
}
assert!((share(w) - k.majority_pct() / 100.0).abs() < 1e-12, "level {}: M at full turnout", k.get());
}
}
#[test]
+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"]);
}
}
+10
View File
@@ -2073,6 +2073,16 @@ async fn a_vote_exists_for_every_state_of_the_buffer() {
ld_server::simulation::discard(&state, true).await.expect("act");
assert!(!exists(unit_id("E3")).await && !exists(unit_id("E4")).await);
assert!(exists(unit_id("E1")).await, "a proven trace stays");
// And the quorum changes again where no trace stays — « tant que l'article ne garde la
// trace d'aucun vote » (F1.2, lot T2), not « ensuite, il ne change plus ».
for code in ["E3", "E4"] {
let (s, v) =
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id(code)), Some(json!({ "quorum": 20 }))).await;
assert_eq!(s, StatusCode::OK, "{code}: {v}");
}
let (s, v) =
admin.call("PUT", &format!("/api/units/{}/quorum", unit_id("E1")), Some(json!({ "quorum": 20 }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")), "E1: its proven trace holds it");
let _ = doc_id;
}
+44
View File
@@ -0,0 +1,44 @@
{
"_source": "seeds/acte-engagement-certification.json au commit 3956a3e : F1.1, F1.2 et N1.1 tels que la production les tient depuis M6b (bb4bdf5, le 03/11 à 08 h 02 ; lot T, le quorum par cran), inchangés par M7 (b2e597b)",
"units": {
"F1.1": {
"label": "1.0.0",
"title": {
"fr": "Formule du seuil WoT",
"en": "WoT threshold formula",
"es": "Fórmula del umbral WoT"
},
"body": {
"fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».",
"en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: near-unanimity when few people vote, the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.",
"es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»."
}
},
"F1.2": {
"label": "1.0.0",
"title": {
"fr": "Paramètres par défaut",
"en": "Default parameters",
"es": "Parámetros por defecto"
},
"body": {
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, un quorum de 50 dans Monnaie libre, et le groupe demande 75 % de « pour » quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3.",
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as nobody has voted on the article; after that, it no longer changes. In Monnaie libre, never less than 2: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3.",
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3."
}
},
"N1.1": {
"label": "1.0.0",
"title": {
"fr": "Niveaux d'inertie",
"en": "Inertia levels",
"es": "Niveles de inercia"
},
"body": {
"fr": "À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque.\n\n**Les cinq crans** : le quorum dans Monnaie libre, la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude et p̃ la participation habituelle divisée par W.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 72, 81, 90, 100 ou 108 voix pour, du cran 1 au cran 5 ; au cran 3, 75 % des 120 votants, soit 90. Sous le quorum, les absents comptent comme « non » : au cran 2 (quorum 12), 9 voix pour passent avec 3 « contre » au plus ; au cran 3 (quorum 50), il faut 39 voix pour, avec 11 « contre » au plus.",
"en": "At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most.",
"es": "En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho."
}
}
}
}
+8
View File
@@ -226,6 +226,14 @@ async fn the_author_of_an_article_sets_its_quorum_before_the_first_vote() {
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("voted")));
// A vote on its change of inertia counts as well: it is the same article.
assert!(ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
// Her vote withdrawn: in the test group (no buffer) it leaves no trace, and the author
// sets the quorum again — « tant que l'article ne garde la trace d'aucun vote » (F1.2,
// lot T2), not « ensuite, il ne change plus ».
luna.vote(&text, None).await;
assert!(!ld_server::quorum::a_vote_exists(&state, &u).await.expect("query"));
assert_eq!(quorum_of(unit_by_id(&chiara.doc("essai").await, &u)), (Some(2), Some(1), Some(true)));
let (s, v) = chiara.set(&u, json!(1)).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
/// A ballot of the chain on a proposal, as the server writes it when a wallet asks.
+512 -43
View File
@@ -7,9 +7,15 @@
//! the rules of the texts (`docs/chantiers/acte-certification/textes-quorum-seed.md`).
//! A guarded seed amendment replaces them in production, on a base made as production
//! is after M6, only where nobody touched them.
//!
//! Lot T2 (2026-11-03, after the review of M6b): three phrases of these texts said more than
//! the rules do — F1.1 « presque l'unanimité quand peu de gens votent », F1.2 « ensuite, il
//! ne change plus », N1.1 « le groupe demande un quorum de votants ». A second guarded
//! amendment, which follows lot T's, writes them true on a base made as production is
//! after M6b; the texts of M6b are kept in `tests/fixtures/seed-quorum-m6b.json`.
use ld_engine::inertia::{InertiaLevel, Participation, Scale, gradient, quorum_bounds};
use ld_engine::threshold::{FormulaParams, judged_voters, no_needed, required_ratio, yes_needed};
use ld_engine::threshold::{FormulaParams, Tally, judged_voters, no_needed, passes, required_ratio, yes_needed};
use ld_server::seed_amend::{self, AMENDMENTS, Version};
use ld_server::{AppState, Config, open_db, seed};
use serde_json::Value;
@@ -50,6 +56,16 @@ fn rules_before() -> Value {
read(fixtures().join("seed-regles-avant.json"))
}
/// F1.1, F1.2 and N1.1 as lot T wrote them: production's since M6b (bb4bdf5), unchanged by M7.
fn m6b_texts() -> Value {
read(fixtures().join("seed-quorum-m6b.json"))
}
/// The seed production runs since M6b: today's, with the texts of lot T.
fn m6b_seed() -> Value {
with_texts(&today_seed(), &m6b_texts(), &CODES)
}
fn unit<'a>(seed: &'a Value, code: &str) -> &'a Value {
seed["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
@@ -470,6 +486,122 @@ fn every_number_of_the_quorum_texts_is_the_engine_s() {
}
}
// ---------------------------------------------------------------------------
// Lot T2: the three phrases the review of M6b left inexact
// ---------------------------------------------------------------------------
/// (article, language, the words of M6b, the words of today): the only change of lot T2.
const PHRASES: [(&str, &str, &str, &str); 9] = [
(
"F1.1",
"fr",
"parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.",
"parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.",
),
(
"F1.1",
"en",
"among all the voters: near-unanimity when few people vote, the majority M when everyone votes.",
"among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.",
),
(
"F1.1",
"es",
"entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.",
"entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.",
),
(
"F1.2",
"fr",
"peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus.",
"peut en fixer un autre tant que l'article ne garde la trace d'aucun vote.",
),
(
"F1.2",
"en",
"can set another one as long as nobody has voted on the article; after that, it no longer changes.",
"can set another one as long as the article keeps no trace of any vote.",
),
(
"F1.2",
"es",
"puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia.",
"puede fijar otro mientras el artículo no guarde rastro de ningún voto.",
),
(
"N1.1",
"fr",
"À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse,",
"À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants : sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà du quorum, plus il y a de votants, plus elle baisse,",
),
(
"N1.1",
"en",
"At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets,",
"At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum, which is not a minimum number of voters: below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: beyond the quorum, the more voters, the lower it gets,",
),
(
"N1.1",
"es",
"En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja,",
"En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum, que no es un número mínimo de votantes: por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá del quórum, cuantos más votantes, más baja,",
),
];
#[test]
fn the_three_phrases_of_the_review_of_m6b_are_written_true() {
let (m6b, today) = (m6b_seed(), today_seed());
for (code, lang, old, new) in PHRASES {
assert!(text(&m6b, code, lang).1.contains(old), "{code} {lang}: the words of M6b");
let (_, body) = text(&today, code, lang);
assert!(!body.contains(old), "{code} {lang}: still the words of M6b\n{body}");
assert!(body.contains(new), "{code} {lang}: missing « {new} »\n{body}");
}
// F1.1 and N1.1, by the engine: the share of a level is that of the quorum while fewer
// vote, its highest; beyond, it falls as turnout rises, down to M when everyone votes —
// never near-unanimity because few vote. Every level, quorum and electorate of the grid.
for (w, median) in [(5707u32, None), (7224, Some(120.0)), (40, Some(9.0)), (2, Some(1.0))] {
let p = Participation::of(w, 50.0, median, 9).ratio;
let scale = Scale { electorate: w, participation: p, quorums: [12, 12, 50, 50, 50] };
for k in InertiaLevel::ALL {
for q in [1u32, 2, 12, 50].into_iter().filter(|q| *q <= w) {
let params = scale.params(k, q);
let share = |t: u32| required_ratio(w, judged_voters(t, q), &params);
let top = share(0);
let mut last = top;
for t in 0..=w {
let s = share(t);
assert!(s <= last, "W {w} k {} Q {q}: the share rises at {t} voters", k.get());
if t <= q {
assert_eq!(s, top, "W {w} k {} Q {q}: under the quorum, the share of the quorum", k.get());
}
last = s;
}
let m = k.majority_pct() / 100.0;
assert!((share(w) - m).abs() < 1e-12, "W {w} k {} Q {q}: M when everyone votes", k.get());
}
}
}
// N1.1: the quorum is no minimum of voters. In Monnaie libre (W 5 707 on 02/11, the
// reference turnout), at every level, fewer voters than its quorum pass when they all
// vote « pour »: 8 of 12, 9 of 12, 39 of 50, 43 of 50, 46 of 50.
let w = 5707;
let f = figures(&today);
let scale = Scale { electorate: w, participation: Participation::of(w, 50.0, None, 0).ratio, quorums: f.quorums };
let fewer: Vec<(u32, u32)> = InertiaLevel::ALL
.iter()
.map(|k| {
let q = scale.quorum(*k, None);
let p = scale.params(*k, q);
let yes = yes_needed(0, w, &p).expect("valid");
assert!(passes(Tally { for_votes: yes, against: 0 }, w, &p).expect("valid"));
(yes, q)
})
.collect();
assert_eq!(fewer, [(8, 12), (9, 12), (39, 50), (43, 50), (46, 50)]);
assert!(fewer.iter().all(|(yes, q)| yes < q));
}
// ---------------------------------------------------------------------------
// The formulas: evaluated as written, against the engine
// ---------------------------------------------------------------------------
@@ -730,28 +862,27 @@ fn the_quorum_texts_follow_the_rules_of_form() {
// Production: a guarded amendment, on a base made as production is after M6
// ---------------------------------------------------------------------------
/// The amendment of an article, by lot T.
fn amendment_t(code: &str) -> &'static seed_amend::Amendment {
/// The amendment of an article by lot T (`quorum`) or lot T2 (`phrases`).
fn amendment(code: &str, lot: &str) -> &'static seed_amend::Amendment {
AMENDMENTS
.iter()
.find(|a| a.unit == code && a.id.starts_with("2026-11-03-"))
.unwrap_or_else(|| panic!("no amendment of lot T for {code}"))
.find(|a| a.unit == code && a.id.starts_with("2026-11-03-") && a.id.ends_with(lot))
.unwrap_or_else(|| panic!("no amendment « {lot} » for {code}"))
}
#[test]
fn the_shipped_amendments_go_from_production_to_the_seed_of_today() {
let (prod, today) = (production_seed(), today_seed());
fn the_amendments_of_lot_t_went_from_m5_to_the_texts_of_m6b() {
let (prod, m6b) = (production_seed(), m6b_seed());
for code in CODES {
let a = amendment_t(code);
let a = amendment(code, "-quorum");
assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}");
let (t, b) = text(&prod, code, "fr");
assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production holds");
let (t, b) = text(&today, code, "fr");
assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed today");
assert_eq!(seed_amend::fingerprint(t, b), a.from, "{code}: the text production held before M6b");
let (t, b) = text(&m6b, code, "fr");
assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text production holds since M6b");
assert_ne!(a.from, a.to, "{code}");
assert_eq!(a.superseded_by, None, "{code}");
// F1.1 was never amended; F1.2 and N1.1 follow the amendment of M5, which wrote the
// text they replace and which the seed no longer gives: it is superseded.
// F1.1 was never amended before; F1.2 and N1.1 follow the amendment of M5, which wrote
// the text they replace and which the seed no longer gives: it is superseded.
match a.follows {
None => assert_eq!(code, "F1.1"),
Some(earlier) => {
@@ -759,6 +890,41 @@ fn the_shipped_amendments_go_from_production_to_the_seed_of_today() {
assert_eq!((m5.unit, m5.to, m5.superseded_by), (code, a.from, Some(a.id)), "{code}");
}
}
// Lot T2 replaces its text: it is superseded in turn.
assert_eq!(a.superseded_by, Some(amendment(code, "-phrases").id), "{code}");
}
}
#[test]
fn the_amendments_of_lot_t2_go_from_the_texts_of_m6b_to_the_seed_of_today() {
let (m6b, today) = (m6b_seed(), today_seed());
for code in CODES {
let a = amendment(code, "-phrases");
let t = amendment(code, "-quorum");
assert_eq!((a.doc, a.version), ("acte-engagement-certification", Version::Open("1.0.0")), "{code}");
let (title, body) = text(&m6b, code, "fr");
assert_eq!(seed_amend::fingerprint(title, body), a.from, "{code}: the text production holds");
let (title, body) = text(&today, code, "fr");
assert_eq!(seed_amend::fingerprint(title, body), a.to, "{code}: the text of the seed today");
assert_ne!(a.from, a.to, "{code}");
// It follows lot T's, which wrote the text it replaces; nothing replaces it yet.
assert_eq!(
(a.follows, t.to, t.superseded_by, a.superseded_by),
(Some(t.id), a.from, Some(a.id), None),
"{code}"
);
// The titles stay, and only the phrases of the review change (`PHRASES`).
for lang in LANGS {
let (before, now) = (text(&m6b, code, lang), text(&today, code, lang));
assert_eq!(before.0, now.0, "{code} {lang}: the title is kept");
let mut b = before.1.to_owned();
for (c, l, old, new) in PHRASES {
if (c, l) == (code, lang) {
b = b.replacen(old, new, 1);
}
}
assert_eq!(b, now.1, "{code} {lang}: nothing else changes");
}
}
}
@@ -827,32 +993,40 @@ async fn kinds(state: &AppState, id: &str) -> Vec<String> {
.expect("revisions")
}
/// Each decision of lot T: (group, unit, outcome, reason).
async fn decisions(state: &AppState) -> Vec<(String, String, String, Option<String>)> {
/// Each decision of lot T (`-quorum`) or lot T2 (`-phrases`): (group, unit, outcome, reason).
async fn decisions(state: &AppState, lot: &str) -> Vec<(String, String, String, Option<String>)> {
sqlx::query_as(
"SELECT d.group_slug, s.unit_code, s.outcome, s.reason FROM seed_amendments s
JOIN documents d ON d.id = s.doc_id
WHERE s.amendment_id LIKE '2026-11-03-%' ORDER BY d.group_slug, s.unit_code",
WHERE s.amendment_id LIKE ? ORDER BY d.group_slug, s.unit_code",
)
.bind(format!("2026-11-03-%{lot}"))
.fetch_all(&state.db)
.await
.expect("decisions")
}
async fn queued(state: &AppState) -> Vec<(String, String)> {
sqlx::query_as(
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id
WHERE o.reason LIKE '%quorum par cran%' ORDER BY o.id",
)
.fetch_all(&state.db)
.await
.expect("outbox")
/// The rows the amendments of a lot added to the publication queue: (group, reason).
async fn queued(state: &AppState, lot: &str) -> Vec<(String, String)> {
let reasons: Vec<&str> = CODES.iter().map(|c| amendment(c, lot).reason).collect();
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id ORDER BY o.id")
.fetch_all(&state.db)
.await
.expect("outbox");
rows.into_iter().filter(|(_, r)| reasons.contains(&r.as_str())).collect()
}
/// A start with the seed of M6b (bb4bdf5, and M7 since): lot T decides, lot T2 does not yet.
fn m6b_config(tag: &str) -> Config {
config(seed_dir(&format!("{tag}-m6b"), &m6b_seed()))
}
/// Lot T, as M6b played it on production (03/11, 08 h 02): started with the seed of M6b.
#[tokio::test]
async fn production_after_m6_gets_the_quorum_texts_once() {
let base = production_after_m6("apply").await;
let (prod, today) = (production_seed(), today_seed());
let (prod, today) = (production_seed(), m6b_seed());
// The base is as production after M6: F1.1 as born, F1.2 and N1.1 as M5 wrote them.
for group in GROUPS {
for code in CODES {
@@ -873,11 +1047,11 @@ async fn production_after_m6_gets_the_quorum_texts_once() {
.await
.expect("count");
assert_eq!((seed_proposals, quorums), (6, 2), "M6: seed proposals and quorums written");
assert!(decisions(&base).await.is_empty(), "the seed of M6 decides nothing of lot T");
assert!(decisions(&base, "-quorum").await.is_empty(), "the seed of M6 decides nothing of lot T");
let (before, before_tr) = (proposals(&base).await, translations(&base).await);
// Started with today's seed.
let state = start(&base.db, Config::for_tests()).await;
// Started with the seed of M6b.
let state = start(&base.db, m6b_config("apply")).await;
let mut touched = Vec::new();
for group in GROUPS {
for code in CODES {
@@ -916,7 +1090,8 @@ async fn production_after_m6_gets_the_quorum_texts_once() {
.iter()
.flat_map(|g| CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None)))
.collect();
assert_eq!(decisions(&state).await, expected);
assert_eq!(decisions(&state, "-quorum").await, expected);
assert!(decisions(&state, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2");
// Nothing else is touched: no other proposal, no other translation, no vote.
let keep = |rows: Vec<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
assert_eq!(keep(proposals(&state).await), keep(before.clone()));
@@ -927,14 +1102,14 @@ async fn production_after_m6_gets_the_quorum_texts_once() {
let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes");
assert_eq!(votes, 0);
// Monnaie libre republishes, once per article; the test group never.
let rows = queued(&state).await;
let rows = queued(&state, "-quorum").await;
assert_eq!(rows.len(), 3, "{rows:?}");
assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}");
// Decided once: a second start changes nothing.
let again = start(&state.db, Config::for_tests()).await;
assert_eq!(decisions(&again).await, expected);
let again = start(&state.db, m6b_config("apply-again")).await;
assert_eq!(decisions(&again, "-quorum").await, expected);
assert_eq!(proposals(&again).await, proposals(&state).await);
assert_eq!(queued(&again).await.len(), 3);
assert_eq!(queued(&again, "-quorum").await.len(), 3);
}
async fn identity(state: &AppState, id: &str) {
@@ -948,6 +1123,7 @@ async fn identity(state: &AppState, id: &str) {
.expect("identity");
}
/// Lot T's guard, as M6b played it: started with the seed of M6b.
#[tokio::test]
async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
let base = production_after_m6("refuse").await;
@@ -993,12 +1169,12 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
.await
.expect("translation");
let state = start(&base.db, Config::for_tests()).await;
let state = start(&base.db, m6b_config("refuse")).await;
let refused =
|g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned()));
let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None);
assert_eq!(
decisions(&state).await,
decisions(&state, "-quorum").await,
[
refused("essai", "F1.1", "translation_edited"),
applied("essai", "F1.2"),
@@ -1008,7 +1184,7 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
refused("monnaie-libre", "N1.1", "ballots"),
]
);
let (prod, today) = (production_seed(), today_seed());
let (prod, today) = (production_seed(), m6b_seed());
for (group, code, kept) in [
("essai", "F1.1", true),
("essai", "F1.2", false),
@@ -1023,14 +1199,14 @@ async fn a_vote_a_ballot_or_a_member_s_translation_keeps_the_text_of_before() {
assert_eq!(revision, 1 + m5 + i64::from(!kept), "{group} {code}");
}
// Republished for the one article applied in Monnaie libre only.
let rows = queued(&state).await;
let rows = queued(&state, "-quorum").await;
assert_eq!(
rows.iter().map(|(g, r)| (g.as_str(), r.contains("F1.1"))).collect::<Vec<_>>(),
[("monnaie-libre", true)]
);
// The refusal stands: decided once, it is not tried again.
let again = start(&state.db, Config::for_tests()).await;
assert_eq!(decisions(&again).await, decisions(&state).await);
let again = start(&state.db, m6b_config("refuse-again")).await;
assert_eq!(decisions(&again, "-quorum").await, decisions(&state, "-quorum").await);
}
#[tokio::test]
@@ -1044,15 +1220,308 @@ async fn a_new_base_is_born_with_the_quorum_texts() {
assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 1), "{group} {code}");
}
}
let got = decisions(&state).await;
// Lot T2's amendments find their text there: present.
let got = decisions(&state, "-phrases").await;
assert_eq!(got.len(), 6);
assert!(got.iter().all(|(_, _, o, _)| o == "present"), "{got:?}");
// The amendments of M5 on F1.2 and N1.1, superseded, decide nothing on a new base.
// The amendments of M5 and of lot T on these articles, superseded, decide nothing on a
// new base.
let m5: Vec<String> = sqlx::query_scalar(
"SELECT unit_code FROM seed_amendments WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles')",
"SELECT amendment_id FROM seed_amendments
WHERE amendment_id IN ('2026-11-02-f1-2-regles', '2026-11-02-n1-1-regles') OR amendment_id LIKE '2026-11-03-%-quorum'",
)
.fetch_all(&state.db)
.await
.expect("m5");
assert!(m5.is_empty(), "{m5:?}");
}
// ---------------------------------------------------------------------------
// Lot T2 in production: on a base made as production is after M6b
// ---------------------------------------------------------------------------
/// Production's history, for these articles, after M6b (03/11, 08 h 02): F1.1 at its
/// revision 2, F1.2 and N1.1 at their revision 3, each written by an amendment of the seed.
async fn production_after_m6b(tag: &str) -> AppState {
let base = production_after_m6(tag).await;
start(&base.db, m6b_config(tag)).await
}
async fn count(state: &AppState, sql: &str) -> i64 {
sqlx::query_scalar(sql).fetch_one(&state.db).await.expect("count")
}
/// The last revision of a proposal: (revision, kind, title, body, author).
async fn last_revision(state: &AppState, id: &str) -> (i64, String, String, String, Option<String>) {
sqlx::query_as(
"SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? ORDER BY id DESC LIMIT 1",
)
.bind(id)
.fetch_one(&state.db)
.await
.expect("revision")
}
#[tokio::test]
async fn production_after_m6b_gets_the_three_phrases_once() {
let base = production_after_m6b("t2-apply").await;
let (m6b, today) = (m6b_seed(), today_seed());
// The base is as production after M6b: the texts of lot T, at revision 2 and 3.
for group in GROUPS {
for code in CODES {
let (id, title, body, revision) = proposal(&base, group, code).await;
assert_eq!((title.as_str(), body.as_str()), text(&m6b, code, "fr"), "{group} {code}");
let m5 = code != "F1.1";
assert_eq!(revision, if m5 { 3 } else { 2 }, "{group} {code}");
let expected: &[&str] = if m5 { &["initial", "amendment", "amendment"] } else { &["initial", "amendment"] };
assert_eq!(kinds(&base, &id).await, expected, "{group} {code}");
}
}
let lot_t = decisions(&base, "-quorum").await;
assert_eq!(lot_t.len(), 6);
assert!(lot_t.iter().all(|(_, _, o, _)| o == "applied"), "{lot_t:?}");
assert!(decisions(&base, "-phrases").await.is_empty(), "the seed of M6b decides nothing of lot T2");
let (before, before_tr) = (proposals(&base).await, translations(&base).await);
let outbox = count(&base, "SELECT COUNT(*) FROM outbox").await;
let revisions = count(&base, "SELECT COUNT(*) FROM revisions").await;
// Started with today's seed.
let state = start(&base.db, Config::for_tests()).await;
let mut touched = Vec::new();
for group in GROUPS {
for code in CODES {
let (id, title, body, revision) = proposal(&state, group, code).await;
assert_eq!((title.as_str(), body.as_str()), text(&today, code, "fr"), "{group} {code}");
let m5 = code != "F1.1";
assert_eq!(revision, if m5 { 4 } else { 3 }, "{group} {code}");
let mut expected = vec!["initial", "amendment", "amendment"];
if m5 {
expected.push("amendment");
}
assert_eq!(kinds(&state, &id).await, expected, "{group} {code}");
// The history keeps the text of lot T, and says the new one, without author.
assert_eq!(
last_revision(&state, &id).await,
(revision, "amendment".to_owned(), title.clone(), body.clone(), None),
"{group} {code}"
);
let (_, kind, t, b, _) = sqlx::query_as::<_, (i64, String, String, String, Option<String>)>(
"SELECT revision, kind, title, body, author_id FROM revisions WHERE proposal_id = ? AND revision = ?",
)
.bind(&id)
.bind(revision - 1)
.fetch_one(&state.db)
.await
.expect("revision of lot T");
assert_eq!(
(kind.as_str(), (t.as_str(), b.as_str())),
("amendment", text(&m6b, code, "fr")),
"{group} {code}"
);
for lang in ["en", "es"] {
let (t, b, hash, origin, by): (String, String, String, String, Option<String>) = sqlx::query_as(
"SELECT title, body, source_hash, origin, updated_by FROM translations WHERE proposal_id = ? AND lang = ?",
)
.bind(&id)
.bind(lang)
.fetch_one(&state.db)
.await
.expect("translation");
assert_eq!((t.as_str(), b.as_str()), text(&today, code, lang), "{group} {code} {lang}");
assert_eq!(hash, seed_amend::fingerprint(&title, &body), "{group} {code} {lang}: up to date");
assert_eq!((origin.as_str(), by), ("seed", None));
}
touched.push(id);
}
}
let expected: Vec<(String, String, String, Option<String>)> = GROUPS
.iter()
.flat_map(|g| CODES.iter().map(move |c| ((*g).to_owned(), (*c).to_owned(), "applied".to_owned(), None)))
.collect();
assert_eq!(decisions(&state, "-phrases").await, expected);
// Lot T's decisions stay as they were taken; superseded, it decides nothing more.
assert_eq!(decisions(&state, "-quorum").await, lot_t);
// Nothing else is touched: no other proposal, no other translation, no vote, six revisions.
let keep = |rows: Vec<Row>| rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>();
assert_eq!(keep(proposals(&state).await), keep(before));
let others = |rows: Vec<(String, String, String, String, String)>| {
rows.into_iter().filter(|r| !touched.contains(&r.0)).collect::<Vec<_>>()
};
assert_eq!(others(translations(&state).await), others(before_tr));
assert_eq!(count(&state, "SELECT COUNT(*) FROM votes").await, 0);
assert_eq!(count(&state, "SELECT COUNT(*) FROM revisions").await, revisions + 6);
// Monnaie libre republishes, once per article; the test group never.
let rows = queued(&state, "-phrases").await;
assert_eq!(rows.len(), 3, "{rows:?}");
assert!(rows.iter().all(|(g, _)| g == "monnaie-libre"), "{rows:?}");
assert_eq!(count(&state, "SELECT COUNT(*) FROM outbox").await, outbox + 3);
// Decided once: a second start replays nothing.
let again = start(&state.db, Config::for_tests()).await;
assert_eq!(decisions(&again, "-phrases").await, expected);
assert_eq!(proposals(&again).await, proposals(&state).await);
assert_eq!(translations(&again).await, translations(&state).await);
assert_eq!(count(&again, "SELECT COUNT(*) FROM revisions").await, revisions + 6);
assert_eq!(count(&again, "SELECT COUNT(*) FROM outbox").await, outbox + 3);
}
/// A person, voting through the interface's own routes (no chain in the test group).
struct Voter {
router: axum::Router,
cookie: String,
}
impl Voter {
async fn call(&self, method: &str, uri: &str, body: Value, cookie: Option<&str>) -> axum::response::Response {
use tower::ServiceExt;
let mut req = axum::http::Request::builder()
.method(method)
.uri(uri)
.header(ld_server::CSRF_HEADER, ld_server::CSRF_VALUE)
.header(axum::http::header::CONTENT_TYPE, "application/json");
if let Some(c) = cookie {
req = req.header(axum::http::header::COOKIE, c);
}
let req = req.body(axum::body::Body::from(body.to_string())).expect("request");
self.router.clone().oneshot(req).await.expect("response")
}
async fn login(state: &AppState, name: &str) -> Self {
let mut v = Self { router: ld_server::app(state.clone()), cookie: String::new() };
let res = v.call("POST", "/api/auth/dev", serde_json::json!({ "name": name }), None).await;
assert_eq!(res.status(), axum::http::StatusCode::OK);
let set = res.headers().get(axum::http::header::SET_COOKIE).expect("cookie").to_str().expect("cookie");
v.cookie = set.split(';').next().expect("cookie").to_owned();
v
}
async fn vote(&self, proposal: &str, choice: Option<&str>) {
let uri = format!("/api/proposals/{proposal}/vote");
let res = self.call("POST", &uri, serde_json::json!({ "choice": choice }), Some(&self.cookie)).await;
assert_eq!(res.status(), axum::http::StatusCode::OK, "{uri} {choice:?}");
}
}
#[tokio::test]
async fn an_article_voted_on_keeps_the_text_of_m6b_and_says_why() {
let base = production_after_m6b("t2-refuse").await;
let doc: String = sqlx::query_scalar("SELECT id FROM documents WHERE group_slug = 'monnaie-libre'")
.fetch_one(&base.db)
.await
.expect("doc");
identity(&base, "dev:vera").await;
// In Monnaie libre, a simulated vote on F1.1, at its revision 2…
let (f11, ..) = proposal(&base, "monnaie-libre", "F1.1").await;
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source)
VALUES (?, 'dev:vera', 'against', 2, '2026-11-03T09:00:00Z', 'simulated')",
)
.bind(&f11)
.execute(&base.db)
.await
.expect("vote");
// …and a ballot still pending on F1.2, at its revision 3: cast, not proven yet.
let (f12, ..) = proposal(&base, "monnaie-libre", "F1.2").await;
sqlx::query(
"INSERT INTO ballots (id, doc_id, requested_by, salt, digest, remark_hash, account, amount, status, mode,
created_at, expires_at)
VALUES ('b2', ?, 'dev:vera', 'salt', 'digest-b2', 'remark-b2', '', 0, 'pending', 'simulated',
'2026-11-03T09:00:00Z', '2026-11-04T09:00:00Z')",
)
.bind(&doc)
.execute(&base.db)
.await
.expect("ballot");
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES ('b2', ?, 3, 'against')")
.bind(&f12)
.execute(&base.db)
.await
.expect("line");
// In the test group, Luna votes « contre » N1.1 and keeps her vote (a « pour » would pass
// there, quorum 1); she votes on F1.2, then withdraws: a direct vote withdrawn leaves no
// trace (the guard's known limit, `seed_amend`).
let luna = Voter::login(&base, "Luna").await;
let (n11, ..) = proposal(&base, "essai", "N1.1").await;
luna.vote(&n11, Some("against")).await;
let (f12_essai, ..) = proposal(&base, "essai", "F1.2").await;
luna.vote(&f12_essai, Some("against")).await;
luna.vote(&f12_essai, None).await;
let traces = |id: String| {
let state = base.clone();
async move {
let sql = "SELECT (SELECT COUNT(*) FROM votes WHERE proposal_id = ?1)
+ (SELECT COUNT(*) FROM ballot_lines WHERE proposal_id = ?1)";
sqlx::query_scalar::<_, i64>(sql).bind(id).fetch_one(&state.db).await.expect("traces")
}
};
assert_eq!((traces(n11.clone()).await, traces(f12_essai.clone()).await), (1, 0));
let state = start(&base.db, Config::for_tests()).await;
let refused =
|g: &str, c: &str, why: &str| (g.to_owned(), c.to_owned(), "refused".to_owned(), Some(why.to_owned()));
let applied = |g: &str, c: &str| (g.to_owned(), c.to_owned(), "applied".to_owned(), None);
assert_eq!(
decisions(&state, "-phrases").await,
[
applied("essai", "F1.1"),
applied("essai", "F1.2"),
refused("essai", "N1.1", "votes"),
refused("monnaie-libre", "F1.1", "votes"),
refused("monnaie-libre", "F1.2", "ballots"),
applied("monnaie-libre", "N1.1"),
]
);
let (m6b, today) = (m6b_seed(), today_seed());
for group in GROUPS {
for code in CODES {
let kept = matches!((group, code), ("essai", "N1.1") | ("monnaie-libre", "F1.1" | "F1.2"));
let (_, title, body, revision) = proposal(&state, group, code).await;
let expected = if kept { text(&m6b, code, "fr") } else { text(&today, code, "fr") };
assert_eq!((title.as_str(), body.as_str()), expected, "{group} {code}");
let lot_t = if code == "F1.1" { 2 } else { 3 };
assert_eq!(revision, lot_t + i64::from(!kept), "{group} {code}");
}
}
// Republished for the one article applied in Monnaie libre only.
let rows = queued(&state, "-phrases").await;
assert_eq!(
rows.iter().map(|(g, r)| (g.as_str(), r.contains("N1.1"))).collect::<Vec<_>>(),
[("monnaie-libre", true)]
);
// The refusals stand: decided once, never tried again, even once the vote is gone.
sqlx::query("DELETE FROM votes WHERE proposal_id = ?").bind(&f11).execute(&state.db).await.expect("delete");
let again = start(&state.db, Config::for_tests()).await;
assert_eq!(decisions(&again, "-phrases").await, decisions(&state, "-phrases").await);
let (_, title, body, _) = proposal(&again, "monnaie-libre", "F1.1").await;
assert_eq!((title.as_str(), body.as_str()), text(&m6b, "F1.1", "fr"));
}
/// A base born on 02/11 with the seed of M5 (F1.2 and N1.1 at their revision 1, in M5's
/// words): lot T wrote their revision 2, and lot T2 follows it there too.
#[tokio::test]
async fn a_base_born_with_the_texts_of_m5_gets_the_three_phrases() {
let prod = production_seed();
let db = open_db("sqlite::memory:").await.expect("db");
let first = AppState::new(config(seed_dir("t2-born-m5", &prod)), db.clone()).await.expect("state");
seed::documents(&first).await.expect("born");
start(&db, config(seed_dir("t2-born-m5-m5", &prod))).await;
start(&db, config(seed_dir("t2-born-m5-m6", &with_texts(&today_seed(), &prod, &CODES)))).await;
let base = start(&db, m6b_config("t2-born-m5")).await;
for group in GROUPS {
for code in CODES {
let (id, ..) = proposal(&base, group, code).await;
assert_eq!(kinds(&base, &id).await, ["initial", "amendment"], "{group} {code}: lot T, alone");
}
}
let state = start(&db, Config::for_tests()).await;
let today = today_seed();
for group in GROUPS {
for code in CODES {
let (id, title, body, revision) = proposal(&state, group, code).await;
assert_eq!(((title.as_str(), body.as_str()), revision), (text(&today, code, "fr"), 3), "{group} {code}");
assert_eq!(kinds(&state, &id).await, ["initial", "amendment", "amendment"], "{group} {code}");
}
}
let got = decisions(&state, "-phrases").await;
assert_eq!(got.len(), 6);
assert!(got.iter().all(|(_, _, o, _)| o == "applied"), "{got:?}");
}
+8 -7
View File
@@ -120,16 +120,17 @@ fn the_shipped_amendments_go_from_the_texts_of_before_to_the_seed_of_m5() {
let (t, b) = fr(v);
assert_eq!(seed_amend::fingerprint(t, b), a.to, "{code}: the text of the seed of M5");
assert_ne!(a.from, a.to, "{code}");
// Today's seed gives it, or another text whose amendment follows this one.
// Today's seed gives it, or the text of the last of the amendments that follow it,
// each replacing the text of the one before (lot T, then lot T2, on 03/11).
let (t, b) = fr(version(&today, code));
let now = seed_amend::fingerprint(t, b);
match a.superseded_by {
None => assert_eq!(now, a.to, "{code}: still the text of today"),
Some(later) => {
let l = AMENDMENTS.iter().find(|l| l.id == later).expect("the later amendment");
assert_eq!((l.unit, l.follows, l.from, l.to), (code, Some(a.id), a.to, now.as_str()), "{code}");
}
let mut last = a;
while let Some(later) = last.superseded_by {
let l = AMENDMENTS.iter().find(|l| l.id == later).expect("the later amendment");
assert_eq!((l.unit, l.follows, l.from), (code, Some(last.id), last.to), "{code}: {}", l.id);
last = l;
}
assert_eq!(now, last.to, "{code}: the text of today");
}
}
+4 -1
View File
@@ -135,4 +135,7 @@ publié : `quorums`.
pour ») décrivent encore C : orch les réécrira au prochain tour de textes, avec la relecture
d'Yvv (ce lot ne touche pas aux textes du seed). *(Fait par le lot T : F1.1, F1.2 et N1.1 disent
le quorum par cran, en ligne depuis M6b, le 03/11, `bb4bdf5` ; relecture d'Yvv :
`docs/chantiers/acte-certification/textes-quorum-seed.md`.)*
`docs/chantiers/acte-certification/textes-quorum-seed.md`. Trois de leurs phrases reprises par
le lot T2, amendement `2026-11-03-…-phrases` qui suit celui de M6b : 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 ; il n'est pas un nombre minimal de votants.)*
@@ -92,3 +92,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-11-02 | automate | local, lot G fusionné (`0919b46`) et les articles des règles réécrits (amendement de seed sur une proposition ouverte) | C1–C33, M1 | 34 / 34 verts (35 tests) |
| 2026-11-03 | automate | local, lot S (Propositions seed, ADR 0011 ; le quorum par cran, ADR 0013 : C3, C4, C5, C9, C18 à C20, C23, C24, C27, M1 repris) | C1–C33, M1 | 34 / 34 verts (35 tests) |
| 2026-11-03 | automate | local, lot U sur `6fb8238` (le lecteur refait : l'ordre en une ligne au bout de sa section, poignées sur place et vote ; oui pour passer · non pour bloquer ; quorum de l'auteur ; panneau sans onglets ; C3, C4, C6, C19 à C21, C23 à C26, C34, M1 repris) | C1–C37, M1 | 38 / 38 verts (40 tests) |
| 2026-11-03 | automate | local, lot T2 sur `f27ee08` (les trois phrases de F1.1, F1.2 et N1.1 ; l'espace perdu du vote déplié ; C34 lit la ligne entière, C37 « ne change pas ») | C1–C37, M1 | 38 / 38 verts (40 tests), du premier coup |
+34 -2
View File
@@ -82,6 +82,22 @@ Lots en cours, en copies de travail (`.claude/worktrees/agent-<id>`) :
commencés. En attente de V.
**Relancé le 03/11 à 07 h 33**, avec la fusion de H, S, V et T, les reprises de la relecture de M6
(« Vote enregistré. », « effacement à l'ouverture de la chaîne ») ; part en M7.
**Fini et fusionné le 03/11 à 08 h 48** (`b2e597b`) : page 2,4 fois plus courte, ordre en une ligne au
bout de sa section et poignées sur place, titre et ordre votés à leur place, Propositions seed en
tête, propositions repliées par section, panneau sans onglets, « oui pour passer · non pour
bloquer », quorum de l'auteur, votes simulés dits simulés ; C35 à C37 vus rouges. Interface seule
(serveur = `bb4bdf5`). Boucle verte dans sa copie (294 Rust, 64 vitest, 40 e2e) ; à regarder par Yvv :
`UX-lecteur.md`, la ligne d'ordre au survol, « le quorum la bloque », trois points sur téléphone.
**M7 en préparation** (`actes/2026-11-03-m7-lecteur.md`) : boucle verte sur `b2e597b` (e2e 40 / 40 au
second passage, C25 hors délai une fois sous charge), image construite à 09 h 01 ; répétition sur
une copie de la production de 08 h 49 : 0 écriture ; recette écran de la revue d'Yvv, avant / après :
page 6 079 → 2 529 px (bureau), 6 744 → 3 365 px (téléphone). Relecture neuve : « à reprendre » à
09 h 22 (le dossier : le quorum de l'administrateur), dossier corrigé, « b2e597b : ok » à 09 h 28.
**M7 en ligne** depuis le 03/11 (pipeline 39, `success` à 09 h 42, santé `b2e597b6`).
- **T2** (`lot/t2-phrases`) : les trois phrases de F1.1, F1.2, N1.1 relevées par la relecture de M6b, la
note d'inertie de l'interface, l'espace perdu de « dont N simulé », par un amendement gardé qui suit
celui de M6b. **Lancé le 03/11 à 09 h 45** ; part en M6c.
- **U2** (après T2, défaut retenu par orch le 03/11 à 09 h 45) : les mineurs de la relecture de M7.
**M6 en ligne** depuis le 03/11 à 07 h 03 (`94ddb17`, pipeline 37) : H, S et V. Production `94ddb175` ;
`main` et `chantier/acte-certification` poussés vers origin par orch. Le dépôt des textes reste à `9e0f45a`
@@ -90,8 +106,11 @@ Lots en cours, en copies de travail (`.claude/worktrees/agent-<id>`) :
**M6b en ligne** depuis le 03/11 à 08 h 02 (`bb4bdf5`, pipeline 38) : lot T, F1.1, F1.2 et N1.1 disent
le quorum par cran. Production `bb4bdf58` ; `main` et `chantier/acte-certification` poussés vers origin
par orch. Copies retirées (orch 08 h 13, intégrateur 08 h 14).
Suite : **U** (en cours, M7), puis **T2** (trois phrases, M6c), un sous-agent à la fois ; PRÊT et actes
par orch.
**M7 en ligne** depuis le 03/11 à 09 h 42 (`b2e597b`, pipeline 39) : lot U, le lecteur refait.
Production `b2e597b6` ; `main` et `chantier/acte-certification` poussés vers origin par orch. Copies
retirées (orch, puis l'intégrateur à 09 h 45).
Suite : **T2** (en cours depuis 09 h 45, M6c), puis **U2** (les mineurs de M7), un sous-agent à la fois ;
PRÊT et actes par orch.
Yvv relit les quatre textes en ligne. Au prochain tour, avec ses retours :
- N1.1 : G se calcule sur p̃ rapporté à W ;
@@ -185,8 +204,21 @@ Défauts posés par orch, sans question (Yvv peut les corriger) :
- M6 (H, S, V) : **exécuté** le 03/11/2026 par orch sous mandat, en ligne à 07 h 03 (pipeline 37) ; figé le 03/11 à 06 h 22 sur `94ddb17` (`actes/2026-11-03-m6-propositions-seed-quorum-tampon.md`) ; répétition faite sur une copie de la production de 06 h 11 ; relecture indépendante « 94ddb17 : ok » inscrite à 06 h 42 ; retour corrigé (couper `LD_PUBLISH_REMOTE` avant, répété) ; PRÊT envoyé à orch (dev-63), qui exécute sous mandat.
- M6b (lot T, textes du quorum) : **exécuté** le 03/11/2026 par orch sous mandat, 07 h 56 – 08 h 13 (`94ddb17..bb4bdf5`, pipeline 38 `success` à 08 h 12) ; F1.1 en révision 2, F1.2 et N1.1 en révision 3 dans les deux groupes, le reste identique à la ligne près ; redémarrage à froid sans rejeu ; dépôt des textes à `9e0f45a` ; image de retour `previous` = `94ddb17`. Copies retirées : celles d'orch à 08 h 13, celles de l'intégrateur à 08 h 14 (`m6/rep`, `repetition-t`). Figé le 03/11 à 07 h 55 sur `bb4bdf5` (`actes/2026-11-03-m6b-textes-quorum.md`) ; répétition sur une copie de la production de 07 h 36 ; boucle verte, image construite à 07 h 48 ; relecture « bb4bdf5 : ok » inscrite à 07 h 55 ; PRÊT M6b envoyé à orch à 07 h 56.
- M7 (lot U, le lecteur) : **exécuté** le 03/11/2026 par orch sous mandat, en ligne (pipeline 39 `success` à 09 h 42) ; santé `b2e597b6` ; redémarrage à froid sans rejeu ; base identique à la ligne près ; dépôt des textes à `9e0f45a` ; image de retour `previous` = `bb4bdf5`. Copies retirées : orch, puis l'intégrateur à 09 h 45. Figé le 03/11 à 09 h 28 sur `b2e597b` (`actes/2026-11-03-m7-lecteur.md`) ; interface seule, aucune écriture en base ; répétition et recette écran sur une copie de la production de 08 h 49 ; relecture « à reprendre » (le dossier) puis « b2e597b : ok » ; PRÊT M7 envoyé à orch à 09 h 29. À dire à Yvv : « Fixer ce quorum » visible pour l'administrateur sur les 33 articles du seed.
Restes mineurs de `71819f9` et du lot B : faits dans le lot D.
Liste d'attente (relecture de `b2e597b`, M7 ; lot U2 proposé après T2) :
- cibles de 36 px sur téléphone : « Tout déplier » (`DeepList.svelte:225`), « Autres versions »
(`VoteFold.svelte:345`), « Fixer ce quorum » (`QuorumSetting.svelte:64`) ;
- « Si tout pa » coupé sur téléphone, sans indice de défilement (`+page.svelte:525`) ;
- « Proposer cet ordre » éteint sans dire pourquoi (`Reorder.svelte:196`, `order.edit.unchanged`) ;
- redites dans le vote déplié : l'inertie deux fois, la ligne d'une Proposition seed au-dessus de son
vote, l'autre version deux fois ;
- « N propositions en cours » compte les ordres ;
- un changement d'inertie proposé sur un ordre ou le titre n'apparaît pas dans « Propositions en
cours » (aucun ouvert en production).
Liste d'attente (relecture de `94ddb17`, M6) :
- `sbom-publish` échoue encore (pipelines 36 et 37) : écart ancien (certificat du serveur
Dependency-Track refusé, HANDOFF), étape en `failure: ignore` ;
@@ -7,6 +7,135 @@
> « L'auteur, avant le 1er vote » ; les Propositions seed au cran 2 (ADR 0011). Les nouveaux textes sont
> collés dans `seeds/acte-engagement-certification.json` et dans un amendement gardé, sur la branche
> `lot/t-textes` : **rien n'est en ligne**.
>
> **Mis à jour le 2026-11-03 par le lot T2** (branche `lot/t2-phrases`), après la relecture
> indépendante de M6b : ces textes sont en ligne depuis M6b (03/11, 08 h 02), et trois de leurs
> phrases disaient plus que les règles. Elles sont reprises ci-dessous, avant et après
> (« Lot T2 »). Les blocs de texte de ce fichier sont ceux du seed d'aujourd'hui, phrases
> reprises comprises. Rien du lot T2 n'est en ligne.
## Lot T2 : trois phrases reprises (après la relecture de M6b)
La relecture indépendante de M6b (`bb4bdf5`) a laissé trois phrases qui disent plus que les
règles. Le lot T2 les réécrit, dans le seed (une base neuve naît avec elles) et par un
**amendement gardé** (`2026-11-03-f1-1-phrases`, `-f1-2-phrases`, `-n1-1-phrases`) qui suit celui
de M6b. Rien d'autre ne change dans ces textes ; les titres restent. Un test le vérifie phrase par
phrase (`the_amendments_of_lot_t2_go_from_the_texts_of_m6b_to_the_seed_of_today`), et que les
anciens mots ont disparu des trois langues (`the_three_phrases_of_the_review_of_m6b_are_written_true`).
### F1.1 — Formule du seuil WoT
- **Avant** : « Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article,
parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout
le monde vote. »
- **Après** : « Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article,
parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation
monte, jusqu'à la majorité M quand tout le monde vote. »
- **Pourquoi** : la part se demande à T' = max(T, Q) votants ; sous le quorum, c'est celle du
quorum, et au-delà elle ne fait que baisser. Elle n'atteint jamais l'unanimité parce que peu de
gens votent : c'est le quorum qui tient le texte. En production (W 5 707, participation de
référence 100 votants), au quorum : **64,4 · 73,5 · 77,4 · 85,3 · 91,9 %** du cran 1 au cran 5 ;
au cran 2, **9 « pour » sur 12** (73,5 % de 12, soit 8,8, arrondi au-dessus). La relecture disait
74 % au cran 2 : le moteur donne 73,5 %. Calculé par `the_control_figures_of_the_quorum`
(`crates/ld-engine/src/inertia.rs`), qui vérifie aussi, à chaque cran, que la part reste celle
du quorum en dessous, ne remonte jamais au-delà et vaut M quand tout le monde vote ; le test
des textes refait la même vérification sur une grille (W, quorum, cran).
### F1.2 — Paramètres par défaut
- **Avant** : « L'auteur de la première version d'un article, un administrateur pour les textes
de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne
change plus. »
- **Après** : « L'auteur de la première version d'un article, un administrateur pour les textes
de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote. »
- **Pourquoi** : « ensuite, il ne change plus » est faux dans deux cas. Après l'acte « supprime »,
les votes simulés effacés ne laissent pas de trace, et le quorum se règle de nouveau
(`quorum::voted_units`, `simulation::discard`). Dans le groupe d'essai, un vote direct retiré
ne laisse pas de trace non plus (`votes` ne garde que le dernier choix). Les deux cas sont
prouvés par les tests (`a_vote_exists_for_every_state_of_the_buffer`,
`the_author_of_an_article_sets_its_quorum_before_the_first_vote` : le quorum se fixe de
nouveau, 200 ; un vote prouvé par la chaîne, même retiré, le tient toujours, 409).
- **La formulation par défaut d'orch**, « tant qu'aucun vote n'est compté sur l'article », n'est
pas celle-ci : elle serait fausse dans l'autre sens. Un vote retiré sur la chaîne, ou en
simulation avant « supprime », n'est plus compté, mais il laisse sa trace et le quorum reste
figé ; de même une version entrée en vigueur dont les votes sont retirés. « Garder la trace »
est le mot de l'ADR 0012 pour cette règle. À trancher (point 8).
### N1.1 — Niveaux d'inertie (première phrase)
- **Avant** : « À chaque cran, le groupe demande un quorum de votants, puis une part de « pour »
parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent
comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation
habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand
tout le monde vote. »
- **Après** : « À chaque cran, le groupe demande une part de « pour » parmi les votants (voir
« Formule du seuil WoT »). Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de
votants : sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du
cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà du
quorum, plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde
vote. »
- **Pourquoi** : « demande un quorum de votants » laisse croire à un minimum. Moins de votants
que le quorum font passer un texte s'ils votent tous « pour » : en production, **8 sur 12, 9 sur
12, 39 sur 50, 43 sur 50, 46 sur 50**, du cran 1 au cran 5 (le moteur, dans
`the_three_phrases_of_the_review_of_m6b_are_written_true`). « Au-delà du quorum » est ajouté à
la troisième phrase : sous le quorum, la part ne baisse pas, elle reste celle du quorum.
### L'interface, dans le même sens
- Sous « Inertie » (`inertia.explain`) : « Plus l'inertie est haute, plus il faut de voix pour
changer ce texte quand peu de gens votent. » devient « Plus l'inertie est haute, plus il faut
de voix pour changer ce texte. Sous le quorum, les votants qui manquent comptent comme « non » ;
au-delà, la part de « pour » demandée baisse quand la participation monte. »
- Sous la formule (`formula.reading`), la même phrase fausse : « À faible participation, il faut
presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Sous le quorum Q, les
votants qui manquent comptent comme « non ». » devient « Sous le quorum Q, les votants qui
manquent comptent comme « non ». Au-delà, la part de « pour » demandée, la plus haute au quorum,
baisse quand la participation monte ; quand tout le monde vote, la majorité M suffit. »
- Le refus d'un quorum (`quorum.error.voted`) : « Quelqu'un a déjà voté sur cet article : son
quorum ne change plus. » devient « … son quorum ne change pas. »
- Le vote déplié affichait « 0 contredont 1 simulé » : l'espace revient (« 0 contre dont
1 simulé »). Dix autres espaces se perdaient de la même façon, en tête d'un bloc Svelte : dans
les détails d'une version (« proposé par Chiara· sur invitation ») et dans le nom du menu lu par
un lecteur d'écran (« Menu· Messages, 1 non lu »). Un test lit tous les composants
(`web/tests/unit/spaces.test.ts`).
- Laissés tels quels, parce qu'ils disent ta décision (« L'auteur, avant le 1er vote ») :
« l'auteur d'un article peut en fixer un autre avant le premier vote » (`settings.quorum.note`),
« Seule la personne qui a écrit l'article fixe son quorum, avant le premier vote »
(`quorum.error.forbidden`), « Tu fixes le quorum de cet article tant que personne n'a voté »
(`quorum.explain`). Ils ont la même imprécision que F1.2 (point 8).
### La garde, en production
Dans les deux groupes, F1.1 est à sa révision 2 (écrite par l'amendement de M6b), F1.2 et N1.1 à
leur révision 3 (M5, puis M6b). La garde de révision est étendue : elle admet la révision écrite
par les amendements que celui-ci suit, appliqués dans leur ordre, **le dernier étant celui de M6b,
et eux seuls** (l'historique « texte initial » puis autant de « texte de départ remplacé »), le
texte étant celui que M6b a posé (empreinte). Une base née le 02/11 avec les textes de M5 (F1.2 et
N1.1 à leur révision 2, écrite par M6b seul) est admise de même. Le reste de la garde ne change
pas : origine du seed, jamais entré en vigueur ni confirmé ; aucune trace de vote (vote du
serveur, prouvé ou simulé ; ligne de bulletin en attente ou prouvée, quel qu'en soit le sort) ;
aucune coquille, traduction de membre ni objection. Les amendements de M6b sont **dépassés** : ils
ne décident plus rien, leurs décisions du 03/11 restent.
Appliqué, dans une transaction, par document : le texte (fr) remplacé, la révision + 1 (F1.1 : 3 ;
F1.2 et N1.1 : 4) ; une ligne « texte de départ remplacé » dans l'historique, sans auteur ; en et es
remplacés ; la décision dans `seed_amendments` ; dans Monnaie libre, une ligne par article dans la
file de publication, qui attend le premier texte en vigueur. Refusé, le texte de M6b reste, avec
sa raison (`votes`, `ballots`, `corrected`, `translation_edited`, `signalled`, `revised`,
`text_differs`…), consignée une fois pour toutes.
Testé (`crates/ld-server/tests/seed_quorum_texts.rs`) sur une base faite comme la production
après M6b — née avec les textes du 21/10, démarrée par le seed de M5, puis par celui de M6, puis
par celui de M6b (les textes de M6b gardés dans `tests/fixtures/seed-quorum-m6b.json`) :
appliqué une fois dans les deux groupes, six révisions et trois lignes de file de plus, rien
d'autre ; au second démarrage, rien n'est rejoué. Refusé avec sa raison là où un vote simulé est
compté (`votes`), là où un bulletin attend (`ballots`), là où un vote direct tient dans le groupe
d'essai (`votes`) ; le refus tient même une fois le vote parti. **Deux limites, écrites** (le
commentaire de `seed_amend.rs`) : dans le groupe d'essai, un vote direct retiré ne laisse pas de
trace, et l'amendement s'applique (testé) ; de même un vote simulé que l'acte « supprime » aurait
effacé avant la mise en ligne de T2 (l'amendement décide une fois, au premier démarrage).
En production aujourd'hui : 0 vote, 0 bulletin, une seule identité (dit par orch).
## Avant de lire
@@ -29,7 +158,7 @@
WoT », « Paramètres par défaut », « Niveaux d'inertie »), jamais par le code ; apostrophes droites ;
« » espacés en français, “ ” en anglais britannique (*favour*), «» serrés en espagnol ; formules en
code, avec × et −, jamais d'astérisque.
- **Longueur** (signes) : fr : F1.1 1081 · F1.2 2055 · N1.1 2372 ; en : F1.1 1058 · F1.2 1977 · N1.1 2246 ; es : F1.1 1083 · F1.2 2035 · N1.1 2454. Limite d'une proposition : 4 000 (`crates/ld-server/src/proposals.rs:32`) ;
- **Longueur** (signes, après le lot T2 ; avant : fr 1081 · 2055 · 2372, en 1058 · 1977 · 2246, es 1083 · 2035 · 2454) : fr : F1.1 1122 · F1.2 2035 · N1.1 2449 ; en : F1.1 1093 · F1.2 1950 · N1.1 2315 ; es : F1.1 1126 · F1.2 2020 · N1.1 2526. Limite d'une proposition : 4 000 (`crates/ld-server/src/proposals.rs:32`) ;
au-delà de 700, l'assistant dit « texte long » (`crates/ld-engine/src/assist.rs:25`), comme pour les
textes actuels de F1.2 et N1.1.
- **Le ton** est celui de F1.2, N1 et O1 tels que M5 les a posés : le groupe, la personne, jamais le
@@ -87,7 +216,7 @@ toutes : l'acte « supprime » de la fin de la simulation ne le fera pas rejouer
Titre : **Formule du seuil WoT**
```markdown
Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.
Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.
`T' = max(T, Q)`
`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`
@@ -109,7 +238,7 @@ Leurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie »
Titre : **WoT threshold formula**
```markdown
The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: near-unanimity when few people vote, the majority M when everyone votes.
The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.
`T' = max(T, Q)`
`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`
@@ -131,7 +260,7 @@ Their values are in “Default parameters” and “Inertia levels”.
Titre : **Fórmula del umbral WoT**
```markdown
El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.
El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.
`T' = max(T, Q)`
`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`
@@ -173,7 +302,7 @@ Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la fa
- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.
- **T** : les votes exprimés sur la version, pour et contre.
- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.
- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.
- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.
- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.
@@ -200,7 +329,7 @@ The values of the letters of the formula (see “WoT threshold formula”) and h
- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.
- **T**: the votes cast on the version, for and against.
- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as nobody has voted on the article; after that, it no longer changes. In Monnaie libre, never less than 2: a single vote never passes a text.
- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as the article keeps no trace of any vote. In Monnaie libre, never less than 2: a single vote never passes a text.
- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.
- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.
@@ -227,7 +356,7 @@ Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y có
- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.
- **T**: los votos emitidos sobre la versión, a favor y en contra.
- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.
- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras el artículo no guarde rastro de ningún voto. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.
- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.
- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.
@@ -250,7 +379,7 @@ Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y có
Titre : **Niveaux d'inertie**
```markdown
À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.
À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants : sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà du quorum, plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.
`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque.
@@ -274,7 +403,7 @@ Exemple : avec 7 224 membres et une participation habituelle de 120 votants, qua
Titre : **Inertia levels**
```markdown
At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.
At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum, which is not a minimum number of voters: below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: beyond the quorum, the more voters, the lower it gets, down to the majority M when everyone votes.
`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing.
@@ -298,7 +427,7 @@ Example: with 7,224 members and a usual turnout of 120 voters, when 120 people v
Titre : **Niveles de inercia**
```markdown
En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.
En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum, que no es un número mínimo de votantes: por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá del quórum, cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.
`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan.
@@ -356,10 +485,29 @@ inertie, le cran de départ de Raison d'être, Unicité du compte et Réciprocit
7. **Ce qui deviendra caduc une fois en ligne**, non touché par ce lot : `docs/adr/0013-quorum.md:133-135`
(« Le texte F1.1 du seed […] et N1.1 […] décrivent encore C ») et
`docs/adr/0011-propositions-seed.md:89-90` (F1.2 et N1.1 disent encore Unicité et Réciprocité au
cran 4).
cran 4). *(Fait le 03/11 après M6b, commit `6aeb690`.)*
8. **Lot T2, F1.2 : « garder la trace » ou « compté »**. « tant que l'article ne garde la trace
d'aucun vote » est exact dans tous les cas de `quorum::voted_units` ; « tant qu'aucun vote
n'est compté sur l'article » (défaut d'orch) se lit mieux mais se trompe pour un vote retiré
sur la chaîne (plus compté, le quorum reste figé). Les trois phrases de l'interface sur le
quorum de l'auteur (« avant le premier vote », « tant que personne n'a voté ») gardent ta
formule : à aligner si tu le veux.
9. **Lot T2, F1.1 et N1.1** : sans chiffre, pour ne pas dater le texte voté ; les chiffres de la
production (64,4 à 91,9 % ; 9 « pour » sur 12) sont ici, calculés par le moteur.
## Les chiffres et leur source
Lot T2 (ces chiffres sont dans ce fichier, pas dans les textes) :
| Chiffre | Source |
|---|---|
| 64,4 · 73,5 · 77,4 · 85,3 · 91,9 % au quorum, W 5 707, référence 100 votants | `threshold::required_ratio` sur `judged_voters(0, Q_k)` ; `the_control_figures_of_the_quorum` |
| 9 « pour » sur 12 au cran 2 | `threshold::yes_needed(0)` ; idem |
| 8/12, 9/12, 39/50, 43/50, 46/50 passent sans autre votant | `yes_needed(0)`, puis `passes` ; `the_three_phrases_of_the_review_of_m6b_are_written_true` |
| La part reste celle du quorum en dessous, ne remonte jamais au-delà, vaut M à W votants | les deux mêmes tests, à chaque cran (et sur une grille de W et de quorums) |
Lot T :
Chaque nombre est vérifié par `every_number_of_the_quorum_texts_is_the_engine_s`
(`crates/ld-server/tests/seed_quorum_texts.rs`), qui le calcule ainsi :
+10 -10
View File
@@ -1,5 +1,5 @@
{
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document. F1.1, F1.2 et N1.1 réécrits pour le quorum par cran (ADR 0013 ; Yvv, 2026-11-02 et 03 ; lot T, textes relus dans docs/chantiers/acte-certification/textes-quorum-seed.md) : sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran.",
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). Aucun article en vigueur au départ. I1, E1, E2 : Propositions seed (« seedProposal »), ouvertes, mises en avant pour amorcer le vote, sans niveau d'inertie ici : elles démarrent au cran SEED_PROPOSAL_LEVEL (crates/ld-server/src/seed.rs : 2 « basse », choix d'Yvv du 2026-11-02, qui remontera leur inertie par un vote une fois les textes passés ; ADR 0011). Tant qu'aucun article n'est en vigueur, la publication attend le premier texte. Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21), inertie 3 / 5. I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2, en vigueur sans vote (seed a minima ; revue du 2026-10-30, ADR 0009). F1.2, N1, N1.1, O1 réécrits selon les règles appliquées (Yvv, 2026-11-02). Quorums par cran (« quorums », ADR 0013, Yvv, 2026-11-02 et 03) : 12 · 12 · 50 · 50 · 50 dans Monnaie libre, 1 partout dans le groupe d'essai ; sous le quorum, les votants qui manquent comptent comme « non » ; voteFloor reste le quorum de référence de la participation p0 = 2 × voteFloor / W. Une base existante reçoit ces changements par des amendements gardés (seed_amend, seed_proposal_amend, quorum_amend), jamais en réinstanciant le document. F1.1, F1.2 et N1.1 réécrits pour le quorum par cran (ADR 0013 ; Yvv, 2026-11-02 et 03 ; lot T, textes relus dans docs/chantiers/acte-certification/textes-quorum-seed.md) : sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part du cran. Trois phrases reprises après la relecture de M6b (lot T2) : la part du cran, la plus haute au quorum, baisse quand la participation monte (F1.1) ; l'auteur fixe le quorum tant que l'article ne garde la trace d'aucun vote (F1.2) ; le quorum n'est pas un nombre minimal de votants (N1.1).",
"slug": "acte-engagement-certification",
"title": {
"fr": "Acte d'engagement Certification",
@@ -672,9 +672,9 @@
"es": "Fórmula del umbral WoT"
},
"body": {
"fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : presque l'unanimité quand peu de gens votent, la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».",
"en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: near-unanimity when few people vote, the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.",
"es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: casi la unanimidad cuando vota poca gente, la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»."
"fr": "Le seuil est le nombre de voix pour qu'il faut à une version pour entrer en vigueur. Sous le quorum Q, les votants qui manquent comptent comme « non » : le seuil se calcule comme si Q personnes avaient voté. Au-delà, il faut la part de « pour » que demande le cran d'inertie de l'article, parmi tous les votants : cette part est la plus haute au quorum et baisse quand la participation monte, jusqu'à la majorité M quand tout le monde vote.\n\n`T' = max(T, Q)`\n`Seuil = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUne version passe quand ses voix pour atteignent le seuil, arrondi à l'entier au-dessus.\n\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q** : le quorum de l'article ; **T'** : les votants retenus, T, ou Q s'il en manque.\n- **W** : le nombre de membres de la toile de confiance Ğ1.\n- **B** : la base ; à la puissance W, elle est si petite qu'elle ne compte pas.\n- **M** : la majorité demandée quand tout le monde vote.\n- **G** : le gradient : plus il est grand, plus la part demandée monte quand peu de gens votent.\n\nLeurs valeurs sont dans « Paramètres par défaut » et « Niveaux d'inertie ».",
"en": "The threshold is the number of votes in favour a version needs to come into force. Below the quorum Q, the voters who are missing count as votes against: the threshold is worked out as if Q people had voted. Beyond it, it takes the share of votes in favour that the article's level of inertia asks for, among all the voters: that share is highest at the quorum and falls as turnout rises, down to the majority M when everyone votes.\n\n`T' = max(T, Q)`\n`Threshold = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nA version passes when its votes in favour reach the threshold, rounded up to a whole number.\n\n- **T**: the votes cast on the version, for and against.\n- **Q**: the article's quorum; **T'**: the voters counted, T, or Q if some are missing.\n- **W**: the number of members of the Ğ1 web of trust.\n- **B**: the base; raised to the power W, it is so small that it does not count.\n- **M**: the majority asked for when everyone votes.\n- **G**: the gradient: the larger it is, the higher the share asked for when few people vote.\n\nTheir values are in “Default parameters” and “Inertia levels”.",
"es": "El umbral es el número de votos a favor que necesita una versión para entrar en vigor. Por debajo del quórum Q, los votantes que faltan cuentan como «no»: el umbral se calcula como si hubieran votado Q personas. Más allá, hace falta la proporción de votos a favor que pide el nivel de inercia del artículo, entre todos los votantes: esa proporción es la más alta en el quórum y baja cuando sube la participación, hasta la mayoría M cuando vota todo el mundo.\n\n`T' = max(T, Q)`\n`Umbral = B^W + (M + (1 − M) × (1 − (T'/W)^G)) × T'`\n\nUna versión pasa cuando sus votos a favor alcanzan el umbral, redondeado al entero superior.\n\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**: el quórum del artículo; **T'**: los votantes contados, T, o Q si faltan.\n- **W**: el número de miembros de la red de confianza Ğ1.\n- **B**: la base; elevada a la potencia W, es tan pequeña que no cuenta.\n- **M**: la mayoría pedida cuando vota todo el mundo.\n- **G**: el gradiente: cuanto mayor es, más alta es la proporción pedida cuando vota poca gente.\n\nSus valores están en «Parámetros por defecto» y «Niveles de inercia»."
}
}
]
@@ -694,9 +694,9 @@
"es": "Parámetros por defecto"
},
"body": {
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que personne n'a voté sur l'article ; ensuite, il ne change plus. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, un quorum de 50 dans Monnaie libre, et le groupe demande 75 % de « pour » quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3.",
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as nobody has voted on the article; after that, it no longer changes. In Monnaie libre, never less than 2: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3.",
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras nadie haya votado sobre el artículo; después, ya no cambia. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3."
"fr": "Les valeurs des lettres de la formule (voir « Formule du seuil WoT ») et la façon dont le vote se déroule.\n\n**Un vote permanent, sans durée**\n\n- Chaque version se vote à tout moment, sans échéance. Chacun peut changer ou retirer son vote : le dernier fait foi.\n- Une proposition entre en vigueur dès qu'elle atteint son seuil. S'il y a déjà un texte en vigueur, il lui faut en plus une marge (voix pour moins voix exigées) plus large que celle de ce texte. Le texte remplacé redevient une proposition, avec ses votes : il peut revenir.\n- Si plusieurs propositions y parviennent, celle qui a la marge la plus large entre en vigueur.\n\n**Les lettres**\n\n- **W** : le nombre de membres de la toile de confiance Ğ1, relu sur la chaîne toutes les 6 heures.\n- **T** : les votes exprimés sur la version, pour et contre.\n- **Q**, le quorum : sous ce nombre de votants, ceux qui manquent comptent comme « non ». Chaque article prend celui de son cran : 12, 12, 50, 50 et 50 dans Monnaie libre, du cran 1 au cran 5. L'auteur de la première version d'un article, un administrateur pour les textes de départ, peut en fixer un autre tant que l'article ne garde la trace d'aucun vote. Dans Monnaie libre, jamais moins de 2 : une seule voix ne fait jamais passer un texte.\n- **B = 0,1** : dans la toile de confiance, `B^W` est si petit qu'il ne compte pas.\n- **M** et **G** : donnés par le cran d'inertie de l'article (voir « Niveaux d'inertie »). G n'est pas fixé à la main : il se déduit de la participation habituelle du groupe.\n\n**Par défaut**\n\n- Le cran par défaut est le 3 (moyenne) : M = 55 %, un quorum de 50 dans Monnaie libre, et le groupe demande 75 % de « pour » quand il vote comme d'habitude. Tout nouvel article commence à ce cran.\n- Au départ, aucun article n'est en vigueur. Raison d'être, Unicité du compte et Réciprocité sont des Propositions seed, mises en avant pour lancer le vote, au cran 2 (basse). Le titre du document est au cran 4 (haute), l'ordre des articles de chaque section au cran 2, tous les autres articles au cran 3.",
"en": "The values of the letters of the formula (see “WoT threshold formula”) and how the vote runs.\n\n**A permanent vote, with no duration**\n\n- Every version can be voted on at any time, with no deadline. Anyone can change or withdraw their vote: the last one is the one that counts.\n- A proposal comes into force as soon as it reaches its threshold. If a text is already in force, it also needs a wider margin (votes in favour minus votes required) than that text has. The replaced text becomes a proposal again, with its votes: it can come back.\n- If several proposals get there, the one with the widest margin comes into force.\n\n**The letters**\n\n- **W**: the number of members of the Ğ1 web of trust, read again from the chain every 6 hours.\n- **T**: the votes cast on the version, for and against.\n- **Q**, the quorum: below this number of voters, those who are missing count as votes against. Each article takes the one of its level: 12, 12, 50, 50 and 50 in Monnaie libre, from level 1 to level 5. The author of the first version of an article, an administrator for the starting texts, can set another one as long as the article keeps no trace of any vote. In Monnaie libre, never less than 2: a single vote never passes a text.\n- **B = 0.1**: in the web of trust, `B^W` is so small that it does not count.\n- **M** and **G**: given by the article's level of inertia (see “Inertia levels”). G is not set by hand: it follows from the group's usual turnout.\n\n**By default**\n\n- The default level is 3 (medium): M = 55%, a quorum of 50 in Monnaie libre, and the group asks for 75% of votes in favour when it votes as usual. Every new article starts at this level.\n- At the start, no article is in force. Purpose, Uniqueness of the account and Reciprocity are seed proposals, put forward to start the vote, at level 2 (low). The title of the document is at level 4 (high), the order of the articles of each section at level 2, all other articles at level 3.",
"es": "Los valores de las letras de la fórmula (ver «Fórmula del umbral WoT») y cómo se desarrolla la votación.\n\n**Una votación permanente, sin duración**\n\n- Cada versión se vota en cualquier momento, sin plazo. Cada cual puede cambiar o retirar su voto: el último es el que cuenta.\n- Una propuesta entra en vigor en cuanto alcanza su umbral. Si ya hay un texto en vigor, necesita además un margen (votos a favor menos votos exigidos) más amplio que el de ese texto. El texto sustituido vuelve a ser una propuesta, con sus votos: puede volver.\n- Si varias propuestas lo consiguen, entra en vigor la que tiene el margen más amplio.\n\n**Las letras**\n\n- **W**: el número de miembros de la red de confianza Ğ1, leído de nuevo en la cadena cada 6 horas.\n- **T**: los votos emitidos sobre la versión, a favor y en contra.\n- **Q**, el quórum: por debajo de este número de votantes, los que faltan cuentan como «no». Cada artículo toma el de su nivel: 12, 12, 50, 50 y 50 en Monnaie libre, del nivel 1 al nivel 5. El autor de la primera versión de un artículo, un administrador para los textos de partida, puede fijar otro mientras el artículo no guarde rastro de ningún voto. En Monnaie libre, nunca menos de 2: un solo voto nunca hace pasar un texto.\n- **B = 0,1**: en la red de confianza, `B^W` es tan pequeño que no cuenta.\n- **M** y **G**: los da el nivel de inercia del artículo (ver «Niveles de inercia»). G no se fija a mano: se deduce de la participación habitual del grupo.\n\n**Por defecto**\n\n- El nivel por defecto es el 3 (media): M = 55 %, un quórum de 50 en Monnaie libre, y el grupo pide un 75 % de votos a favor cuando vota como de costumbre. Todo artículo nuevo empieza en este nivel.\n- Al inicio, ningún artículo está en vigor. Razón de ser, Unicidad de la cuenta y Reciprocidad son Propuestas seed, puestas en primer plano para lanzar la votación, en el nivel 2 (baja). El título del documento está en el nivel 4 (alta), el orden de los artículos de cada sección en el nivel 2, todos los demás artículos en el nivel 3."
}
}
]
@@ -760,9 +760,9 @@
"es": "Niveles de inercia"
},
"body": {
"fr": "À chaque cran, le groupe demande un quorum de votants, puis une part de « pour » parmi eux (voir « Formule du seuil WoT »). Sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque.\n\n**Les cinq crans** : le quorum dans Monnaie libre, la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude et p̃ la participation habituelle divisée par W.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 72, 81, 90, 100 ou 108 voix pour, du cran 1 au cran 5 ; au cran 3, 75 % des 120 votants, soit 90. Sous le quorum, les absents comptent comme « non » : au cran 2 (quorum 12), 9 voix pour passent avec 3 « contre » au plus ; au cran 3 (quorum 50), il faut 39 voix pour, avec 11 « contre » au plus.",
"en": "At each level, the group asks for a quorum of voters, then for a share of votes in favour among them (see “WoT threshold formula”). Below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most.",
"es": "En cada nivel, el grupo pide un quórum de votantes, y luego una proporción de votos a favor entre ellos (ver «Fórmula del umbral WoT»). Por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho."
"fr": "À chaque cran, le groupe demande une part de « pour » parmi les votants (voir « Formule du seuil WoT »). Chaque cran a aussi son quorum, qui n'est pas un nombre minimal de votants : sous le quorum, les votants qui manquent comptent comme « non ». La part dépend du cran et du nombre de votants, comparé à la participation habituelle du groupe : au-delà du quorum, plus il y a de votants, plus elle baisse, jusqu'à la majorité M quand tout le monde vote.\n\n`Part exigée = M + (1 − M) × (1 − (T'/W)^G)`, où T' est le nombre de votants, ou le quorum s'il en manque.\n\n**Les cinq crans** : le quorum dans Monnaie libre, la part exigée quand le groupe vote comme d'habitude, puis quand tout le monde vote, et ce qui est à ce cran au départ.\n\n- **Cran 1 · très basse** : quorum 12 ; 60 % comme d'habitude, 50 % quand tout le monde vote. Aucun article au départ.\n- **Cran 2 · basse** : quorum 12 ; 67 % comme d'habitude, 50 % quand tout le monde vote. Raison d'être, Unicité du compte, Réciprocité, et l'ordre des articles de chaque section.\n- **Cran 3 · moyenne** : quorum 50 ; 75 % comme d'habitude, 55 % quand tout le monde vote. Le cran par défaut : tous les autres articles, et chaque nouvel article.\n- **Cran 4 · haute** : quorum 50 ; 83 % comme d'habitude, 60 % quand tout le monde vote. Le titre du document.\n- **Cran 5 · très haute** : quorum 50 ; 90 % comme d'habitude, 66 % quand tout le monde vote. Aucun article au départ.\n\n**La participation habituelle** est la médiane du nombre de votants des propositions du groupe votées depuis un an. Tant que le groupe n'a pas voté sur 5 propositions, elle vaut deux fois le quorum de référence du document : 100 votants dans Monnaie libre. Elle ne dépend pas du quorum d'un article. Elle reste entre 1 % et 50 % de l'électorat, et se mesure une fois par jour.\n\n**G** se calcule pour que la part exigée, à la participation habituelle, soit exactement celle du cran : `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, où s est la part exigée comme d'habitude et p̃ la participation habituelle divisée par W.\n\nExemple : avec 7 224 membres et une participation habituelle de 120 votants, quand 120 personnes votent, il faut 72, 81, 90, 100 ou 108 voix pour, du cran 1 au cran 5 ; au cran 3, 75 % des 120 votants, soit 90. Sous le quorum, les absents comptent comme « non » : au cran 2 (quorum 12), 9 voix pour passent avec 3 « contre » au plus ; au cran 3 (quorum 50), il faut 39 voix pour, avec 11 « contre » au plus.",
"en": "At each level, the group asks for a share of votes in favour among the voters (see “WoT threshold formula”). Each level also has its quorum, which is not a minimum number of voters: below the quorum, the voters who are missing count as votes against. The share depends on the level and on the number of voters, compared with the group's usual turnout: beyond the quorum, the more voters, the lower it gets, down to the majority M when everyone votes.\n\n`Share asked = M + (1 − M) × (1 − (T'/W)^G)`, where T' is the number of voters, or the quorum if some are missing.\n\n**The five levels**: the quorum in Monnaie libre, the share asked when the group votes as usual, then when everyone votes, and what is at that level at the start.\n\n- **Level 1 · very low**: quorum 12; 60% as usual, 50% when everyone votes. No article at the start.\n- **Level 2 · low**: quorum 12; 67% as usual, 50% when everyone votes. Purpose, Uniqueness of the account, Reciprocity, and the order of the articles of each section.\n- **Level 3 · medium**: quorum 50; 75% as usual, 55% when everyone votes. The default level: all other articles, and every new article.\n- **Level 4 · high**: quorum 50; 83% as usual, 60% when everyone votes. The title of the document.\n- **Level 5 · very high**: quorum 50; 90% as usual, 66% when everyone votes. No article at the start.\n\n**The usual turnout** is the median number of voters on the group's proposals voted on over the past year. Until the group has voted on 5 proposals, it is twice the document's reference quorum: 100 voters in Monnaie libre. It does not depend on the quorum of an article. It stays between 1% and 50% of the electorate, and is measured once a day.\n\n**G** is computed so that the share asked, at the usual turnout, is exactly that of the level: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, where s is the share asked as usual and p̃ the usual turnout divided by W.\n\nExample: with 7,224 members and a usual turnout of 120 voters, when 120 people vote, it takes 72, 81, 90, 100 or 108 votes in favour, from level 1 to level 5; at level 3, 75% of the 120 voters, i.e. 90. Below the quorum, those missing count as votes against: at level 2 (quorum 12), 9 votes in favour pass with 3 votes against at most; at level 3 (quorum 50), it takes 39 votes in favour, with 11 votes against at most.",
"es": "En cada nivel, el grupo pide una proporción de votos a favor entre los votantes (ver «Fórmula del umbral WoT»). Cada nivel tiene además su quórum, que no es un número mínimo de votantes: por debajo del quórum, los votantes que faltan cuentan como «no». La proporción depende del nivel y del número de votantes, comparado con la participación habitual del grupo: más allá del quórum, cuantos más votantes, más baja, hasta la mayoría M cuando vota todo el mundo.\n\n`Proporción exigida = M + (1 − M) × (1 − (T'/W)^G)`, donde T' es el número de votantes, o el quórum si faltan.\n\n**Los cinco niveles**: el quórum en Monnaie libre, la proporción exigida cuando el grupo vota como de costumbre, luego cuando vota todo el mundo, y lo que está en ese nivel al inicio.\n\n- **Nivel 1 · muy baja**: quórum 12; 60 % como de costumbre, 50 % cuando vota todo el mundo. Ningún artículo al inicio.\n- **Nivel 2 · baja**: quórum 12; 67 % como de costumbre, 50 % cuando vota todo el mundo. Razón de ser, Unicidad de la cuenta, Reciprocidad, y el orden de los artículos de cada sección.\n- **Nivel 3 · media**: quórum 50; 75 % como de costumbre, 55 % cuando vota todo el mundo. El nivel por defecto: todos los demás artículos, y todo artículo nuevo.\n- **Nivel 4 · alta**: quórum 50; 83 % como de costumbre, 60 % cuando vota todo el mundo. El título del documento.\n- **Nivel 5 · muy alta**: quórum 50; 90 % como de costumbre, 66 % cuando vota todo el mundo. Ningún artículo al inicio.\n\n**La participación habitual** es la mediana del número de votantes de las propuestas del grupo votadas en el último año. Hasta que el grupo haya votado 5 propuestas, vale dos veces el quórum de referencia del documento: 100 votantes en Monnaie libre. No depende del quórum de un artículo. Se mantiene entre el 1 % y el 50 % del electorado, y se mide una vez al día.\n\n**G** se calcula para que la proporción exigida, con la participación habitual, sea exactamente la del nivel: `G = ln(1 − (s − M)/(1 − M)) / ln(p̃)`, donde s es la proporción exigida como de costumbre y p̃ la participación habitual dividida por W.\n\nEjemplo: con 7224 miembros y una participación habitual de 120 votantes, cuando votan 120 personas, hacen falta 72, 81, 90, 100 o 108 votos a favor, del nivel 1 al nivel 5; en el nivel 3, el 75 % de los 120 votantes, es decir 90. Por debajo del quórum, los que faltan cuentan como «no»: en el nivel 2 (quórum 12), 9 votos a favor pasan con 3 en contra como mucho; en el nivel 3 (quórum 50), hacen falta 39 votos a favor, con 11 en contra como mucho."
}
}
]
+2 -1
View File
@@ -230,7 +230,8 @@ test('C37 — le quorum d’un article : son auteur le fixe avant le premier vot
expect(r.ok()).toBe(true);
await field.fill('2');
await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click();
await expect(quorum.getByRole('alert')).toHaveText("Quelqu'un a déjà voté sur cet article : son quorum ne change plus.");
// « ne change pas », not « plus »: a vote withdrawn here leaves no trace (lot T2).
await expect(quorum.getByRole('alert')).toHaveText("Quelqu'un a déjà voté sur cet article : son quorum ne change pas.");
await page.reload();
await expect(fold).toBeVisible();
await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0);
+4
View File
@@ -260,6 +260,10 @@ test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simul
await expect(fold.getByText("La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé.", { exact: false })).toHaveCount(1);
const n = before.p.tallySimulated.against + 1;
await expect(fold.getByText(n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`, { exact: true })).toBeVisible();
// The line reads with its space: never « 0 contredont 1 simulé » (review of M6b).
await expect(fold.locator('p.tally')).toHaveText(
`0 pour · ${before.p.tally.against + 1} contre ${n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`}`,
);
// Counted at once, marked as simulated.
const after = await version(page, title);
expect(after.p.myVoteState).toBe('simulated');
+7 -7
View File
@@ -242,7 +242,7 @@
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/if}
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
{#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited}{' '}· {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}
</p>
</header>
@@ -255,7 +255,7 @@
{#if detail.diff}
<button class="btn quiet small" type="button" onclick={() => (showDiff = !showDiff)}>{showDiff ? t('panel.text') : t('panel.changes')}</button>
{/if}
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale}{' '}· <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
{:else if p.kind === 'remove' && detail.inForce}
<del class="removed"><Prose text={detail.inForce.body} /></del>
{:else if p.kind === 'order'}
@@ -266,7 +266,7 @@
{#each orderNote({ ...p, order }) as note (note)}<p class="faint note">{note}</p>{/each}
<p class="faint note">{t('module.order.rule')}</p>
{:else if p.kind === 'title'}
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated}{' '}· {t('doc.lang.translation')}{#if text.stale}{' '}· <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
{/if}
{#if live}
@@ -302,7 +302,7 @@
<li class="fix {fix.status}">
<DiffText parts={condense(fix.diff)} />
<p class="faint small">
{t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)}
{t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited}{' '}· {t('author.invited')}{/if} · {i18n.date(fix.at)}
</p>
<p class="small state">{typoState(fix)}</p>
{#if fix.gestures.length}
@@ -339,7 +339,7 @@
{#each detail.history.slice(1) as h (h.at + h.revision)}
<li>
<p class="small">
<b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}
<b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author}{' '}· {t('history.by', { name: h.author })}{#if h.authorInvited}{' '}· {t('author.invited')}{/if}{/if}
{#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if}
</p>
{#if h.diff}<DiffText parts={h.diff} />{/if}
@@ -361,8 +361,8 @@
</p>
<p class="reason">{o.reason}</p>
<p class="faint small">
{t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)}
· {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if}
{t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited}{' '}· {t('author.invited')}{/if} · {i18n.date(o.at)}
· {stateOf(o)}{#if standing && o.answer}{' '}· {t(`objection.answer.${o.answer}`)}{/if}
</p>
{#if standing && o.held}<p class="small held">{t('objection.held.here')}</p>{/if}
{#if standing}
+1 -1
View File
@@ -160,7 +160,7 @@
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} <span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against}{' '}<span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
{#if p.evaluation}
{#if need}<p class="need" title={need.title}>{need.long}</p>{/if}
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
+3 -3
View File
@@ -304,7 +304,7 @@ export const en: Messages = {
'quorum.reset.done.one': 'Quorum back to the default: {n} voter.',
'quorum.reset.done.other': 'Quorum back to the default: {n} voters.',
'quorum.error.quorum': 'This quorum is out of bounds: at least 2 voters (1 in the test group), at most {w}, the electorate.',
'quorum.error.voted': 'Someone has already voted on this article: its quorum no longer changes.',
'quorum.error.voted': 'Someone has already voted on this article: its quorum does not change.',
'quorum.error.forbidden': 'Only the person who wrote the article sets its quorum, before the first vote.',
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
@@ -400,7 +400,7 @@ export const en: Messages = {
'ballots.verify.unreadable': 'This is not a readable vote receipt.',
'inertia.title': 'Inertia',
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text. Below the quorum, the missing voters count as “no”; beyond it, the share of “yes” asked for falls as turnout rises.',
'inertia.change': 'Propose an inertia change',
'inertia.change.to': 'New inertia',
'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.",
@@ -421,7 +421,7 @@ export const en: Messages = {
'formula.B': 'B — base: {n}',
'formula.Q': 'Q — quorum: {n}',
'formula.R': 'R — current threshold: {n} votes in favor',
'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Below the quorum Q, the missing voters count as against.',
'formula.reading': 'Below the quorum Q, the missing voters count as against. Beyond it, the share of votes in favour asked for, highest at the quorum, falls as turnout rises; when everyone votes, majority M is enough.',
'common.pct': '{n}%',
'settings.title': 'Formula settings',
'settings.intro': 'To change a text, the group asks for a number of votes in favour that depends on its electorate, its usual turnout and the inertia of the article.',
+3 -3
View File
@@ -304,7 +304,7 @@ export const es: Messages = {
'quorum.reset.done.one': 'Quórum de vuelta al valor por defecto: {n} votante.',
'quorum.reset.done.other': 'Quórum de vuelta al valor por defecto: {n} votantes.',
'quorum.error.quorum': 'Este quórum se sale de los límites: al menos 2 votantes (1 en el grupo de prueba), como mucho {w}, el electorado.',
'quorum.error.voted': 'Alguien ya ha votado sobre este artículo: su quórum ya no cambia.',
'quorum.error.voted': 'Alguien ya ha votado sobre este artículo: su quórum no cambia.',
'quorum.error.forbidden': 'Solo la persona que escribió el artículo fija su quórum, antes del primer voto.',
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
@@ -400,7 +400,7 @@ export const es: Messages = {
'ballots.verify.unreadable': 'No es un recibo de voto legible.',
'inertia.title': 'Inercia',
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto. Por debajo del quórum, los votantes que faltan cuentan como «no»; más allá, la proporción de «sí» pedida baja cuando sube la participación.',
'inertia.change': 'Proponer un cambio de inercia',
'inertia.change.to': 'Nueva inercia',
'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia del artículo: {level}.',
@@ -421,7 +421,7 @@ export const es: Messages = {
'formula.B': 'B — base: {n}',
'formula.Q': 'Q — quórum: {n}',
'formula.R': 'R — umbral actual: {n} votos a favor',
'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Por debajo del quórum Q, los votantes que faltan cuentan como «no».',
'formula.reading': 'Por debajo del quórum Q, los votantes que faltan cuentan como «no». Más allá, la proporción de votos a favor pedida, la más alta en el quórum, baja cuando sube la participación; cuando vota todo el mundo, basta la mayoría M.',
'common.pct': '{n} %',
'settings.title': 'Ajustes de la fórmula',
'settings.intro': 'Para cambiar un texto, el grupo pide un número de votos a favor que depende de su electorado, de su participación habitual y de la inercia del artículo.',
+3 -3
View File
@@ -308,7 +308,7 @@ export const fr = {
'quorum.reset.done.one': 'Quorum revenu au défaut : {n} votant.',
'quorum.reset.done.other': 'Quorum revenu au défaut : {n} votants.',
'quorum.error.quorum': "Ce quorum sort des bornes : au moins 2 votants (1 dans le groupe d'essai), au plus {w}, l'électorat.",
'quorum.error.voted': "Quelqu'un a déjà voté sur cet article : son quorum ne change plus.",
'quorum.error.voted': "Quelqu'un a déjà voté sur cet article : son quorum ne change pas.",
'quorum.error.forbidden': "Seule la personne qui a écrit l'article fixe son quorum, avant le premier vote.",
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
@@ -404,7 +404,7 @@ export const fr = {
'ballots.verify.unreadable': "Ce n'est pas un reçu de vote lisible.",
'inertia.title': 'Inertie',
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte. Sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part de « pour » demandée baisse quand la participation monte.",
'inertia.change': "Proposer un changement d'inertie",
'inertia.change.to': 'Nouvelle inertie',
'inertia.change.note': "Un changement d'inertie se vote lui-même à l'inertie de l'article : {level}.",
@@ -425,7 +425,7 @@ export const fr = {
'formula.B': 'B — base : {n}',
'formula.Q': 'Q — quorum : {n}',
'formula.R': 'R — seuil actuel : {n} voix pour',
'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Sous le quorum Q, les votants qui manquent comptent comme « non ».",
'formula.reading': "Sous le quorum Q, les votants qui manquent comptent comme « non ». Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la majorité M suffit.",
'common.pct': '{n} %',
'settings.title': 'Réglages de la formule',
'settings.intro': "Pour changer un texte, le groupe demande un nombre de voix pour qui dépend de son électorat, de sa participation habituelle et de l'inertie de l'article.",
+1 -1
View File
@@ -44,7 +44,7 @@
<header class="topbar">
<a class="brand" href="/"><Logo size={1.2} /></a>
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread} · {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread}{' '}· {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
{#if session.unread}<span class="badge" aria-hidden="true">{session.unread}</span>{/if}
</button>
</header>
+15
View File
@@ -28,6 +28,21 @@ describe('five levels of inertia', () => {
expect(fr['formula.reading']).toContain('les votants qui manquent comptent comme « non »');
expect(fr['settings.quorum.note']).toContain('avant le premier vote');
});
it('the share asked is highest at the quorum, never near-unanimity because few vote (review of M6b, lot T2)', () => {
const lowTurnout = /unanimi|peu de gens votent|faible participation|few people vote|low turnout|vota poca gente|poca participación/;
for (const cat of [fr, en, es]) {
for (const k of ['inertia.explain', 'formula.reading'] as const) expect(cat[k], k).not.toMatch(lowTurnout);
// After the act « supprime », or a direct vote withdrawn, the quorum changes again.
expect(cat['quorum.error.voted']).not.toMatch(/ne change plus|no longer changes|ya no cambia/);
}
expect(fr['inertia.explain']).toBe(
"Plus l'inertie est haute, plus il faut de voix pour changer ce texte. Sous le quorum, les votants qui manquent comptent comme « non » ; au-delà, la part de « pour » demandée baisse quand la participation monte.",
);
expect(fr['formula.reading']).toBe(
"Sous le quorum Q, les votants qui manquent comptent comme « non ». Au-delà, la part de « pour » demandée, la plus haute au quorum, baisse quand la participation monte ; quand tout le monde vote, la majorité M suffit.",
);
expect(fr['quorum.error.voted']).toBe("Quelqu'un a déjà voté sur cet article : son quorum ne change pas.");
});
it('a change of inertia is said to be voted at the level of the article, no longer one above', () => {
for (const cat of [fr, en, es]) expect(cat['inertia.change.note']).toContain('{level}');
expect(fr['inertia.change.note']).toContain("à l'inertie de l'article");
+98
View File
@@ -0,0 +1,98 @@
// A space at the start or the end of a block ({#if}, {:else}, {#each}…) is dropped by Svelte,
// in the browser as on the server: glued to what comes before, « {n} contre{#if s} <span>dont
// 1 simulé</span>{/if} » reads « 0 contredont 1 simulé » (review of M6b). Every component is
// read here, and a space a block would drop is pointed at: `{' '}` keeps it.
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { parse } from 'svelte/compiler';
import { describe, expect, it } from 'vitest';
const SRC = join(__dirname, '../../src');
function components(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const path = join(dir, e.name);
return e.isDirectory() ? components(path) : e.name.endsWith('.svelte') ? [path] : [];
});
}
interface Node {
type: string;
start: number;
data?: string;
elseif?: boolean;
[key: string]: unknown;
}
interface Fragment {
nodes: Node[];
}
/** The fragments a block shows, its `{:else if}` chain included. */
function branches(b: Node): Fragment[] {
const out: Fragment[] = [];
for (const key of ['consequent', 'alternate', 'body', 'fallback', 'pending', 'then', 'catch', 'fragment']) {
const f = b[key] as Fragment | null | undefined;
if (!f?.nodes) continue;
const only = f.nodes.length === 1 ? f.nodes[0] : undefined;
if (key === 'alternate' && only?.type === 'IfBlock' && only.elseif) out.push(...branches(only));
else out.push(f);
}
return out;
}
const BLOCKS = new Set(['IfBlock', 'EachBlock', 'AwaitBlock', 'KeyBlock']);
/** A space or a tab, not the newline that only lays the markup out. */
const LEADS = /^[ \t]/;
const TRAILS = /[ \t]$/;
/** Each space a block drops while something is glued to it, as `line: text`. */
function dropped(source: string): string[] {
const ast = parse(source, { modern: true }) as unknown as { fragment: Fragment };
const out: string[] = [];
const line = (at: number) => source.slice(0, at).split('\n').length;
const walk = (f: Fragment) => {
f.nodes.forEach((n, i) => {
if (BLOCKS.has(n.type)) {
const before = f.nodes[i - 1];
const after = f.nodes[i + 1];
const gluedBefore = before !== undefined && !(before.type === 'Text' && /\s$/.test(before.data ?? ''));
const gluedAfter = after !== undefined && !(after.type === 'Text' && /^\s/.test(after.data ?? ''));
for (const b of branches(n)) {
const first = b.nodes[0];
const last = b.nodes.at(-1);
if (gluedBefore && first?.type === 'Text' && LEADS.test(first.data ?? '')) {
out.push(`${line(first.start)}: « ${(first.data ?? '').slice(0, 30)} »`);
}
if (gluedAfter && last?.type === 'Text' && TRAILS.test(last.data ?? '')) {
out.push(`${line(last.start)}: « ${(last.data ?? '').slice(-30)} »`);
}
}
}
for (const value of Object.values(n)) {
const child = value as Fragment | null;
if (child && typeof child === 'object' && Array.isArray(child.nodes)) walk(child);
}
});
};
walk(ast.fragment);
return out;
}
describe('a space a block would drop', () => {
it('is seen where it is glued', () => {
expect(dropped('<p>{a}{#if s} <span>x</span>{/if}</p>')).toHaveLength(1);
expect(dropped('<p>{a}{#if s} · x{:else if t} · y{:else} · z{/if}</p>')).toHaveLength(3);
expect(dropped('<p>{#each xs as x}{x} {/each}<b>y</b></p>')).toHaveLength(1);
// Kept: `{' '}`, a space outside the block, or a newline that lays the markup out.
expect(dropped("<p>{a}{#if s}{' '}<span>x</span>{/if}</p>")).toEqual([]);
expect(dropped('<p>{a} {#if s} · x{/if}</p>')).toEqual([]);
expect(dropped('<div>{a}{#if s}\n <p>x</p>\n{/if}</div>')).toEqual([]);
});
it('is never dropped in a component of the interface', () => {
const found = components(SRC).flatMap((path) =>
dropped(readFileSync(path, 'utf8')).map((d) => `${relative(SRC, path)}:${d}`),
);
expect(found).toEqual([]);
});
});
+68
View File
@@ -0,0 +1,68 @@
// The vote unfolded under the article, rendered as the server would (svelte/server): what
// it reads, letter for letter. A space at the start of a block is lost (Svelte trims it):
// « 0 contredont 1 simulé » (review of M6b).
import { render } from 'svelte/server';
import { describe, expect, it } from 'vitest';
import VoteFold from '../../src/lib/components/VoteFold.svelte';
import type { DocState } from '../../src/lib/doc.svelte';
import type { Proposal, Tally, Unit } from '../../src/lib/types';
function proposal(tally: Tally, tallySimulated: Tally): Proposal {
return {
id: 'p1',
unitId: 'u1',
kind: 'text',
inertiaTarget: null,
title: 'Logiciel auditable',
body: '',
status: 'open',
origin: 'seed',
label: null,
author: null,
mine: false,
revision: 1,
createdAt: '2026-11-03T00:00:00Z',
updatedAt: '2026-11-03T00:00:00Z',
submittedAt: null,
enteredAt: null,
confirmedAt: null,
seedMinimal: false,
tally,
tallyPending: { for: 0, against: 0 },
tallySimulated,
evaluation: null,
votedAtInertia: 3,
blocked: false,
votable: true,
objections: 0,
myVote: null,
myVoteState: null,
myVoteStale: false,
myVoteChain: false,
myBallot: null,
translations: {},
};
}
function fold(p: Proposal): string {
const unit: Unit = { id: 'u1', code: 'E9', section: null, position: 1, inertia: 3, inertiaChanges: [], inForce: null, proposals: [p] };
const doc = { view: null, original: false, articleTitle: () => '' } as unknown as DocState;
const noop = () => {};
const { body } = render(VoteFold, {
props: { doc, proposal: p, unit, onopen: noop, onsettings: noop, ondetails: noop, onanswer: noop, onsign: noop },
});
const line = /<p class="tally[^"]*">([\s\S]*?)<\/p>/.exec(body)?.[1];
if (line === undefined) throw new Error(`no line of totals in ${body}`);
// The text read: tags and the markers of the server rendering left out.
return line.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]+>/g, '');
}
describe('the totals of the vote unfolded', () => {
it('say the simulated votes after a space', () => {
expect(fold(proposal({ for: 0, against: 1 }, { for: 0, against: 1 }))).toBe('0 pour · 1 contre dont 1 simulé');
expect(fold(proposal({ for: 2, against: 1 }, { for: 2, against: 1 }))).toBe('2 pour · 1 contre dont 3 simulés');
});
it('say nothing more without a simulated vote', () => {
expect(fold(proposal({ for: 4, against: 2 }, { for: 0, against: 0 }))).toBe('4 pour · 2 contre');
});
});