From 02210c6be9ef50c2ff753bceb294ec6271a28237 Mon Sep 17 00:00:00 2001 From: Reuven Date: Wed, 18 Mar 2026 08:02:35 -0400 Subject: [PATCH] fix: WasmMinCut Node.js panic from std::time (fixes #267) The WASM build was panicking in Node.js because std::time::Instant is not supported on wasm32-unknown-unknown target. This fix: - Adds time_compat module with PortableInstant/PortableTimestamp - Uses monotonic counter in WASM mode (sufficient for ordering/stats) - Uses std::time::Instant on native platforms (accurate timing) - Updates algorithm, canonical, certificate, optimization, subpolynomial modules The fix uses conditional compilation via the existing `wasm` feature flag. Closes #267 Co-Authored-By: claude-flow --- crates/ruvector-mincut/src/algorithm/mod.rs | 8 +- crates/ruvector-mincut/src/canonical/mod.rs | 9 +- .../ruvector-mincut/src/certificate/audit.rs | 7 +- crates/ruvector-mincut/src/certificate/mod.rs | 31 +--- crates/ruvector-mincut/src/lib.rs | 1 + .../src/optimization/wasm_batch.rs | 5 +- .../ruvector-mincut/src/subpolynomial/mod.rs | 6 +- crates/ruvector-mincut/src/time_compat.rs | 154 ++++++++++++++++++ 8 files changed, 175 insertions(+), 46 deletions(-) create mode 100644 crates/ruvector-mincut/src/time_compat.rs diff --git a/crates/ruvector-mincut/src/algorithm/mod.rs b/crates/ruvector-mincut/src/algorithm/mod.rs index 741267ec..47e75484 100644 --- a/crates/ruvector-mincut/src/algorithm/mod.rs +++ b/crates/ruvector-mincut/src/algorithm/mod.rs @@ -19,10 +19,10 @@ use crate::error::{MinCutError, Result}; use crate::euler::EulerTourTree; use crate::graph::{DynamicGraph, Edge, EdgeId, VertexId, Weight}; use crate::linkcut::LinkCutTree; +use crate::time_compat::PortableInstant; use crate::tree::HierarchicalDecomposition; use parking_lot::RwLock; use std::sync::Arc; -use std::time::Instant; /// Configuration for the minimum cut algorithm #[derive(Debug, Clone)] @@ -190,7 +190,7 @@ impl DynamicMinCut { /// Insert an edge pub fn insert_edge(&mut self, u: VertexId, v: VertexId, weight: Weight) -> Result { - let start_time = Instant::now(); + let start_time = PortableInstant::now(); // Add edge to graph (use write lock) { @@ -242,7 +242,7 @@ impl DynamicMinCut { /// Delete an edge pub fn delete_edge(&mut self, u: VertexId, v: VertexId) -> Result { - let start_time = Instant::now(); + let start_time = PortableInstant::now(); // Remove from graph first (use write lock) { @@ -279,7 +279,7 @@ impl DynamicMinCut { /// Get the current minimum cut value (O(1)) pub fn min_cut_value(&self) -> f64 { - let start_time = Instant::now(); + let start_time = PortableInstant::now(); let value = self.current_min_cut; diff --git a/crates/ruvector-mincut/src/canonical/mod.rs b/crates/ruvector-mincut/src/canonical/mod.rs index cfa6b005..2edbc085 100644 --- a/crates/ruvector-mincut/src/canonical/mod.rs +++ b/crates/ruvector-mincut/src/canonical/mod.rs @@ -34,10 +34,10 @@ mod tests; use crate::algorithm::{self, MinCutConfig}; use crate::graph::{DynamicGraph, VertexId, Weight}; +use crate::time_compat::PortableTimestamp; use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::hash::{Hash, Hasher}; -use std::time::{SystemTime, UNIX_EPOCH}; // --------------------------------------------------------------------------- // FixedWeight -- deterministic 32.32 fixed-point weight @@ -1178,10 +1178,9 @@ impl CanonicalMinCut for CanonicalMinCutImpl { fn witness_receipt(&self) -> WitnessReceipt { let result = self.canonical_cut(); - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0); + // Use portable timestamp - on native this is real nanoseconds, + // on WASM this is a monotonic counter (sufficient for ordering) + let ts = PortableTimestamp::now().as_secs() * 1_000_000_000; WitnessReceipt { epoch: self.epoch, diff --git a/crates/ruvector-mincut/src/certificate/audit.rs b/crates/ruvector-mincut/src/certificate/audit.rs index 73e90fe7..fec34688 100644 --- a/crates/ruvector-mincut/src/certificate/audit.rs +++ b/crates/ruvector-mincut/src/certificate/audit.rs @@ -4,10 +4,10 @@ use super::{CertLocalKCutQuery, LocalKCutResponse, LocalKCutResultSummary, UpdateTrigger}; use crate::instance::WitnessHandle; +use crate::time_compat::PortableTimestamp; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::sync::{Arc, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; /// Audit log entry #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,10 +25,7 @@ pub struct AuditEntry { impl AuditEntry { /// Create a new audit entry pub fn new(id: u64, entry_type: AuditEntryType, data: AuditData) -> Self { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let timestamp = PortableTimestamp::now().as_secs(); Self { id, diff --git a/crates/ruvector-mincut/src/certificate/mod.rs b/crates/ruvector-mincut/src/certificate/mod.rs index c8777a41..832323a6 100644 --- a/crates/ruvector-mincut/src/certificate/mod.rs +++ b/crates/ruvector-mincut/src/certificate/mod.rs @@ -8,8 +8,8 @@ use crate::graph::{EdgeId, VertexId}; use crate::instance::WitnessHandle; +use crate::time_compat::PortableTimestamp; use serde::{Deserialize, Serialize}; -use std::time::SystemTime; pub mod audit; @@ -38,35 +38,12 @@ pub struct CutCertificate { pub localkcut_responses: Vec, /// Index of the best witness (smallest boundary) pub best_witness_idx: Option, - /// Timestamp when certificate was created - #[serde(with = "system_time_serde")] - pub timestamp: SystemTime, + /// Timestamp when certificate was created (seconds since UNIX epoch) + pub timestamp: u64, /// Certificate version for compatibility pub version: u32, } -/// Serde serialization for SystemTime -mod system_time_serde { - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::time::{SystemTime, UNIX_EPOCH}; - - pub fn serialize(time: &SystemTime, serializer: S) -> Result - where - S: Serializer, - { - let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default(); - duration.as_secs().serialize(serializer) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let secs = u64::deserialize(deserializer)?; - Ok(UNIX_EPOCH + std::time::Duration::from_secs(secs)) - } -} - /// A response from the LocalKCut oracle #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalKCutResponse { @@ -183,7 +160,7 @@ impl CutCertificate { witness_summaries: Vec::new(), localkcut_responses: Vec::new(), best_witness_idx: None, - timestamp: SystemTime::now(), + timestamp: PortableTimestamp::now().as_secs(), version: CERTIFICATE_VERSION, } } diff --git a/crates/ruvector-mincut/src/lib.rs b/crates/ruvector-mincut/src/lib.rs index de162beb..e7e29c3c 100644 --- a/crates/ruvector-mincut/src/lib.rs +++ b/crates/ruvector-mincut/src/lib.rs @@ -218,6 +218,7 @@ pub mod jtree; // Internal modules mod core; +pub mod time_compat; // Optional feature-gated modules #[cfg(feature = "monitoring")] diff --git a/crates/ruvector-mincut/src/optimization/wasm_batch.rs b/crates/ruvector-mincut/src/optimization/wasm_batch.rs index 2a8a7d09..92b586bc 100644 --- a/crates/ruvector-mincut/src/optimization/wasm_batch.rs +++ b/crates/ruvector-mincut/src/optimization/wasm_batch.rs @@ -9,6 +9,7 @@ //! Target: 10x reduction in FFI overhead use crate::graph::VertexId; +use crate::time_compat::PortableInstant; use std::collections::HashMap; /// Configuration for WASM batch operations @@ -240,14 +241,14 @@ impl WasmBatchOps { /// Execute all pending operations pub fn execute_batch(&mut self) -> Vec { - let _start = std::time::Instant::now(); + let _start = PortableInstant::now(); // Drain pending operations to avoid borrow conflict let pending_ops: Vec<_> = self.pending.drain(..).collect(); let mut results = Vec::with_capacity(pending_ops.len()); for op in pending_ops { - let op_start = std::time::Instant::now(); + let op_start = PortableInstant::now(); let result = self.execute_operation(op); let elapsed = op_start.elapsed().as_micros() as u64; diff --git a/crates/ruvector-mincut/src/subpolynomial/mod.rs b/crates/ruvector-mincut/src/subpolynomial/mod.rs index 6b50fd15..5b41bfda 100644 --- a/crates/ruvector-mincut/src/subpolynomial/mod.rs +++ b/crates/ruvector-mincut/src/subpolynomial/mod.rs @@ -40,12 +40,12 @@ //! ``` use std::collections::{HashMap, HashSet, VecDeque}; -use std::time::Instant; use crate::cluster::hierarchy::{ Expander, HierarchyCluster, HierarchyConfig, Precluster, ThreeLevelHierarchy, }; use crate::error::{MinCutError, Result}; +use crate::time_compat::PortableInstant; use crate::expander::{ExpanderComponent, ExpanderDecomposition}; use crate::fragmentation::{Fragmentation, FragmentationConfig, TrimResult}; use crate::graph::{DynamicGraph, EdgeId, VertexId, Weight}; @@ -255,7 +255,7 @@ impl SubpolynomialMinCut { /// Insert an edge pub fn insert_edge(&mut self, u: VertexId, v: VertexId, weight: Weight) -> Result { - let start = Instant::now(); + let start = PortableInstant::now(); let key = Self::edge_key(u, v); if self.edges.contains(&key) { @@ -294,7 +294,7 @@ impl SubpolynomialMinCut { /// Delete an edge pub fn delete_edge(&mut self, u: VertexId, v: VertexId) -> Result { - let start = Instant::now(); + let start = PortableInstant::now(); let key = Self::edge_key(u, v); if !self.edges.remove(&key) { diff --git a/crates/ruvector-mincut/src/time_compat.rs b/crates/ruvector-mincut/src/time_compat.rs new file mode 100644 index 00000000..0cad7a23 --- /dev/null +++ b/crates/ruvector-mincut/src/time_compat.rs @@ -0,0 +1,154 @@ +//! WASM-compatible time abstraction +//! +//! Provides a monotonic time source that works across native and WASM targets. +//! On native targets, uses `std::time::Instant` for accurate timing. +//! On WASM targets (when `wasm` feature is enabled), uses a monotonic counter +//! since `std::time::Instant` is not supported in wasm32-unknown-unknown. + +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Global monotonic counter for WASM builds +#[cfg(feature = "wasm")] +static MONOTONIC_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// A WASM-compatible instant type +#[derive(Debug, Clone, Copy)] +pub struct PortableInstant { + #[cfg(not(feature = "wasm"))] + inner: std::time::Instant, + #[cfg(feature = "wasm")] + counter: u64, +} + +impl PortableInstant { + /// Get the current instant + #[cfg(not(feature = "wasm"))] + pub fn now() -> Self { + Self { + inner: std::time::Instant::now(), + } + } + + /// Get the current instant (WASM version - uses monotonic counter) + #[cfg(feature = "wasm")] + pub fn now() -> Self { + let counter = MONOTONIC_COUNTER.fetch_add(1, Ordering::SeqCst); + Self { counter } + } + + /// Get elapsed time in microseconds + #[cfg(not(feature = "wasm"))] + pub fn elapsed_micros(&self) -> u64 { + self.inner.elapsed().as_micros() as u64 + } + + /// Get elapsed "time" in WASM (returns counter difference as proxy) + #[cfg(feature = "wasm")] + pub fn elapsed_micros(&self) -> u64 { + let current = MONOTONIC_COUNTER.load(Ordering::SeqCst); + // In WASM, we can't measure real time, so return counter diff + // This is sufficient for relative ordering and statistics + current.saturating_sub(self.counter) + } + + /// Get elapsed time as Duration + #[cfg(not(feature = "wasm"))] + pub fn elapsed(&self) -> std::time::Duration { + self.inner.elapsed() + } + + /// Get elapsed "time" as Duration in WASM (returns pseudo-duration) + #[cfg(feature = "wasm")] + pub fn elapsed(&self) -> std::time::Duration { + std::time::Duration::from_micros(self.elapsed_micros()) + } + + /// Get duration since another instant + #[cfg(not(feature = "wasm"))] + pub fn duration_since(&self, earlier: Self) -> std::time::Duration { + self.inner.duration_since(earlier.inner) + } + + /// Get duration since another instant (WASM version) + #[cfg(feature = "wasm")] + pub fn duration_since(&self, earlier: Self) -> std::time::Duration { + let diff = self.counter.saturating_sub(earlier.counter); + std::time::Duration::from_micros(diff) + } +} + +impl Default for PortableInstant { + fn default() -> Self { + Self::now() + } +} + +/// A WASM-compatible timestamp type for certificates and audit logs +#[derive(Debug, Clone, Copy)] +pub struct PortableTimestamp { + /// Seconds since UNIX epoch (or monotonic counter in WASM) + pub secs: u64, +} + +impl PortableTimestamp { + /// Get current timestamp + #[cfg(not(feature = "wasm"))] + pub fn now() -> Self { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Self { secs } + } + + /// Get current timestamp (WASM version - uses monotonic counter) + #[cfg(feature = "wasm")] + pub fn now() -> Self { + let secs = MONOTONIC_COUNTER.fetch_add(1, Ordering::SeqCst); + Self { secs } + } + + /// Convert to u64 seconds + pub fn as_secs(&self) -> u64 { + self.secs + } +} + +impl Default for PortableTimestamp { + fn default() -> Self { + Self::now() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_portable_instant() { + let start = PortableInstant::now(); + // Do some work + let _sum: u64 = (0..1000).sum(); + let elapsed = start.elapsed_micros(); + // Should be non-zero on native, may be 0 on WASM due to counter + #[cfg(not(feature = "wasm"))] + assert!(elapsed >= 0); + } + + #[test] + fn test_portable_timestamp() { + let ts1 = PortableTimestamp::now(); + let ts2 = PortableTimestamp::now(); + // Second timestamp should be >= first + assert!(ts2.secs >= ts1.secs); + } + + #[test] + fn test_instant_ordering() { + let t1 = PortableInstant::now(); + let t2 = PortableInstant::now(); + let d = t2.duration_since(t1); + // Duration should be non-negative + assert!(d.as_micros() >= 0); + } +}