computer: stateful snapshot

This commit is contained in:
nym21
2025-12-17 17:08:54 +01:00
parent f9fad2d775
commit df09b3aa28
20 changed files with 656 additions and 151 deletions
@@ -9,7 +9,7 @@ use brk_types::{Dollars, Height, Sats};
use crate::{
CachedUnrealizedState, PriceToAmount, RealizedState, SupplyState, UnrealizedState,
grouped::{PERCENTILES, PERCENTILES_LEN},
grouped::PERCENTILES_LEN,
utils::OptionExt,
};
@@ -321,38 +321,12 @@ impl CohortState {
}
/// Compute prices at percentile thresholds.
/// Uses O(19 * log n) Fenwick tree queries instead of O(n) iteration.
pub fn compute_percentile_prices(&self) -> [Dollars; PERCENTILES_LEN] {
let mut result = [Dollars::NAN; PERCENTILES_LEN];
let price_to_amount = match self.price_to_amount.as_ref() {
Some(p) => p,
None => return result,
};
if price_to_amount.is_empty() || self.supply.value == Sats::ZERO {
return result;
match self.price_to_amount.as_ref() {
Some(p) if !p.is_empty() => p.compute_percentiles(),
_ => [Dollars::NAN; PERCENTILES_LEN],
}
let total = u64::from(self.supply.value);
let targets = PERCENTILES.map(|p| total * u64::from(p) / 100);
let mut accumulated = 0u64;
let mut pct_idx = 0;
for (&price, &sats) in price_to_amount.iter() {
accumulated += u64::from(sats);
while pct_idx < PERCENTILES_LEN && accumulated >= targets[pct_idx] {
result[pct_idx] = price;
pct_idx += 1;
}
if pct_idx >= PERCENTILES_LEN {
break;
}
}
result
}
/// Compute unrealized profit/loss at current price.
@@ -2,8 +2,11 @@
//!
//! When a new block arrives, UTXOs age. Some cross day boundaries
//! and need to move between age-based cohorts.
//!
//! Optimization: Instead of iterating all ~800k blocks O(n), we binary search
//! for blocks at each day boundary O(k * log n) where k = number of boundaries.
use brk_grouper::{Filter, Filtered};
use brk_grouper::AGE_BOUNDARIES;
use brk_types::{ONE_DAY_IN_SEC, Timestamp};
use crate::states::BlockState;
@@ -15,58 +18,70 @@ impl UTXOCohorts {
///
/// UTXOs age with each block. When they cross day boundaries,
/// they move between age-based cohorts (e.g., from "0-1d" to "1-7d").
///
/// Complexity: O(k * (log n + m)) where:
/// - k = 19 boundaries to check
/// - n = total blocks in chain_state
/// - m = blocks crossing each boundary (typically 0-2 per boundary per block)
pub fn tick_tock_next_block(&mut self, chain_state: &[BlockState], timestamp: Timestamp) {
if chain_state.is_empty() {
return;
}
let prev_timestamp = chain_state.last().unwrap().timestamp;
// Optimization: Only blocks whose age % ONE_DAY >= threshold can cross a day boundary.
// Saves computation vs checking days_old for every block.
let elapsed = (*timestamp).saturating_sub(*prev_timestamp);
let threshold = ONE_DAY_IN_SEC.saturating_sub(elapsed);
// Collect age_range cohorts with their filters and states
let mut age_cohorts: Vec<(Filter, &mut Option<_>)> = self
.0
.age_range
.iter_mut()
.map(|v| (v.filter().clone(), &mut v.state))
.collect();
// Skip if no time has passed
if elapsed == 0 {
return;
}
// Process blocks that might cross a day boundary
chain_state
.iter()
.filter(|block_state| {
let age = (*prev_timestamp).saturating_sub(*block_state.timestamp);
age % ONE_DAY_IN_SEC >= threshold
})
.for_each(|block_state| {
// Get age_range cohort states (indexed 0..20)
// Cohort i covers days [BOUNDARIES[i-1], BOUNDARIES[i])
// Cohort 0 covers [0, 1) days
// Cohort 19 covers [15*365, infinity) days
let mut age_cohorts: Vec<_> = self.0.age_range.iter_mut().map(|v| &mut v.state).collect();
// For each boundary, find blocks that just crossed it
for (boundary_idx, &boundary_days) in AGE_BOUNDARIES.iter().enumerate() {
let boundary_seconds = (boundary_days as u32) * ONE_DAY_IN_SEC;
// Blocks crossing boundary B have timestamps in (prev - B*DAY, curr - B*DAY]
// prev_days < B and curr_days >= B
// means: block was younger than B days, now is B days or older
let upper_timestamp = (*timestamp).saturating_sub(boundary_seconds);
let lower_timestamp = (*prev_timestamp).saturating_sub(boundary_seconds);
// Skip if the range is empty (would happen if boundary > chain age)
if upper_timestamp <= lower_timestamp {
continue;
}
// Binary search to find blocks in the timestamp range (lower, upper]
let start_idx = chain_state.partition_point(|b| *b.timestamp <= lower_timestamp);
let end_idx = chain_state.partition_point(|b| *b.timestamp <= upper_timestamp);
// Process blocks that crossed this boundary
for block_state in &chain_state[start_idx..end_idx] {
// Double-check the day boundary was actually crossed
// (handles edge cases with day boundaries)
let prev_days = prev_timestamp.difference_in_days_between(block_state.timestamp);
let curr_days = timestamp.difference_in_days_between(block_state.timestamp);
if prev_days == curr_days {
return;
if prev_days >= boundary_days || curr_days < boundary_days {
continue;
}
// Update age_range cohort states
age_cohorts.iter_mut().for_each(|(filter, state)| {
let is_now = filter.contains_time(curr_days);
let was_before = filter.contains_time(prev_days);
if is_now && !was_before {
state
.as_mut()
.unwrap()
.increment(&block_state.supply, block_state.price);
} else if was_before && !is_now {
state
.as_mut()
.unwrap()
.decrement(&block_state.supply, block_state.price);
}
});
});
// Block crossed from cohort[boundary_idx] to cohort[boundary_idx + 1]
// Decrement from the "younger" cohort
if let Some(state) = age_cohorts[boundary_idx].as_mut() {
state.decrement(&block_state.supply, block_state.price);
}
// Increment in the "older" cohort
if let Some(state) = age_cohorts[boundary_idx + 1].as_mut() {
state.increment(&block_state.supply, block_state.price);
}
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Fenwick Tree (Binary Indexed Tree) for O(log n) prefix sums.
//!
//! Used for efficient percentile computation over price distributions.
/// Fenwick tree for O(log n) prefix sum queries and updates.
///
/// Supports:
/// - `add(idx, delta)`: O(log n) - add delta to position idx
/// - `prefix_sum(idx)`: O(log n) - sum of elements 0..=idx
/// - `lower_bound(target)`: O(log n) - find smallest idx where prefix_sum >= target
#[derive(Clone, Debug)]
pub struct FenwickTree {
tree: Vec<u64>,
len: usize,
}
impl FenwickTree {
/// Create a new Fenwick tree with given capacity.
pub fn new(len: usize) -> Self {
Self {
tree: vec![0; len + 1], // 1-indexed
len,
}
}
/// Add delta to position idx. O(log n).
pub fn add(&mut self, idx: usize, delta: u64) {
let mut i = idx + 1; // Convert to 1-indexed
while i <= self.len {
self.tree[i] += delta;
i += i & i.wrapping_neg(); // Add LSB
}
}
/// Subtract delta from position idx. O(log n).
pub fn sub(&mut self, idx: usize, delta: u64) {
let mut i = idx + 1;
while i <= self.len {
self.tree[i] -= delta;
i += i & i.wrapping_neg();
}
}
/// Get prefix sum of elements 0..=idx. O(log n).
pub fn prefix_sum(&self, idx: usize) -> u64 {
let mut sum = 0u64;
let mut i = idx + 1; // Convert to 1-indexed
while i > 0 {
sum += self.tree[i];
i -= i & i.wrapping_neg(); // Remove LSB
}
sum
}
/// Find smallest index where prefix_sum >= target. O(log n).
/// Returns None if no such index exists (target > total sum).
pub fn lower_bound(&self, target: u64) -> Option<usize> {
if target == 0 {
return Some(0);
}
let mut sum = 0u64;
let mut pos = 0usize;
// Find highest bit position
let mut bit = 1usize << (usize::BITS - 1 - self.len.leading_zeros());
while bit > 0 {
let next_pos = pos + bit;
if next_pos <= self.len && sum + self.tree[next_pos] < target {
sum += self.tree[next_pos];
pos = next_pos;
}
bit >>= 1;
}
// pos is now the largest index where prefix_sum < target
// So pos + 1 is the smallest where prefix_sum >= target
if pos < self.len {
Some(pos) // Convert back to 0-indexed
} else {
None
}
}
/// Get total sum of all elements. O(log n).
pub fn total(&self) -> u64 {
self.prefix_sum(self.len.saturating_sub(1))
}
/// Reset all values to zero. O(n).
pub fn clear(&mut self) {
self.tree.fill(0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_operations() {
let mut ft = FenwickTree::new(10);
ft.add(0, 5);
ft.add(2, 3);
ft.add(5, 7);
assert_eq!(ft.prefix_sum(0), 5);
assert_eq!(ft.prefix_sum(1), 5);
assert_eq!(ft.prefix_sum(2), 8);
assert_eq!(ft.prefix_sum(5), 15);
assert_eq!(ft.total(), 15);
}
#[test]
fn test_lower_bound() {
let mut ft = FenwickTree::new(10);
ft.add(0, 10);
ft.add(2, 20);
ft.add(5, 30);
assert_eq!(ft.lower_bound(5), Some(0));
assert_eq!(ft.lower_bound(10), Some(0));
assert_eq!(ft.lower_bound(11), Some(2));
assert_eq!(ft.lower_bound(30), Some(2));
assert_eq!(ft.lower_bound(31), Some(5));
assert_eq!(ft.lower_bound(60), Some(5));
assert_eq!(ft.lower_bound(61), None);
}
}
+3
View File
@@ -1,6 +1,8 @@
mod block;
// mod cohorts;
mod fenwick;
mod flushable;
mod price_buckets;
mod price_to_amount;
mod realized;
mod supply;
@@ -10,6 +12,7 @@ mod unrealized;
pub use block::*;
// pub use cohorts::*;
pub use flushable::*;
pub use price_buckets::*;
pub use price_to_amount::*;
pub use realized::*;
pub use supply::*;
@@ -0,0 +1,244 @@
//! Logarithmic price buckets with Fenwick tree for O(log n) percentile queries.
//!
//! Uses logarithmic buckets to maintain constant relative precision across all price levels.
//! Bucket i represents prices in range [MIN_PRICE * BASE^i, MIN_PRICE * BASE^(i+1)).
use brk_types::{Dollars, Sats};
use super::fenwick::FenwickTree;
use crate::grouped::{PERCENTILES, PERCENTILES_LEN};
/// Minimum price tracked (sub-cent for early Bitcoin days).
const MIN_PRICE: f64 = 0.001;
/// Maximum price tracked ($100M for future-proofing).
const MAX_PRICE: f64 = 100_000_000.0;
/// Base for logarithmic buckets (0.1% precision).
const BASE: f64 = 1.001;
/// Pre-computed ln(BASE) for efficiency.
const LN_BASE: f64 = 0.0009995003; // ln(1.001)
/// Pre-computed ln(MIN_PRICE) for efficiency.
const LN_MIN_PRICE: f64 = -6.907755279; // ln(0.001)
/// Number of buckets needed: ceil(ln(MAX/MIN) / ln(BASE)).
/// ln(100_000_000 / 0.001) / ln(1.001) ≈ 25,328
const NUM_BUCKETS: usize = 25_400; // Rounded up for safety
/// Logarithmic price buckets with O(log n) percentile queries.
#[derive(Clone, Debug)]
pub struct PriceBuckets {
/// Fenwick tree for O(log n) prefix sums.
fenwick: FenwickTree,
/// Direct bucket access for iteration (needed for unrealized computation).
buckets: Vec<Sats>,
/// Total supply tracked.
total: Sats,
}
impl Default for PriceBuckets {
fn default() -> Self {
Self::new()
}
}
impl PriceBuckets {
/// Create new empty price buckets.
pub fn new() -> Self {
Self {
fenwick: FenwickTree::new(NUM_BUCKETS),
buckets: vec![Sats::ZERO; NUM_BUCKETS],
total: Sats::ZERO,
}
}
/// Convert price to bucket index. O(1).
#[inline]
pub fn price_to_bucket(price: Dollars) -> usize {
let price_f64 = f64::from(price);
if price_f64 <= MIN_PRICE {
return 0;
}
let bucket = ((price_f64.ln() - LN_MIN_PRICE) / LN_BASE) as usize;
bucket.min(NUM_BUCKETS - 1)
}
/// Convert bucket index to representative price (bucket midpoint). O(1).
#[inline]
pub fn bucket_to_price(bucket: usize) -> Dollars {
// Use geometric mean of bucket range for better accuracy
let low = MIN_PRICE * BASE.powi(bucket as i32);
let high = low * BASE;
Dollars::from((low * high).sqrt())
}
/// Add amount at given price. O(log n).
pub fn increment(&mut self, price: Dollars, amount: Sats) {
if amount == Sats::ZERO {
return;
}
let bucket = Self::price_to_bucket(price);
self.fenwick.add(bucket, u64::from(amount));
self.buckets[bucket] += amount;
self.total += amount;
}
/// Remove amount at given price. O(log n).
pub fn decrement(&mut self, price: Dollars, amount: Sats) {
if amount == Sats::ZERO {
return;
}
let bucket = Self::price_to_bucket(price);
self.fenwick.sub(bucket, u64::from(amount));
self.buckets[bucket] -= amount;
self.total -= amount;
}
/// Check if empty.
pub fn is_empty(&self) -> bool {
self.total == Sats::ZERO
}
/// Get total supply.
pub fn total(&self) -> Sats {
self.total
}
/// Compute all percentile prices. O(19 * log n) ≈ O(323 ops).
pub fn compute_percentiles(&self) -> [Dollars; PERCENTILES_LEN] {
let mut result = [Dollars::NAN; PERCENTILES_LEN];
if self.total == Sats::ZERO {
return result;
}
let total = u64::from(self.total);
for (i, &percentile) in PERCENTILES.iter().enumerate() {
let target = total * u64::from(percentile) / 100;
if let Some(bucket) = self.fenwick.lower_bound(target) {
result[i] = Self::bucket_to_price(bucket);
}
}
result
}
/// Get amount in a specific bucket.
pub fn get_bucket(&self, bucket: usize) -> Sats {
self.buckets.get(bucket).copied().unwrap_or(Sats::ZERO)
}
/// Iterate over non-empty buckets in a price range.
/// Used for unrealized computation flip range.
pub fn iter_range(
&self,
from_price: Dollars,
to_price: Dollars,
) -> impl Iterator<Item = (Dollars, Sats)> + '_ {
let from_bucket = Self::price_to_bucket(from_price);
let to_bucket = Self::price_to_bucket(to_price);
let (start, end) = if from_bucket <= to_bucket {
(from_bucket, to_bucket)
} else {
(to_bucket, from_bucket)
};
(start..=end).filter_map(move |bucket| {
let amount = self.buckets[bucket];
if amount > Sats::ZERO {
Some((Self::bucket_to_price(bucket), amount))
} else {
None
}
})
}
/// Iterate over all non-empty buckets (for full unrealized computation).
pub fn iter(&self) -> impl Iterator<Item = (Dollars, Sats)> + '_ {
self.buckets
.iter()
.enumerate()
.filter_map(|(bucket, &amount)| {
if amount > Sats::ZERO {
Some((Self::bucket_to_price(bucket), amount))
} else {
None
}
})
}
/// Get the lowest price bucket with non-zero amount.
pub fn min_price(&self) -> Option<Dollars> {
self.buckets
.iter()
.position(|&s| s > Sats::ZERO)
.map(Self::bucket_to_price)
}
/// Get the highest price bucket with non-zero amount.
pub fn max_price(&self) -> Option<Dollars> {
self.buckets
.iter()
.rposition(|&s| s > Sats::ZERO)
.map(Self::bucket_to_price)
}
/// Clear all data.
pub fn clear(&mut self) {
self.fenwick.clear();
self.buckets.fill(Sats::ZERO);
self.total = Sats::ZERO;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bucket_conversion() {
// Test price -> bucket -> price roundtrip
let prices = [0.01, 1.0, 100.0, 10000.0, 50000.0, 100000.0];
for &price in &prices {
let bucket = PriceBuckets::price_to_bucket(Dollars::from(price));
let recovered = PriceBuckets::bucket_to_price(bucket);
let ratio = f64::from(recovered) / price;
// Should be within 0.1% (our bucket precision)
assert!(
(0.999..=1.001).contains(&ratio),
"price={}, recovered={}, ratio={}",
price,
f64::from(recovered),
ratio
);
}
}
#[test]
fn test_percentiles() {
let mut buckets = PriceBuckets::new();
// Add 100 sats at $10, 200 sats at $20, 300 sats at $30
buckets.increment(Dollars::from(10.0), Sats::from(100u64));
buckets.increment(Dollars::from(20.0), Sats::from(200u64));
buckets.increment(Dollars::from(30.0), Sats::from(300u64));
// Total = 600 sats
// 50th percentile = 300 sats = should be around $20-$30
let percentiles = buckets.compute_percentiles();
// Median (index 9 in PERCENTILES which is 50%)
let median = percentiles[9]; // PERCENTILES[9] = 50
let median_f64 = f64::from(median);
assert!(
(15.0..=35.0).contains(&median_f64),
"median={} should be around $20-$30",
median_f64
);
}
}
@@ -11,12 +11,17 @@ use pco::standalone::{simple_decompress, simpler_compress};
use serde::{Deserialize, Serialize};
use vecdb::Bytes;
use crate::{states::SupplyState, utils::OptionExt};
use crate::{grouped::PERCENTILES_LEN, states::SupplyState, utils::OptionExt};
use super::PriceBuckets;
#[derive(Clone, Debug)]
pub struct PriceToAmount {
pathbuf: PathBuf,
state: Option<State>,
/// Logarithmic buckets for O(log n) percentile queries.
/// Rebuilt on load, not persisted.
buckets: Option<PriceBuckets>,
}
const STATE_AT_: &str = "state_at_";
@@ -27,6 +32,7 @@ impl PriceToAmount {
Self {
pathbuf: path.join(format!("{name}_price_to_amount")),
state: None,
buckets: None,
}
}
@@ -35,7 +41,16 @@ impl PriceToAmount {
let (&height, path) = files.range(..=height).next_back().ok_or(Error::NotFound(
"No price state found at or before height".into(),
))?;
self.state = Some(State::deserialize(&fs::read(path)?)?);
let state = State::deserialize(&fs::read(path)?)?;
// Rebuild buckets from loaded state
let mut buckets = PriceBuckets::new();
for (&price, &amount) in state.iter() {
buckets.increment(price, amount);
}
self.state = Some(state);
self.buckets = Some(buckets);
Ok(height)
}
@@ -65,6 +80,9 @@ impl PriceToAmount {
pub fn increment(&mut self, price: Dollars, supply_state: &SupplyState) {
*self.state.um().entry(price).or_default() += supply_state.value;
if let Some(buckets) = self.buckets.as_mut() {
buckets.increment(price, supply_state.value);
}
}
pub fn decrement(&mut self, price: Dollars, supply_state: &SupplyState) {
@@ -73,6 +91,9 @@ impl PriceToAmount {
if *amount == Sats::ZERO {
self.state.um().remove(&price);
}
if let Some(buckets) = self.buckets.as_mut() {
buckets.decrement(price, supply_state.value);
}
} else {
dbg!(price, &self.pathbuf);
unreachable!();
@@ -81,6 +102,16 @@ impl PriceToAmount {
pub fn init(&mut self) {
self.state.replace(State::default());
self.buckets.replace(PriceBuckets::new());
}
/// Compute percentile prices using O(log n) Fenwick tree queries.
pub fn compute_percentiles(&self) -> [Dollars; PERCENTILES_LEN] {
if let Some(buckets) = self.buckets.as_ref() {
buckets.compute_percentiles()
} else {
[Dollars::NAN; PERCENTILES_LEN]
}
}
pub fn clean(&mut self) -> Result<()> {