fix: stop timing the catch-up, and name windows for what they are
Planck's page reported a **0.175 s** block time against a true 29.8 — a factor of 170 — flagged *measured* rather than nominal, 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. `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. `record()` marked every head-stream block `at_tip: true`, including the head that landed right after `fill_gap` closed a 1,251-block gap — so the pair straddling it measured how fast this observer caught up. `ingest` now forgets its tip samples whenever it fills a gap and records the closing head with `at_tip: false`. The interval goes nominal until twenty fresh samples exist, which is `MIN_TIP_SAMPLES` doing its job: nominal and labelled nominal beats measured and wrong. The reported symptom was smaller and had the same root. `baba-gorchitsa` showed three blocks in Planck's "six hours" having left for mainnet a day earlier — and it was right to: 3,600 Planck blocks currently span **29 hours**, because the chain's rate fell ninefold when its miners left. The label was built by multiplying the block count by the current interval, which describes the rate now rather than the period covered. It comes from `span_seconds` instead — the authored-time span of exactly the blocks tallied, already the denominator of every per-miner hashrate — and carries no "~", because nothing is estimated. So the names went too. A window is a block count, and calling one `six_hours` is a promise the site cannot keep on a chain whose rate moves; `/planck/six_hours` was the reason a reader believed a day-old row was current. The selector reads 600 / 3.6k / 14.4k / 100.8k and the URL carries the count. Less friendly than `6h`, and true on every chain. The old names still parse and are never emitted, so shared links keep working — the router rewrites them. Closes #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
25
CLAUDE.md
25
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ChainRuntime>, store: Store, refresh: Duration)
|
||||
chain: chain.id(),
|
||||
window,
|
||||
rows,
|
||||
window_seconds: chain.window_span_seconds(window),
|
||||
},
|
||||
Some(window),
|
||||
);
|
||||
|
||||
@@ -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<f64>,
|
||||
rows: Vec<LeaderboardRow>,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<f64> {
|
||||
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<LeaderboardRow> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<Utc>| 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<LeaderboardRow>,
|
||||
/// 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<f64>,
|
||||
/// The tail of the block ticker, newest last.
|
||||
recent_blocks: Vec<RecentBlock>,
|
||||
},
|
||||
@@ -146,6 +186,9 @@ pub enum ServerMessage {
|
||||
window: Window,
|
||||
/// The standings.
|
||||
rows: Vec<LeaderboardRow>,
|
||||
/// What the window actually spanned, by the chain's own clock.
|
||||
#[ts(type = "number | null")]
|
||||
window_seconds: Option<f64>,
|
||||
},
|
||||
/// 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::<Window>().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::<Window>().unwrap(), Window::B600);
|
||||
assert_eq!("six_hours".parse::<Window>().unwrap(), Window::B3600);
|
||||
assert_eq!("6h".parse::<Window>().unwrap(), Window::B3600);
|
||||
assert_eq!("day".parse::<Window>().unwrap(), Window::B14400);
|
||||
assert_eq!("week".parse::<Window>().unwrap(), Window::B100800);
|
||||
assert!("fortnight".parse::<Window>().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
22
readme.md
22
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
|
||||
|
||||
@@ -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}
|
||||
</Link>
|
||||
@@ -282,7 +289,7 @@ export default function App() {
|
||||
<h2 className="panel-title">The Standings</h2>
|
||||
<span className="eyebrow">
|
||||
last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '}
|
||||
{windowSpan(activeWindow.blocks, interval)}
|
||||
{measuredSpan(state.windowSeconds, activeWindow.blocks, interval)}
|
||||
{pinned.length > 0 && ` · ${pinned.length} marked yours`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,17 @@ summary: ChainSummary,
|
||||
* Full standings for the window.
|
||||
*/
|
||||
leaderboard: Array<LeaderboardRow>,
|
||||
/**
|
||||
* 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<LeaderboardRow>, } | { "type": "chain_status",
|
||||
rows: Array<LeaderboardRow>,
|
||||
/**
|
||||
* What the window actually spanned, by the chain's own clock.
|
||||
*/
|
||||
window_seconds: number | null, } | { "type": "chain_status",
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -30,10 +30,10 @@ import { Sparkline, type SparkPoint } from './Sparkline'
|
||||
|
||||
/** Kept in step with `Window::blocks()` in the entities crate. */
|
||||
const WINDOW_BLOCKS: Record<WindowName, number> = {
|
||||
hour: 600,
|
||||
six_hours: 3600,
|
||||
day: 14400,
|
||||
week: 100800,
|
||||
'600': 600,
|
||||
'3600': 3600,
|
||||
'14400': 14400,
|
||||
'100800': 100800,
|
||||
}
|
||||
|
||||
function Stat({
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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<string, WindowName> = {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user