fix: a miner's history chart spans its window, not a hard-coded hour
`bucketing` mapped each window to a fixed duration — `600 blocks -> 1 hour`, `3,600 -> 6 hours` — which is only true at a 6 s target. It was the last place still assuming a window's block count implies a duration, which is the assumption the rename retired. On Planck the gap is visible: its rate fell ninefold when its miners left for mainnet, so a 600-block window spans about five hours, and the chart underneath was drawing one. A miner's rank was computed over one period and its history plotted over another, with nothing on the page saying so. The range is the window's measured span now, with buckets sized to hit `MINER_SERIES_POINTS`, so a chart stays the same width in points however fast the chain is running. Where no span is measured yet — only before the window has filled — it falls back to the block count at the chain's target rate, which is the same guess the old table encoded. Bounded at both ends, because a measurement can be extreme in both directions: a floor so a burst cannot collapse the chart to minutes, a ceiling so `100800-blocks` on a slow chain cannot ask the database for a year of buckets. Closes #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
@@ -76,6 +76,15 @@ the coarser version of the same error. `subscribe_new_heads` sends a `SeenHead`
|
||||
carrying the moment the frame was read off the socket, and that is what becomes
|
||||
`observed_at`. Do not replace it with a fresh `now()`.
|
||||
|
||||
**A miner's history chart spans its window, not a hard-coded duration.**
|
||||
`bucketing` used to read `600 blocks -> 1 hour`, true only at a 6 s target — so
|
||||
on Planck, whose rate fell ninefold, a rank computed over a five-hour window sat
|
||||
above a chart drawing one hour of it, and nothing said so. It takes the window's
|
||||
measured span now and sizes buckets to hit `MINER_SERIES_POINTS`, falling back
|
||||
to the block count at the chain's target rate only before the window has filled.
|
||||
Bounded at both ends: a floor so a burst cannot collapse the chart to minutes, a
|
||||
ceiling so the longest window on a slow chain cannot ask the database for a year.
|
||||
|
||||
**A window's duration is measured, never `blocks x interval`.** The block count
|
||||
is the window; the duration is a consequence of the chain's rate, and that rate
|
||||
changes. `RollingWindow::span_seconds` is the authored-time span of exactly the
|
||||
|
||||
@@ -642,18 +642,52 @@ async fn series(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Points a miner's history chart aims for.
|
||||
///
|
||||
/// Buckets are sized to hit roughly this many: a chart with ten thousand points
|
||||
/// is slower to draw and no more informative than one with a hundred.
|
||||
const MINER_SERIES_POINTS: u32 = 72;
|
||||
|
||||
/// The shortest and longest a history chart will reach back.
|
||||
///
|
||||
/// The floor keeps a chart from collapsing to minutes on a chain that has just
|
||||
/// produced a burst; the ceiling keeps `100800-blocks` on a slow chain from
|
||||
/// asking the database for a year.
|
||||
const MINER_SERIES_MIN: Duration = Duration::from_secs(1_800);
|
||||
const MINER_SERIES_MAX: Duration = Duration::from_secs(30 * 86_400);
|
||||
|
||||
/// How far back a miner's history chart reaches, and how finely it is bucketed.
|
||||
///
|
||||
/// Buckets are chosen so every range renders roughly the same number of points:
|
||||
/// a chart with ten thousand points is slower to draw and no more informative
|
||||
/// than one with a hundred and fifty.
|
||||
fn bucketing(window: Window) -> (Duration, Duration) {
|
||||
match window {
|
||||
Window::B600 => (Duration::from_secs(3_600), Duration::from_secs(60)),
|
||||
Window::B3600 => (Duration::from_secs(21_600), Duration::from_secs(300)),
|
||||
Window::B14400 => (Duration::from_secs(86_400), Duration::from_secs(900)),
|
||||
Window::B100800 => (Duration::from_secs(604_800), Duration::from_secs(7_200)),
|
||||
}
|
||||
/// **Derived from what the window actually spanned**, not from a duration
|
||||
/// hard-coded per window. The table this replaced read `600 blocks -> 1 hour`,
|
||||
/// which was true only at a 6 s target — and the whole reason windows stopped
|
||||
/// being named for durations is that a chain's rate moves. Planck's fell
|
||||
/// ninefold when its miners left for mainnet, so its 600-block window spans
|
||||
/// closer to five hours, and the chart under it was showing one: a miner's rank
|
||||
/// was computed over a window five times longer than the history drawn beneath
|
||||
/// it, with nothing saying so.
|
||||
///
|
||||
/// `None` falls back to the nominal span — the block count at the chain's
|
||||
/// target rate — which is the same guess the old table encoded and is only
|
||||
/// reached before the window has filled.
|
||||
fn bucketing(
|
||||
window: Window,
|
||||
span_seconds: Option<f64>,
|
||||
target_block_time: f64,
|
||||
) -> (Duration, Duration) {
|
||||
let nominal = f64::from(window.blocks()) * target_block_time;
|
||||
let span = span_seconds
|
||||
.filter(|s| s.is_finite() && *s > 0.0)
|
||||
.unwrap_or(nominal)
|
||||
.clamp(
|
||||
MINER_SERIES_MIN.as_secs_f64(),
|
||||
MINER_SERIES_MAX.as_secs_f64(),
|
||||
);
|
||||
let bucket = (span / f64::from(MINER_SERIES_POINTS)).max(1.0);
|
||||
(
|
||||
Duration::from_secs_f64(span),
|
||||
Duration::from_secs_f64(bucket),
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET /v1/chains/{chain}/miners/{miner}?window=day`
|
||||
@@ -680,7 +714,11 @@ async fn miner(
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
|
||||
let (span, bucket) = bucketing(window);
|
||||
let (span, bucket) = bucketing(
|
||||
window,
|
||||
runtime.window_span_seconds(window),
|
||||
runtime.spec.target_block_time_seconds,
|
||||
);
|
||||
let since = chrono::Utc::now()
|
||||
- chrono::Duration::from_std(span).unwrap_or_else(|_| chrono::Duration::days(7));
|
||||
let series = state
|
||||
@@ -2055,6 +2093,40 @@ mod tests {
|
||||
|
||||
/// The runtime's own spelling is unambiguous and unreadable. Only the
|
||||
/// associated-type qualifier goes; a generic is information.
|
||||
/// The chart reaches back as far as the window it sits under.
|
||||
///
|
||||
/// The table this replaced said `600 blocks -> 1 hour`, true only at a 6 s
|
||||
/// target. Planck's rate fell ninefold when its miners left, so its
|
||||
/// 600-block window spans hours and the chart beneath it was drawing one —
|
||||
/// a rank computed over one period, a history drawn over another.
|
||||
#[test]
|
||||
fn a_history_chart_spans_the_window_it_sits_under() {
|
||||
// A chain running to target: the measured span and the nominal agree.
|
||||
let (span, bucket) = bucketing(Window::B600, Some(3_600.0), 6.0);
|
||||
assert_eq!(span, Duration::from_secs(3_600));
|
||||
assert_eq!(bucket, Duration::from_secs(50));
|
||||
|
||||
// The same window on a chain nine times slower. The chart follows.
|
||||
let (slow, bucket) = bucketing(Window::B600, Some(32_400.0), 6.0);
|
||||
assert_eq!(slow, Duration::from_secs(32_400));
|
||||
assert_eq!(bucket, Duration::from_secs(450));
|
||||
assert_eq!(
|
||||
slow.as_secs() / bucket.as_secs(),
|
||||
u64::from(MINER_SERIES_POINTS)
|
||||
);
|
||||
|
||||
// No measurement yet: the block count at the chain's target rate, which
|
||||
// is the guess the old table encoded.
|
||||
let (nominal, _) = bucketing(Window::B3600, None, 12.0);
|
||||
assert_eq!(nominal, Duration::from_secs(43_200));
|
||||
|
||||
// Bounded at both ends.
|
||||
let (floor, _) = bucketing(Window::B600, Some(1.0), 6.0);
|
||||
assert_eq!(floor, MINER_SERIES_MIN);
|
||||
let (ceiling, _) = bucketing(Window::B100800, Some(9_000_000.0), 6.0);
|
||||
assert_eq!(ceiling, MINER_SERIES_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_name_loses_its_qualifier_and_keeps_its_generics() {
|
||||
assert_eq!(
|
||||
@@ -2137,13 +2209,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_window_gets_a_bucket_count_a_browser_can_draw() {
|
||||
// Across every window and a range of chain rates, from a chain running
|
||||
// to a 6 s target to one nine times slower than its 12 s one — which is
|
||||
// where Planck actually is.
|
||||
for w in Window::all() {
|
||||
let (span, bucket) = bucketing(w);
|
||||
let points = span.as_secs() / bucket.as_secs();
|
||||
assert!(
|
||||
(50..=400).contains(&points),
|
||||
"{w:?} would render {points} points"
|
||||
);
|
||||
for measured in [
|
||||
None,
|
||||
Some(f64::from(w.blocks()) * 6.0),
|
||||
Some(f64::from(w.blocks()) * 108.0),
|
||||
] {
|
||||
let (span, bucket) = bucketing(w, measured, 12.0);
|
||||
let points = span.as_secs() / bucket.as_secs();
|
||||
assert!(
|
||||
(50..=400).contains(&points),
|
||||
"{w:?} at {measured:?} would render {points} points"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user