crates/installer/nordisk-pairing/src/elect.rs
//! **The weakest box becomes the twin — decided ONCE, on the front, from two
//! capacity reports.** The owner's ruling (2026-09-21) that the role never
//! changes later is kept: the front records the verdict with the numbers and
//! never re-decides; a box that joins again (a re-image) is handed the same
//! role. This module is the RULE; the front's ledger is where the verdict is
//! kept.
pub mod disktest;
use serde::{Deserialize, Serialize};
use std::net::Ipv4Addr;
/// What a box measured about itself at first boot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Capacity {
pub cores: u32,
pub ram_bytes: u64,
/// The data set's room.
pub data_bytes: u64,
/// The start-up disk test's throughput.
pub disk_mib_s: u32,
}
impl Capacity {
/// The order the comparison walks: fewer cores loses, then less RAM, then
/// less data room, then a slower disk. A weaker box on ANY earlier axis is
/// the weaker box; the later axes only break ties.
fn key(&self) -> (u32, u64, u64, u32) {
(self.cores, self.ram_bytes, self.data_bytes, self.disk_mib_s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
pub twin: String,
pub primary: String,
/// The sentence the front prints and keeps beside the numbers.
pub why: String,
}
/// One report: name, overlay address, capacity.
pub type Report<'a> = (&'a str, Ipv4Addr, &'a Capacity);
/// **The election.** Weakest is twin; a perfect tie goes to the LOWER overlay
/// address, so the answer is a pure function of the two reports and both
/// boxes could compute it themselves and agree.
pub fn weakest(a: Report<'_>, b: Report<'_>) -> Verdict {
let (an, ao, ac) = a;
let (bn, bo, bc) = b;
let (twin, primary, why) = match ac.key().cmp(&bc.key()) {
std::cmp::Ordering::Less => (an, bn, format!("`{an}` is the weaker box ({}) against `{bn}` ({})", say(ac), say(bc))),
std::cmp::Ordering::Greater => (bn, an, format!("`{bn}` is the weaker box ({}) against `{an}` ({})", say(bc), say(ac))),
std::cmp::Ordering::Equal => {
if ao <= bo {
(an, bn, format!("`{an}` and `{bn}` measure the same ({}); the lower address {ao} is the twin", say(ac)))
} else {
(bn, an, format!("`{an}` and `{bn}` measure the same ({}); the lower address {bo} is the twin", say(bc)))
}
}
};
Verdict { twin: twin.to_string(), primary: primary.to_string(), why }
}
fn say(c: &Capacity) -> String {
format!("{} cores, {} GiB RAM, {} GiB data, {} MiB/s disk", c.cores, c.ram_bytes >> 30, c.data_bytes >> 30, c.disk_mib_s)
}
/// Pure: **the role a box pulls and reports as** (A7 row 1; Gunnar b009cbcc, gunnar-server
/// `repair::pull_role`; moved here from monetize-embed `fleet::pull_role` so a community build
/// reads it without linking any monetize crate), asked again on every round, never fixed at
/// the start:
///
/// * `flag` — the medium's or the operator's own role (`--fleet-role`, an enrolment's
/// `role`), when it names one;
/// * `twin_root` — this box's own record says it is a twin (it became one after the start);
/// * `door` — the role the installer's door ELECTED (`joined.toml`): `None` on a box no door
/// paired, `Some(None)` while the door has not elected one yet, `Some(Some("twin"))`, or
/// `Some(Some(_))` for the primary.
///
/// `None`: not known yet. The box WAITS and reports nothing: a twin that reported as `main`
/// before its election would pin its key in main's window and close it.
pub fn pull_role(flag: &str, twin_root: bool, door: Option<Option<&str>>) -> Option<String> {
if !flag.trim().is_empty() {
return Some(flag.trim().to_owned());
}
if twin_root {
return Some("twin".to_owned());
}
match door {
// No door paired this box: the medium's own, a main.
None => Some("main".to_owned()),
// Fed, and the door has not said what this box is yet.
Some(None) => None,
Some(Some("twin")) => Some("twin".to_owned()),
Some(Some(_)) => Some("main".to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
const BIG: Capacity = Capacity { cores: 4, ram_bytes: 8 << 30, data_bytes: 40 << 30, disk_mib_s: 900 };
const SMALL: Capacity = Capacity { cores: 2, ram_bytes: 8 << 30, data_bytes: 40 << 30, disk_mib_s: 900 };
#[test]
fn the_weaker_box_is_the_twin_whichever_side_it_is_on() {
let v = weakest(("main", crate::OVERLAY_BOX_A, &BIG), ("twin", crate::OVERLAY_BOX_B, &SMALL));
assert_eq!((v.twin.as_str(), v.primary.as_str()), ("twin", "main"));
let v = weakest(("main", crate::OVERLAY_BOX_A, &SMALL), ("twin", crate::OVERLAY_BOX_B, &BIG));
assert_eq!((v.twin.as_str(), v.primary.as_str()), ("main", "twin"));
assert!(v.why.contains("weaker box"), "{}", v.why);
}
#[test]
fn a_tie_goes_to_the_lower_address_and_a_slower_disk_only_breaks_ties() {
let v = weakest(("b", crate::OVERLAY_BOX_B, &BIG), ("a", crate::OVERLAY_BOX_A, &BIG));
assert_eq!(v.twin, "a", "{}", v.why);
let slow = Capacity { disk_mib_s: 100, ..BIG };
let v = weakest(("a", crate::OVERLAY_BOX_A, &slow), ("b", crate::OVERLAY_BOX_B, &BIG));
assert_eq!(v.twin, "a");
// fewer cores beats a faster disk
let few_fast = Capacity { cores: 2, disk_mib_s: 5000, ..BIG };
let v = weakest(("a", crate::OVERLAY_BOX_A, &few_fast), ("b", crate::OVERLAY_BOX_B, &BIG));
assert_eq!(v.twin, "a");
}
/// ★ A7 row 1 (moved from monetize-embed `fleet::pull_role`, Gunnar b009cbcc): a box
/// elected twin AFTER its start pulls as the twin; one the door has not elected yet
/// waits; the operator's flag and the box's own twin record win.
#[test]
fn the_role_is_the_flag_then_the_twin_record_then_the_doors_election() {
assert_eq!(pull_role(" twin ", false, Some(Some("primary"))).as_deref(), Some("twin"));
assert_eq!(pull_role("", true, Some(Some("primary"))).as_deref(), Some("twin"));
assert_eq!(pull_role("", false, Some(None)), None, "fed and not elected: wait");
assert_eq!(pull_role("", false, Some(Some("twin"))).as_deref(), Some("twin"));
assert_eq!(pull_role("", false, Some(Some("primary"))).as_deref(), Some("main"));
assert_eq!(pull_role("", false, None).as_deref(), Some("main"), "no door: the medium's main");
assert_eq!(pull_role(" ", false, None).as_deref(), Some("main"), "a blank flag names no role");
}
}