diff --git a/CLAUDE.md b/CLAUDE.md index 73e6200..4dd15ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 93e3c8c..8e22fa2 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -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, + 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" + ); + } } }