v3 : inertie à cinq crans — moteur, données, serveur (L10)

Moteur (ld-engine/inertia.rs) : cinq crans, M_k = 50, 50, 55, 60, 66 % ;
s_k = 60, 67, 75, 83, 90 % de « pour » au-delà de C à la participation
habituelle p̃ ; G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃). p̃ = médiane
des votants des propositions du groupe (au moins 5, votées dans l'année),
sinon la référence 2C/W ; bornée à [1 %, 50 %]. Le changement d'inertie
hérite du cran de l'article (remplace « inertie + »). Un seuil qui tombe
sur un entier n'y gagne plus une voix par arrondi.

Données : migration 0005 (les quatre mots deviennent 2 à 5, gardés tels
quels dans *_legacy ; adoptions intactes ; journaux inertia_changes et
inertia_amendments). Seed : 3 partout, 4 pour E1 et E2. Amendement
d'inertie gardé (E1, E2 : 3 → 4, seulement si aucun vote n'a changé leur
inertie ; une proposition d'inertie ouverte reste ouverte, votée au
nouveau cran), journalisé, une seule fois, republié.

Serveur : participation mesurée une fois par jour (settings), échelle
dans les seuils, le balayage et l'adoption ; crans, M, G, s_k, p̃ et sa
source servis au client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 15:14:40 +01:00
co-authored by Claude Opus 5.5
parent 8727315612
commit d762301706
16 changed files with 1817 additions and 224 deletions
+39 -8
View File
@@ -1,8 +1,9 @@
//! Entering into force — automatic, as soon as a proposal reaches its
//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle
//! remonte en vigueur »). The human gesture is the vote itself; the engine
//! only counts. Every entry is recorded with its numbers and queued for
//! publication to the texts repository.
//! only counts. Every entry is recorded with its numbers — the level of
//! inertia, M, G, s_k and the usual participation it was judged by — and
//! queued for publication to the texts repository.
use crate::AppState;
use crate::docs::{self, DocRow, DocState};
@@ -66,16 +67,16 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
let st = docs::load_state(state, doc_id).await?;
confirm_seeds(state, &st).await?;
let units = st.unit_states();
let winners = compose::adoptions(&units, &st.proposal_states(), st.electorate, st.doc.vote_floor)
let scale = st.scale();
let winners = compose::adoptions(&units, &st.proposal_states(), &scale)
.map_err(|_| crate::error::AppError::Invalid("electorate"))?;
let mut entered = Vec::new();
for id in winners {
let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue };
let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue };
let us = units.iter().find(|u| u.id == unit.id);
let tally = st.tally(&p.id);
let eval = st.evaluation(unit, p);
let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor));
let params = kept_params(&st, unit, p);
let now = util::now();
let mut tx = state.db.begin().await?;
// The text that enters is the text that was counted: a revision that landed
@@ -140,10 +141,22 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(target.as_str())
.bind(target.as_db())
.bind(&unit.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, proposal_id, at)
VALUES (?, ?, ?, ?, 'vote', ?, ?)",
)
.bind(&unit.id)
.bind(&st.doc.id)
.bind(docs::inertia_of(unit).as_db())
.bind(target.as_db())
.bind(&p.id)
.bind(&now)
.execute(&mut *tx)
.await?;
"inertia"
}
};
@@ -159,7 +172,7 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
.bind(i64::from(tally.against))
.bind(i64::from(st.electorate))
.bind(eval.map_or(0, |e| i64::from(e.required)))
.bind(serde_json::to_string(&params).unwrap_or_default())
.bind(params.to_string())
.bind(&now)
.execute(&mut *tx)
.await?;
@@ -174,12 +187,30 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
Ok(entered)
}
/// What an adoption keeps of the formula it was judged by. The four letters
/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`,
/// `constant`: read as they were written); the level and the scale join them.
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
let level = st.voting_level(unit, p);
let f = st.scale().params(level);
serde_json::json!({
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant,
"level": level, "share_pct": level.share_pct(),
"participation": st.participation.ratio, "participation_source": st.participation.source,
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
})
}
/// The electorate changes (the web of trust grows or shrinks): every few
/// minutes, every document is passed through the engine again.
/// minutes, every document is passed through the engine again — after the
/// usual participation of each group is measured again, once a day.
pub async fn sweeper(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(300));
loop {
tick.tick().await;
if let Err(e) = crate::participation::refresh(&state, false).await {
tracing::warn!(error = %e, "usual participation");
}
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
Ok(ids) => ids,
Err(e) => {
+117 -20
View File
@@ -6,11 +6,13 @@
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::error::{AppError, AppResult};
use crate::participation;
use crate::util::{self, LangMap};
use axum::Json;
use axum::extract::{Path, Query, State};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate};
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -231,16 +233,17 @@ impl ProposalRow {
pub fn kind(&self) -> ProposalKind {
match self.kind.as_str() {
"remove" => ProposalKind::Remove,
"inertia" => ProposalKind::Inertia(
self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard),
),
"inertia" => {
ProposalKind::Inertia(self.inertia_target.as_deref().and_then(InertiaLevel::parse).unwrap_or_default())
}
_ => ProposalKind::Text,
}
}
}
pub fn inertia_of(unit: &UnitRow) -> InertiaPreset {
InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard)
/// The level of a unit: a digit, or one of the four words of before.
pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
InertiaLevel::parse(&unit.inertia).unwrap_or_default()
}
pub struct DocState {
@@ -253,6 +256,9 @@ pub struct DocState {
pub signal_counts: HashMap<String, i64>,
pub electorate: u32,
pub electorate_source: &'static str,
/// p̃, from the group's last measure (or the reference).
pub participation: Participation,
pub measured_at: Option<String>,
}
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
@@ -351,10 +357,40 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
}
}
let (electorate, electorate_source) = electorate(state, &group).await?;
Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source })
let measure = participation::stored(state, &group.slug).await?;
let participation = Participation::of(
electorate,
doc.vote_floor,
measure.as_ref().and_then(|m| m.median),
measure.as_ref().map_or(0, |m| m.sample),
);
let measured_at = measure.map(|m| m.measured_at);
Ok(DocState {
doc,
group,
units,
proposals,
tallies,
blocked,
signal_counts,
electorate,
electorate_source,
participation,
measured_at,
})
}
impl DocState {
/// What this document's votes are judged by: W, C and p̃.
pub fn scale(&self) -> Scale {
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
}
/// p̃ · W, as a whole number of voters.
pub fn usual_voters(&self) -> u32 {
whole(self.participation.voters(self.electorate))
}
pub fn tally(&self, id: &str) -> Tally {
self.tallies.get(id).copied().unwrap_or_default()
}
@@ -396,9 +432,45 @@ impl DocState {
}
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok()
self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
}
/// The level a proposal is voted at — a change of inertia inherits its article's.
pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
compose::voting_level(&us, p.kind())
}
/// The usual participation, as the screen tells it.
pub fn participation_json(&self) -> serde_json::Value {
let p = &self.participation;
json!({
"ratio": p.ratio, "source": p.source, "sample": p.sample, "median": p.median, "bounded": p.bounded,
"voters": self.usual_voters(), "minSample": Participation::MIN_SAMPLE,
"windowDays": participation::WINDOW_DAYS, "everyHours": participation::EVERY_HOURS,
"bounds": [Participation::LOWEST, Participation::HIGHEST], "measuredAt": self.measured_at,
})
}
/// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes.
pub fn levels_json(&self, cast: u32) -> Vec<serde_json::Value> {
let scale = self.scale();
InertiaLevel::ALL
.iter()
.map(|k| {
let params = scale.params(*k);
json!({
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
})
})
.collect()
}
}
/// A non-negative count, rounded to the nearest whole.
fn whole(x: f64) -> u32 {
if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 }
}
// ---------------------------------------------------------------------------
@@ -462,12 +534,9 @@ fn proposal_json(
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())),
};
let voted_at = match p.kind() {
ProposalKind::Inertia(_) => inertia_of(unit).raised(),
_ => inertia_of(unit),
ProposalKind::Inertia(t) => ("inertia", Some(t)),
};
let voted_at = st.voting_level(unit, p);
json!({
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
@@ -480,7 +549,7 @@ fn proposal_json(
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at.as_str(),
"votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
@@ -514,6 +583,7 @@ pub async fn document(
.map(|v| (v.proposal_id.clone(), v))
.collect();
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st
.units
.iter()
@@ -529,7 +599,8 @@ pub async fn document(
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia,
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
"inForce": in_force, "proposals": proposals,
})
})
@@ -545,15 +616,41 @@ pub async fn document(
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
"electorate": st.electorate, "electorateSource": st.electorate_source,
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
"participation": st.participation_json(),
"publication": publication,
},
"units": units,
"inertia": InertiaPreset::ALL.iter().map(|p| json!({
"key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(),
})).collect::<Vec<_>>(),
// The five levels, with the *for* votes asked at the usual participation.
"levels": st.levels_json(st.usual_voters()),
})))
}
/// The past of each article's inertia (unit id → changes, oldest first).
async fn inertia_changes(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Vec<serde_json::Value>>> {
#[derive(sqlx::FromRow)]
struct Row {
unit_id: String,
from_level: String,
to_level: String,
cause: String,
at: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT unit_id, from_level, to_level, cause, at FROM inertia_changes WHERE doc_id = ? ORDER BY id",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut out: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for r in rows {
out.entry(r.unit_id).or_default().push(json!({
"from": InertiaLevel::parse(&r.from_level), "to": InertiaLevel::parse(&r.to_level),
"cause": r.cause, "at": r.at,
}));
}
Ok(out)
}
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
#[derive(sqlx::FromRow)]
struct Row {
@@ -598,7 +695,7 @@ pub async fn preview(
Some("wellrated") => PreviewMode::WellRated,
_ => PreviewMode::InForce,
};
let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, mode)
let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode)
.map_err(|_| AppError::Invalid("electorate"))?;
// Translations of the versions shown.
let shown: HashSet<String> = units
+309
View File
@@ -0,0 +1,309 @@
//! Inertia amendments — a starting level of the seed raised in an existing base.
//!
//! Yvv, 2026-10-30: « Tu mets par défaut au milieu (3) pour toutes les
//! propositions, (4) pour les 2 engagements structurels » — Unicité du compte
//! (E1) and Réciprocité (E2). New bases get level 4 from the seed. An existing
//! base, where both are in force at the starting level (`standard`, now 3),
//! gets it through an amendment listed here, and **only if no vote ever
//! changed the article's inertia**:
//!
//! - no adoption of a change of inertia on the article (`adoptions`, and the
//! journal `inertia_changes`);
//! - its level is still the one the amendment raises (the seed's 3).
//!
//! An open proposal to change the inertia does not stop it (orch, 2026-10-30:
//! two such proposals exist in production, I1 → 5 and E1 → 5, at zero votes):
//! it stays open, with its target, and is voted at the article's new level,
//! since a change of inertia inherits it (ADR 0007).
//!
//! Like the seed amendments (`seed_amend`), each document is decided once, at
//! the first start that knows the amendment; the decision is kept in
//! `inertia_amendments` (applied, refused and why, or present) and in the
//! logs. Applied, the level is written in one transaction with a line in
//! `inertia_changes` (the article says so) and, where the document publishes,
//! a row in the publication queue (`document.json` carries the levels).
use crate::AppState;
use crate::seed::SeedDoc;
use crate::util;
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// One starting level to raise, in every document instantiated from a seed.
pub struct InertiaAmendment {
/// Stable identifier: each document's decision is kept under it.
pub id: &'static str,
/// Slug of the seed document.
pub doc: &'static str,
/// Code of the article.
pub unit: &'static str,
/// The level it must still hold.
pub from: u8,
/// The level it gets: the seed's today, which must say so.
pub to: u8,
/// Reason given to the publication queue, and so to the commit of the texts.
pub reason: &'static str,
}
pub const AMENDMENTS: &[InertiaAmendment] = &[
InertiaAmendment {
id: "2026-10-30-e1-inertie-4",
doc: "acte-engagement-certification",
unit: "E1",
from: 3,
to: 4,
reason: "inertie de départ relevée : E1, 3 → 4",
},
InertiaAmendment {
id: "2026-10-30-e2-inertie-4",
doc: "acte-engagement-certification",
unit: "E2",
from: 3,
to: 4,
reason: "inertie de départ relevée : E2, 3 → 4",
},
];
/// What the database says of the article.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Facts {
/// `units.inertia` as written.
pub raw: String,
/// Changes of its inertia that a vote brought into force.
pub voted_changes: i64,
/// Open proposals to change its inertia (they stay open; told in the logs).
pub open_proposals: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// The level is already there: nothing to do.
Present,
Refuse(&'static str),
}
/// The rule, apart from the database: a vote on the inertia keeps it.
pub fn verdict(facts: Option<&Facts>, a: &InertiaAmendment) -> Verdict {
let Some(f) = facts else { return Verdict::Refuse("no_article") };
let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") };
if level.get() == a.to {
return Verdict::Present;
}
if f.voted_changes > 0 {
Verdict::Refuse("changed_by_vote")
} else if level.get() != a.from {
Verdict::Refuse("level_differs")
} else {
Verdict::Apply
}
}
/// A decision taken at this start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub amendment: &'static str,
pub group: String,
pub verdict: Verdict,
}
/// Decides every amendment not yet decided, document by document.
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment]) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
if !seed_says(seeds, a) {
continue;
}
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, open)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue };
match v {
Verdict::Apply => tracing::info!(
amendment = a.id, %group, unit = a.unit, from = a.from, to = a.to, open_inertia_proposals = open,
"inertia amendment applied"
),
Verdict::Present => {
tracing::info!(amendment = a.id, %group, unit = a.unit, "inertia amendment: level already there")
}
Verdict::Refuse(why) => tracing::warn!(
amendment = a.id, %group, unit = a.unit, reason = why,
"inertia amendment refused: the article keeps its inertia"
),
}
if v == Verdict::Apply && publish_path.is_some() {
state.publish_wake.notify_one();
}
out.push(Outcome { amendment: a.id, group, verdict: v });
}
}
Ok(out)
}
/// The seed must hold the level the amendment gives: a seed edited later never
/// reaches a base by this way.
fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool {
let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit));
match level {
Some(u) if u.inertia.get() == a.to => true,
_ => {
tracing::error!(
amendment = a.id,
doc = a.doc,
unit = a.unit,
"inertia amendment: the seed does not give this level, nothing decided"
);
false
}
}
}
async fn facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<Facts> {
let voted_changes: i64 = sqlx::query_scalar(
"SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia')
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')",
)
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
let open_proposals: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE unit_id = ? AND kind = 'inertia' AND status = 'open'")
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
Ok(Facts { raw, voted_changes, open_proposals })
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &InertiaAmendment,
doc_id: &str,
publishes: bool,
) -> anyhow::Result<Option<(Verdict, i64)>> {
let mut tx = state.db.begin().await?;
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_amendments WHERE amendment_id = ? AND doc_id = ?")
.bind(a.id)
.bind(doc_id)
.fetch_one(&mut *tx)
.await?;
if decided > 0 {
return Ok(None);
}
let unit: Option<(String, String)> = sqlx::query_as("SELECT id, inertia FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.fetch_optional(&mut *tx)
.await?;
let found = match &unit {
Some((id, raw)) => Some(facts(&mut tx, id, raw.clone()).await?),
None => None,
};
let mut v = verdict(found.as_ref(), a);
let now = util::now();
let to = InertiaLevel::new(a.to).map(InertiaLevel::as_db).unwrap_or_default();
if let (Verdict::Apply, Some((unit_id, raw))) = (v, &unit) {
// Written only over the level that was judged.
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
.bind(&to)
.bind(unit_id)
.bind(raw)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
sqlx::query(
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at)
VALUES (?, ?, ?, ?, 'amendment', ?, ?)",
)
.bind(unit_id)
.bind(doc_id)
.bind(a.from.to_string())
.bind(&to)
.bind(a.id)
.bind(&now)
.execute(&mut *tx)
.await?;
if publishes {
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
.bind(doc_id)
.bind(a.reason)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
} else {
v = Verdict::Refuse("changed");
}
}
let (outcome, reason) = match v {
Verdict::Apply => ("applied", None),
Verdict::Present => ("present", None),
Verdict::Refuse(why) => ("refused", Some(why)),
};
sqlx::query(
"INSERT INTO inertia_amendments (amendment_id, doc_id, unit_code, from_level, to_level, outcome, reason, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(a.unit)
.bind(unit.as_ref().map(|(_, raw)| raw.clone()))
.bind(&to)
.bind(outcome)
.bind(reason)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((v, found.map_or(0, |f| f.open_proposals))))
}
#[cfg(test)]
mod tests {
use super::*;
const A: InertiaAmendment =
InertiaAmendment { id: "test", doc: "doc", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée" };
fn at(raw: &str) -> Facts {
Facts { raw: raw.into(), ..Facts::default() }
}
#[test]
fn the_starting_level_is_raised() {
assert_eq!(verdict(Some(&at("3")), &A), Verdict::Apply);
// The word of before, if an older image wrote it.
assert_eq!(verdict(Some(&at("standard")), &A), Verdict::Apply);
}
#[test]
fn an_open_change_of_inertia_does_not_stop_it() {
let f = Facts { open_proposals: 1, ..at("3") };
assert_eq!(verdict(Some(&f), &A), Verdict::Apply);
}
#[test]
fn the_level_already_there_is_left_as_it_is() {
assert_eq!(verdict(Some(&at("4")), &A), Verdict::Present);
assert_eq!(verdict(Some(&at("high")), &A), Verdict::Present);
}
#[test]
fn a_vote_on_the_inertia_keeps_it() {
let cases = [
(None, "no_article"),
(Some(Facts { voted_changes: 1, ..at("3") }), "changed_by_vote"),
(Some(at("5")), "level_differs"),
(Some(at("2")), "level_differs"),
(Some(at("haute")), "level_unreadable"),
];
for (facts, reason) in cases {
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
}
}
}
+3
View File
@@ -11,7 +11,9 @@ pub mod auth;
pub mod config;
pub mod docs;
pub mod error;
pub mod inertia_amend;
pub mod overview;
pub mod participation;
pub mod proposals;
pub mod publish;
pub mod seed;
@@ -197,6 +199,7 @@ pub async fn run() -> anyhow::Result<()> {
let state = AppState::new(cfg, db).await?;
seed::ensure(&state).await?;
participation::refresh(&state, false).await?;
auth::bootstrap(&state).await?;
if let Err(e) = publish::ensure_key(&state).await {
tracing::warn!(error = %e, "publication key");
+132
View File
@@ -0,0 +1,132 @@
//! p̃ — the usual participation of each group, the anchor of the five levels
//! of inertia (ADR 0007, `ld_engine::inertia`).
//!
//! What is measured: the proposals of the group (every document, every kind,
//! drafts aside) that hold at least one vote on their current revision, the
//! last one cast within the past year. For each, T is the number of those
//! votes **at the time of measuring** — the same T the formula sees, since the
//! vote is permanent. The median of these T, and how many proposals it rests
//! on, are kept in `settings` under `participation:<group>`.
//!
//! When: once a day (at start, then by the sweeper), never at each vote — a
//! threshold that moved with every vote cast elsewhere could not be read. W
//! moves on its own (the web of trust, every six hours): p̃ = T̃ / W follows it
//! at once, so the scale still asks s_k of the same usual number of voters.
use crate::AppState;
use crate::util;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// Only proposals voted on within this many days count.
pub const WINDOW_DAYS: i64 = 365;
/// A measure older than this is taken again.
pub const EVERY_HOURS: i64 = 24;
const KEY: &str = "participation:";
/// What was measured for a group.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Measure {
/// The median number of voters, when at least one proposal was voted on.
pub median: Option<f64>,
/// Proposals it rests on.
pub sample: u32,
pub measured_at: String,
}
/// The numbers of voters of the group's proposals voted on since `since`.
pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result<Vec<u32>> {
let rows: Vec<i64> = sqlx::query_scalar(
"SELECT COUNT(*) FROM votes v
JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id
JOIN documents d ON d.id = u.doc_id
WHERE d.group_slug = ? AND p.status <> 'draft' AND v.revision = p.revision
GROUP BY v.proposal_id
HAVING MAX(v.cast_at) >= ?",
)
.bind(group)
.bind(since)
.fetch_all(&state.db)
.await?;
Ok(rows.into_iter().map(|n| u32::try_from(n).unwrap_or(u32::MAX)).collect())
}
/// Measures the group now.
pub async fn measure(state: &AppState, group: &str) -> sqlx::Result<Measure> {
let now = OffsetDateTime::now_utc();
let since = util::stamp(now - time::Duration::days(WINDOW_DAYS));
let votes = sample(state, group, &since).await?;
Ok(Measure {
median: ld_engine::inertia::median(&votes),
sample: u32::try_from(votes.len()).unwrap_or(u32::MAX),
measured_at: util::stamp(now),
})
}
/// The last measure kept for the group, if any.
pub async fn stored(state: &AppState, group: &str) -> sqlx::Result<Option<Measure>> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(format!("{KEY}{group}"))
.fetch_optional(&state.db)
.await?;
Ok(raw.and_then(|v| serde_json::from_str(&v).ok()))
}
async fn keep(state: &AppState, group: &str, m: &Measure) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(format!("{KEY}{group}"))
.bind(serde_json::to_string(m)?)
.bind(&m.measured_at)
.execute(&state.db)
.await?;
Ok(())
}
fn is_stale(m: &Measure, now: OffsetDateTime) -> bool {
OffsetDateTime::parse(&m.measured_at, &Rfc3339)
.map_or(true, |at| now - at >= time::Duration::hours(EVERY_HOURS) || at > now)
}
/// Measures again every group whose measure is missing or a day old (all of
/// them with `force`). Returns the groups measured.
pub async fn refresh(state: &AppState, force: bool) -> anyhow::Result<Vec<String>> {
let groups: Vec<String> =
sqlx::query_scalar("SELECT slug FROM groups ORDER BY position").fetch_all(&state.db).await?;
let now = OffsetDateTime::now_utc();
let mut done = Vec::new();
for group in groups {
let due = force || stored(state, &group).await?.is_none_or(|m| is_stale(&m, now));
if !due {
continue;
}
let m = measure(state, &group).await?;
keep(state, &group, &m).await?;
tracing::info!(%group, median = ?m.median, sample = m.sample, "usual participation measured");
done.push(group);
}
Ok(done)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_measure_is_taken_again_after_a_day() {
let now = OffsetDateTime::now_utc();
let at =
|hours: i64| Measure { measured_at: util::stamp(now - time::Duration::hours(hours)), ..Measure::default() };
assert!(!is_stale(&at(1), now));
assert!(!is_stale(&at(23), now));
assert!(is_stale(&at(24), now));
assert!(is_stale(&at(-2), now), "a measure from the future is taken again");
assert!(is_stale(&Measure::default(), now), "unreadable");
}
}
+23 -21
View File
@@ -21,7 +21,7 @@ use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
use ld_engine::edit::{self, EditKind};
use ld_engine::threshold::InertiaPreset;
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize;
use serde_json::json;
@@ -191,13 +191,14 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)",
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
)
.bind(&id)
.bind(&st.doc.id)
.bind(format!("N{}", n + 1))
.bind(section)
.bind(position_after(&st.units, after))
.bind(InertiaLevel::DEFAULT.as_db())
.bind(util::now())
.execute(&state.db)
.await?;
@@ -626,28 +627,33 @@ pub async fn detail(
_ => None,
};
let eval = st.evaluation(unit, &p);
let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) };
let voted_at = st.voting_level(unit, &p);
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
let tr = docs::translations_for(&state, &[&p]).await?;
Ok(Json(json!({
"proposal": {
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body,
"id": p.id, "kind": p.kind, "inertiaTarget": target, "title": p.title, "body": p.body,
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(),
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
},
"unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia },
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
"diff": diff,
"history": history,
"signals": signals,
"electorate": st.electorate,
"electorateSource": st.electorate_source,
"voteFloor": st.doc.vote_floor,
"participation": st.participation_json(),
// The five levels, with the *for* votes each would ask at this version's participation.
"levels": st.levels_json(st.tally(&p.id).cast()),
})))
}
@@ -783,18 +789,20 @@ pub async fn lift_signal(
#[derive(Deserialize)]
pub struct InertiaBody {
target: String,
/// A level, 1 to 5 (a word of before is read as its level).
target: serde_json::Value,
}
/// Propose a change of inertia; it is voted one level above the unit. An open
/// proposal for the same target is reused rather than duplicated.
/// Propose a change of inertia; it is voted at the level of the unit (it
/// inherits it, ADR 0007). An open proposal for the same target is reused
/// rather than duplicated.
pub async fn propose_inertia(
State(state): State<AppState>,
User(user): User,
Path(unit_id): Path<String>,
Json(b): Json<InertiaBody>,
) -> AppResult<Json<serde_json::Value>> {
let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?;
let target = serde_json::from_value::<InertiaLevel>(b.target).map_err(|_| AppError::Invalid("inertia"))?;
let doc_id = doc_of_unit(&state, &unit_id).await?;
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
@@ -806,17 +814,18 @@ pub async fn propose_inertia(
p.unit_id == unit_id
&& p.kind == "inertia"
&& p.status == "open"
&& p.inertia_target.as_deref() == Some(target.as_str())
&& p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(target)
}) {
return Ok(Json(json!({ "id": existing.id, "existing": true })));
}
let title = format!("inertia:{}", target.as_str());
let title = format!("inertia:{}", target.get());
let level = target.as_db();
let id = insert_proposal(
&state,
NewProposal {
unit_id: &unit_id,
kind: "inertia",
inertia_target: Some(target.as_str()),
inertia_target: Some(&level),
title: &title,
body: "",
author: &user.id,
@@ -869,14 +878,7 @@ mod tests {
use super::*;
fn unit(id: &str, position: f64) -> UnitRow {
UnitRow {
id: id.into(),
code: id.into(),
section: None,
position,
inertia: "standard".into(),
in_force_id: None,
}
UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "3".into(), in_force_id: None }
}
#[test]
+1 -1
View File
@@ -347,7 +347,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
}
let tally = st.tally(&p.id);
json!({
"code": u.code, "title": title, "body": body, "inertia": u.inertia,
"code": u.code, "title": title, "body": body, "inertia": crate::docs::inertia_of(u),
"status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" },
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
})
+11 -5
View File
@@ -5,11 +5,15 @@
//! yet**. An existing document is never instantiated again: from its first
//! vote on, it is runtime data. The one way a newer seed text reaches an
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
//! and applied only to an article nobody has touched.
//! and applied only to an article nobody has touched; a newer starting level of
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
//! no vote changed.
use crate::AppState;
use crate::inertia_amend;
use crate::seed_amend;
use crate::util::{self, LangMap};
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize;
use std::path::Path;
@@ -37,7 +41,8 @@ pub struct SeedUnit {
pub code: String,
pub section: Option<String>,
pub position: f64,
pub inertia: String,
/// A level, 1 to 5 (a word of before is read as its level).
pub inertia: InertiaLevel,
pub versions: Vec<SeedVersion>,
}
@@ -67,11 +72,12 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
Ok(())
}
/// On start: the groups, the documents missing from them, then the seed
/// amendments (each decided once per document).
/// On start: the groups, the documents missing from them, then the seed and
/// inertia amendments (each decided once per document).
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
let seeds = documents(state).await?;
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
Ok(())
}
@@ -159,7 +165,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
.bind(&unit.code)
.bind(&unit.section)
.bind(unit.position)
.bind(&unit.inertia)
.bind(unit.inertia.as_db())
.bind(&now)
.execute(&mut *tx)
.await?;