tempo: an explicit null vote withdraws the standing one; the Pulse says so in its own field and the reply may omit the vote key

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 09:05:26 +01:00
commit 20fb808e7b
9 changed files with 108 additions and 38 deletions

View file

@ -96,6 +96,12 @@ impl<T> Absent for Option<T> {
}
}
impl Absent for bool {
fn absent(&self) -> bool {
!*self
}
}
impl<T> Absent for Vec<T> {
fn absent(&self) -> bool {
self.is_empty()
@ -591,7 +597,10 @@ fn pulse_is_well_formed(pulse: &PipelinePulse, at: &str) -> Result<(), PipelineR
{
return Err(format_error(&format!("{at}.urgency_permille"), &urgency.to_string()));
}
if pulse.tempo_vote_beat_s.is_none() && pulse.urgency_permille.is_none() {
if pulse.tempo_vote_withdrawn && pulse.tempo_vote_beat_s.is_some() {
return Err(format_error(&format!("{at}.tempo_vote_withdrawn"), "a pulse that votes a beat and withdraws the vote"));
}
if pulse.tempo_vote_beat_s.is_none() && !pulse.tempo_vote_withdrawn && pulse.urgency_permille.is_none() {
return Err(format_error(&format!("{at}.tempo_vote_beat_s"), "a pulse that votes and declares nothing"));
}
Ok(())
@ -736,12 +745,14 @@ value_types! {
budget_usd: Short, claims: Vec<PipelineRef>[64], campaigns: Vec<Slug>[8],
} => run_is_well_formed
/// One Mind's heartbeat vote, in her own mind: the beat she wants (the swarm's
/// tempo is the lower median of every Mind's newest vote) and the urgency she
/// declares for herself until her next Persona turn. Either may be absent, not
/// both. Instance-rooted: the key is `<instance>:pulse:<label>`, the label
/// tempo is the lower median of every Mind's newest vote), the withdrawal of
/// her standing vote (`tempo_vote_withdrawn`, an explicit null: she counts as
/// silent again), and the urgency she declares for herself until her next
/// Persona turn. A pulse says at least one of the three; a withdrawal and a
/// beat are never both. Instance-rooted: the key is `<instance>:pulse:<label>`, the label
/// time-ordered like a run's.
pub struct PipelinePulse {
instance: Slug, label: Label, tempo_vote_beat_s: Option<u32>, urgency_permille: Option<u16>, note: Line,
instance: Slug, label: Label, tempo_vote_beat_s: Option<u32>, tempo_vote_withdrawn: bool = absent, urgency_permille: Option<u16>, note: Line,
} => pulse_is_well_formed
}
@ -1407,7 +1418,7 @@ mod tests {
campaigns: vec![slug(CAMPAIGN)],
}), format!("{INSTANCE}:run:mind-2026-10-09-1")),
(D::Pulse(PipelinePulse {
instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: Some(600), urgency_permille: Some(250),
instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: Some(600), tempo_vote_withdrawn: false, urgency_permille: Some(250),
note: "The swarm is idle; a shorter beat.".into(),
}), format!("{INSTANCE}:pulse:2026-10-31t041200-t0")),
]
@ -1703,7 +1714,7 @@ mod tests {
#[test]
fn a_pulse_is_in_bounds_and_says_something() {
let validated = |beat: Option<u32>, urgency: Option<u16>| {
PipelineDocument::Pulse(PipelinePulse { instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: beat, urgency_permille: urgency, note: "".into() }).validate()
PipelineDocument::Pulse(PipelinePulse { instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: beat, tempo_vote_withdrawn: false, urgency_permille: urgency, note: "".into() }).validate()
};
for (beat, urgency) in [(Some(60), None), (Some(86_400), None), (None, Some(0)), (None, Some(1000)), (Some(600), Some(250))] {
assert_eq!(validated(beat, urgency), Ok(()), "{beat:?} {urgency:?}");

View file

@ -2453,7 +2453,7 @@ mod tests {
fn a_pulse_of_another_mind_is_a_foreign_instance_and_her_own_is_admitted() {
let mut mind = with_specs();
let pulse = |instance: &str| {
D::Pulse(eureka_pipeline::PipelinePulse { instance: slug(instance), label: l("p1"), tempo_vote_beat_s: Some(600), urgency_permille: None, note: "".into() })
D::Pulse(eureka_pipeline::PipelinePulse { instance: slug(instance), label: l("p1"), tempo_vote_beat_s: Some(600), tempo_vote_withdrawn: false, urgency_permille: None, note: "".into() })
};
assert_eq!(
refusal(admit(&mut mind, vec![pulse(OTHER_INSTANCE)])),

View file

@ -141,7 +141,7 @@ pub enum PipelineFacts {
Stewardship { instance: Slug, repo: OrgRepo, sequence: u32, assigned_on: Date, note: Line },
HandOff { from_instance: Slug, to_instance: Slug, repo: OrgRepo, handed_on: Date },
Run { label: Label, operated_by: RunOperator, turn: RunTurn, started_on: Date, claims: Vec<PipelineRef> },
Pulse { label: Label, tempo_vote_beat_s: Option<u32>, urgency_permille: Option<u16> },
Pulse { label: Label, tempo_vote_beat_s: Option<u32>, #[serde(default)] tempo_vote_withdrawn: bool, urgency_permille: Option<u16> },
}
impl PipelineFacts {
@ -243,6 +243,7 @@ impl PipelineFacts {
D::Pulse(value) => Self::Pulse {
label: value.label.clone(),
tempo_vote_beat_s: value.tempo_vote_beat_s,
tempo_vote_withdrawn: value.tempo_vote_withdrawn,
urgency_permille: value.urgency_permille,
},
}
@ -1468,6 +1469,7 @@ mod tests {
instance: Slug(INSTANCE.into()),
label: Label(label.into()),
tempo_vote_beat_s: beat,
tempo_vote_withdrawn: false,
urgency_permille: urgency,
note: "n".into(),
})

View file

@ -48,7 +48,7 @@ pub(crate) enum Alias {
/// unrun, so a reader of her last real turn skips the withdrawn ones in the
/// index instead of walking past them.
Ran,
/// `true` on a pulse that casts a tempo vote.
/// `true` on a pulse that casts a tempo vote or withdraws the standing one.
Voted,
/// `true` on a pulse that declares an urgency.
Urged,
@ -244,7 +244,7 @@ impl SelectionRow {
put(Alias::Ran, if withdrawn { Vec::new() } else { vec!["true".into()] });
}
D::Pulse(pulse) => {
put(Alias::Voted, if pulse.tempo_vote_beat_s.is_some() { vec!["true".into()] } else { Vec::new() });
put(Alias::Voted, if pulse.tempo_vote_beat_s.is_some() || pulse.tempo_vote_withdrawn { vec!["true".into()] } else { Vec::new() });
put(Alias::Urged, if pulse.urgency_permille.is_some() { vec!["true".into()] } else { Vec::new() });
}
_ => {}

View file

@ -450,12 +450,13 @@ fn label_of(run: &eureka_pipeline::PipelineRef) -> String {
run.id.0.rsplit(':').next().unwrap_or_default().to_string()
}
/// Her standing vote: the newest pulse that carries one, by admission ordinal. A
/// vote stands until she replaces it, however many pulses of other kinds follow.
/// Her standing vote: the newest pulse that votes or withdraws, by admission
/// ordinal. A vote stands until she casts another or withdraws it, however many
/// pulses of other kinds follow; a withdrawal leaves her silent.
fn standing_vote(mind: &dyn MindPort, instance: &Slug) -> Result<Option<u32>> {
let pulses = any_of(any_of(of_kind(PipelineKind::Pulse), "root", &[&instance.0]), "voted", &["true"]);
Ok(newest(mind, pulses)?.and_then(|header| match header.facts {
PipelineFacts::Pulse { tempo_vote_beat_s, .. } => tempo_vote_beat_s,
PipelineFacts::Pulse { tempo_vote_beat_s, tempo_vote_withdrawn: false, .. } => tempo_vote_beat_s,
_ => None,
}))
}

View file

@ -220,6 +220,7 @@ impl Rig {
instance: Slug(NAMES[who].into()),
label: eureka_pipeline::Label(label),
tempo_vote_beat_s: beat,
tempo_vote_withdrawn: false,
urgency_permille: urgency,
note: "test".into(),
})]);
@ -603,6 +604,7 @@ fn ann_queries_with(pulses: usize, beat: Option<u32>, urgency: Option<u16>) -> u
instance: Slug("ann".into()),
label: eureka_pipeline::Label(format!("c{}", done + n)),
tempo_vote_beat_s: beat,
tempo_vote_withdrawn: false,
urgency_permille: urgency,
note: "cost".into(),
})
@ -655,3 +657,28 @@ fn her_standing_vote_is_the_newest_by_admission_not_by_the_clock() {
let trace = rig.store.traces.borrow().last().cloned().unwrap();
assert_eq!(trace.instances[ANN].vote_beat_s, Some(7200), "admitted second, stamped earlier");
}
#[test]
fn a_null_vote_withdraws_the_standing_one_and_an_urgency_only_pulse_leaves_it() {
let rig = Rig::new();
rig.turn(ANN, 3000);
rig.turn(BOB, 3000);
rig.pulse(ANN, 300, Some(60), None);
rig.pulse(ANN, 200, None, Some(5));
rig.poll();
let kept = rig.store.traces.borrow().last().cloned().unwrap();
assert_eq!(kept.instances[ANN].vote_beat_s, Some(60), "an urgency-only pulse leaves her vote standing");
rig.minds[ANN].admit_at(rig.now() - Duration::seconds(100));
rig.minds[ANN].committed(vec![eureka_pipeline::PipelineDocument::Pulse(eureka_pipeline::PipelinePulse {
instance: Slug("ann".into()),
label: eureka_pipeline::Label("withdraw".into()),
tempo_vote_beat_s: None,
tempo_vote_withdrawn: true,
urgency_permille: None,
note: "test".into(),
})]);
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");
}

View file

@ -179,6 +179,11 @@ impl Config {
}
}
/// A key that is present, null or not, reads as `Some`; an omitted key stays `None`.
fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result<Option<T>, D::Error> {
T::deserialize(deserializer).map(Some)
}
/// Her one reply to a turn. The connector is asked for exactly this and the
/// answer is decoded into exactly this.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -188,9 +193,10 @@ pub struct PersonaReply {
/// Required, null when she starts nothing: a reply that omits it is not a reply.
#[serde(deserialize_with = "Option::deserialize")]
pub start_self_run: Option<StartSelfRun>,
/// The beat she votes for, in seconds; null votes nothing.
#[serde(deserialize_with = "Option::deserialize")]
pub tempo_vote_beat_s: Option<u32>,
/// The beat she votes for, in seconds. Omitted, she says nothing about her
/// vote and her standing one stands; an explicit null withdraws it.
#[serde(default, deserialize_with = "present")]
pub tempo_vote_beat_s: Option<Option<u32>>,
/// The urgency she declares for herself until her next turn, in thousandths; null declares none.
#[serde(deserialize_with = "Option::deserialize")]
pub urgency_permille: Option<u16>,
@ -230,7 +236,7 @@ fn campaign_in_bounds(campaign: &str) -> bool {
}
fn vote_in_bounds(reply: &PersonaReply) -> bool {
reply.tempo_vote_beat_s.is_none_or(|beat| (TEMPO_VOTE_MIN_S..=TEMPO_VOTE_MAX_S).contains(&beat)) && reply.urgency_permille.is_none_or(|urgency| urgency <= URGENCY_MAX_PERMILLE)
reply.tempo_vote_beat_s.flatten().is_none_or(|beat| (TEMPO_VOTE_MIN_S..=TEMPO_VOTE_MAX_S).contains(&beat)) && reply.urgency_permille.is_none_or(|urgency| urgency <= URGENCY_MAX_PERMILLE)
}
fn start_in_bounds(ask: &StartSelfRun) -> bool {
@ -264,7 +270,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String]) -> String {
json!({
"type": "object",
"additionalProperties": false,
"required": ["say", "start_self_run", "tempo_vote_beat_s", "urgency_permille"],
"required": ["say", "start_self_run", "urgency_permille"],
"properties": {
"say": {
"type": "array",
@ -815,7 +821,8 @@ fn admit_pulse(organs: &Organs, run: &PipelineRef, turn: u32, reply: &PersonaRep
let pulse = PipelineDocument::Pulse(PipelinePulse {
instance: organs.mind.instance().clone(),
label: Label(format!("{}-t{turn}", label_of(run))),
tempo_vote_beat_s: reply.tempo_vote_beat_s,
tempo_vote_beat_s: reply.tempo_vote_beat_s.flatten(),
tempo_vote_withdrawn: reply.tempo_vote_beat_s == Some(None),
urgency_permille: reply.urgency_permille,
note: "Voted in a Persona turn.".into(),
});
@ -825,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, null to vote nothing) 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 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 latest: Option<PollTrace> = 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);

View file

@ -169,7 +169,7 @@ fn readout() -> UsageReadout {
fn says(lines: &[(&str, &str)]) -> String {
let say: Vec<_> = lines.iter().map(|(room, text)| json!({ "room": room, "text": text })).collect();
json!({ "say": say, "start_self_run": null, "tempo_vote_beat_s": null, "urgency_permille": null }).to_string()
json!({ "say": say, "start_self_run": null, "urgency_permille": null }).to_string()
}
impl Rig {
@ -403,7 +403,7 @@ fn a_reply_is_decoded_as_the_one_type_and_nothing_else_is_scanned() {
assert!(decode_reply(&says(&[("swarm", "x")]), &c.rooms).is_ok());
for bad in [
"I will start_self_run now",
r#"{"say":[],"start_self_run":null,"tempo_vote_beat_s":null,"urgency_permille":null} and more"#,
r#"{"say":[],"start_self_run":null,"urgency_permille":null} and more"#,
r#"{"say":[],"start_self_run":null,"extra":1}"#,
"",
] {
@ -465,7 +465,7 @@ fn start_self_run_admits_brief_and_launches() {
#[test]
fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ack() {
let reply = json!({ "say": [], "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN }, "tempo_vote_beat_s": null, "urgency_permille": null }).to_string();
let reply = json!({ "say": [], "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN }, "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[reply]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::Undecodable));
@ -477,7 +477,7 @@ fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ac
fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
let c = config();
assert_eq!(decode_reply(r#"{"say":[]}"#, &c.rooms), Err(Failure::Undecodable), "the schema requires the field, so does the decoder");
assert!(decode_reply(r#"{"say":[],"start_self_run":null,"tempo_vote_beat_s":null,"urgency_permille":null}"#, &c.rooms).is_ok());
assert!(decode_reply(r#"{"say":[],"start_self_run":null,"urgency_permille":null}"#, &c.rooms).is_ok());
let rig = Rig::new(vec![operator_says(1, "go")], &[r#"{"say":[{"room":"operator","text":"hi"}]}"#.into()]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::Undecodable));
@ -487,7 +487,7 @@ fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
#[test]
fn the_decoder_holds_the_bounds_the_schema_states() {
let c = config();
let start = |brief: &str, repo: &str, campaign: &str| json!({ "say": [], "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign }, "tempo_vote_beat_s": null, "urgency_permille": null }).to_string();
let start = |brief: &str, repo: &str, campaign: &str| json!({ "say": [], "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign }, "urgency_permille": null }).to_string();
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms).is_ok();
assert!(ok("b", "GameCult/Huginn", "eureka-body"));
assert!(ok(&"b".repeat(1000), "a-b/c.d_e-f", &"c".repeat(64)));
@ -676,7 +676,7 @@ fn no_member_text_name_or_context_can_read_as_an_operator_line() {
#[test]
fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is_admitted() {
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign" }, "tempo_vote_beat_s": null, "urgency_permille": null }).to_string();
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign" }, "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[reply]);
let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") };
assert_eq!((failure, self_run), (Some(Failure::SelfRun), None));
@ -684,7 +684,7 @@ fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is
assert!(rig.launcher.started.borrow().is_empty());
assert!(rig.chat.acked.borrow().is_empty());
// A prefix of a real campaign's name is not that campaign.
let prefix = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1] }, "tempo_vote_beat_s": null, "urgency_permille": null }).to_string();
let prefix = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1] }, "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[prefix]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::SelfRun));
@ -810,7 +810,7 @@ fn the_campaigns_in_force_are_listed_and_are_the_schemas_campaign_enum() {
#[test]
fn a_self_run_naming_a_listed_campaign_passes_the_campaign_check() {
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN }, "tempo_vote_beat_s": null, "urgency_permille": null }).to_string();
let reply = json!({ "say": [], "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN }, "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[reply]);
let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, None);
@ -882,13 +882,18 @@ fn the_turn_input_says_how_to_read_an_addressee() {
assert!(input_for(&[], "Aqua").contains("A message addressed to another Mind is theirs to answer. Answer it only if you hold something they need, and say so."));
}
fn voting(beat: serde_json::Value, urgency: serde_json::Value) -> String {
json!({ "say": [{ "room": "operator", "text": "ok" }], "start_self_run": null, "tempo_vote_beat_s": beat, "urgency_permille": urgency }).to_string()
/// A reply that votes `beat` (`None` leaves the key out; `Some(null)` withdraws) and declares `urgency`.
fn voting(beat: Option<serde_json::Value>, urgency: serde_json::Value) -> String {
let mut reply = json!({ "say": [{ "room": "operator", "text": "ok" }], "start_self_run": null, "urgency_permille": urgency });
if let Some(beat) = beat {
reply["tempo_vote_beat_s"] = beat;
}
reply.to_string()
}
#[test]
fn a_vote_and_a_declared_urgency_in_the_reply_become_one_pulse_in_her_own_mind() {
let rig = Rig::new(vec![operator_says(1, "how fast should we go?")], &[voting(json!(600), json!(250))]);
let rig = Rig::new(vec![operator_says(1, "how fast should we go?")], &[voting(Some(json!(600)), json!(250))]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, None);
let pulses = rig.mind.ids(K::Pulse);
@ -918,7 +923,7 @@ fn a_vote_or_urgency_outside_its_bounds_fails_the_turn_without_a_pulse_or_an_ack
#[test]
fn the_reply_schema_bounds_the_vote_and_the_urgency_to_the_pulse_kind() {
let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[])).unwrap();
assert_eq!(schema["required"], json!(["say", "start_self_run", "tempo_vote_beat_s", "urgency_permille"]));
assert_eq!(schema["required"], json!(["say", "start_self_run", "urgency_permille"]));
assert_eq!(schema["properties"]["tempo_vote_beat_s"]["anyOf"][1], json!({ "type": "integer", "minimum": 60, "maximum": 86_400 }));
assert_eq!(schema["properties"]["urgency_permille"]["anyOf"][1], json!({ "type": "integer", "minimum": 0, "maximum": 1000 }));
}
@ -1108,13 +1113,27 @@ fn the_persona_flags_come_from_the_environment_when_the_command_line_lacks_them(
#[test]
fn a_vote_alone_or_an_urgency_alone_is_still_one_pulse() {
for (beat, urgency) in [(json!(600), json!(null)), (json!(null), json!(250))] {
for (beat, urgency) in [(Some(json!(600)), json!(null)), (None, json!(250))] {
let rig = Rig::new(vec![operator_says(1, "go")], &[voting(beat.clone(), urgency.clone())]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, None, "{beat} {urgency}");
assert_eq!(failure, None, "{beat:?} {urgency}");
let pulses = rig.mind.ids(K::Pulse);
assert_eq!(pulses.len(), 1, "{beat} {urgency}");
assert_eq!(pulses.len(), 1, "{beat:?} {urgency}");
let PipelineDocument::Pulse(pulse) = rig.mind.view(K::Pulse, &pulses[0]).unwrap().document else { panic!("a pulse") };
assert_eq!((pulse.tempo_vote_beat_s.map(i64::from), pulse.urgency_permille.map(i64::from)), (beat.as_i64(), urgency.as_i64()));
assert_eq!((pulse.tempo_vote_beat_s.map(i64::from), pulse.urgency_permille.map(i64::from)), (beat.as_ref().and_then(|beat| beat.as_i64()), urgency.as_i64()));
}
}
#[test]
fn an_explicit_null_vote_is_a_pulse_that_withdraws_and_an_omitted_vote_is_no_vote_at_all() {
let rig = Rig::new(vec![operator_says(1, "withdraw")], &[voting(Some(json!(null)), json!(null))]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, None);
let pulses = rig.mind.ids(K::Pulse);
assert_eq!(pulses.len(), 1);
let PipelineDocument::Pulse(pulse) = rig.mind.view(K::Pulse, &pulses[0]).unwrap().document else { panic!("a pulse") };
assert_eq!((pulse.tempo_vote_beat_s, pulse.tempo_vote_withdrawn, pulse.urgency_permille), (None, true, None));
let silent = Rig::new(vec![operator_says(1, "nothing")], &[voting(None, json!(null))]);
let Outcome::Served { .. } = silent.go() else { panic!("served") };
assert!(silent.mind.ids(K::Pulse).is_empty(), "no vote key and no urgency is no pulse");
}

View file

@ -19,7 +19,10 @@ 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`, so a lone voter cannot move the beat.
/// `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.
pub fn tally(votes: &[Option<u32>]) -> u32 {
let mut sorted: Vec<u32> = votes.iter().map(|vote| vote.unwrap_or(DEFAULT_BEAT_S)).collect();
sorted.sort_unstable();