diff --git a/CLAUDE.md b/CLAUDE.md index 3d930c3..d9f060a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,31 @@ orders of magnitude that still renders as a number. `RollingWindow::push` takes an explicit `at_tip` flag and backfilled blocks contribute no timing sample. The `Interval` enum exists so a caller cannot forget to say which kind it has. +**A head that closes a gap is not a tip observation.** `measured_interval` +divides elapsed time by height difference, which is the chain's rate only if +every height between two samples was *watched arriving*. A gap fill is proof +they were not: the heights advanced while this process was elsewhere, so a +sample pair straddling one measures how fast the observer caught up. Planck read +**0.175 s/block** against a true 29.8 — a factor of 170 — and it was flagged +*measured*, so the headline block time, the network hashrate (877 GH/s on a +testnet nobody mines) and the window label were all wrong and none of them looked +it. `ingest` now calls `forget_tip_samples()` whenever it fills a gap and records +the closing head with `at_tip: false`. The interval falls back to nominal until +twenty fresh samples exist, which is `MIN_TIP_SAMPLES` doing its job: nominal and +labelled nominal beats measured and wrong. The same applies to an ordinary burst +import, where the next head is several heights on with no elapsed time. + +**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 +blocks tallied and is already the denominator of every per-miner hashrate — so +the label is built from it and carries no "~". Multiplying the count by the +*current* interval describes the rate now rather than the period covered, and on +Planck those differed by a factor of five even before the interval was itself +wrong. Windows are also **named** for their block count, in the selector and the +URL both, for the same reason: `/planck/six_hours` was a claim the site could not +stand behind. The old names still parse and are never emitted. + **A miner's hashrate is its summed work over time, never its share of the network.** Difficulty *is* expected hashes per block, so the difficulty of the blocks a miner won, divided by the time they spanned, is its hashrate directly diff --git a/crates/blackbeard-api/src/config.rs b/crates/blackbeard-api/src/config.rs index a6e4b64..3c188e7 100644 --- a/crates/blackbeard-api/src/config.rs +++ b/crates/blackbeard-api/src/config.rs @@ -236,7 +236,7 @@ fn default_leaderboard_refresh() -> u64 { 5 } fn default_warm_start_blocks() -> usize { - blackbeard_entities::Window::Week.blocks() as usize + blackbeard_entities::Window::B100800.blocks() as usize } fn default_max_gap_fill() -> u64 { 5_000 diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index 0c7680e..13f799b 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -345,14 +345,24 @@ async fn ingest( // fetched here — otherwise the leaderboard would quietly under-count // exactly the miners who won blocks during a burst. let last = chain.read().height; - if let Some(last) = last - && height > last + 1 - { - let gap_start = (last + 1).max(height.saturating_sub(chain.spec.max_gap_fill_blocks)); - fill_gap(&chain, &store, gap_start, height).await; - } + let caught_up = match last { + Some(last) if height > last + 1 => { + let gap_start = + (last + 1).max(height.saturating_sub(chain.spec.max_gap_fill_blocks)); + fill_gap(&chain, &store, gap_start, height).await; + // Everything sampled before this is on the far side of a + // discontinuity: the heights advanced without this process + // watching them arrive, so bridging across it measures the + // catch-up rather than the chain. + chain.write().window.forget_tip_samples(); + true + } + _ => false, + }; - record(&chain, &store, &header, height, ticker_blocks).await; + // And this head is itself the far end of that catch-up, so it is not a + // tip observation either — it is the block that closed the gap. + record(&chain, &store, &header, height, ticker_blocks, !caught_up).await; } tracing::warn!(chain = %chain.id(), "head stream ended; ingest stopping"); } @@ -496,6 +506,9 @@ async fn record( header: &Header, height: u64, ticker_blocks: usize, + // Whether this head was watched arriving, rather than being the block that + // closed a gap. Only a watched head times anything. + at_tip: bool, ) { let Some(miner) = digest::author_preimage(&header.digest.logs) else { // Genesis, or a header shape we do not decode. Not an error: the block @@ -568,7 +581,7 @@ async fn record( authored_at, difficulty: magnitude, }, - true, + at_tip, ); inner.height = Some(height); inner.last_block_at = Some(observed_at); @@ -767,6 +780,7 @@ async fn housekeeping(chain: Arc, store: Store, refresh: Duration) chain: chain.id(), window, rows, + window_seconds: chain.window_span_seconds(window), }, Some(window), ); diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 9b3e21b..330a5cb 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -175,6 +175,8 @@ struct LeaderboardResponse { /// Rows actually held in the window, which is fewer than `window_blocks` /// until the observer has watched that many. observed_blocks: u32, + /// What the window actually spanned, by the chain's own clock. + window_seconds: Option, rows: Vec, } @@ -194,6 +196,7 @@ async fn leaderboard( window, window_blocks: window.blocks(), observed_blocks, + window_seconds: runtime.window_span_seconds(window), rows, })) } @@ -637,10 +640,10 @@ async fn series( /// than one with a hundred and fifty. fn bucketing(window: Window) -> (Duration, Duration) { match window { - Window::Hour => (Duration::from_secs(3_600), Duration::from_secs(60)), - Window::SixHours => (Duration::from_secs(21_600), Duration::from_secs(300)), - Window::Day => (Duration::from_secs(86_400), Duration::from_secs(900)), - Window::Week => (Duration::from_secs(604_800), Duration::from_secs(7_200)), + 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)), } } @@ -2158,7 +2161,7 @@ mod tests { window: None, limit: None, }; - assert_eq!(q.window().ok(), Some(Window::SixHours)); + assert_eq!(q.window().ok(), Some(Window::B3600)); } #[test] diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index f279e8c..cb59bce 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -187,7 +187,7 @@ impl ChainRuntime { inner: RwLock::new(ChainInner { // Sized to the longest window: every shorter one is then a tail // of the same buffer rather than separate state to keep in sync. - window: RollingWindow::new(Window::Week.blocks() as usize), + window: RollingWindow::new(Window::B100800.blocks() as usize), attributor: Attributor::new(), ticker: std::collections::VecDeque::new(), leaderboards: HashMap::new(), @@ -235,10 +235,10 @@ impl ChainRuntime { fn slot(window: Window) -> usize { match window { - Window::Hour => 0, - Window::SixHours => 1, - Window::Day => 2, - Window::Week => 3, + Window::B600 => 0, + Window::B3600 => 1, + Window::B14400 => 2, + Window::B100800 => 3, } } @@ -321,7 +321,7 @@ impl ChainRuntime { pub fn summary(&self) -> ChainSummary { let inner = self.read(); let interval = self.interval(&inner); - let (tallies, total) = inner.window.tally(Window::SixHours.blocks() as usize); + let (tallies, total) = inner.window.tally(Window::B3600.blocks() as usize); ChainSummary { chain: self.id(), // A chain with no node of ours still has a height — telemetry's, @@ -396,6 +396,17 @@ impl ChainRuntime { (rows, changed) } + /// What a window actually spanned, by the chain's own clock. + /// + /// The authored-time span of exactly the blocks the board tallied — the same + /// number that is already the denominator of every per-miner hashrate. The + /// label is built from this rather than from `blocks x interval`, which + /// describes the rate *now* and is wrong by however much the chain's rate + /// has moved since the oldest block in the window. + pub fn window_span_seconds(&self, window: Window) -> Option { + self.read().window.span_seconds(window.blocks() as usize) + } + /// The standings for a window, recomputing them if the cache has aged out. pub fn leaderboard(&self, window: Window, max_age: std::time::Duration) -> Vec { if let Some((computed_at, rows)) = self.read().leaderboards.get(&window) @@ -534,7 +545,7 @@ mod tests { assert_eq!(inner.window.len(), 20_058, "the restore has to land"); } - let (rows, _) = c.recompute_leaderboard(Window::Week); + let (rows, _) = c.recompute_leaderboard(Window::B100800); let served: u32 = rows.iter().map(|r| r.blocks).sum(); assert_eq!( served, 20_058, @@ -542,7 +553,7 @@ mod tests { 20_058 ); - let (rows, _) = c.recompute_leaderboard(Window::SixHours); + let (rows, _) = c.recompute_leaderboard(Window::B3600); let served: u32 = rows.iter().map(|r| r.blocks).sum(); assert_eq!( served, 3_600, @@ -554,8 +565,8 @@ mod tests { fn nothing_is_computed_for_a_window_nobody_is_watching() { let c = runtime(); assert!(c.watched_windows().is_empty()); - let guard = c.watch(Window::SixHours); - assert_eq!(c.watched_windows(), vec![Window::SixHours]); + let guard = c.watch(Window::B3600); + assert_eq!(c.watched_windows(), vec![Window::B3600]); drop(guard); // A dropped socket must not leave its window pinned forever. assert!(c.watched_windows().is_empty()); @@ -564,10 +575,10 @@ mod tests { #[test] fn several_watchers_of_one_window_all_have_to_leave() { let c = runtime(); - let a = c.watch(Window::Day); - let b = c.watch(Window::Day); + let a = c.watch(Window::B14400); + let b = c.watch(Window::B14400); drop(a); - assert_eq!(c.watched_windows(), vec![Window::Day]); + assert_eq!(c.watched_windows(), vec![Window::B14400]); drop(b); assert!(c.watched_windows().is_empty()); } @@ -625,11 +636,11 @@ mod tests { } } assert!( - c.recompute_leaderboard(Window::Hour).1, + c.recompute_leaderboard(Window::B600).1, "first compute is a change" ); assert!( - !c.recompute_leaderboard(Window::Hour).1, + !c.recompute_leaderboard(Window::B600).1, "an unchanged table must not be rebroadcast every few seconds" ); } @@ -653,7 +664,7 @@ mod tests { ); } assert_eq!( - c.leaderboard(Window::Hour, Duration::from_secs(60)).len(), + c.leaderboard(Window::B600, Duration::from_secs(60)).len(), 1 ); { @@ -671,10 +682,10 @@ mod tests { } // A cache still inside its age is served as-is... assert_eq!( - c.leaderboard(Window::Hour, Duration::from_secs(60)).len(), + c.leaderboard(Window::B600, Duration::from_secs(60)).len(), 1 ); // ...and one past it is rebuilt. - assert_eq!(c.leaderboard(Window::Hour, Duration::ZERO).len(), 2); + assert_eq!(c.leaderboard(Window::B600, Duration::ZERO).len(), 2); } } diff --git a/crates/blackbeard-api/src/ws.rs b/crates/blackbeard-api/src/ws.rs index fcb1ac4..f3d6bf0 100644 --- a/crates/blackbeard-api/src/ws.rs +++ b/crates/blackbeard-api/src/ws.rs @@ -148,6 +148,7 @@ async fn subscription( window, summary: chain.summary(), leaderboard: chain.leaderboard(window, MAX_SNAPSHOT_AGE), + window_seconds: chain.window_span_seconds(window), recent_blocks: chain.ticker(), }; send(&tx, &snapshot).await; @@ -174,6 +175,7 @@ async fn subscription( window, summary: chain.summary(), leaderboard: chain.leaderboard(window, MAX_SNAPSHOT_AGE), + window_seconds: chain.window_span_seconds(window), recent_blocks: chain.ticker(), }; send(&tx, &snapshot).await; diff --git a/crates/blackbeard-cli/src/main.rs b/crates/blackbeard-cli/src/main.rs index c018e58..3ad1283 100644 --- a/crates/blackbeard-cli/src/main.rs +++ b/crates/blackbeard-cli/src/main.rs @@ -91,8 +91,8 @@ enum Command { /// Chain id. #[arg(long, default_value = "planck")] chain: String, - /// Window: hour, six_hours, day or week. - #[arg(long, default_value = "six_hours")] + /// Window, as a block count: 600, 3600, 14400 or 100800. + #[arg(long, default_value = "3600")] window: String, /// Rows to print. #[arg(long, default_value_t = 20)] diff --git a/crates/blackbeard-core/src/window.rs b/crates/blackbeard-core/src/window.rs index c32e453..6d39a13 100644 --- a/crates/blackbeard-core/src/window.rs +++ b/crates/blackbeard-core/src/window.rs @@ -107,6 +107,25 @@ impl RollingWindow { self.blocks.push_back(observed); } + /// Forget every tip sample, because the observer just proved it was not + /// watching. + /// + /// A gap fill is that proof. `measured_interval` divides elapsed time by + /// height difference, which is only the chain's rate if every height between + /// two samples was *watched arriving* — and a gap fill means, by definition, + /// that they were not. A pair straddling one measures how fast this + /// observer caught up. + /// + /// Planck read **0.175 s/block** that way against a true 29.8, a factor of + /// 170, and it was flagged *measured* rather than nominal — so the headline + /// block time, the network hashrate and the window label were all wrong and + /// none of them looked it. Dropping the samples costs the interval until + /// twenty fresh ones accumulate, which is `MIN_TIP_SAMPLES` doing its job: + /// nominal and labelled nominal beats measured and wrong. + pub fn forget_tip_samples(&mut self) { + self.tip_samples.clear(); + } + /// Blocks currently held. pub fn len(&self) -> usize { self.blocks.len() @@ -345,6 +364,50 @@ mod tests { /// it served divided by 1,065. One of the two had to be wrong, and this /// pins the half that lives in this crate — so an investigation upstream of /// it does not have to re-establish that a deque holds what was pushed. + /// A gap fill means the observer was not watching, so nothing sampled + /// before it can be bridged across it. + /// + /// #14: Planck read 0.175 s/block against a true 29.8 — a factor of 170 — + /// because a head that closed a 1,251-block gap was pushed as a tip + /// observation. `measured_interval` divides elapsed time by height + /// difference, so the pair straddling the catch-up measured how fast this + /// process caught up. Nominal-and-labelled-nominal beats measured-and-wrong. + #[test] + fn a_catch_up_does_not_become_a_measured_interval() { + let mut w = RollingWindow::new(1000); + let start = Utc::now(); + let block = |h: u64, at: DateTime| Observed { + height: h, + miner: MinerId("m".into()), + observed_at: at, + authored_at: Some(at), + difficulty: None, + }; + + // Twenty honest tip observations, ten seconds apart. + for i in 0..MIN_TIP_SAMPLES as u64 { + w.push( + block(i, start + chrono::TimeDelta::seconds(10 * i as i64)), + true, + ); + } + let honest = w.measured_interval().expect("twenty samples is enough"); + assert!((honest - 10.0).abs() < 0.001, "{honest}"); + + // A gap fill happens: heights advanced while nobody watched. + w.forget_tip_samples(); + assert_eq!( + w.measured_interval(), + None, + "with the samples gone the interval is nominal, not a catch-up rate" + ); + + // The block that closed the gap is a thousand heights on and one second + // later. Had it been kept as a tip sample, it would have read 0.001 s. + w.push(block(1_020, start + chrono::TimeDelta::seconds(201)), false); + assert_eq!(w.measured_interval(), None); + } + #[test] fn a_restored_window_tallies_the_whole_requested_span() { let mut w = RollingWindow::new(100_800); diff --git a/crates/blackbeard-entities/src/error.rs b/crates/blackbeard-entities/src/error.rs index 2dc98f0..c18c2d6 100644 --- a/crates/blackbeard-entities/src/error.rs +++ b/crates/blackbeard-entities/src/error.rs @@ -9,7 +9,9 @@ use ts_rs::TS; #[derive(Debug, thiserror::Error)] pub enum EntityError { /// A window name that is not one of the four. - #[error("unknown window `{0}` (expected hour, six_hours, day or week)")] + #[error( + "unknown window `{0}` (expected 600, 3600, 14400 or 100800 — a block count, which is what a window is)" + )] UnknownWindow(String), /// A miner id that is not 0x-prefixed 32-byte hex. diff --git a/crates/blackbeard-entities/src/ws.rs b/crates/blackbeard-entities/src/ws.rs index 4ef934d..c0f330c 100644 --- a/crates/blackbeard-entities/src/ws.rs +++ b/crates/blackbeard-entities/src/ws.rs @@ -23,36 +23,58 @@ use crate::{ChainId, ChainInfo, ChainSummary, LeaderboardRow, RecentBlock}; /// last hour" can contain half a million blocks. Block counts stay honest in /// both regimes, and the UI renders the approximate duration beside them from /// the measured interval. +/// **Named for the block count, because that is what is windowed on.** +/// +/// They used to be `hour`, `six_hours`, `day` and `week`, which was a promise +/// the site could not keep: a window is a fixed number of blocks, so its +/// duration is whatever the chain's rate makes it. When Planck's miners left for +/// mainnet its rate fell ninefold, and `/planck/six_hours` went on saying "six +/// hours" over a window that reached back **twenty-nine**. A miner that had not +/// touched the chain in a day sat in the standings looking current, and the name +/// in the address bar was the reason a reader believed it. +/// +/// The old names still parse, so links that are already out there keep working — +/// but nothing emits them. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] #[ts(export, export_to = "Window.ts")] pub enum Window { - /// 600 blocks — about an hour at a 6 s target. - Hour, - /// 3,600 blocks — about six hours. The default, matching the arena - /// exporter's window so the two agree. + /// 600 blocks. + #[serde(rename = "600")] + B600, + /// 3,600 blocks. The default, matching the arena exporter's window so the + /// two agree. #[default] - SixHours, - /// 14,400 blocks — about a day. - Day, - /// 100,800 blocks — about a week. - Week, + #[serde(rename = "3600")] + B3600, + /// 14,400 blocks. + #[serde(rename = "14400")] + B14400, + /// 100,800 blocks. + #[serde(rename = "100800")] + B100800, } impl Window { /// The window's length in blocks. pub fn blocks(self) -> u32 { match self { - Window::Hour => 600, - Window::SixHours => 3_600, - Window::Day => 14_400, - Window::Week => 100_800, + Window::B600 => 600, + Window::B3600 => 3_600, + Window::B14400 => 14_400, + Window::B100800 => 100_800, } } /// Every window, for building a selector. pub fn all() -> [Window; 4] { - [Window::Hour, Window::SixHours, Window::Day, Window::Week] + [Window::B600, Window::B3600, Window::B14400, Window::B100800] + } +} + +impl std::fmt::Display for Window { + /// The block count, which is the URL segment and the wire value both. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.blocks()) } } @@ -61,10 +83,18 @@ impl std::str::FromStr for Window { fn from_str(s: &str) -> Result { match s { - "hour" => Ok(Window::Hour), - "six_hours" | "6h" => Ok(Window::SixHours), - "day" => Ok(Window::Day), - "week" => Ok(Window::Week), + // What the site emits now. + "600" => Ok(Window::B600), + "3600" => Ok(Window::B3600), + "14400" => Ok(Window::B14400), + "100800" => Ok(Window::B100800), + // Links already in the wild. Accepted forever, emitted never: the + // cost of keeping them is four match arms, and the cost of dropping + // them is every shared standings link 404ing. + "hour" => Ok(Window::B600), + "six_hours" | "6h" => Ok(Window::B3600), + "day" => Ok(Window::B14400), + "week" => Ok(Window::B100800), other => Err(crate::EntityError::UnknownWindow(other.to_owned())), } } @@ -119,6 +149,16 @@ pub enum ServerMessage { summary: ChainSummary, /// Full standings for the window. leaderboard: Vec, + /// What the window actually spanned, by the chain's own clock. + /// + /// The *measured* span of exactly the blocks tallied, not the block + /// count multiplied by a current interval. On a chain whose rate has + /// moved — Planck's fell ninefold when its miners left for mainnet — + /// those are different by more than a factor of two, and the second is + /// the one that told readers 3,600 Planck blocks was six hours when it + /// was twenty-nine. + #[ts(type = "number | null")] + window_seconds: Option, /// The tail of the block ticker, newest last. recent_blocks: Vec, }, @@ -146,6 +186,9 @@ pub enum ServerMessage { window: Window, /// The standings. rows: Vec, + /// What the window actually spanned, by the chain's own clock. + #[ts(type = "number | null")] + window_seconds: Option, }, /// A chain's reachability changed — the node went away, or a chain that was /// awaiting launch has started producing blocks. @@ -164,3 +207,38 @@ pub enum ServerMessage { message: String, }, } + +#[cfg(test)] +mod tests { + use super::*; + + /// The wire value, the URL segment and the block count are one string. + #[test] + fn a_window_is_named_for_its_block_count() { + for w in Window::all() { + assert_eq!(w.to_string(), w.blocks().to_string()); + assert_eq!(w.to_string().parse::().unwrap(), w); + // Serde and `Display` must not drift: the first is the WebSocket + // protocol and the second is the address bar. + assert_eq!( + serde_json::to_string(&w).unwrap(), + format!("\"{}\"", w.blocks()) + ); + } + } + + /// Links shared before the rename still resolve. + /// + /// Accepted and never emitted — every producer goes through `Display`, + /// which is the block count. Dropping them would 404 every standings link + /// anyone has shared. + #[test] + fn the_old_duration_names_still_parse() { + assert_eq!("hour".parse::().unwrap(), Window::B600); + assert_eq!("six_hours".parse::().unwrap(), Window::B3600); + assert_eq!("6h".parse::().unwrap(), Window::B3600); + assert_eq!("day".parse::().unwrap(), Window::B14400); + assert_eq!("week".parse::().unwrap(), Window::B100800); + assert!("fortnight".parse::().is_err()); + } +} diff --git a/readme.md b/readme.md index e638c51..898de31 100644 --- a/readme.md +++ b/readme.md @@ -105,9 +105,25 @@ actually have a subscriber. ## Windows are block counts, not durations -Every window on the site — 1h, 6h, 24h, 7d — is a **block count** (600, 3 600, -14 400, 100 800). The labels are the approximate duration at the measured -interval, and the UI says "~". +Every window on the site is a **block count** — 600, 3 600, 14 400, 100 800 — +and since those are what is windowed on, they are also what the selector says +and what the URL carries: `/quantus/3600`, not `/quantus/six_hours`. + +The duration names came off because they were a promise the site could not keep. +A window is a fixed number of blocks, so how far back it reaches is whatever the +chain's rate makes it. When Planck's miners left for mainnet its rate fell +ninefold, and `/planck/six_hours` went on saying six hours over a window that +spanned **twenty-nine** — with a miner that had not touched the chain in a day +sitting in the standings looking current. The name in the address bar was the +reason a reader believed it. + +The duration is still shown, next to the count, but it is now **measured**: the +authored-time span of exactly the blocks tallied, which is the same figure that +is already the denominator of every per-miner hashrate. It carries no "~", +because nothing is being estimated. Only the windows that are *not* being +displayed still show an estimate from the current interval, and those keep the +"~". The old URLs still resolve — the router rewrites them to the block count — +so links already shared keep working. This is not pedantry. A duration is meaningless while a node is catching up: it imports historical blocks at disk speed, so "the last hour" can contain half a diff --git a/web/src/App.tsx b/web/src/App.tsx index cfdaf12..87f067a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,7 +29,7 @@ import { SectionNav } from './components/SectionNav' import { ThemeToggle } from './components/ThemeToggle' import { StateIndex } from './components/StateIndex' import { StatBar } from './components/StatBar' -import { seconds, windowSpan } from './lib/format' +import { measuredSpan, seconds, windowSpan } from './lib/format' import { href, parse, WINDOWS } from './lib/routes' import { useObserver, usePinnedMiners, useWatch } from './lib/store' @@ -144,7 +144,14 @@ export default function App() { // reason to stop looking at that miner. to={href({ ...route, chain, window: w.id })} aria-current={w.id === route.window ? 'page' : undefined} - title={`${w.blocks.toLocaleString('en-US')} blocks — ${windowSpan(w.blocks, interval)}`} + // The active window's span is measured; the others can only + // be estimated from the current interval, and say so with a + // `~`. Both beat a label that names a duration it is not. + title={`${w.blocks.toLocaleString('en-US')} blocks — ${ + w.id === route.window + ? measuredSpan(state.windowSeconds, w.blocks, interval) + : windowSpan(w.blocks, interval) + }`} > {w.label} @@ -282,7 +289,7 @@ export default function App() {

The Standings

last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '} - {windowSpan(activeWindow.blocks, interval)} + {measuredSpan(state.windowSeconds, activeWindow.blocks, interval)} {pinned.length > 0 && ` · ${pinned.length} marked yours`} diff --git a/web/src/api/generated/ServerMessage.ts b/web/src/api/generated/ServerMessage.ts index f2d2b24..c41294d 100644 --- a/web/src/api/generated/ServerMessage.ts +++ b/web/src/api/generated/ServerMessage.ts @@ -30,6 +30,17 @@ summary: ChainSummary, * Full standings for the window. */ leaderboard: Array, +/** + * What the window actually spanned, by the chain's own clock. + * + * The *measured* span of exactly the blocks tallied, not the block + * count multiplied by a current interval. On a chain whose rate has + * moved — Planck's fell ninefold when its miners left for mainnet — + * those are different by more than a factor of two, and the second is + * the one that told readers 3,600 Planck blocks was six hours when it + * was twenty-nine. + */ +window_seconds: number | null, /** * The tail of the block ticker, newest last. */ @@ -61,7 +72,11 @@ window: Window, /** * The standings. */ -rows: Array, } | { "type": "chain_status", +rows: Array, +/** + * What the window actually spanned, by the chain's own clock. + */ +window_seconds: number | null, } | { "type": "chain_status", /** * Which chain. */ diff --git a/web/src/api/generated/Window.ts b/web/src/api/generated/Window.ts index f1fff0d..0fb2b72 100644 --- a/web/src/api/generated/Window.ts +++ b/web/src/api/generated/Window.ts @@ -8,5 +8,17 @@ * last hour" can contain half a million blocks. Block counts stay honest in * both regimes, and the UI renders the approximate duration beside them from * the measured interval. + * **Named for the block count, because that is what is windowed on.** + * + * They used to be `hour`, `six_hours`, `day` and `week`, which was a promise + * the site could not keep: a window is a fixed number of blocks, so its + * duration is whatever the chain's rate makes it. When Planck's miners left for + * mainnet its rate fell ninefold, and `/planck/six_hours` went on saying "six + * hours" over a window that reached back **twenty-nine**. A miner that had not + * touched the chain in a day sat in the standings looking current, and the name + * in the address bar was the reason a reader believed it. + * + * The old names still parse, so links that are already out there keep working — + * but nothing emits them. */ -export type Window = "hour" | "six_hours" | "day" | "week"; +export type Window = "600" | "3600" | "14400" | "100800"; diff --git a/web/src/api/socket.ts b/web/src/api/socket.ts index 0b89e21..5bb138f 100644 --- a/web/src/api/socket.ts +++ b/web/src/api/socket.ts @@ -34,6 +34,15 @@ export interface ObserverState { window: WindowName summary: ChainSummary | null leaderboard: LeaderboardRow[] + /** + * What the window actually spanned, by the chain's own clock. + * + * `null` until the first snapshot, or where too few blocks in the window + * carry a timestamp to span anything. Never inferred from the block count + * and the current interval — that is the arithmetic that told readers 3,600 + * Planck blocks was six hours when it was twenty-nine. + */ + windowSeconds: number | null /** Newest first — the order the ticker renders in. */ blocks: RecentBlock[] /** True once the first snapshot for the current subscription has landed. */ @@ -44,9 +53,10 @@ const INITIAL: ObserverState = { connection: 'connecting', chains: [], chain: null, - window: 'six_hours', + window: '3600', summary: null, leaderboard: [], + windowSeconds: null, blocks: [], ready: false, } @@ -176,6 +186,7 @@ export class Observer { window: windowName, summary: null, leaderboard: [], + windowSeconds: null, blocks: [], ready: false, }) @@ -201,6 +212,7 @@ export class Observer { this.patch({ summary: message.summary, leaderboard: message.leaderboard, + windowSeconds: message.window_seconds, blocks: [...message.recent_blocks].reverse(), ready: true, }) @@ -213,7 +225,7 @@ export class Observer { case 'leaderboard': if (message.chain !== this.state.chain || message.window !== this.state.window) return - this.patch({ leaderboard: message.rows }) + this.patch({ leaderboard: message.rows, windowSeconds: message.window_seconds }) break case 'block': { diff --git a/web/src/components/StatBar.tsx b/web/src/components/StatBar.tsx index 1719e5b..82b4cf2 100644 --- a/web/src/components/StatBar.tsx +++ b/web/src/components/StatBar.tsx @@ -30,10 +30,10 @@ import { Sparkline, type SparkPoint } from './Sparkline' /** Kept in step with `Window::blocks()` in the entities crate. */ const WINDOW_BLOCKS: Record = { - hour: 600, - six_hours: 3600, - day: 14400, - week: 100800, + '600': 600, + '3600': 3600, + '14400': 14400, + '100800': 100800, } function Stat({ diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 173cea5..756baf9 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -93,6 +93,29 @@ export function windowSpan(blocks: number, intervalSeconds: number): string { return `~${Math.round(total / 86400)} d` } +/** + * How far back a window actually reached, from the chain's own clock. + * + * No `~`, because nothing is being estimated: this is the authored-time span of + * exactly the blocks that were tallied. `windowSpan` above multiplies a block + * count by the *current* interval, which describes the rate now rather than the + * period covered — on Planck those differed by a factor of five even before its + * measured interval was itself wrong. + * + * Falls back to the estimate when the window holds too few timestamped blocks + * to span anything, which is only true in the first moments after a start. + */ +export function measuredSpan( + seconds: number | null, + blocks: number, + intervalSeconds: number, +): string { + if (seconds === null || seconds <= 0) return windowSpan(blocks, intervalSeconds) + if (seconds < 5400) return `${Math.round(seconds / 60)} min` + if (seconds < 172800) return `${Math.round(seconds / 3600)} h` + return `${Math.round(seconds / 86400)} d` +} + /** * A balance in the chain's smallest unit, as a readable token amount. * diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index ae47e17..e62eedd 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -18,14 +18,44 @@ import type { Window as WindowName } from '../api/generated/Window' +/** + * The windows, named and labelled by the thing they actually are. + * + * The labels used to read `1h`, `6h`, `24h`, `7d`, and the URLs matched. That + * was a promise the site could not keep: a window is a fixed number of blocks, + * so how long it reaches back is whatever the chain's rate makes it. Planck's + * rate fell ninefold when its miners left for mainnet, and `/planck/six_hours` + * kept saying six hours over a window that spanned twenty-nine — with a miner + * that had not touched the chain in a day sitting in the standings looking + * current. + * + * So both the label and the URL are the block count now, and the duration is + * measured and shown beside it rather than baked into a name. `3.6k` is not as + * friendly as `6h`; it has the advantage of being true on every chain. + */ export const WINDOWS: { id: WindowName; label: string; blocks: number }[] = [ - { id: 'hour', label: '1h', blocks: 600 }, - { id: 'six_hours', label: '6h', blocks: 3600 }, - { id: 'day', label: '24h', blocks: 14400 }, - { id: 'week', label: '7d', blocks: 100800 }, + { id: '600', label: '600', blocks: 600 }, + { id: '3600', label: '3.6k', blocks: 3600 }, + { id: '14400', label: '14.4k', blocks: 14400 }, + { id: '100800', label: '100.8k', blocks: 100800 }, ] -export const DEFAULT_WINDOW: WindowName = 'six_hours' +export const DEFAULT_WINDOW: WindowName = '3600' + +/** + * Window names this site used to emit, mapped to what they became. + * + * A link somebody sent last week still has to land on the right page. The + * router rewrites these to the block count rather than serving them, so the + * address bar never keeps a name the site no longer stands behind. + */ +export const LEGACY_WINDOWS: Record = { + hour: '600', + six_hours: '3600', + '6h': '3600', + day: '14400', + week: '100800', +} /** What the address bar currently says the page should show. */ export interface Route { @@ -93,7 +123,12 @@ export const EMPTY: Route = { } function asWindow(segment: string | undefined): WindowName | null { - return WINDOWS.find((w) => w.id === segment)?.id ?? null + const current = WINDOWS.find((w) => w.id === segment)?.id + if (current) return current + // A link from before the rename. Resolved here rather than rejected, so an + // address somebody shared still opens the page it named — `href` will then + // write the block count back into the bar. + return (segment && LEGACY_WINDOWS[segment]) ?? null } /**