feat(attribution): offer a best-effort name, and say how thin the evidence is
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled

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:
2026-09-09 14:44:48 +03:00
parent ef3a4b2689
commit bfd3470840
8 changed files with 104 additions and 27 deletions

View File

@@ -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);

View File

@@ -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.01.0.
/// Fraction of the cast votes agreeing with the held name, 0.01.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
);
}

View File

@@ -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());
}

View File

@@ -86,6 +86,11 @@ pub struct LeaderboardRow {
/// Fraction of attributed blocks pointing at the named node, 0.01.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.01.0.

View File

@@ -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>
)

View File

@@ -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.
*/

View File

@@ -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>

View File

@@ -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. */