global: added support for oracle histograms

This commit is contained in:
nym21
2026-05-25 16:44:09 +02:00
parent ee20175cbf
commit 66037c862f
18 changed files with 493 additions and 265 deletions
+13 -13
View File
@@ -9870,35 +9870,35 @@ impl BrkClient {
/// Smoothed round-dollar payment histogram at the live tip: the committed EMA with the forming mempool block blended in. A flat array of log-scale bins.
///
/// Endpoint: `GET /api/oracle/histogram/ema/live`
pub fn get_oracle_histogram_ema_live(&self) -> Result<Histogram_uint16> {
pub fn get_oracle_histogram_ema_live(&self) -> Result<Vec<i64>> {
self.base.get_json(&format!("/api/oracle/histogram/ema/live"))
}
/// EMA histogram at height
/// EMA histogram at height or day
///
/// Smoothed round-dollar payment histogram for a confirmed height. A flat array of log-scale bins.
/// Smoothed round-dollar payment histogram for a confirmed point: a block height (`840000`) gives that block's EMA, a calendar date (`YYYY-MM-DD`) gives the average of that day's per-block EMAs. A flat array of log-scale bins.
///
/// Endpoint: `GET /api/oracle/histogram/ema/{height}`
pub fn get_oracle_histogram_ema(&self, height: Height) -> Result<Histogram_uint16> {
self.base.get_json(&format!("/api/oracle/histogram/ema/{height}"))
/// Endpoint: `GET /api/oracle/histogram/ema/{point}`
pub fn get_oracle_histogram_ema(&self, point: &str) -> Result<Vec<i64>> {
self.base.get_json(&format!("/api/oracle/histogram/ema/{point}"))
}
/// Live raw histogram
///
/// Un-smoothed per-block round-dollar counts for the forming mempool block. A flat array of log-scale bins, all zero when no mempool is configured.
/// Unfiltered output histogram for the forming mempool block: every live output binned by value, with none of the round-dollar payment filters applied. A flat array of log-scale bins, all zero when no mempool is configured.
///
/// Endpoint: `GET /api/oracle/histogram/raw/live`
pub fn get_oracle_histogram_raw_live(&self) -> Result<Histogram_uint32> {
pub fn get_oracle_histogram_raw_live(&self) -> Result<Vec<i64>> {
self.base.get_json(&format!("/api/oracle/histogram/raw/live"))
}
/// Raw histogram at height
/// Raw histogram at height or day
///
/// Un-smoothed round-dollar counts for a single confirmed block. A flat array of log-scale bins.
/// Unfiltered output histogram for a confirmed point: a block height (`840000`) gives that block's outputs, coinbase included, binned by value with no payment filtering; a calendar date (`YYYY-MM-DD`) sums every block that day. A flat array of log-scale bins.
///
/// Endpoint: `GET /api/oracle/histogram/raw/{height}`
pub fn get_oracle_histogram_raw(&self, height: Height) -> Result<Histogram_uint32> {
self.base.get_json(&format!("/api/oracle/histogram/raw/{height}"))
/// Endpoint: `GET /api/oracle/histogram/raw/{point}`
pub fn get_oracle_histogram_raw(&self, point: &str) -> Result<Vec<i64>> {
self.base.get_json(&format!("/api/oracle/histogram/raw/{point}"))
}
/// Txid by index
@@ -24,51 +24,60 @@ pub struct RecoveredState {
/// Returns Height::ZERO if any validation fails (triggers fresh start).
pub(crate) fn recover_state(
height: Height,
chain_state_rollback: vecdb::Result<Stamp>,
chain_state_rollback: Option<vecdb::Result<Stamp>>,
any_addr_indexes: &mut AnyAddrIndexesVecs,
addrs_data: &mut AddrsDataVecs,
utxo_cohorts: &mut UTXOCohorts,
addr_cohorts: &mut AddrCohorts,
) -> Result<RecoveredState> {
let stamp = Stamp::from(height);
// `None`: clean resume, already at the checkpoint, nothing to undo.
// `Some`: reorg, undo state past the resume point.
let consistent_height = match chain_state_rollback {
None => height,
Some(chain_state_rollback) => {
let stamp = Stamp::from(height);
// Rollback address state vectors
let addr_indexes_rollback = any_addr_indexes.rollback_before(stamp);
let addr_data_rollback = addrs_data.rollback_before(stamp);
// Rollback address state vectors
let addr_indexes_rollback = any_addr_indexes.rollback_before(stamp);
let addr_data_rollback = addrs_data.rollback_before(stamp);
// Verify rollback consistency - all must agree on the same height
let consistent_height = rollback_states(
chain_state_rollback,
addr_indexes_rollback,
addr_data_rollback,
);
// Verify rollback consistency - all must agree on the same height
let consistent_height = rollback_states(
chain_state_rollback,
addr_indexes_rollback,
addr_data_rollback,
);
// If rollbacks are inconsistent, start fresh
if consistent_height.is_zero() {
warn!("Rollback consistency check failed: inconsistent heights");
return Ok(RecoveredState {
starting_height: Height::ZERO,
});
}
// If rollbacks are inconsistent, start fresh
if consistent_height.is_zero() {
warn!("Rollback consistency check failed: inconsistent heights");
return Ok(RecoveredState {
starting_height: Height::ZERO,
});
}
// Rollback can land at an earlier height (multi-block change file), which is fine.
// But if it lands AHEAD of target, that means rollback failed (missing change files).
if consistent_height > height {
warn!(
"Rollback failed: still at {} but target was {}, falling back to fresh start",
consistent_height, height
);
return Ok(RecoveredState {
starting_height: Height::ZERO,
});
}
// Rollback can land at an earlier height (multi-block change file), which is fine.
// But if it lands AHEAD of target, that means rollback failed (missing change files).
if consistent_height > height {
warn!(
"Rollback failed: still at {} but target was {}, falling back to fresh start",
consistent_height, height
);
return Ok(RecoveredState {
starting_height: Height::ZERO,
});
}
if consistent_height != height {
debug!(
"Rollback landed at {} instead of {}, will resume from there",
consistent_height, height
);
}
if consistent_height != height {
debug!(
"Rollback landed at {} instead of {}, will resume from there",
consistent_height, height
);
}
consistent_height
}
};
// Import UTXO cohort states - all must succeed
debug!(
+6 -5
View File
@@ -341,12 +341,13 @@ impl Vecs {
// Try to resume from checkpoint, fall back to fresh start if needed
let recovered_height = match start_mode {
StartMode::Resume(height) => {
let stamp = Stamp::from(height);
// Roll back only on a reorg. A clean resume has nothing to undo, and an
// interrupted run wrote no rollback metadata (periodic flushes use
// with_changes=false; only the final write creates the `changes/` dir),
// so `rollback_before` would fail with `NotFound`.
let chain_state_rollback = (height < current_height)
.then(|| self.supply_state.rollback_before(Stamp::from(height)));
// Rollback BytesVec state and capture results for validation
let chain_state_rollback = self.supply_state.rollback_before(stamp);
// Validate all rollbacks and imports are consistent
let recovered = recover_state(
height,
chain_state_rollback,
+15 -7
View File
@@ -11,13 +11,21 @@ impl Mempool {
self.read().info.clone()
}
/// Snapshot of pre-bucketed oracle bins across all live mempool tx
/// outputs. The total is maintained incrementally by `TxStore` on
/// every insert/remove, so this hot path is `O(NUM_BINS)` regardless
/// of pool size. Used by `live_price` to blend the mempool into the
/// committed oracle without re-parsing scripts per request.
/// Snapshot of pre-bucketed round-dollar-eligible bins across all live
/// mempool tx outputs. Maintained incrementally by `TxStore` on every
/// insert/remove, so this hot path is `O(NUM_BINS)` regardless of pool
/// size. Used by `live_price` to blend the mempool into the committed
/// oracle without re-parsing scripts per request.
#[must_use]
pub fn live_histogram(&self) -> HistogramRaw {
self.read().txs.live_histogram()
pub fn live_eligible_histogram(&self) -> HistogramRaw {
self.read().txs.live_eligible_histogram()
}
/// Snapshot of the raw histogram: every live mempool output binned by
/// value with no payment filtering. Backs the `histogram/raw/live`
/// endpoint.
#[must_use]
pub fn live_raw_histogram(&self) -> HistogramRaw {
self.read().txs.live_raw_histogram()
}
}
@@ -0,0 +1,61 @@
use brk_oracle::{HistogramRaw, for_each_round_dollar_bin, sats_to_bin};
use brk_types::Transaction;
use crate::stores::tx_store::TxRecord;
/// The two live per-bin histograms the pool maintains incrementally as txs
/// enter and leave: `eligible` applies the round-dollar payment filter (it
/// feeds the oracle blend), `raw` bins every output by value with no filtering.
/// Add and remove run through the same code so the two stay symmetric.
#[derive(Default)]
pub struct LiveHistograms {
eligible: HistogramRaw,
raw: HistogramRaw,
}
impl LiveHistograms {
/// Fold a record's outputs into both histograms.
pub fn add(&mut self, record: &TxRecord) {
Self::eligible_bins(&record.tx, |bin| self.eligible[bin as usize] += 1);
for bin in Self::raw_bins(&record.tx) {
self.raw[bin] += 1;
}
}
/// Reverse a previous `add` for the same record.
pub fn remove(&mut self, record: &TxRecord) {
Self::eligible_bins(&record.tx, |bin| self.eligible[bin as usize] -= 1);
for bin in Self::raw_bins(&record.tx) {
self.raw[bin] -= 1;
}
}
/// Round-dollar-eligible bins, blended into the oracle by `live_price`.
pub fn eligible(&self) -> HistogramRaw {
self.eligible.clone()
}
/// Every live output binned by value, no payment filtering.
pub fn raw(&self) -> HistogramRaw {
self.raw.clone()
}
/// Round-dollar-eligible bins, applying the oracle payment filter. Calls
/// `emit(bin)` per eligible output. Deterministic over a tx's outputs,
/// which are never mutated after insert, so add and remove recompute it
/// identically rather than caching. Live mempool txs are post-tip, always
/// above the historical max-outputs cap window, so the cap never applies.
fn eligible_bins(tx: &Transaction, emit: impl FnMut(u16)) {
for_each_round_dollar_bin(
usize::MAX,
tx.output.iter().map(|o| (o.value, o.type_())),
emit,
);
}
/// Raw bin index per output, dropping only values outside the bin domain
/// (zero / out-of-range).
fn raw_bins(tx: &Transaction) -> impl Iterator<Item = usize> + '_ {
tx.output.iter().filter_map(|o| sats_to_bin(o.value))
}
}
+2 -2
View File
@@ -4,13 +4,13 @@
//! one lock-order discipline.
mod addr_tracker;
mod live_histograms;
mod outpoint_spends;
mod output_bins;
mod tx_graveyard;
mod tx_store;
pub use addr_tracker::AddrTracker;
pub use live_histograms::LiveHistograms;
pub use outpoint_spends::OutpointSpends;
pub use output_bins::OutputBins;
pub use tx_graveyard::{TxGraveyard, TxTombstone};
pub use tx_store::TxStore;
@@ -1,26 +0,0 @@
use brk_oracle::for_each_round_dollar_bin;
use brk_types::Transaction;
use smallvec::SmallVec;
/// Pre-bucketed oracle bins for a tx's eligible outputs. Computed once on
/// insert so `Mempool::live_histogram` can bin all live outputs without
/// re-parsing scripts or recomputing eligibility per request.
pub struct OutputBins(SmallVec<[u16; 4]>);
impl OutputBins {
pub fn from_tx(tx: &Transaction) -> Self {
let mut bins = SmallVec::new();
// Live mempool txs are post-tip, always above the historical max-outputs
// cap window, so the cap never applies here.
for_each_round_dollar_bin(
usize::MAX,
tx.output.iter().map(|o| (o.value, o.type_())),
|bin| bins.push(bin),
);
Self(bins)
}
pub fn iter(&self) -> impl Iterator<Item = u16> + '_ {
self.0.iter().copied()
}
}
+53 -27
View File
@@ -2,27 +2,20 @@ use brk_oracle::HistogramRaw;
use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin};
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{state::TxEntry, stores::OutputBins};
use crate::{state::TxEntry, stores::LiveHistograms};
const RECENT_CAP: usize = 10;
/// Per-tx record: live tx body, its mempool entry, and the pre-bucketed
/// oracle bins for its outputs. Kept under one key so a single map probe
/// returns everything readers need.
/// Per-tx record: live tx body and its mempool entry, kept under one key
/// so a single map probe returns everything readers need.
pub struct TxRecord {
pub tx: Transaction,
pub entry: TxEntry,
pub output_bins: OutputBins,
}
impl TxRecord {
pub fn new(tx: Transaction, entry: TxEntry) -> Self {
let output_bins = OutputBins::from_tx(&tx);
Self {
tx,
entry,
output_bins,
}
Self { tx, entry }
}
}
@@ -32,15 +25,15 @@ impl TxRecord {
/// set of prefixes whose tx still has at least one `prevout: None`,
/// maintained on every `insert` / `remove_by_prefix` / `apply_fills`
/// so the post-update prevout filler can early-exit when empty.
/// `live_histogram` mirrors the union of each record's `OutputBins`,
/// kept in sync on `insert` / `remove_by_prefix` so the oracle-blend
/// read path is a single array clone, not a full pool walk.
/// `histograms` holds the eligible (oracle-blend) and raw per-bin output
/// histograms, kept in sync on `insert` / `remove_by_prefix` so each read
/// path is a single array clone, not a full pool walk.
#[derive(Default)]
pub struct TxStore {
records: FxHashMap<TxidPrefix, TxRecord>,
recent: Vec<MempoolRecentTx>,
unresolved: FxHashSet<TxidPrefix>,
live_histogram: HistogramRaw,
histograms: LiveHistograms,
}
impl TxStore {
@@ -92,9 +85,7 @@ impl TxStore {
self.unresolved.insert(prefix);
}
let record = TxRecord::new(tx, entry);
for bin in record.output_bins.iter() {
self.live_histogram[bin as usize] += 1;
}
self.histograms.add(&record);
self.records.insert(prefix, record);
}
@@ -112,16 +103,21 @@ impl TxStore {
pub fn remove_by_prefix(&mut self, prefix: &TxidPrefix) -> Option<TxRecord> {
let record = self.records.remove(prefix)?;
self.unresolved.remove(prefix);
for bin in record.output_bins.iter() {
self.live_histogram[bin as usize] -= 1;
}
self.histograms.remove(&record);
Some(record)
}
/// Snapshot the live oracle-bin histogram. Maintained incrementally
/// on insert/remove, so this is `O(NUM_BINS)`, not `O(live_outputs)`.
pub fn live_histogram(&self) -> HistogramRaw {
self.live_histogram.clone()
/// Snapshot the round-dollar-eligible histogram that feeds the oracle
/// blend. Maintained incrementally, so this is `O(NUM_BINS)`, not
/// `O(live_outputs)`.
pub fn live_eligible_histogram(&self) -> HistogramRaw {
self.histograms.eligible()
}
/// Snapshot the raw histogram: every live output binned by value with no
/// payment filtering. Maintained incrementally alongside the eligible one.
pub fn live_raw_histogram(&self) -> HistogramRaw {
self.histograms.raw()
}
/// Set of prefixes with at least one unfilled prevout. Used by the
@@ -338,11 +334,41 @@ mod tests {
store.insert(tx_a, entry_a);
store.insert(tx_b, entry_b);
let total_after_both: u32 = store.live_histogram().iter().sum();
let total_after_both: u32 = store.live_eligible_histogram().iter().sum();
assert_eq!(total_after_both, 3, "two outputs + one output");
store.remove_by_prefix(&prefix_a);
let total_after_remove: u32 = store.live_histogram().iter().sum();
let total_after_remove: u32 = store.live_eligible_histogram().iter().sum();
assert_eq!(total_after_remove, 1);
}
#[test]
fn raw_histogram_bins_outputs_the_eligible_filter_drops() {
let mut store = TxStore::default();
// 2_345 sats is a round-dollar-eligible payment; 100_000_000 sats (1 BTC)
// is a round-BTC value the eligible filter drops but raw still bins.
let tx = fake_tx(
30,
&[Some(TxOut::from((p2wpkh_script(1), Sats::from(50_000u64))))],
&[(p2wpkh_script(2), 2_345), (p2wpkh_script(3), 100_000_000)],
);
let entry = entry_for(&tx, 100, 100);
let prefix = entry.txid_prefix();
store.insert(tx, entry);
assert_eq!(
store.live_eligible_histogram().iter().sum::<u32>(),
1,
"round-BTC output filtered out of the eligible histogram"
);
assert_eq!(
store.live_raw_histogram().iter().sum::<u32>(),
2,
"raw histogram bins every output"
);
store.remove_by_prefix(&prefix);
assert_eq!(store.live_eligible_histogram().iter().sum::<u32>(), 0);
assert_eq!(store.live_raw_histogram().iter().sum::<u32>(), 0);
}
}
+39 -20
View File
@@ -253,6 +253,7 @@ fn arm_pattern(ema: &HistogramEma, center: i64, tau: f64) -> String {
/// guard: if the half- or double-price bin lights up strictly more stencil arms
/// and carries comparable mass, snap to it. This escapes a ½×/2× alias lock that
/// the ±window can never climb the 60 bins out of on its own.
#[allow(clippy::too_many_arguments)]
fn guarded_best_bin(
ema: &HistogramEma,
prev_bin: f64,
@@ -340,10 +341,11 @@ fn guarded_best_bin(
best = Some((n, qn, raw_n));
}
}
if let Some((n, qn, _)) = best {
if qn >= qb + guard.q_margin && qn >= guard.q_min {
target = n;
}
if let Some((n, qn, _)) = best
&& qn >= qb + guard.q_margin
&& qn >= guard.q_min
{
target = n;
}
} else {
let mut best: Option<(usize, f64)> = None;
@@ -587,12 +589,18 @@ fn main() {
.and_then(|l| l.parse().ok())
.unwrap_or_else(|| {
let o = height_ohlc.get(start - 1).copied().unwrap_or([0.0; 4]);
if o[3] > 0.0 { o[3] } else { (o[1] + o[2]) / 2.0 }
if o[3] > 0.0 {
o[3]
} else {
(o[1] + o[2]) / 2.0
}
});
// Exact seed override (reproduce the committed prices.txt seed at a start the
// truncated working-tree prices.txt no longer covers).
let start_price =
std::env::var("SEED").ok().and_then(|s| s.parse().ok()).unwrap_or(start_price);
let start_price = std::env::var("SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(start_price);
let mut config = Config::default();
if let Some(w) = std::env::var("EMA_WINDOW")
@@ -664,8 +672,7 @@ fn main() {
// ARM_PROFILE.
let profile_seed = std::env::var("PROFILE_SEED").ok();
let bootstrap_profile = profile_seed.as_deref() == Some("bootstrap");
let uniform_profile =
matches!(profile_seed.as_deref(), Some("uniform") | Some("flat"));
let uniform_profile = matches!(profile_seed.as_deref(), Some("uniform") | Some("flat"));
// Stencil-sum weight (default 1). Set 0 for SHAPE-ONLY scoring: the shape match
// does both within-octave localization and octave discrimination, no stencil
// term and no cw balance to tune.
@@ -708,7 +715,9 @@ fn main() {
for &i in &ALIAS_ARMS {
arm_weights[i] = alias_weight;
}
eprintln!(" disc_weight={disc_weight} on {DISC_ARMS:?}; alias_weight={alias_weight} on {ALIAS_ARMS:?}; corr_weight={corr_weight}");
eprintln!(
" disc_weight={disc_weight} on {DISC_ARMS:?}; alias_weight={alias_weight} on {ALIAS_ARMS:?}; corr_weight={corr_weight}"
);
let anom_thresh: f64 = std::env::var("ANOM_THRESH")
.ok()
.and_then(|s| s.parse().ok())
@@ -765,7 +774,9 @@ fn main() {
guard.global_radius,
);
if switch_at != 0 {
eprintln!(" switch: at height {switch_at} -> window={switch_window} alpha={switch_alpha:.5}");
eprintln!(
" switch: at height {switch_at} -> window={switch_window} alpha={switch_alpha:.5}"
);
}
let (sb, sa) = (config.search_below, config.search_above);
let mut window_size = config.window_size;
@@ -821,9 +832,7 @@ fn main() {
let mut sharp_cursor = 0usize;
let mut sharp_filled = 0usize;
let mut sharp_ema = HistogramEma::zeros();
eprintln!(
" sharp: span={sharp_span:.0} window={sharp_window} alpha={sharp_alpha:.5}"
);
eprintln!(" sharp: span={sharp_span:.0} window={sharp_window} alpha={sharp_alpha:.5}");
let total_txs = indexer.vecs.transactions.txid.len();
let total_outputs = indexer.vecs.outputs.value.len();
@@ -922,14 +931,14 @@ fn main() {
filled += 1;
}
ema.fill(0.0);
for age in 0..filled {
(0..filled).for_each(|age| {
let idx = (ring_cursor + window_size - 1 - age) % window_size;
let w = weights[age];
let block = &ring[idx];
for b in 0..NUM_BINS {
ema[b] += w * block[b];
}
}
});
// Sharp detection EMA (diagnostic only - does not drive the price).
{
let slot = &mut sharp_ring[sharp_cursor];
@@ -942,17 +951,27 @@ fn main() {
sharp_filled += 1;
}
sharp_ema.fill(0.0);
for age in 0..sharp_filled {
(0..sharp_filled).for_each(|age| {
let idx = (sharp_cursor + sharp_window - 1 - age) % sharp_window;
let w = sharp_weights[age];
let block = &sharp_ring[idx];
for b in 0..NUM_BINS {
sharp_ema[b] += w * block[b];
}
}
});
let cw = if h < corr_until { corr_weight } else { 0.0 };
ref_bin =
guarded_best_bin(&ema, ref_bin, sb, sa, &guard, &arm_weights, cw, &profile, metric, stencil_weight);
ref_bin = guarded_best_bin(
&ema,
ref_bin,
sb,
sa,
&guard,
&arm_weights,
cw,
&profile,
metric,
stencil_weight,
);
let oracle_price = bin_to_cents(ref_bin) as f64 / 100.0;
if verify_prod {
+94 -60
View File
@@ -1,13 +1,13 @@
use std::sync::Arc;
use std::{ops::Range, sync::Arc};
use brk_computer::prices::Vecs as PricesVecs;
use brk_error::{Error, Result};
use brk_indexer::Lengths;
use brk_oracle::{
Config, HistogramEmaCompact, HistogramRaw, Oracle, START_HEIGHT_SLOW, cents_to_bin,
for_each_round_dollar_bin,
Config, HistogramEma, HistogramEmaCompact, HistogramRaw, Oracle, START_HEIGHT_SLOW,
cents_to_bin, sats_to_bin,
};
use brk_types::{Dollars, OutputType, Sats, TxIndex, TxOutIndex};
use brk_types::{Day1, Dollars, Sats, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex};
use crate::Query;
@@ -27,31 +27,67 @@ impl Query {
/// seed-independent, so the result is exact.
pub fn confirmed_histogram_ema(&self, height: usize) -> Result<HistogramEmaCompact> {
let safe = self.check_histogram_height(height)?;
let ref_bin = self.seed_bin_at(height)?;
Ok(self.warm_oracle(ref_bin, height + 1, &safe).ema().to_compact())
Ok(self.ema_oracle_at(height, &safe)?.ema().to_compact())
}
/// Un-smoothed per-block round-dollar counts at the live tip: the mempool's
/// forming-block histogram, or zeros when no mempool is configured.
/// Smoothed EMA histogram for a calendar `day`: the bin-by-bin average of
/// every confirmed block's per-block EMA. Each block's EMA is reconstructed
/// independently (seed-independent, so exact); averaging keeps the result an
/// intensive per-block rate rather than letting a busy day dominate.
pub fn confirmed_histogram_ema_day(&self, day: Day1) -> Result<HistogramEmaCompact> {
let safe = self.safe_lengths();
let range = self.day_block_range(day, &safe)?;
let count = range.len() as f64;
let mut acc = HistogramEma::zeros();
for height in range {
let oracle = self.ema_oracle_at(height, &safe)?;
acc.iter_mut()
.zip(oracle.ema().iter())
.for_each(|(a, &e)| *a += e);
}
acc.iter_mut().for_each(|a| *a /= count);
Ok(acc.to_compact())
}
/// Unfiltered per-bin output counts at the live tip: every forming-block
/// mempool output binned by value, with none of the round-dollar payment
/// filters applied. Zeros when no mempool is configured.
pub fn live_histogram_raw(&self) -> Result<HistogramRaw> {
Ok(match self.mempool() {
Some(mempool) => mempool.live_histogram(),
Some(mempool) => mempool.live_raw_histogram(),
None => HistogramRaw::zeros(),
})
}
/// Un-smoothed per-block round-dollar counts for a confirmed `height`.
/// Unfiltered per-bin output counts for a confirmed `height`: every output
/// in the block binned by value, with no payment filtering.
pub fn confirmed_histogram_raw(&self, height: usize) -> Result<HistogramRaw> {
let safe = self.check_histogram_height(height)?;
Ok(self.block_raw_histogram(height, &safe))
}
/// Unfiltered per-bin output counts for a calendar `day`: every block's raw
/// histogram summed bin-by-bin. Raw counts are additive, so the day total is
/// just the sum across its confirmed blocks.
pub fn confirmed_histogram_raw_day(&self, day: Day1) -> Result<HistogramRaw> {
let safe = self.safe_lengths();
let range = self.day_block_range(day, &safe)?;
let mut acc = HistogramRaw::zeros();
for height in range {
let block = self.block_raw_histogram(height, &safe);
acc.iter_mut()
.zip(block.iter())
.for_each(|(a, &v)| *a += v);
}
Ok(acc)
}
/// The live tip oracle: the cached committed base, with the forming block's
/// mempool outputs blended in as a final slot when a mempool is configured.
fn live_oracle(&self) -> Result<Oracle> {
let mut oracle = (*self.cached_oracle()?).clone();
if let Some(mempool) = self.mempool() {
oracle.process_histogram(&mempool.live_histogram());
oracle.process_histogram(&mempool.live_eligible_histogram());
}
Ok(oracle)
}
@@ -86,6 +122,14 @@ impl Query {
Ok(oracle)
}
/// Oracle warmed to just after `height`, ready for its per-block EMA. Seeds
/// from the stored spot price at `height`, though the EMA is seed-independent
/// so the seed only sets the price read-out, not the window contents.
fn ema_oracle_at(&self, height: usize, safe: &Lengths) -> Result<Oracle> {
let seed_bin = self.seed_bin_at(height)?;
Ok(self.warm_oracle(seed_bin, height + 1, safe))
}
/// An oracle seeded at `seed_bin` and warmed by replaying the `window_size`
/// committed blocks ending just before `end`. Reads are capped at `safe` so
/// concurrent indexer writes past the cap stay invisible.
@@ -133,72 +177,62 @@ impl Query {
Ok(safe)
}
/// One confirmed block's round-dollar histogram, built from batched columnar
/// reads and the shared `for_each_round_dollar_bin` filter. Kept separate from
/// the hot-path `feed_blocks` (cursor + reusable buffers over a block range).
/// The confirmed block heights `[first, end)` of calendar `day`, clamped to
/// the same histogram-available bound as `check_histogram_height`. 404 when
/// the day has no committed blocks in range.
fn day_block_range(&self, day: Day1, safe: &Lengths) -> Result<Range<usize>> {
let first_height = &self.computer().indexes.day1.first_height;
let bound = self
.computer()
.prices
.spot
.cents
.height
.len()
.min(safe.height.to_usize());
let start = first_height
.collect_one(day)
.map_or(usize::MAX, |h| h.to_usize())
.max(START_HEIGHT_SLOW);
let end = first_height
.collect_one(day + 1)
.map_or(bound, |h| h.to_usize())
.min(bound);
if start >= end {
return Err(Error::NotFound(format!(
"oracle histogram unavailable for day {day}"
)));
}
Ok(start..end)
}
/// One confirmed block's unfiltered histogram: every output in the block,
/// coinbase included, binned by value via `sats_to_bin` with no payment
/// filtering. Built from a single batched columnar read of the block's
/// output-value range.
fn block_raw_histogram(&self, height: usize, safe: &Lengths) -> HistogramRaw {
let indexer = self.indexer();
let total_txs = safe.tx_index.to_usize();
let total_outputs = safe.txout_index.to_usize();
let next_height = (height + 2).min(safe.height.to_usize());
let first_tx_indexes: Vec<TxIndex> = indexer
.vecs
.transactions
.first_tx_index
.collect_range_at(height, next_height);
let out_firsts: Vec<TxOutIndex> = indexer
.vecs
.outputs
.first_txout_index
.collect_range_at(height, next_height);
let block_first_tx = first_tx_indexes[0].to_usize() + 1;
let next_first_tx = first_tx_indexes
.get(1)
.copied()
.unwrap_or(TxIndex::from(total_txs))
.to_usize();
let tx_count = next_first_tx - block_first_tx;
let mut hist = HistogramRaw::zeros();
if tx_count == 0 {
return hist;
}
let out_start = out_firsts[0].to_usize();
let out_end = out_firsts
.get(1)
.copied()
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
let tx_starts: Vec<usize> = indexer
.vecs
.transactions
.first_txout_index
.collect_range_at(block_first_tx, next_first_tx)
.into_iter()
.map(|t| t.to_usize())
.collect();
let out_start = tx_starts.first().copied().unwrap_or(out_end);
let mut hist = HistogramRaw::zeros();
let values: Vec<Sats> = indexer.vecs.outputs.value.collect_range_at(out_start, out_end);
let output_types: Vec<OutputType> = indexer
.vecs
.outputs
.output_type
.collect_range_at(out_start, out_end);
for tx in 0..tx_count {
let lo = tx_starts[tx] - out_start;
let hi = tx_starts
.get(tx + 1)
.map(|s| s - out_start)
.unwrap_or(out_end - out_start);
let outputs = values[lo..hi]
.iter()
.copied()
.zip(output_types[lo..hi].iter().copied());
for_each_round_dollar_bin(height, outputs, |bin| hist.increment(bin as usize));
for sats in values {
if let Some(bin) = sats_to_bin(sats) {
hist.increment(bin);
}
}
hist
}
+4 -3
View File
@@ -184,9 +184,10 @@ All errors return structured JSON with a consistent format:
transaction outputs, with no external price feed. Payment activity is binned on a \
log scale, and a smoothed EMA over recent blocks locates the price.\n\n\
Histograms come in two flavors, each available at the live tip (mempool-blended) \
or at any confirmed height: `raw` (per-block counts) and `ema` (the smoothed \
window). The live price is also at `/api/mempool/price`. Confirmed per-height \
price history is at `/api/vecs/height-to-price`."
or at any confirmed height: `raw` bins every output by value with no filtering, \
while `ema` is the smoothed round-dollar window the price is read from. The live \
price is also at `/api/mempool/price`. Confirmed per-height price history is at \
`/api/vecs/height-to-price`."
.to_string(),
),
..Default::default()
+61 -26
View File
@@ -2,14 +2,15 @@ use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, State},
http::{HeaderMap, Uri},
response::IntoResponse,
};
use brk_oracle::{HistogramEmaCompact, HistogramRaw};
use brk_types::{Dollars, Version};
use brk_types::{Day1, Dollars, Version};
use crate::{
AppState,
extended::TransformResponseExtended,
params::{Empty, HeightParam},
params::{Empty, HeightOrDate, HeightOrDateParam},
};
pub trait OracleRoutes {
@@ -67,26 +68,42 @@ impl OracleRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/oracle/histogram/ema/{height}",
"/api/oracle/histogram/ema/{point}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<HeightParam>,
Path(path): Path<HeightOrDateParam>,
_: Empty,
State(state): State<AppState>| {
let strategy = state.height_strategy(Version::new(brk_oracle::VERSION), path.height);
state
.respond_json(&headers, strategy, &uri, move |q| {
q.confirmed_histogram_ema(usize::from(path.height))
})
.await
let version = Version::new(brk_oracle::VERSION);
match path.resolve() {
Ok(HeightOrDate::Date(date)) => {
let strategy = state.date_strategy(version, date);
state
.respond_json(&headers, strategy, &uri, move |q| {
q.confirmed_histogram_ema_day(Day1::try_from(date)?)
})
.await
}
Ok(HeightOrDate::Height(height)) => {
let strategy = state.height_strategy(version, height);
state
.respond_json(&headers, strategy, &uri, move |q| {
q.confirmed_histogram_ema(usize::from(height))
})
.await
}
Err(e) => e.into_response(),
}
},
|op| {
op.id("get_oracle_histogram_ema")
.oracle_tag()
.summary("EMA histogram at height")
.summary("EMA histogram at height or day")
.description(
"Smoothed round-dollar payment histogram for a confirmed height. \
"Smoothed round-dollar payment histogram for a confirmed point: a \
block height (`840000`) gives that block's EMA, a calendar date \
(`YYYY-MM-DD`) gives the average of that day's per-block EMAs. \
A flat array of log-scale bins.",
)
.json_response::<HistogramEmaCompact>()
@@ -112,9 +129,10 @@ impl OracleRoutes for ApiRouter<AppState> {
.oracle_tag()
.summary("Live raw histogram")
.description(
"Un-smoothed per-block round-dollar counts for the forming mempool \
block. A flat array of log-scale bins, all zero when no mempool is \
configured.",
"Unfiltered output histogram for the forming mempool block: every \
live output binned by value, with none of the round-dollar payment \
filters applied. A flat array of log-scale bins, all zero when no \
mempool is configured.",
)
.json_response::<HistogramRaw>()
.not_modified()
@@ -123,27 +141,44 @@ impl OracleRoutes for ApiRouter<AppState> {
),
)
.api_route(
"/api/oracle/histogram/raw/{height}",
"/api/oracle/histogram/raw/{point}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<HeightParam>,
Path(path): Path<HeightOrDateParam>,
_: Empty,
State(state): State<AppState>| {
let strategy = state.height_strategy(Version::new(brk_oracle::VERSION), path.height);
state
.respond_json(&headers, strategy, &uri, move |q| {
q.confirmed_histogram_raw(usize::from(path.height))
})
.await
let version = Version::new(brk_oracle::VERSION);
match path.resolve() {
Ok(HeightOrDate::Date(date)) => {
let strategy = state.date_strategy(version, date);
state
.respond_json(&headers, strategy, &uri, move |q| {
q.confirmed_histogram_raw_day(Day1::try_from(date)?)
})
.await
}
Ok(HeightOrDate::Height(height)) => {
let strategy = state.height_strategy(version, height);
state
.respond_json(&headers, strategy, &uri, move |q| {
q.confirmed_histogram_raw(usize::from(height))
})
.await
}
Err(e) => e.into_response(),
}
},
|op| {
op.id("get_oracle_histogram_raw")
.oracle_tag()
.summary("Raw histogram at height")
.summary("Raw histogram at height or day")
.description(
"Un-smoothed round-dollar counts for a single confirmed block. A \
flat array of log-scale bins.",
"Unfiltered output histogram for a confirmed point: a block height \
(`840000`) gives that block's outputs, coinbase included, binned by \
value with no payment filtering; a calendar date (`YYYY-MM-DD`) sums \
every block that day. A flat array of log-scale bins.",
)
.json_response::<HistogramRaw>()
.not_modified()
+4 -3
View File
@@ -16,7 +16,8 @@ use brk_query::{Query as BrkQuery, ResolvedQuery};
use brk_traversable::TreeNode;
use brk_types::{
DataRangeFormat, Format, IndexInfo, Output, PaginatedSeries, Pagination, SearchQuery,
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection,
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesOutput, SeriesSelection,
Version,
};
use crate::{
@@ -67,7 +68,7 @@ pub(super) async fn serve(
.await)
}
fn output_to_bytes(out: brk_types::SeriesOutput) -> BrkResult<Bytes> {
fn output_to_bytes(out: SeriesOutput) -> BrkResult<Bytes> {
Ok(match out.output {
Output::CSV(s) => Bytes::from(s),
Output::Json(v) => Bytes::from(v),
@@ -365,7 +366,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
.series_tag()
.summary("Get series version")
.description("Returns the current version of a series. Changes when the series data is updated.")
.json_response::<brk_types::Version>()
.json_response::<Version>()
.not_modified()
.not_found(),
),
@@ -0,0 +1,38 @@
use brk_types::{Date, Height};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::Error;
/// Path parameter accepting either a block height (`840000`) or a calendar date
/// (`YYYY-MM-DD`). The handler resolves it and dispatches to the per-height or
/// per-day variant, choosing the matching cache strategy.
#[derive(Deserialize, JsonSchema)]
pub struct HeightOrDateParam {
#[schemars(example = &"840000")]
pub point: String,
}
/// A resolved [`HeightOrDateParam`]: a confirmed block height or a calendar day.
pub enum HeightOrDate {
Height(Height),
Date(Date),
}
impl HeightOrDateParam {
/// Parses the raw `point`: a `YYYY-MM-DD` string is a [`Date`], an all-digit
/// string is a [`Height`], anything else is a 400. Dates are tried first
/// because their dashes keep them from parsing as a height.
pub fn resolve(&self) -> Result<HeightOrDate, Error> {
if let Ok(date) = self.point.parse::<Date>() {
Ok(HeightOrDate::Date(date))
} else if let Ok(height) = self.point.parse::<usize>() {
Ok(HeightOrDate::Height(Height::from(height)))
} else {
Err(Error::bad_request(format!(
"expected a block height or YYYY-MM-DD date, got `{}`",
self.point
)))
}
}
}
+2
View File
@@ -5,6 +5,7 @@ mod blockhash_param;
mod blockhash_start_index;
mod blockhash_tx_index;
mod empty;
mod height_or_date_param;
mod height_param;
mod next_block_hash_param;
mod pool_slug_param;
@@ -25,6 +26,7 @@ pub use blockhash_param::*;
pub use blockhash_start_index::*;
pub use blockhash_tx_index::*;
pub use empty::*;
pub use height_or_date_param::*;
pub use height_param::*;
pub use next_block_hash_param::*;
pub use pool_slug_param::*;
+7
View File
@@ -112,4 +112,11 @@ impl<T: JsonSchema, const N: usize> JsonSchema for Histogram<T, N> {
fn json_schema(generator: &mut SchemaGenerator) -> schemars::Schema {
Vec::<T>::json_schema(generator)
}
/// Inline as a plain array rather than registering a named `Histogram_uintN`
/// component: the wire shape is just a flat array of counts, and the synthetic
/// generic-mangled name has no real type for the Rust client to resolve to.
fn inline_schema() -> bool {
true
}
}