diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index 2923c4e..c5afcef 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -342,7 +342,7 @@ impl ChainRuntime { let telemetry = &self.telemetry; let rows = blackbeard_core::window::leaderboard(&tallies, total, network, |miner| { let a = attributor.attribute(miner, |key| telemetry.name_of(key)); - (a.display, a.source, a.confidence) + (a.display, a.source, a.confidence, a.votes) }); let changed = leaderboards.get(&window).map(|(_, cached)| cached) != Some(&rows); diff --git a/crates/blackbeard-core/src/attribution.rs b/crates/blackbeard-core/src/attribution.rs index 67583cd..c84bfb3 100644 --- a/crates/blackbeard-core/src/attribution.rs +++ b/crates/blackbeard-core/src/attribution.rs @@ -61,10 +61,16 @@ pub const TAKEOVER_MARGIN: u32 = 2; /// Votes that must actually have been cast before any name is shown. /// -/// The companion to measuring confidence over cast votes: without it a single -/// lucky vote in an otherwise silent window reads as 100% agreement. Three is -/// the same evidence bar [`MIN_ATTEMPTS`] sets for blocks checked. -pub const MIN_VOTES: usize = 3; +/// One: a name is offered as soon as there is any evidence for it, and +/// [`TAKEOVER_MARGIN`] lets a better-supported node replace it later. The site +/// would rather show a best-effort mapping that can be corrected than withhold +/// every name on a chain where a first reporter resolves on about one block in +/// seven — most miners would simply never earn one. +/// +/// The honesty is moved to the presentation rather than dropped: [`Attribution`] +/// carries the number of votes behind the name as well as the fraction agreeing, +/// so one vote cannot render as the same thing as nine. +pub const MIN_VOTES: usize = 1; /// Votes kept per author. /// /// Bounded so a node that restarts or is renamed — which gives it a new @@ -93,8 +99,11 @@ pub struct Attribution { pub display: String, /// Whether `display` is an inferred node name or the abbreviated preimage. pub source: AttributionSource, - /// Fraction of the vote window agreeing with the held name, 0.0–1.0. + /// Fraction of the cast votes agreeing with the held name, 0.0–1.0. pub confidence: f32, + /// How many votes are behind it. A fraction alone cannot distinguish one + /// vote of one from nine of nine, and those are not the same claim. + pub votes: u32, } #[derive(Debug, Default, Clone)] @@ -191,6 +200,7 @@ impl Attributor { display: miner.abbreviated(), source: AttributionSource::Preimage, confidence: 0.0, + votes: 0, }; let Some(entry) = self.authors.get_mut(miner) else { return fallback; @@ -220,10 +230,18 @@ impl Attributor { if let Some((best_key, best_n)) = best { match &held { None => { - if entry.attempts >= MIN_ATTEMPTS - && cast >= MIN_VOTES - && best_n as f32 / total >= MIN_CONFIDENCE - { + // A strict plurality, not a share of the window. The name is + // offered as a best effort as soon as one node leads, and a + // later vote can take it away again; what is refused is a + // dead heat, where choosing between equals would be a coin + // toss wearing a badge. + let runner_up = counts + .iter() + .filter(|(k, _)| **k != &best_key) + .map(|(_, n)| *n) + .max() + .unwrap_or(0); + if entry.attempts >= MIN_ATTEMPTS && cast >= MIN_VOTES && best_n > runner_up { held = Some(best_key); } } @@ -245,12 +263,14 @@ impl Attributor { let Some(held) = held else { return fallback; }; - let confidence = counts.get(&held).copied().unwrap_or(0) as f32 / total.max(1.0); + let backing = counts.get(&held).copied().unwrap_or(0); + let confidence = backing as f32 / total.max(1.0); match name_of(&held) { Some(name) if !name.is_empty() => Attribution { display: name, source: AttributionSource::Telemetry, confidence, + votes: backing, }, _ => fallback, } @@ -360,18 +380,39 @@ mod tests { assert!(got.confidence > 0.99, "unanimous cast votes: {got:?}"); } + /// Policy change, made deliberately: a lone vote now *does* name. + /// + /// The bar was three votes, which on a chain resolving a first reporter for + /// about one block in seven left almost every miner anonymous forever. A + /// best-effort mapping that a later vote can overturn is more useful than a + /// permanent blank — provided the row says how thin the evidence is, which + /// is what `votes` is for. #[test] - fn a_single_lucky_vote_still_names_nobody() { - // The other half of measuring over cast votes: one vote in an otherwise - // silent window is 100% agreement and almost no evidence. + fn one_vote_names_but_says_it_is_only_one() { let mut a = Attributor::new(); let m = MinerId("0xbb".into()); a.observe(&m, Some((NodeKey::Peer("QmOnce".into()), Some(90)))); for _ in 0..11 { a.observe(&m, None); } + let got = a.attribute(&m, |_| Some("whoever".into())); + assert_eq!(got.source, AttributionSource::Telemetry); + assert_eq!(got.display, "whoever"); + assert_eq!(got.votes, 1, "the UI must be able to see this is one vote"); + } + + /// A dead heat still names nobody: choosing between equals would be a coin + /// toss wearing a telemetry badge, which is a different thing from a thin + /// but real lead. + #[test] + fn a_dead_heat_names_nobody() { + let mut a = Attributor::new(); + let m = MinerId("0xff".into()); + for i in 0..4 { + a.observe(&m, Some((NodeKey::Peer(format!("Qm{}", i % 2)), Some(70)))); + } assert_eq!( - a.attribute(&m, |_| Some("whoever".into())).source, + a.attribute(&m, |_| Some("either".into())).source, AttributionSource::Preimage ); } diff --git a/crates/blackbeard-core/src/window.rs b/crates/blackbeard-core/src/window.rs index 94f5f6d..982e0bc 100644 --- a/crates/blackbeard-core/src/window.rs +++ b/crates/blackbeard-core/src/window.rs @@ -185,7 +185,7 @@ pub fn leaderboard( mut attribute: F, ) -> Vec where - F: FnMut(&MinerId) -> (String, AttributionSource, f32), + F: FnMut(&MinerId) -> (String, AttributionSource, f32, u32), { let mut ranked: Vec<(&MinerId, &MinerTally)> = tallies.iter().collect(); ranked.sort_by(|a, b| { @@ -202,13 +202,14 @@ where .into_iter() .enumerate() .map(|(i, (miner, tally))| { - let (display, attribution, confidence) = attribute(miner); + let (display, attribution, confidence, attribution_votes) = attribute(miner); LeaderboardRow { rank: i as u32 + 1, miner: miner.clone(), display, attribution, confidence, + attribution_votes, blocks: tally.blocks, share: if window_total == 0 { 0.0 @@ -379,7 +380,7 @@ mod tests { } let (t, total) = w.tally(100); let rows = leaderboard(&t, total, Some(1000.0), |m| { - (m.abbreviated(), AttributionSource::Preimage, 0.0) + (m.abbreviated(), AttributionSource::Preimage, 0.0, 0) }); assert_eq!(rows.len(), 3); // 1 and 2 both have two blocks; 2's most recent is higher, so it leads. @@ -404,7 +405,7 @@ mod tests { let rows = leaderboard(&t, total, None, |m| { // Miners 1 and 2 share a name; miner 3 has its own. let name = if m == &miner(3) { "solo" } else { "rig-01" }; - (name.to_owned(), AttributionSource::Telemetry, 1.0) + (name.to_owned(), AttributionSource::Telemetry, 1.0, 1) }); let displays: Vec<&str> = rows.iter().map(|r| r.display.as_str()).collect(); assert_eq!( @@ -430,7 +431,7 @@ mod tests { w.push(obs(1, 1, 1), true); let (t, total) = w.tally(100); let rows = leaderboard(&t, total, None, |_| { - ("alice".to_owned(), AttributionSource::Telemetry, 1.0) + ("alice".to_owned(), AttributionSource::Telemetry, 1.0, 1) }); assert_eq!(rows[0].display, "alice"); } @@ -441,7 +442,7 @@ mod tests { let (t, total) = w.tally(100); assert!(w.is_empty()); let rows = leaderboard(&t, total, Some(1000.0), |m| { - (m.abbreviated(), AttributionSource::Preimage, 0.0) + (m.abbreviated(), AttributionSource::Preimage, 0.0, 0) }); assert!(rows.is_empty()); } diff --git a/crates/blackbeard-entities/src/miner.rs b/crates/blackbeard-entities/src/miner.rs index 38eb1cf..d79830f 100644 --- a/crates/blackbeard-entities/src/miner.rs +++ b/crates/blackbeard-entities/src/miner.rs @@ -86,6 +86,11 @@ pub struct LeaderboardRow { /// Fraction of attributed blocks pointing at the named node, 0.0–1.0. Zero /// when `attribution` is `Preimage`. pub confidence: f32, + /// How many blocks that fraction is out of. A name is offered as soon as one + /// node leads, so a row can carry 100% agreement on a single observation — + /// the UI needs the count to tell that apart from a settled mapping, and to + /// avoid presenting a best-effort guess as an established fact. + pub attribution_votes: u32, /// Blocks authored inside the window. pub blocks: u32, /// Share of the window's blocks, 0.0–1.0. diff --git a/web/src/App.tsx b/web/src/App.tsx index 3df6f67..10cdeb5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -243,6 +243,12 @@ export default function App() { Hashrates are estimates from blocks won over a finite window, not measurements of anyone's hardware. + + Node names are a best-effort mapping, inferred from which node reported a block to + telemetry first — never a claim the miner made. A node? tag marks one resting + on a single block or a narrow lead; later blocks correct it. The reward preimage beside it + is the only identity the chain itself asserts. + ) diff --git a/web/src/api/generated/LeaderboardRow.ts b/web/src/api/generated/LeaderboardRow.ts index b54fcbb..580d731 100644 --- a/web/src/api/generated/LeaderboardRow.ts +++ b/web/src/api/generated/LeaderboardRow.ts @@ -28,6 +28,13 @@ attribution: AttributionSource, * when `attribution` is `Preimage`. */ confidence: number, +/** + * How many blocks that fraction is out of. A name is offered as soon as one + * node leads, so a row can carry 100% agreement on a single observation — + * the UI needs the count to tell that apart from a settled mapping, and to + * avoid presenting a best-effort guess as an established fact. + */ +attribution_votes: number, /** * Blocks authored inside the window. */ diff --git a/web/src/components/Leaderboard.tsx b/web/src/components/Leaderboard.tsx index f232d9b..5bc7b4a 100644 --- a/web/src/components/Leaderboard.tsx +++ b/web/src/components/Leaderboard.tsx @@ -29,6 +29,9 @@ function Row({ onSelect: (miner: string) => void }) { const named = row.attribution === 'telemetry' + // Thin evidence: a single supporting block, or a lead the rest of the votes + // do not back. Either way the mapping is a guess that a later block may undo. + const tentative = named && (row.attribution_votes < 3 || row.confidence < 0.6) return ( {row.rank} @@ -56,14 +59,19 @@ function Row({ {mine && You} {named && ( - node + {tentative ? 'node?' : 'node'} )} diff --git a/web/src/index.css b/web/src/index.css index 94f92a2..deb05a5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -679,6 +679,15 @@ a.ticker-height:hover { color: var(--text-muted); } +/* A mapping resting on one block, or on a narrow lead. Dashed rather than a + second colour: the palette's accent is reserved for identity and alarm, and + this is neither — it is the same claim held more loosely, which a change in + border weight says without introducing a hue a reader has to decode. */ +.chip-guess { + border-style: dashed; + opacity: 0.85; +} + /* Share: the percentage as the direct label, the bar beneath it as magnitude. One hue, 4px rounded data-end at the far side, square against the baseline it grows from. */