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:
parent
ef218e3dab
commit
20fb808e7b
9 changed files with 108 additions and 38 deletions
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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)])),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue