feat(attribution): offer a best-effort name, and say how thin the evidence is
A name now appears as soon as one node leads the vote, rather than waiting for three. On a chain that resolves a first reporter for about one block in seven, the old bar left almost every miner permanently anonymous — and since a later block can overturn a mapping, a guess that corrects itself is more useful than a blank that never fills in. What is refused is a dead heat. Choosing between two equally-voted nodes would be a coin toss wearing a telemetry badge, which is a different thing from a thin but real lead. The honesty moves to the presentation instead of being dropped. A percentage alone cannot separate "100% of 1" from "86% of 7", so `Attribution` and `LeaderboardRow` now carry the number of votes behind the name. The leaderboard marks a mapping resting on a single block or a narrow lead with `node?` and a dashed chip, and the tooltip states both figures and that a later block can correct it. Dashed rather than a second colour: the accent hue is reserved for identity and alarm, and this is neither — the same claim held more loosely. The footer now says node names are inferred from which node reported a block to telemetry first, never something the miner asserted, and that the reward preimage beside them is the only identity the chain itself vouches for. `a_single_lucky_vote_still_names_nobody` asserted the old policy and is replaced by `one_vote_names_but_says_it_is_only_one`, which pins the new contract: it names, and it reports `votes == 1` so the UI can mark it. A new `a_dead_heat_names_nobody` keeps the case that is still refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ pub fn leaderboard<F>(
|
||||
mut attribute: F,
|
||||
) -> Vec<LeaderboardRow>
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -243,6 +243,12 @@ export default function App() {
|
||||
Hashrates are estimates from blocks won over a finite window, not measurements of anyone's
|
||||
hardware.
|
||||
</span>
|
||||
<span>
|
||||
Node names are a best-effort mapping, inferred from which node reported a block to
|
||||
telemetry first — never a claim the miner made. A <code>node?</code> 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.
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 (
|
||||
<tr className={mine ? 'mine' : undefined}>
|
||||
<td className={`rank rank-${row.rank}`}>{row.rank}</td>
|
||||
@@ -56,14 +59,19 @@ function Row({
|
||||
{mine && <span className="chip chip-you">You</span>}
|
||||
{named && (
|
||||
<span
|
||||
className="chip chip-named"
|
||||
// An attributed name is an inference from who reported the block
|
||||
// first, not a claim the miner made. The chip and the confidence
|
||||
// in its tooltip keep that visible rather than presenting a guess
|
||||
// as a fact.
|
||||
title={`Name inferred from telemetry — ${Math.round(row.confidence * 100)}% of recent blocks agree`}
|
||||
// first, not a claim the miner made. A name is offered as soon as
|
||||
// one node leads, so the chip has to distinguish a thin guess from
|
||||
// a settled mapping — `100% of 1` and `86% of 7` are not the same
|
||||
// claim, and a percentage alone cannot tell them apart.
|
||||
className={tentative ? 'chip chip-named chip-guess' : 'chip chip-named'}
|
||||
title={
|
||||
`Name inferred from telemetry: ${Math.round(row.confidence * 100)}% of ` +
|
||||
`${row.attribution_votes} attributed block${row.attribution_votes === 1 ? '' : 's'} ` +
|
||||
`point here. Best effort — a later block can correct it.`
|
||||
}
|
||||
>
|
||||
node
|
||||
{tentative ? 'node?' : 'node'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user