From acee839b4bcba5bd2c85f4e2b179e2946d700330 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 12:41:44 +0100 Subject: [PATCH] tempo-silence-is-abstention: the beat is the lower median of cast votes only; a Persona turn admits a Pulse only for a vote, a withdrawal or a nonzero urgency Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/heartbeat.rs | 2 +- crates/mind-body/src/heartbeat/tests.rs | 2 +- crates/mind-body/src/persona.rs | 6 +++--- crates/mind-body/src/persona/tests.rs | 8 ++++++++ crates/mind-body/src/tempo.rs | 23 +++++++++++------------ crates/mind-body/src/tempo/tests.rs | 11 +++++++---- 6 files changed, 31 insertions(+), 21 deletions(-) diff --git a/crates/mind-body/src/heartbeat.rs b/crates/mind-body/src/heartbeat.rs index f44fb6d..c12ef5b 100644 --- a/crates/mind-body/src/heartbeat.rs +++ b/crates/mind-body/src/heartbeat.rs @@ -586,7 +586,7 @@ pub fn poll(organs: &Organs, config: &Config) -> Result { rows.push(row); } - // The tempo is the tally over every Mind of the roster, braked or not; a Mind with no vote or no readable mind counts as the default beat. + // The tempo is the tally over the votes cast by the Minds of the roster, braked or not; a Mind with no vote or no readable mind abstains and is not counted. let votes: Vec> = rows.iter().map(|row| row.read.as_ref().and_then(|read| read.vote)).collect(); let beat_s = tempo::tally(&votes); diff --git a/crates/mind-body/src/heartbeat/tests.rs b/crates/mind-body/src/heartbeat/tests.rs index 660ffe4..a6f03bc 100644 --- a/crates/mind-body/src/heartbeat/tests.rs +++ b/crates/mind-body/src/heartbeat/tests.rs @@ -689,7 +689,7 @@ fn a_null_vote_withdraws_the_standing_one_and_an_urgency_only_pulse_leaves_it() rig.poll(); let withdrawn = rig.store.traces.borrow().last().cloned().unwrap(); assert_eq!(withdrawn.instances[ANN].vote_beat_s, None, "an explicit null withdraws it"); - assert_eq!(withdrawn.tempo_s, 900, "she counts as silent again"); + assert_eq!(withdrawn.tempo_s, 900, "she abstains again"); } #[test] diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 4486f4c..d3c58c9 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -285,7 +285,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String]) -> String { } }, "start_self_run": start, - "tempo_vote_beat_s": { "description": "Omit this key to leave your standing vote unchanged. A number votes that many seconds per beat. null only withdraws your vote, so you count as silent again.", "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": TEMPO_VOTE_MIN_S, "maximum": TEMPO_VOTE_MAX_S }] }, + "tempo_vote_beat_s": { "description": "Omit this key to leave your standing vote unchanged. A number votes that many seconds per beat. null only withdraws your vote, so you abstain again and are not counted.", "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": TEMPO_VOTE_MIN_S, "maximum": TEMPO_VOTE_MAX_S }] }, "urgency_permille": { "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": 0, "maximum": URGENCY_MAX_PERMILLE }] } } }) @@ -692,7 +692,7 @@ fn one_turn( } } } - if reply.tempo_vote_beat_s.is_some() || reply.urgency_permille.is_some() { + if reply.tempo_vote_beat_s.is_some() || reply.urgency_permille.is_some_and(|urgency| urgency > 0) { outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("pulse tempo_vote_beat_s={:?} urgency_permille={:?}", reply.tempo_vote_beat_s, reply.urgency_permille) }); if !admit_pulse(organs, run, turn, &reply)? { organs.trace.record(&record(outputs, "failed: pulse".into()))?; @@ -832,7 +832,7 @@ fn admit_pulse(organs: &Organs, run: &PipelineRef, turn: u32, reply: &PersonaRep /// The tempo the Minds hold between them, from the newest `heartbeat.cc` trace: /// the tally, and each Mind vote and declared urgency, her own marked. fn render_tempo(view: Option<&dyn TempoView>, instance: &Slug) -> String { - let guidance = "The swarm beat is the lower median of every Mind newest vote. You vote with tempo_vote_beat_s (60 to 86400 seconds per beat; omit the key to leave your standing vote as it is, and send null to withdraw it so you count as silent again) and declare your own urgency with urgency_permille (0 to 1000, null for none); an urgency lasts until your next turn, so use it as etiquette, not as a habit."; + let guidance = "The swarm beat is the lower median of the votes cast: a Mind with no standing vote abstains and is not counted, and with no vote cast the beat is 900 s. A vote only needs changing when conditions change; leave it standing otherwise. You vote with tempo_vote_beat_s (60 to 86400 seconds per beat; omit the key to leave your standing vote as it is, and send null to withdraw it and abstain) and declare your own urgency with urgency_permille (0 to 1000, null for none); an urgency lasts until your next turn, so use it as etiquette, not as a habit."; let latest: Option = view.and_then(|view| view.latest().ok().flatten()); let Some(trace) = latest else { return format!("Tempo: not yet read. {guidance}") }; let mut out = format!("Tempo: the beat is {} s. {guidance}\nMinds:\n", trace.tempo_s); diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 0623ce0..a92e556 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -1143,6 +1143,14 @@ fn an_explicit_null_vote_is_a_pulse_that_withdraws_and_an_omitted_vote_is_no_vot assert!(silent.mind.ids(K::Pulse).is_empty(), "no vote key and no urgency is no pulse"); } +#[test] +fn a_zero_urgency_alone_is_no_pulse() { + let rig = Rig::new(vec![operator_says(1, "go")], &[voting(None, json!(0))]); + let Outcome::Served { failure, .. } = rig.go() else { panic!("served") }; + assert_eq!(failure, None); + assert!(rig.mind.ids(K::Pulse).is_empty(), "urgency 0 declares nothing"); +} + /// A Self run of hers, closed Recorded with `rationale`. fn self_run_closed_with(rig: &Rig, label: &str, rationale: &str) { rig.mind.committed(vec![run(label, RunTurn::SelfRun, RunOperator::Mind, &[])]); diff --git a/crates/mind-body/src/tempo.rs b/crates/mind-body/src/tempo.rs index 527ccb1..eab4a76 100644 --- a/crates/mind-body/src/tempo.rs +++ b/crates/mind-body/src/tempo.rs @@ -9,26 +9,25 @@ use std::cmp::Ordering; -/// The beat, in seconds, when no Mind has voted. +/// The beat, in seconds, when no vote is cast. pub const DEFAULT_BEAT_S: u32 = 900; /// How many orders of magnitude a full urgency may multiply a Mind's wait by. /// Interim, per `swarm-shares-log-bound`, until the swarm's governance sets it. pub const LOG_BOUND_ORDERS: f64 = 1.0; -/// The swarm's tempo (ruling `eureka-body:ruling:tempo-silent-means-default`): -/// the lower median over every Mind in the roster, each her newest vote in seconds -/// per beat. A Mind with no standing vote, or whose mind cannot be read, is -/// `None` and counts as `DEFAULT_BEAT_S`. The lower median moves below the -/// default only when at least half the roster, rounded up, votes at or below it -/// (ruling `eureka-body:ruling:tempo-lower-median-stands`): on a roster of one or -/// two a single Mind decides, on the live eight it takes four. +/// The swarm's tempo (ruling `eureka-body:ruling:tempo-silence-is-abstention`): +/// the lower median of the votes actually cast, each a Mind's newest standing +/// vote in seconds per beat. Silence is abstention: a Mind with no standing vote +/// (never voted, or withdrew) or whose mind cannot be read is `None` and is not +/// counted, so the beat follows those who care to vote. With no vote cast it is +/// `DEFAULT_BEAT_S`. On one voter that voter decides; on two the lower of them. pub fn tally(votes: &[Option]) -> u32 { - let mut sorted: Vec = votes.iter().map(|vote| vote.unwrap_or(DEFAULT_BEAT_S)).collect(); - sorted.sort_unstable(); - match sorted.len() { + let mut cast: Vec = votes.iter().flatten().copied().collect(); + cast.sort_unstable(); + match cast.len() { 0 => DEFAULT_BEAT_S, - n => sorted[(n - 1) / 2], + n => cast[(n - 1) / 2], } } diff --git a/crates/mind-body/src/tempo/tests.rs b/crates/mind-body/src/tempo/tests.rs index 4166c18..a8b4f8b 100644 --- a/crates/mind-body/src/tempo/tests.rs +++ b/crates/mind-body/src/tempo/tests.rs @@ -52,10 +52,13 @@ fn the_tally_is_the_lower_median_and_one_outlier_cannot_move_it() { } #[test] -fn a_silent_or_unreadable_mind_counts_as_the_default_beat() { - let silent = |voters: usize, vote: u32, total: usize| -> Vec> { (0..total).map(|i| (i < voters).then_some(vote)).collect() }; - assert_eq!(tally(&silent(1, 60, 8)), 900, "one voter among seven silent Minds cannot move the beat"); - assert_eq!(tally(&silent(5, 60, 8)), 60, "a majority of the roster moves it"); +fn silence_is_abstention_and_only_cast_votes_are_counted() { + let mut eight = vec![None; 8]; + eight[0] = Some(60); + assert_eq!(tally(&eight), 60, "one voter among eight silent Minds decides"); + assert_eq!(tally(&[None; 8]), DEFAULT_BEAT_S, "none cast is the default"); + assert_eq!(tally(&[Some(3600), None, Some(60)]), 60, "two cast: the lower median"); + assert_eq!(tally(&[Some(3600), Some(1800), None, None, Some(60)]), 1800, "abstainers do not dilute the median"); } #[test]