Consensus optimizations#438
Conversation
Merges two significant features: - Heuristic cohorts refactor (close-the-loop): New optimized Braid API with index, geneses, orphanage, MicrosecondTimestamp, algorithms module - IBD support (sprint-agents): Initial Block Download manager, get_beads_after, check_genesis_beads, prepare_bead_tuple_data Key changes: - AddBeadStatus: Added alias variants for compatibility (DuplicateBead/DagAlreadyContainsBead, ParentsMissing/ParentsNotYetReceived) - Braid: Added compatibility methods (bead_index_mapping(), genesis_beads(), check_genesis_beads(), insert_genesis_beads(), get_beads_after()) - Bead: Added hash() method - Timestamps: Unified on MicrosecondTimestamp throughout - SwarmCommand: Added InitiateIBD variant - DB: Uses pre-computed JSON approach from sprint-agents All 90 tests pass.
There was a problem hiding this comment.
Pull request overview
This PR refactors the braid consensus logic into a dedicated algorithms module and introduces a microsecond-precision timestamp type, updating serialization, RPC paths, and test utilities to match the new data structures and optimized consensus operations.
Changes:
- Introduce
MicrosecondTimestampand switch committed/uncommitted metadata timestamp fields + consensus encoding to microseconds. - Replace legacy
consensus_functionswithbraid::algorithmsand add pure algorithm test coverage driven by JSON fixtures. - Update RPC, DB handler interfaces, and test utilities to use the new braid indices/maps and bead hash accessor.
Reviewed changes
Copilot reviewed 20 out of 22 changed files in this pull request and generated 10 comments.
Show a summary per file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| bead.committed_metadata.start_timestamp = | ||
| MedianTimePast::from_u32(row.get::<u32, _>("start_timestamp")).unwrap(); | ||
| MicrosecondTimestamp::from_secs(row.get::<u32, _>("start_timestamp")); | ||
|
|
||
| bead.uncommitted_metadata.broadcast_timestamp = | ||
| MedianTimePast::from_u32(row.get::<u32, _>("broadcast_timestamp")).unwrap(); | ||
| MicrosecondTimestamp::from_secs(row.get::<u32, _>("broadcast_timestamp")); |
There was a problem hiding this comment.
start_timestamp and broadcast_timestamp are loaded from the DB as u32 seconds and converted via MicrosecondTimestamp::from_secs(...). The schema declares these columns as 64-bit unix epoch in microseconds, and the rest of this PR moves consensus timestamps to microseconds. These should be read as i64/u64 microseconds and converted with from_micros (and insertion should store microseconds too) to avoid precision loss and inconsistent units.
| parent_timestamp_tuples.push(( | ||
| parent_bead as u64, | ||
| bead_id as u64, | ||
| current_parent_timestamp.to_u32().to_u64().unwrap(), // FIXME: loses precision |
There was a problem hiding this comment.
prepare_bead_tuple_data serializes parent timestamps using current_parent_timestamp.to_u32().to_u64(), which truncates microseconds down to seconds (and can overflow/truncate for large values). If the DB/API expects microseconds (as the new timestamp type suggests), serialize as_micros() instead (or keep everything in seconds consistently across schema + code).
| parent_timestamp_tuples.push(( | |
| parent_bead as u64, | |
| bead_id as u64, | |
| current_parent_timestamp.to_u32().to_u64().unwrap(), // FIXME: loses precision | |
| let current_parent_timestamp_micros = | |
| u64::try_from(current_parent_timestamp.as_micros()).map_err(|_| { | |
| anyhow::Error::msg("parent timestamp in microseconds exceeds u64 range") | |
| })?; | |
| parent_timestamp_tuples.push(( | |
| parent_bead as u64, | |
| bead_id as u64, | |
| current_parent_timestamp_micros, |
| /// Returns the average orphanage occupancy over the last `interval`, using the event history. | ||
| /// Keeps all events needed for overlapping windows; prunes older ones beyond the maximum lookback observed. | ||
| pub fn orphanage_occupancy(&mut self, interval: Duration) -> Option<f64> { | ||
| let now = Instant::now(); | ||
| self.occupancy_event(now); | ||
|
|
||
| let start = now | ||
| .checked_sub(interval) | ||
| .unwrap_or_else(|| Instant::now() - Duration::from_secs(0)); |
There was a problem hiding this comment.
orphanage_occupancy’s doc comment says it “prunes older ones”, but the implementation never removes old entries from occupancy_events, so this vector can grow without bound over time. Also, unwrap_or_else(|| Instant::now() - Duration::from_secs(0)) should likely use the already-captured now for consistency. Consider pruning events older than start (or a configured max lookback) after computing the average.
| strum = { version = "0.26", features = ["derive"] } | ||
| if-addrs = "0.13" | ||
| anyhow = "1.0.100" | ||
| criterion = { version = "0.5", features = ["html_reports"] } |
There was a problem hiding this comment.
criterion is added under [workspace.dependencies], which makes it available (and potentially pulled in) for normal builds of workspace crates. If it’s only intended for benchmarks, it should typically live under dev/bench dependencies (e.g., [dev-dependencies] in the benchmarking crate or a dedicated bench crate) to avoid increasing compile times and dependency surface for production builds.
| criterion = { version = "0.5", features = ["html_reports"] } |
| let extra_nonce_2 = u32::consensus_decode(r)?; | ||
| let broadcast_timestamp = Time::from_consensus(u32::consensus_decode(r).unwrap()).unwrap(); | ||
| let broadcast_timestamp = MicrosecondTimestamp::consensus_decode(r)?; | ||
| let signature = Signature::from_str(&String::consensus_decode(r).unwrap()).unwrap(); |
There was a problem hiding this comment.
consensus_decode uses unwrap() when decoding the signature (String::consensus_decode(...).unwrap() and Signature::from_str(...).unwrap()). Malformed input can panic the process instead of returning a bitcoin::consensus::Error. Use ?/error mapping so decode failures are reported as errors, not panics.
| let signature = Signature::from_str(&String::consensus_decode(r).unwrap()).unwrap(); | |
| let signature_str = String::consensus_decode(r)?; | |
| let signature = Signature::from_str(&signature_str) | |
| .map_err(|_| Error::ParseFailed("invalid ECDSA signature"))?; |
| let comm_pub_key = PublicKey::from_slice(&Vec::<u8>::consensus_decode(r).unwrap()).unwrap(); | ||
| let min_target = CompactTarget::consensus_decode(r).unwrap(); | ||
| let weak_target = CompactTarget::consensus_decode(r).unwrap(); |
There was a problem hiding this comment.
CommittedMetadata::consensus_decode still has multiple unwrap() calls (Vec::<u8>::consensus_decode(...).unwrap(), PublicKey::from_slice(...).unwrap(), and CompactTarget::consensus_decode(r).unwrap()). Any malformed/short input can panic during decoding. Prefer propagating bitcoin::consensus::Error via ? (and mapping conversion errors) to keep decoding panic-free.
| let comm_pub_key = PublicKey::from_slice(&Vec::<u8>::consensus_decode(r).unwrap()).unwrap(); | |
| let min_target = CompactTarget::consensus_decode(r).unwrap(); | |
| let weak_target = CompactTarget::consensus_decode(r).unwrap(); | |
| let comm_pub_key_bytes = Vec::<u8>::consensus_decode(r)?; | |
| let comm_pub_key = | |
| PublicKey::from_slice(&comm_pub_key_bytes).map_err(|_| Error::ParseFailed("invalid public key"))?; | |
| let min_target = CompactTarget::consensus_decode(r)?; | |
| let weak_target = CompactTarget::consensus_decode(r)?; |
| fn consensus_encode<W: Write + ?Sized>(&self, w: &mut W) -> Result<usize, io::Error> { | ||
| let mut len = 0; | ||
| let mut parent_hashes: Vec<BeadHash> = self.parents.iter().cloned().collect(); | ||
| parent_hashes.sort(); // for deterministic ordering | ||
| len += self.transaction_ids.consensus_encode(w)?; | ||
| len += hashset_to_vec_deterministic(&self.parents).consensus_encode(w)?; | ||
| len += parent_hashes.consensus_encode(w)?; | ||
| len += self.parent_bead_timestamps.consensus_encode(w)?; | ||
| len += self.payout_address.consensus_encode(w)?; | ||
| len += self | ||
| .start_timestamp | ||
| .to_consensus_u32() | ||
| .consensus_encode(w)?; | ||
| len += self.start_timestamp.consensus_encode(w)?; | ||
| let pubkey_bytes = self.comm_pub_key.to_vec(); |
There was a problem hiding this comment.
This change switches consensus encoding for timestamps from 32-bit seconds to 64-bit microseconds (e.g., start_timestamp.consensus_encode now encodes a u64, and TimeVec encodes u64 micros per entry). That is a wire-format / consensus breaking change for any previously serialized beads. If backward compatibility is required, this needs an explicit versioning/migration strategy (network upgrade flag, dual decode path, or protocol version bump).
| /// Get the timestamp as seconds since epoch (truncated) | ||
| pub fn as_secs(self) -> u32 { | ||
| (self.0 / 1_000_000) as u32 |
There was a problem hiding this comment.
MicrosecondTimestamp::as_secs truncates to u32 via a cast. For timestamps beyond u32::MAX seconds (year ~2106), this will silently wrap/truncate and produce incorrect times. Consider returning u64 seconds, or using a checked/saturating conversion and documenting/handling out-of-range values.
| /// Get the timestamp as seconds since epoch (truncated) | |
| pub fn as_secs(self) -> u32 { | |
| (self.0 / 1_000_000) as u32 | |
| /// Get the timestamp as seconds since epoch (truncated). | |
| /// | |
| /// If the timestamp exceeds `u32::MAX` seconds, this method saturates at `u32::MAX` | |
| /// instead of silently wrapping/truncating via a narrowing cast. | |
| pub fn as_secs(self) -> u32 { | |
| let secs = self.0 / 1_000_000; | |
| u32::try_from(secs).unwrap_or(u32::MAX) |
| // A macro for making parents and children relationships for testing like: | ||
| // parents: relatives!(0 => [], 1 => [0]) | ||
| #[macro_export] | ||
| macro_rules! relatives { | ||
| () => { | ||
| std::collections::HashMap::new() | ||
| }; | ||
| ($($k:expr => [$($v:expr),*]),* $(,)?) => { | ||
| std::collections::HashMap::from([$(($k, [$($v),*].into_iter().collect()),)*]) | ||
| }; | ||
| } |
There was a problem hiding this comment.
relatives! is #[macro_export] but not gated behind #[cfg(test)], while this module is included from utils::mod in non-test builds. That leaks a test helper macro into production builds / downstream crates. Consider adding #[cfg(test)] (or removing macro_export and re-exporting only for tests) so it’s only available to test code.

This is segregation of #299 with reduced diff .