fix(attribution): a held name survives silence, and only a clear lead replaces it
Reported from the live site: a name would appear and then be "often forgotten if it doesn't mine a new block within the window". `attribute` dropped the held key the moment the vote window contained no vote for it. That reads silence as contradiction — the same mistake as counting abstentions in the confidence denominator, one layer further on. The window holds twenty observations and mainnet resolves a first reporter on roughly one block in seven, so an incumbent's votes routinely roll out before any replacement earns one. The name vanished at that moment and the row fell back to the raw preimage until three fresh votes rebuilt it. A held name is now replaced rather than dropped: it stands until a different node leads it by `TAKEOVER_MARGIN`. The margin is the other half of the same problem — a window carrying about three cast votes flips on a single vote under a bare majority rule, and pools demonstrably run several nodes that all report their blocks (this miner has answered to both `QUANPOOL - quanpool-com` and `quanpool-payout-mainnet`). Requiring a lead of two rides that out instead of oscillating between siblings. Takeover after a restart or rename still works, and is still tested: a node that genuinely owns the blocks accumulates votes and passes the margin within a few blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
@@ -49,6 +49,16 @@ pub const MIN_ATTEMPTS: u32 = 3;
|
||||
/// *were* cast.
|
||||
pub const MIN_CONFIDENCE: f32 = 0.6;
|
||||
|
||||
/// Votes by which a challenger must lead the held node before taking its name.
|
||||
///
|
||||
/// A held name is replaced, never merely dropped, and replacement needs a clear
|
||||
/// margin. Both halves matter at mainnet's resolution rate: a twenty-block
|
||||
/// window holds only about three cast votes, so a single vote for a sibling
|
||||
/// node would otherwise flip the name back and forth — and pools do run several
|
||||
/// nodes that all report their blocks, which is exactly the ambiguity this has
|
||||
/// to ride out rather than oscillate on.
|
||||
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
|
||||
@@ -197,11 +207,9 @@ impl Attributor {
|
||||
let total = cast as f32;
|
||||
|
||||
let mut held = entry.held.clone();
|
||||
if let Some(h) = &held
|
||||
&& counts.get(h).copied().unwrap_or(0) == 0
|
||||
{
|
||||
held = None;
|
||||
}
|
||||
let held_votes = held
|
||||
.as_ref()
|
||||
.map_or(0, |h| counts.get(h).copied().unwrap_or(0));
|
||||
// Deterministic winner: ties break by key, not by hash iteration order,
|
||||
// so an author's name cannot flip back and forth between two equally
|
||||
// voted nodes on successive renders.
|
||||
@@ -219,7 +227,14 @@ impl Attributor {
|
||||
held = Some(best_key);
|
||||
}
|
||||
}
|
||||
Some(h) if &best_key != h && best_n > counts.get(h).copied().unwrap_or(0) => {
|
||||
// Replaced only by a challenger that clearly leads it. A held
|
||||
// name is never dropped for want of votes: at a fourteen
|
||||
// percent resolution rate a twenty-block window routinely
|
||||
// contains no vote for the incumbent, and treating that silence
|
||||
// as a contradiction is what made names appear and vanish again
|
||||
// a minute later. Silence is not evidence; a better-supported
|
||||
// node is.
|
||||
Some(h) if &best_key != h && best_n >= held_votes + TAKEOVER_MARGIN => {
|
||||
held = Some(best_key);
|
||||
}
|
||||
_ => {}
|
||||
@@ -254,6 +269,71 @@ impl NodeKey {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A name, once earned, must not evaporate because the feed went quiet.
|
||||
///
|
||||
/// Reported from the live site: names appeared and were "often forgotten if
|
||||
/// it doesn't mine a new block within the window". The incumbent's votes
|
||||
/// roll out of a twenty-observation window long before a replacement earns
|
||||
/// one, and dropping the name at that moment is what produced the flicker.
|
||||
#[test]
|
||||
fn a_held_name_outlives_its_own_votes_rolling_out_of_the_window() {
|
||||
let mut a = Attributor::new();
|
||||
let m = MinerId("0xdd".into());
|
||||
let node = NodeKey::Peer("QmHeld".into());
|
||||
for _ in 0..6 {
|
||||
a.observe(&m, Some((node.clone(), Some(70))));
|
||||
}
|
||||
assert_eq!(
|
||||
a.attribute(&m, |_| Some("held-node".into())).source,
|
||||
AttributionSource::Telemetry
|
||||
);
|
||||
|
||||
// Two full windows of blocks the feed could not resolve. Every vote for
|
||||
// the held node is now gone from the window.
|
||||
for _ in 0..(VOTE_WINDOW * 2) {
|
||||
a.observe(&m, None);
|
||||
}
|
||||
let got = a.attribute(&m, |_| Some("held-node".into()));
|
||||
assert_eq!(got.source, AttributionSource::Telemetry, "{got:?}");
|
||||
assert_eq!(got.display, "held-node");
|
||||
}
|
||||
|
||||
/// The other half: a sibling node must not steal the name on one vote.
|
||||
///
|
||||
/// Pools run several nodes and all of them report the pool's blocks, so at
|
||||
/// three cast votes per window a bare majority rule oscillates.
|
||||
#[test]
|
||||
fn one_vote_for_a_sibling_node_does_not_flip_the_name() {
|
||||
let mut a = Attributor::new();
|
||||
let m = MinerId("0xee".into());
|
||||
let held = NodeKey::Peer("QmPoolA".into());
|
||||
for _ in 0..6 {
|
||||
a.observe(&m, Some((held.clone(), Some(70))));
|
||||
}
|
||||
assert_eq!(a.attribute(&m, |_| Some("pool-a".into())).display, "pool-a");
|
||||
|
||||
// The window rolls clear, then one vote for a sibling arrives.
|
||||
for _ in 0..VOTE_WINDOW {
|
||||
a.observe(&m, None);
|
||||
}
|
||||
a.observe(&m, Some((NodeKey::Peer("QmPoolB".into()), Some(70))));
|
||||
let name = |k: &NodeKey| match k {
|
||||
NodeKey::Peer(p) if p == "QmPoolA" => Some("pool-a".into()),
|
||||
_ => Some("pool-b".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
a.attribute(&m, name).display,
|
||||
"pool-a",
|
||||
"one vote must not flip"
|
||||
);
|
||||
|
||||
// A clear lead does take it over.
|
||||
for _ in 0..TAKEOVER_MARGIN {
|
||||
a.observe(&m, Some((NodeKey::Peer("QmPoolB".into()), Some(70))));
|
||||
}
|
||||
assert_eq!(a.attribute(&m, name).display, "pool-b");
|
||||
}
|
||||
|
||||
/// The bug this file was changed to fix.
|
||||
///
|
||||
/// Mainnet resolves a clean first-reporter on roughly a quarter of blocks;
|
||||
|
||||
Reference in New Issue
Block a user