From f29a4ede460b02c15780e2c15bbc58b102710e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 16:42:06 +0200 Subject: [PATCH 01/11] update version number - v0.1.2 -> v.0.1.3 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 3462a48..a0a3d2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "temporalseries" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "High-performance Rust library for quantitative time-series analysis" license = "MIT" From 278dc54c481acb523647f4bf379e24d9b583d446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 17:03:06 +0200 Subject: [PATCH 02/11] update with next steps --- ROADMAP.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 437766d..ba6df27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1 +1,17 @@ # Roadmap + +## v0.1.3 + +* [ ] Expand benchmark surface — add benchmarks for Panel analytical methods and the `TemporalSeries` generic path; decide coverage for storage backends. +* [ ] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. +* [ ] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. +* [ ] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. +* [ ] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. +* [ ] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries` and `TemporalSeries`; propagate to `Panel`. +* [ ] Serde support — optional `serde` feature flag; derive `Serialize`/`Deserialize` for `TimeSeries` and `Panel`. + +## v0.2+ + +* [ ] Forecasting module (`crates/forecasting/`) — ARIMA, exponential smoothing; large enough scope to warrant its own crate boundary. +* [ ] Index-based slicing and resampling — `slice(from, to)`, frequency conversion; requires a design decision on index-sorted invariant. + From 36e42415eb63f74861c22d2082c500c75e72b816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 17:11:55 +0200 Subject: [PATCH 03/11] implement ChunkedBackend storage backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ChunkedBackend, a StorageBackend that partitions values into fixed-size Vec> chunks. A new chunk is allocated only when the current one is full, so no existing data is copied on append — the main advantage over ColumnarBackend for high-frequency streaming workloads. - crates/storage/chunked.rs: full implementation with new(), chunk_size(), n_chunks(), and the complete StorageBackend trait (len, is_empty, get, push, slice, iter). get() is O(1) via chunk/offset index arithmetic. - crates/storage/mod.rs: export ChunkedBackend alongside existing backends. - crates/storage/backend.rs: add ChunkedBackend to the trait doc table. - tests/storage/test_chunked.rs: 20 unit tests covering construction (exact/partial/oversized/empty chunks), get (first, last, chunk boundary, out-of-bounds), push (triggers new chunk, fills partial, works on empty backend), slice (subset, preserved chunk_size, source unchanged), and iter. - examples/temporal_series_with_chunked_backend.rs: demonstrates construction from existing data, get, iteration, incremental appends, and slice. - README.md: Storage Backends section extended with ChunkedBackend subsection, comparison table updated, new example added to the examples table. - ROADMAP.md: mark ChunkedBackend item as complete. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 2 +- README.md | 46 +++- ROADMAP.md | 2 +- crates/storage/backend.rs | 1 + crates/storage/chunked.rs | 171 +++++++++++++ crates/storage/mod.rs | 1 + .../temporal_series_with_chunked_backend.rs | 83 ++++++ tests/storage/mod.rs | 1 + tests/storage/test_chunked.rs | 240 ++++++++++++++++++ 9 files changed, 540 insertions(+), 7 deletions(-) create mode 100644 examples/temporal_series_with_chunked_backend.rs create mode 100644 tests/storage/test_chunked.rs diff --git a/Cargo.lock b/Cargo.lock index da9b182..bb1b94a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,7 +543,7 @@ dependencies = [ [[package]] name = "temporalseries" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "criterion", diff --git a/README.md b/README.md index 4a59b01..196d571 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,40 @@ let series = TemporalSeries::new(index, backend).unwrap(); println!("{:?}", series.get(2)); // Some(30.0) ``` +### Chunked + +Values are stored in a `Vec>` of fixed-size chunks. A new chunk is +allocated only when the current one is full, so no existing data is ever copied +on append. This makes `ChunkedBackend` the best choice when you build a series +incrementally — streaming records from a queue or cursor — and want to avoid +the periodic full-buffer reallocation that a growing `Vec` incurs. + +```rust +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ChunkedBackend; + +// 5 values split into chunks of 2: [[10, 20], [30, 40], [50]] +let backend = ChunkedBackend::new(vec![10.0_f64, 20.0, 30.0, 40.0, 50.0], 2); + +println!("n_chunks: {}", backend.n_chunks()); // 3 + +let series = TemporalSeries::new(vec![1_i64, 2, 3, 4, 5], backend).unwrap(); +println!("{:?}", series.get(2)); // Some(30.0) +``` + +Incremental appends — a new chunk is only allocated when the previous one is full: + +```rust +use temporalseries::storage::{ChunkedBackend, StorageBackend}; + +let mut backend: ChunkedBackend = ChunkedBackend::new(vec![], 3); +for v in [1.0, 2.0, 3.0, 4.0] { + backend.push(v); +} +// chunks: [[1, 2, 3], [4]] +assert_eq!(backend.n_chunks(), 2); +``` + ### Row Each observation is stored as a `RowRecord { timestamp, value }`. This layout @@ -125,11 +159,12 @@ let series = TemporalSeries::new(index, backend).unwrap(); println!("{:?}", series.get(2)); // Some(30.0) ``` -| | Columnar | Row | -|---|---|---| -| Memory layout | values packed, index separate | timestamp + value interleaved per record | -| Best for | column scans, aggregations | record-at-a-time ingestion | -| Extra allocation | separate index `Vec` | none beyond the records themselves | +| | Columnar | Row | Chunked | +|---|---|---|---| +| Memory layout | values packed, index separate | timestamp + value interleaved per record | fixed-size `Vec>` chunks | +| Best for | column scans, aggregations | record-at-a-time ingestion | high-frequency appends | +| Append cost | amortised O(1), may copy full buffer | amortised O(1), may copy full buffer | amortised O(1), never copies existing chunks | +| Random access | O(1) | O(1) | O(1) | ## Timestamp units (`chrono` feature) @@ -292,6 +327,7 @@ output length equal to the input length and preserves index alignment. | `input_output` | `cargo run --example input_output` | Reads a series from `examples/input.csv`, writes it to `examples/output.csv`, and reads it back | | `temporal_series_with_columnar_backend` | `cargo run --example temporal_series_with_columnar_backend` | Builds a `TemporalSeries` with a `ColumnarBackend` and demonstrates access and iteration | | `temporal_series_with_row_backend` | `cargo run --example temporal_series_with_row_backend` | Builds a `TemporalSeries` with a `RowBackend` and demonstrates access and iteration | +| `temporal_series_with_chunked_backend` | `cargo run --example temporal_series_with_chunked_backend` | Builds a `TemporalSeries` with a `ChunkedBackend`, demonstrates chunk layout, incremental appends, and slicing | | `panel` | `cargo run --example panel` | Builds a `Panel` of named series on a shared index and demonstrates all analytical methods (statistics, returns, moving averages, volatility, autocorrelation, stationarity, and normality) applied column-by-column | | `temporal_series_with_chrono` | `cargo run --example temporal_series_with_chrono --features chrono` | Builds a `TemporalSeries` from `DateTime` values and round-trips the index back to calendar dates | | `dickey_fuller_test` | `cargo run --example dickey_fuller_test` | Contrasts a trending vs. alternating series with the Dickey-Fuller test, shown for both `TimeSeries` and `TemporalSeries` | diff --git a/ROADMAP.md b/ROADMAP.md index ba6df27..0874b17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ * [ ] Expand benchmark surface — add benchmarks for Panel analytical methods and the `TemporalSeries` generic path; decide coverage for storage backends. * [ ] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. * [ ] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. -* [ ] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. +* [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. * [ ] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. * [ ] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries` and `TemporalSeries`; propagate to `Panel`. * [ ] Serde support — optional `serde` feature flag; derive `Serialize`/`Deserialize` for `TimeSeries` and `Panel`. diff --git a/crates/storage/backend.rs b/crates/storage/backend.rs index 5a83923..7f57671 100644 --- a/crates/storage/backend.rs +++ b/crates/storage/backend.rs @@ -14,6 +14,7 @@ use std::ops::Range; /// |---|---|---| /// | [`ColumnarBackend`](crate::storage::ColumnarBackend) | Contiguous `Vec` | Column scans, aggregations | /// | [`RowBackend`](crate::storage::RowBackend) | `Vec>` (timestamp + value per record) | Record-at-a-time ingestion | +/// | [`ChunkedBackend`](crate::storage::ChunkedBackend) | `Vec>` of fixed-size chunks | High-frequency appends without full-buffer reallocation | /// /// # Implementing a custom backend /// diff --git a/crates/storage/chunked.rs b/crates/storage/chunked.rs index 8b13789..573348a 100644 --- a/crates/storage/chunked.rs +++ b/crates/storage/chunked.rs @@ -1 +1,172 @@ +use std::ops::Range; +use super::StorageBackend; + +/// A [`StorageBackend`] that stores values in fixed-size contiguous chunks. +/// +/// `ChunkedBackend` partitions the value sequence into a `Vec>` where +/// every inner vector (chunk) holds at most `chunk_size` elements. Only the +/// final chunk may be shorter. +/// +/// ## When to use this backend +/// +/// | Property | Details | +/// |---|---| +/// | Append cost | Amortised O(1) — a new chunk is allocated only when the current one is full, so existing data is never reallocated | +/// | Random access | O(1) — `idx / chunk_size` gives the chunk, `idx % chunk_size` gives the offset | +/// | Iteration | Slightly more pointer-chasing than [`ColumnarBackend`](crate::storage::ColumnarBackend) because chunks are not contiguous | +/// | Best for | High-frequency append workloads where growing a single `Vec` would cause repeated reallocation | +/// +/// ## Compared to the other backends +/// +/// - [`ColumnarBackend`](crate::storage::ColumnarBackend) — single contiguous +/// `Vec`; best cache locality for scans, but reallocation on growth copies +/// the entire buffer. +/// - [`RowBackend`](crate::storage::RowBackend) — values co-located with +/// timestamps; natural for record-at-a-time ingestion. +/// - `ChunkedBackend` — fixed-size chunks; avoids reallocation on append at +/// the cost of slightly higher scan overhead. +/// +/// # Example +/// +/// ```rust +/// use temporalseries::storage::{ChunkedBackend, StorageBackend}; +/// +/// // Store 5 values in chunks of 2. +/// // Layout: [[1.0, 2.0], [3.0, 4.0], [5.0]] +/// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0], 2); +/// +/// assert_eq!(backend.len(), 5); +/// assert_eq!(backend.n_chunks(), 3); +/// assert_eq!(backend.get(2), Some(&3.0)); +/// ``` +pub struct ChunkedBackend { + chunks: Vec>, + chunk_size: usize, + len: usize, +} + +impl ChunkedBackend { + /// Creates a new `ChunkedBackend` by splitting `data` into fixed-size chunks. + /// + /// The last chunk may contain fewer than `chunk_size` elements if + /// `data.len()` is not a multiple of `chunk_size`. + /// + /// # Panics + /// + /// Panics if `chunk_size` is zero. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::storage::{ChunkedBackend, StorageBackend}; + /// + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0], 2); + /// + /// // 5 values in chunks of 2 → 3 chunks: [1,2], [3,4], [5] + /// assert_eq!(backend.n_chunks(), 3); + /// assert_eq!(backend.len(), 5); + /// ``` + pub fn new(data: Vec, chunk_size: usize) -> Self { + assert!(chunk_size > 0, "chunk_size must be greater than zero"); + let len = data.len(); + let chunks = data.chunks(chunk_size).map(<[T]>::to_vec).collect(); + Self { + chunks, + chunk_size, + len, + } + } + + /// Returns the configured maximum size of each chunk. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::storage::ChunkedBackend; + /// + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0], 2); + /// assert_eq!(backend.chunk_size(), 2); + /// ``` + pub fn chunk_size(&self) -> usize { + self.chunk_size + } + + /// Returns the number of chunks currently allocated. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::storage::ChunkedBackend; + /// + /// // 6 values in chunks of 2 → 3 full chunks + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0, 6.0], 2); + /// assert_eq!(backend.n_chunks(), 3); + /// + /// // 5 values in chunks of 2 → 2 full + 1 partial chunk + /// let backend = ChunkedBackend::new(vec![1.0_f64, 2.0, 3.0, 4.0, 5.0], 2); + /// assert_eq!(backend.n_chunks(), 3); + /// ``` + pub fn n_chunks(&self) -> usize { + self.chunks.len() + } +} + +impl StorageBackend for ChunkedBackend { + /// Returns the total number of values across all chunks. + fn len(&self) -> usize { + self.len + } + + /// Returns `true` if the backend contains no values. + fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Returns a reference to the value at position `idx`, or `None` if out of bounds. + /// + /// Access is O(1): `chunk = idx / chunk_size`, `offset = idx % chunk_size`. + fn get(&self, idx: usize) -> Option<&T> { + if idx >= self.len { + return None; + } + self.chunks + .get(idx / self.chunk_size)? + .get(idx % self.chunk_size) + } + + /// Appends `value` to the end of the backend. + /// + /// A new chunk is allocated only when the current last chunk is full, + /// so existing chunk allocations are never disturbed. + fn push(&mut self, value: T) { + if self.chunks.is_empty() + || self.chunks.last().map(Vec::len).unwrap_or(0) == self.chunk_size + { + self.chunks.push(Vec::with_capacity(self.chunk_size)); + } + self.chunks.last_mut().unwrap().push(value); + self.len += 1; + } + + /// Returns a new `ChunkedBackend` containing only the values in `range`. + /// + /// The result uses the same `chunk_size` as the source. The range is + /// re-chunked from scratch, so the chunk boundaries in the result may + /// differ from those in the source. + fn slice(&self, range: Range) -> Self { + let data: Vec = self + .iter() + .skip(range.start) + .take(range.end.saturating_sub(range.start)) + .cloned() + .collect(); + Self::new(data, self.chunk_size) + } + + /// Returns an iterator over references to each value in order, + /// flattening across chunk boundaries. + fn iter(&self) -> Box + '_> { + Box::new(self.chunks.iter().flat_map(|c| c.iter())) + } +} diff --git a/crates/storage/mod.rs b/crates/storage/mod.rs index 5c6b442..ffc010f 100644 --- a/crates/storage/mod.rs +++ b/crates/storage/mod.rs @@ -4,5 +4,6 @@ pub mod columnar; pub mod row; pub use backend::StorageBackend; +pub use chunked::ChunkedBackend; pub use columnar::ColumnarBackend; pub use row::{RowBackend, RowRecord}; diff --git a/examples/temporal_series_with_chunked_backend.rs b/examples/temporal_series_with_chunked_backend.rs new file mode 100644 index 0000000..34db702 --- /dev/null +++ b/examples/temporal_series_with_chunked_backend.rs @@ -0,0 +1,83 @@ +//! Demonstrates building a [`TemporalSeries`] with a [`ChunkedBackend`]. +//! +//! A `ChunkedBackend` stores values in fixed-size chunks (`Vec>`) rather +//! than a single contiguous buffer. The key trade-off versus [`ColumnarBackend`] +//! is: +//! +//! - **Append cost** — a new chunk is allocated only when the current one is +//! full, so no existing data is ever copied on growth. +//! - **Access cost** — O(1) via `chunk = idx / chunk_size`, `offset = idx % chunk_size`. +//! - **Scan cost** — slightly more pointer-chasing than columnar because chunks +//! are not contiguous in memory. +//! +//! Use `ChunkedBackend` when you build the series incrementally (record-by-record +//! from a stream or cursor) and want to avoid the periodic full-buffer +//! reallocation that a single `Vec` incurs as it grows. +//! +//! # Layout +//! +//! ```text +//! values: [10.0, 20.0, 30.0, 40.0, 50.0] chunk_size = 2 +//! chunks: [[10.0, 20.0], [30.0, 40.0], [50.0]] +//! ``` +//! +//! # Run +//! +//! ```bash +//! cargo run --example temporal_series_with_chunked_backend +//! ``` + +use temporalseries::series::TemporalSeries; +use temporalseries::storage::ChunkedBackend; + +fn main() { + // ----------------------------------------------------------------------- + // Construction from existing data + // ----------------------------------------------------------------------- + + let index = vec![1_i64, 2, 3, 4, 5]; + let values = vec![10.0_f64, 20.0, 30.0, 40.0, 50.0]; + let backend = ChunkedBackend::new(values, 2); + + println!("chunk_size : {}", backend.chunk_size()); + println!("n_chunks : {}", backend.n_chunks()); // 3: [10,20], [30,40], [50] + + let series = TemporalSeries::new(index, backend).unwrap(); + + println!("len : {}", series.len()); + println!("get(0) : {:?}", series.get(0)); // Some(10.0) + println!("get(2) : {:?}", series.get(2)); // Some(30.0) — first element of second chunk + println!("get(4) : {:?}", series.get(4)); // Some(50.0) — partial last chunk + println!("get(99) : {:?}", series.get(99)); // None + + // ----------------------------------------------------------------------- + // Iteration — flattens across chunk boundaries + // ----------------------------------------------------------------------- + + let values_collected: Vec = series.iter().copied().collect(); + println!("\nall values : {:?}", values_collected); + + // ----------------------------------------------------------------------- + // Incremental appends — core strength of ChunkedBackend + // ----------------------------------------------------------------------- + + let mut growing_backend: ChunkedBackend = ChunkedBackend::new(vec![], 3); + for v in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0] { + growing_backend.push(v); + } + println!("\nafter 7 pushes with chunk_size=3:"); + println!(" n_chunks : {}", growing_backend.n_chunks()); // 3: [1,2,3],[4,5,6],[7] + println!(" len : {}", growing_backend.len()); + println!(" get(6) : {:?}", growing_backend.get(6)); // Some(7.0) + + // ----------------------------------------------------------------------- + // Slice — returns a new ChunkedBackend with the same chunk_size + // ----------------------------------------------------------------------- + + use temporalseries::storage::StorageBackend; + let sliced: ChunkedBackend = growing_backend.slice(2..5); + println!("\nslice(2..5):"); + println!(" len : {}", sliced.len()); // 3 + let sliced_values: Vec = sliced.iter().copied().collect(); + println!(" values : {:?}", sliced_values); // [3.0, 4.0, 5.0] +} diff --git a/tests/storage/mod.rs b/tests/storage/mod.rs index c999f13..1e89a03 100644 --- a/tests/storage/mod.rs +++ b/tests/storage/mod.rs @@ -1,2 +1,3 @@ +mod test_chunked; mod test_columnar; mod test_row; diff --git a/tests/storage/test_chunked.rs b/tests/storage/test_chunked.rs new file mode 100644 index 0000000..4751059 --- /dev/null +++ b/tests/storage/test_chunked.rs @@ -0,0 +1,240 @@ +use temporalseries::storage::{ChunkedBackend, StorageBackend}; + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_values__when_new__then_len_is_correct() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 2); + // Then + assert_eq!(backend.len(), 5); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_values_divisible_by_chunk_size__when_new__then_n_chunks_is_exact() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0], 2); + // Then — 4 values / chunk_size 2 → exactly 2 full chunks + assert_eq!(backend.n_chunks(), 2); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_values_not_divisible_by_chunk_size__when_new__then_last_chunk_is_partial() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 2); + // Then — 5 values / chunk_size 2 → 2 full + 1 partial chunk + assert_eq!(backend.n_chunks(), 3); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_values__when_new__then_is_empty() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![], 4); + // Then + assert!(backend.is_empty()); + assert_eq!(backend.len(), 0); + assert_eq!(backend.n_chunks(), 0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_chunk_size_equals_len__when_new__then_single_chunk() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0], 3); + // Then + assert_eq!(backend.n_chunks(), 1); + assert_eq!(backend.len(), 3); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_chunk_size_larger_than_len__when_new__then_single_partial_chunk() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 10); + // Then + assert_eq!(backend.n_chunks(), 1); + assert_eq!(backend.len(), 2); +} + +// --------------------------------------------------------------------------- +// chunk_size accessor +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_chunk_size__then_returns_configured_size() { + // Given & When + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0], 2); + // Then + assert_eq!(backend.chunk_size(), 2); +} + +// --------------------------------------------------------------------------- +// get +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_first_element__then_returns_correct_value() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0], 2); + // When & Then + assert_eq!(backend.get(0), Some(&10.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_last_element__then_returns_correct_value() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0], 2); + // When & Then + assert_eq!(backend.get(2), Some(&30.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_element_at_chunk_boundary__then_returns_correct_value() { + // Given — chunk_size 2: chunks are [10, 20] and [30, 40] + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0, 40.0], 2); + // When — index 2 is the first element of the second chunk + // Then + assert_eq!(backend.get(2), Some(&30.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_get_out_of_bounds__then_returns_none() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 2); + // When & Then + assert!(backend.get(99).is_none()); +} + +// --------------------------------------------------------------------------- +// push +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_push__then_len_increases() { + // Given + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 2); + // When + backend.push(3.0); + // Then + assert_eq!(backend.len(), 3); + assert_eq!(backend.get(2), Some(&3.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_full_last_chunk__when_push__then_new_chunk_is_allocated() { + // Given — 2 values in chunks of 2 → last chunk is full + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0], 2); + assert_eq!(backend.n_chunks(), 1); + // When + backend.push(3.0); + // Then — a new chunk must have been allocated + assert_eq!(backend.n_chunks(), 2); + assert_eq!(backend.get(2), Some(&3.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_partial_last_chunk__when_push__then_no_new_chunk_allocated() { + // Given — 3 values in chunks of 2 → last chunk has 1 slot free + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0], 2); + let chunks_before = backend.n_chunks(); + // When + backend.push(4.0); + // Then — same number of chunks, last chunk now full + assert_eq!(backend.n_chunks(), chunks_before); + assert_eq!(backend.len(), 4); + assert_eq!(backend.get(3), Some(&4.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_backend__when_push__then_first_chunk_is_created() { + // Given + let mut backend: ChunkedBackend = ChunkedBackend::new(vec![], 3); + assert_eq!(backend.n_chunks(), 0); + // When + backend.push(42.0); + // Then + assert_eq!(backend.n_chunks(), 1); + assert_eq!(backend.len(), 1); + assert_eq!(backend.get(0), Some(&42.0)); +} + +// --------------------------------------------------------------------------- +// slice +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_slice__then_returns_correct_subset() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![10.0, 20.0, 30.0, 40.0], 2); + // When + let sliced: ChunkedBackend = backend.slice(1..3); + // Then + assert_eq!(sliced.len(), 2); + assert_eq!(sliced.get(0), Some(&20.0)); + assert_eq!(sliced.get(1), Some(&30.0)); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_slice_preserves_chunk_size() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 3); + // When + let sliced: ChunkedBackend = backend.slice(0..4); + // Then — same chunk_size, re-chunked + assert_eq!(sliced.chunk_size(), 3); + assert_eq!(sliced.len(), 4); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_slice_does_not_mutate_source() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0], 2); + // When + let _sliced = backend.slice(0..2); + // Then — source is unchanged + assert_eq!(backend.len(), 4); +} + +// --------------------------------------------------------------------------- +// iter +// --------------------------------------------------------------------------- + +#[test] +#[allow(non_snake_case)] +fn test__given_backend__when_iter__then_yields_all_values_in_order() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], 2); + // When + let collected: Vec<&f64> = backend.iter().collect(); + // Then + assert_eq!(collected, vec![&1.0, &2.0, &3.0, &4.0, &5.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_backend__when_iter__then_yields_nothing() { + // Given + let backend: ChunkedBackend = ChunkedBackend::new(vec![], 4); + // When + let collected: Vec<&f64> = backend.iter().collect(); + // Then + assert!(collected.is_empty()); +} From f744fa0eb0a796766defd21221d2fc98386ef0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 17:23:59 +0200 Subject: [PATCH 04/11] expand RollingSeries with sum, min, max, and std methods Adds four new sliding-window methods to RollingSeries: - sum: window sum with NaN fill during warm-up - min/max: window extremes via fold over INFINITY / NEG_INFINITY - std: Bessel-corrected sample std deviation; rejects window < 2 with InvalidWindow Includes 25 new tests (given/when/then) across test_sum, test_min, test_max, and test_std, and four new Criterion benchmarks (window=20, n=1000) alongside the existing rolling_mean benchmark. bench helper refactored to make_ts. Co-Authored-By: Claude Sonnet 4.6 --- benchmark/time_series.rs | 54 +++++++++-- crates/rolling/core.rs | 188 ++++++++++++++++++++++++++++++++++++-- tests/rolling/mod.rs | 4 + tests/rolling/test_max.rs | 76 +++++++++++++++ tests/rolling/test_min.rs | 76 +++++++++++++++ tests/rolling/test_std.rs | 90 ++++++++++++++++++ tests/rolling/test_sum.rs | 76 +++++++++++++++ 7 files changed, 547 insertions(+), 17 deletions(-) create mode 100644 tests/rolling/test_max.rs create mode 100644 tests/rolling/test_min.rs create mode 100644 tests/rolling/test_std.rs create mode 100644 tests/rolling/test_sum.rs diff --git a/benchmark/time_series.rs b/benchmark/time_series.rs index cab90ad..4c26467 100644 --- a/benchmark/time_series.rs +++ b/benchmark/time_series.rs @@ -2,25 +2,61 @@ use criterion::{Criterion, criterion_group, criterion_main}; use std::hint::black_box; use temporalseries::series::TimeSeries; -fn bench_pct_change(c: &mut Criterion) { - let index: Vec = (0..1000).collect(); - let values: Vec = (0..1000).map(|i| 100.0 + i as f64).collect(); - let ts = TimeSeries::new(index, values).unwrap(); +fn make_ts(n: usize) -> TimeSeries { + let index: Vec = (0..n as i64).collect(); + let values: Vec = (0..n).map(|i| 100.0 + i as f64).collect(); + TimeSeries::new(index, values).unwrap() +} +fn bench_pct_change(c: &mut Criterion) { + let ts = make_ts(1000); c.bench_function("pct_change 1000", |b| { b.iter(|| black_box(&ts).pct_change().unwrap()) }); } fn bench_rolling_mean(c: &mut Criterion) { - let index: Vec = (0..1000).collect(); - let values: Vec = (0..1000).map(|i| 100.0 + i as f64).collect(); - let ts = TimeSeries::new(index, values).unwrap(); - + let ts = make_ts(1000); c.bench_function("rolling mean window=20 1000", |b| { b.iter(|| black_box(&ts).rolling(20).mean().unwrap()) }); } -criterion_group!(benches, bench_pct_change, bench_rolling_mean); +fn bench_rolling_sum(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling sum window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).sum().unwrap()) + }); +} + +fn bench_rolling_min(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling min window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).min().unwrap()) + }); +} + +fn bench_rolling_max(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling max window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).max().unwrap()) + }); +} + +fn bench_rolling_std(c: &mut Criterion) { + let ts = make_ts(1000); + c.bench_function("rolling std window=20 1000", |b| { + b.iter(|| black_box(&ts).rolling(20).std().unwrap()) + }); +} + +criterion_group!( + benches, + bench_pct_change, + bench_rolling_mean, + bench_rolling_sum, + bench_rolling_min, + bench_rolling_max, + bench_rolling_std, +); criterion_main!(benches); diff --git a/crates/rolling/core.rs b/crates/rolling/core.rs index 6916573..f631d22 100644 --- a/crates/rolling/core.rs +++ b/crates/rolling/core.rs @@ -4,14 +4,20 @@ use crate::series::TimeSeries; /// A sliding-window view over a [`TimeSeries`]. /// /// `RollingSeries` is a lazy handle — it holds a reference to the original -/// series and a window size, but performs no computation until a method such -/// as [`RollingSeries::mean`] is called. +/// series and a window size, but performs no computation until a terminal +/// method (`mean`, `sum`, `min`, `max`, `std`) is called. /// /// The lifetime parameter `'a` ties the `RollingSeries` to the [`TimeSeries`] /// it was created from: the view cannot outlive the original series. /// /// Construct a `RollingSeries` via [`TimeSeries::rolling`] rather than directly. /// +/// # NaN convention +/// +/// All methods fill the first `window - 1` positions with `NaN` because there +/// are not enough preceding observations to fill the window. This keeps the +/// output length equal to the input length and preserves index alignment. +/// /// # Example /// /// ```rust @@ -24,6 +30,7 @@ use crate::series::TimeSeries; /// /// let rolling = ts.rolling(3); /// let mean = rolling.mean().unwrap(); +/// let sum = rolling.sum().unwrap(); /// ``` #[derive(Debug, Clone)] pub struct RollingSeries<'a> { @@ -51,10 +58,7 @@ impl<'a> RollingSeries<'a> { /// Computes the rolling mean over a sliding window. /// - /// For each position `t`, the result is the arithmetic mean of - /// `values[t - window + 1..=t]`. The first `window - 1` elements - /// are set to `NaN` because there are not enough preceding observations - /// to fill the window. + /// $$MA_t^{(w)} = \frac{1}{w} \sum_{i=0}^{w-1} x_{t-i}$$ /// /// # Example /// @@ -83,8 +87,176 @@ impl<'a> RollingSeries<'a> { for (i, item) in result.iter_mut().enumerate().skip(self.window - 1) { let slice: &[f64] = &self.series.values[i + 1 - self.window..=i]; - let mean: f64 = slice.iter().sum::() / self.window as f64; - *item = mean; + *item = slice.iter().sum::() / self.window as f64; + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling sum over a sliding window. + /// + /// $$S_t^{(w)} = \sum_{i=0}^{w-1} x_{t-i}$$ + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![1.0, 2.0, 3.0, 4.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).sum().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 6.0).abs() < 1e-6); // 1 + 2 + 3 + /// assert!((result.values[4] - 12.0).abs() < 1e-6); // 3 + 4 + 5 + /// ``` + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn sum(&self) -> Result { + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + *val = self.series.values[i + 1 - self.window..=i] + .iter() + .sum::(); + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling minimum over a sliding window. + /// + /// $$\min_t^{(w)} = \min_{i=0}^{w-1} x_{t-i}$$ + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![3.0, 1.0, 4.0, 1.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).min().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 1.0).abs() < 1e-6); // min(3, 1, 4) + /// assert!((result.values[3] - 1.0).abs() < 1e-6); // min(1, 4, 1) + /// assert!((result.values[4] - 1.0).abs() < 1e-6); // min(4, 1, 5) + /// ``` + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn min(&self) -> Result { + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + *val = self.series.values[i + 1 - self.window..=i] + .iter() + .cloned() + .fold(f64::INFINITY, f64::min); + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling maximum over a sliding window. + /// + /// $$\max_t^{(w)} = \max_{i=0}^{w-1} x_{t-i}$$ + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![3.0, 1.0, 4.0, 1.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).max().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 4.0).abs() < 1e-6); // max(3, 1, 4) + /// assert!((result.values[3] - 4.0).abs() < 1e-6); // max(1, 4, 1) + /// assert!((result.values[4] - 5.0).abs() < 1e-6); // max(4, 1, 5) + /// ``` + /// + /// # Errors + /// + /// Returns [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn max(&self) -> Result { + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + *val = self.series.values[i + 1 - self.window..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + } + + TimeSeries::new(self.series.index.clone(), result) + } + + /// Computes the rolling sample standard deviation (Bessel-corrected) over a + /// sliding window. + /// + /// $$\sigma_t^{(w)} = \sqrt{\frac{1}{w-1} \sum_{i=0}^{w-1} \left(x_{t-i} - \bar{x}_t^{(w)}\right)^2}$$ + /// + /// Requires `window >= 2` because Bessel's correction divides by `w - 1`. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![1.0, 2.0, 3.0, 4.0, 5.0], + /// ).unwrap(); + /// + /// let result = ts.rolling(3).std().unwrap(); + /// + /// assert!(result.values[0].is_nan()); + /// assert!(result.values[1].is_nan()); + /// assert!((result.values[2] - 1.0).abs() < 1e-6); // std(1, 2, 3) = 1.0 + /// assert!((result.values[4] - 1.0).abs() < 1e-6); // std(3, 4, 5) = 1.0 + /// ``` + /// + /// # Errors + /// + /// - [`TemporalSeriesError::InvalidWindow`] if `window < 2`. + /// - [`TemporalSeriesError`] if the resulting series cannot be constructed. + pub fn std(&self) -> Result { + if self.window < 2 { + return Err(TemporalSeriesError::InvalidWindow { + window: self.window, + series_len: self.series.len(), + }); + } + + let n = self.series.len(); + let mut result = vec![f64::NAN; n]; + + for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { + let slice = &self.series.values[i + 1 - self.window..=i]; + let mean = slice.iter().sum::() / self.window as f64; + let variance = slice.iter().map(|x| (x - mean).powi(2)).sum::() + / (self.window - 1) as f64; + *val = variance.sqrt(); } TimeSeries::new(self.series.index.clone(), result) diff --git a/tests/rolling/mod.rs b/tests/rolling/mod.rs index 41008c3..8fc2ce2 100644 --- a/tests/rolling/mod.rs +++ b/tests/rolling/mod.rs @@ -1 +1,5 @@ +mod test_max; mod test_mean; +mod test_min; +mod test_std; +mod test_sum; diff --git a/tests/rolling/test_max.rs b/tests/rolling/test_max.rs new file mode 100644 index 0000000..fb4063f --- /dev/null +++ b/tests/rolling/test_max.rs @@ -0,0 +1,76 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).max().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max_window_3__then_computes_correctly() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).max().unwrap(); + // Then + assert!((result.values[2] - 4.0).abs() < 1e-6); // max(3, 1, 4) + assert!((result.values[3] - 4.0).abs() < 1e-6); // max(1, 4, 1) + assert!((result.values[4] - 5.0).abs() < 1e-6); // max(4, 1, 5) +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max_window_1__then_equals_original() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![5.0, 2.0, 8.0]).unwrap(); + // When + let result = ts.rolling(1).max().unwrap(); + // Then + assert!((result.values[0] - 5.0).abs() < 1e-6); + assert!((result.values[1] - 2.0).abs() < 1e-6); + assert!((result.values[2] - 8.0).abs() < 1e-6); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).max().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_max__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![5.0, 3.0, 8.0, 1.0, 6.0]).unwrap(); + // When + let result = ts.rolling(2).max().unwrap(); + // Then + assert_eq!(result.index, index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_ascending_series__when_rolling_max_window_2__then_always_second_value() { + // Given — strictly ascending: each new value is always the maximum + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + // When + let result = ts.rolling(2).max().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 3.0).abs() < 1e-6); + assert!((result.values[2] - 6.0).abs() < 1e-6); + assert!((result.values[3] - 10.0).abs() < 1e-6); +} diff --git a/tests/rolling/test_min.rs b/tests/rolling/test_min.rs new file mode 100644 index 0000000..63c45ef --- /dev/null +++ b/tests/rolling/test_min.rs @@ -0,0 +1,76 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).min().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min_window_3__then_computes_correctly() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![3.0, 1.0, 4.0, 1.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).min().unwrap(); + // Then + assert!((result.values[2] - 1.0).abs() < 1e-6); // min(3, 1, 4) + assert!((result.values[3] - 1.0).abs() < 1e-6); // min(1, 4, 1) + assert!((result.values[4] - 1.0).abs() < 1e-6); // min(4, 1, 5) +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min_window_1__then_equals_original() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![5.0, 2.0, 8.0]).unwrap(); + // When + let result = ts.rolling(1).min().unwrap(); + // Then + assert!((result.values[0] - 5.0).abs() < 1e-6); + assert!((result.values[1] - 2.0).abs() < 1e-6); + assert!((result.values[2] - 8.0).abs() < 1e-6); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).min().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_min__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![5.0, 3.0, 8.0, 1.0, 6.0]).unwrap(); + // When + let result = ts.rolling(2).min().unwrap(); + // Then + assert_eq!(result.index, index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_descending_series__when_rolling_min_window_2__then_always_second_value() { + // Given — strictly descending: each new value is always the minimum + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![10.0, 8.0, 6.0, 4.0]).unwrap(); + // When + let result = ts.rolling(2).min().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 8.0).abs() < 1e-6); + assert!((result.values[2] - 6.0).abs() < 1e-6); + assert!((result.values[3] - 4.0).abs() < 1e-6); +} diff --git a/tests/rolling/test_std.rs b/tests/rolling/test_std.rs new file mode 100644 index 0000000..7045c5c --- /dev/null +++ b/tests/rolling/test_std.rs @@ -0,0 +1,90 @@ +use temporalseries::{errors::TemporalSeriesError, series::TimeSeries}; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_std_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).std().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_linear_series__when_rolling_std_window_3__then_computes_correctly() { + // Given — equally spaced values: std of any window of 3 is 1.0 + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).std().unwrap(); + // Then + assert!((result.values[2] - 1.0).abs() < 1e-6); // std(1, 2, 3) = 1.0 + assert!((result.values[3] - 1.0).abs() < 1e-6); // std(2, 3, 4) = 1.0 + assert!((result.values[4] - 1.0).abs() < 1e-6); // std(3, 4, 5) = 1.0 +} + +#[test] +#[allow(non_snake_case)] +fn test__given_constant_series__when_rolling_std_window_2__then_returns_zero() { + // Given — constant series has zero deviation + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![5.0, 5.0, 5.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).std().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 0.0).abs() < 1e-10); + assert!((result.values[2] - 0.0).abs() < 1e-10); + assert!((result.values[3] - 0.0).abs() < 1e-10); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_window_of_2__when_rolling_std__then_computes_correctly() { + // Given — std([1, 3]) = sqrt(((1-2)^2 + (3-2)^2) / 1) = sqrt(2) ≈ 1.4142 + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 3.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).std().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 2_f64.sqrt()).abs() < 1e-6); // std(1, 3) + assert!((result.values[2] - 2_f64.sqrt()).abs() < 1e-6); // std(3, 5) +} + +#[test] +#[allow(non_snake_case)] +fn test__given_window_1__when_rolling_std__then_returns_invalid_window_error() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + // When + let result = ts.rolling(1).std(); + // Then — window=1 gives 0 degrees of freedom; must be rejected + assert!(matches!( + result, + Err(TemporalSeriesError::InvalidWindow { .. }) + )); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_std__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).std().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_std__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).std().unwrap(); + // Then + assert_eq!(result.index, index); +} diff --git a/tests/rolling/test_sum.rs b/tests/rolling/test_sum.rs new file mode 100644 index 0000000..9cc6d88 --- /dev/null +++ b/tests/rolling/test_sum.rs @@ -0,0 +1,76 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_3__then_first_two_are_nan() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).sum().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_3__then_computes_correctly() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).sum().unwrap(); + // Then + assert!((result.values[2] - 6.0).abs() < 1e-6); // 1 + 2 + 3 + assert!((result.values[3] - 9.0).abs() < 1e-6); // 2 + 3 + 4 + assert!((result.values[4] - 12.0).abs() < 1e-6); // 3 + 4 + 5 +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_1__then_equals_original() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let result = ts.rolling(1).sum().unwrap(); + // Then + assert!((result.values[0] - 10.0).abs() < 1e-6); + assert!((result.values[1] - 20.0).abs() < 1e-6); + assert!((result.values[2] - 30.0).abs() < 1e-6); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum__then_output_length_equals_input() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(3).sum().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum__then_preserves_index() { + // Given + let index = vec![10_i64, 20, 30, 40, 50]; + let ts = TimeSeries::new(index.clone(), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + // When + let result = ts.rolling(2).sum().unwrap(); + // Then + assert_eq!(result.index, index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_rolling_sum_window_2__then_computes_pairwise_sums() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 3.0, 6.0, 10.0]).unwrap(); + // When + let result = ts.rolling(2).sum().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!((result.values[1] - 4.0).abs() < 1e-6); // 1 + 3 + assert!((result.values[2] - 9.0).abs() < 1e-6); // 3 + 6 + assert!((result.values[3] - 16.0).abs() < 1e-6); // 6 + 10 +} From 449d872a4f5a676c3bef365164adc5e6a38701f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 17:25:09 +0200 Subject: [PATCH 05/11] update roadmap checklist --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0874b17..3245937 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ * [ ] Expand benchmark surface — add benchmarks for Panel analytical methods and the `TemporalSeries` generic path; decide coverage for storage backends. * [ ] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. -* [ ] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. +* [x] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. * [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. * [ ] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. * [ ] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries` and `TemporalSeries`; propagate to `Panel`. From 531e5d05142d120c34bad688e35cdf74bfc98ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 17:40:06 +0200 Subject: [PATCH 06/11] implement Panel I/O: wide-format CSV reader and writer Adds read_panel_csv and write_panel_csv to crates/io/csv.rs. The format is one symbol per column with a shared index column: index,AAPL,MSFT 1,150.0,300.0 2,152.0,305.0 read_panel_csv parses the header to extract symbol names, validates field counts per row, and delegates construction to Panel::new so all existing invariants are enforced. write_panel_csv serialises in the same layout; the round-trip is value-lossless for all f64 values with an exact decimal representation. Also adds: - Panel::index() accessor (required by the writer to iterate timestamps) - 14 integration tests across test_read_panel_csv and test_write_panel_csv (given/when/then convention): shape, symbols, values, index preservation, non-existent file, single-symbol, malformed value, empty file, header-only, wrong field count, invalid path, round-trip, header format, row count - examples/panel_io.rs demonstrating the full round-trip - examples/panel_input.csv fixture committed for read tests - .gitignore entries for runtime-generated test CSVs Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + ROADMAP.md | 2 +- crates/io/csv.rs | 134 +++++++++++++++++++++++++++++++ crates/io/mod.rs | 2 +- crates/panel/core.rs | 19 +++++ examples/panel_input.csv | 6 ++ examples/panel_io.rs | 98 ++++++++++++++++++++++ tests/io/mod.rs | 2 + tests/io/test_read_panel_csv.rs | 116 ++++++++++++++++++++++++++ tests/io/test_write_panel_csv.rs | 108 +++++++++++++++++++++++++ 10 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 examples/panel_input.csv create mode 100644 examples/panel_io.rs create mode 100644 tests/io/test_read_panel_csv.rs create mode 100644 tests/io/test_write_panel_csv.rs diff --git a/.gitignore b/.gitignore index 42c249b..31de1c3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ # test outputs test_output.csv +test_panel_*.csv +examples/panel_output.csv diff --git a/ROADMAP.md b/ROADMAP.md index 3245937..20d6c3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ * [ ] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. * [x] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. * [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. -* [ ] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. +* [x] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. * [ ] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries` and `TemporalSeries`; propagate to `Panel`. * [ ] Serde support — optional `serde` feature flag; derive `Serialize`/`Deserialize` for `TimeSeries` and `Panel`. diff --git a/crates/io/csv.rs b/crates/io/csv.rs index cc69945..6a34447 100644 --- a/crates/io/csv.rs +++ b/crates/io/csv.rs @@ -1,4 +1,5 @@ use crate::errors::TemporalSeriesError; +use crate::panel::Panel; use crate::series::TimeSeries; use std::fs::File; use std::io::{BufRead, BufReader, Write}; @@ -84,6 +85,139 @@ pub fn read_csv(path: &str) -> Result { TimeSeries::new(index, values) } +/// Reads a wide-format CSV file into a [`Panel`]. +/// +/// The first row must be a header with `index` as the first field, followed by +/// one symbol name per column. Each subsequent row contains the timestamp +/// followed by one `f64` value per symbol column. +/// +/// ```text +/// index,AAPL,MSFT +/// 1,150.0,300.0 +/// 2,152.0,305.0 +/// 3,149.0,298.0 +/// ``` +/// +/// Symbol order in the resulting [`Panel`] matches the column order in the +/// file. Blank lines between data rows are silently skipped. +/// +/// # Errors +/// +/// - [`TemporalSeriesError::IoError`] if the file cannot be opened or read. +/// - [`TemporalSeriesError::ParseError`] if the file is empty, the header has +/// no symbol columns, a row has the wrong number of fields, or any field +/// cannot be parsed as the expected type. +pub fn read_panel_csv(path: &str) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut lines = reader.lines(); + + let header = lines + .next() + .ok_or_else(|| TemporalSeriesError::ParseError("empty file: missing header".into()))??; + + let mut header_parts = header.split(','); + header_parts.next(); // skip "index" label + let symbols: Vec = header_parts.map(|s| s.trim().to_string()).collect(); + let n_cols = symbols.len(); + + if n_cols == 0 { + return Err(TemporalSeriesError::ParseError( + "header has no symbol columns".into(), + )); + } + + let mut index: Vec = Vec::new(); + let mut values: Vec> = vec![Vec::new(); n_cols]; + + for (line_no, line) in lines.enumerate() { + let line = line?; + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() != n_cols + 1 { + return Err(TemporalSeriesError::ParseError(format!( + "line {}: expected {} fields, found {}", + line_no + 2, + n_cols + 1, + parts.len() + ))); + } + + let idx = parts[0].trim().parse::().map_err(|e| { + TemporalSeriesError::ParseError(format!( + "line {}: invalid index — {}", + line_no + 2, + e + )) + })?; + index.push(idx); + + for col in 0..n_cols { + let val = parts[col + 1].trim().parse::().map_err(|e| { + TemporalSeriesError::ParseError(format!( + "line {}: invalid value for column '{}' — {}", + line_no + 2, + symbols[col], + e + )) + })?; + values[col].push(val); + } + } + + Panel::new(index, symbols, values) +} + +/// Writes a [`Panel`] to a wide-format CSV file. +/// +/// The first row is `index` followed by the symbol names. Each subsequent row +/// contains the timestamp followed by the value of each symbol at that +/// timestamp, in the same column order as [`Panel::symbols`]. +/// +/// ```text +/// index,AAPL,MSFT +/// 1,150.0,300.0 +/// 2,152.0,305.0 +/// 3,149.0,298.0 +/// ``` +/// +/// The written file can be read back with [`read_panel_csv`] without any loss +/// of data for all `f64` values that have an exact decimal representation. +/// +/// # Errors +/// +/// Returns [`TemporalSeriesError::IoError`] if the file cannot be created or +/// written. +pub fn write_panel_csv(panel: &Panel, path: &str) -> Result<(), TemporalSeriesError> { + let mut file = File::create(path)?; + let symbols = panel.symbols(); + + write!(file, "index")?; + for sym in symbols { + write!(file, ",{sym}")?; + } + writeln!(file)?; + + let col_series: Vec<_> = symbols + .iter() + .map(|s| panel.get_series(s).unwrap()) + .collect(); + + for (row, &ts) in panel.index().iter().enumerate() { + write!(file, "{ts}")?; + for col in &col_series { + write!(file, ",{}", col.values[row])?; + } + writeln!(file)?; + } + + Ok(()) +} + /// Writes a [`TimeSeries`] to a CSV file. /// /// The output has a header row (`index,value`) followed by one row per diff --git a/crates/io/mod.rs b/crates/io/mod.rs index 493a2e3..ec7b34e 100644 --- a/crates/io/mod.rs +++ b/crates/io/mod.rs @@ -1,3 +1,3 @@ pub mod csv; -pub use csv::{read_csv, write_csv}; +pub use csv::{read_csv, read_panel_csv, write_csv, write_panel_csv}; diff --git a/crates/panel/core.rs b/crates/panel/core.rs index 2387eee..f69fd34 100644 --- a/crates/panel/core.rs +++ b/crates/panel/core.rs @@ -138,6 +138,25 @@ impl Panel { self.index.is_empty() } + /// Returns the shared time index as a slice. + /// + /// # Example + /// + /// ```rust + /// use temporalseries::panel::Panel; + /// + /// let panel = Panel::new( + /// vec![10, 20, 30], + /// vec!["A".into()], + /// vec![vec![1.0, 2.0, 3.0]], + /// ).unwrap(); + /// + /// assert_eq!(panel.index(), &[10, 20, 30]); + /// ``` + pub fn index(&self) -> &[Timestamp] { + &self.index + } + /// Returns the number of series (columns) in the panel. /// /// # Example diff --git a/examples/panel_input.csv b/examples/panel_input.csv new file mode 100644 index 0000000..da02d21 --- /dev/null +++ b/examples/panel_input.csv @@ -0,0 +1,6 @@ +index,AAPL,MSFT +1,150.0,300.0 +2,152.0,305.0 +3,149.0,298.0 +4,153.0,310.0 +5,155.0,308.0 diff --git a/examples/panel_io.rs b/examples/panel_io.rs new file mode 100644 index 0000000..275e78c --- /dev/null +++ b/examples/panel_io.rs @@ -0,0 +1,98 @@ +//! Demonstrates reading and writing a [`Panel`] via wide-format CSV. +//! +//! The wide-format used by this library places one symbol per column, with a +//! shared `index` column as the first field: +//! +//! ```text +//! index,AAPL,MSFT +//! 1,150.0,300.0 +//! 2,152.0,305.0 +//! 3,149.0,298.0 +//! ``` +//! +//! This example exercises the full round-trip: +//! +//! 1. Build a `Panel` in memory with two symbols over five timestamps. +//! 2. [`write_panel_csv`] — serialize it to `examples/panel_output.csv`. +//! 3. [`read_panel_csv`] — parse the file back into a `Panel`. +//! 4. Verify that shape, symbols, index, and values are all preserved. +//! +//! # Files +//! +//! - **`examples/panel_input.csv`** — pre-existing fixture (five observations, +//! two symbols) used by integration tests. +//! - **`examples/panel_output.csv`** — created by this example at runtime; +//! safe to delete. +//! +//! # Run +//! +//! ```bash +//! cargo run --example panel_io +//! ``` + +use temporalseries::io::{read_panel_csv, write_panel_csv}; +use temporalseries::panel::Panel; + +fn main() { + // ----------------------------------------------------------------------- + // Build a panel in memory + // ----------------------------------------------------------------------- + + let panel = Panel::new( + vec![1, 2, 3, 4, 5], + vec!["AAPL".into(), "MSFT".into()], + vec![ + vec![150.0, 152.0, 149.0, 153.0, 155.0], + vec![300.0, 305.0, 298.0, 310.0, 308.0], + ], + ) + .unwrap(); + + println!("Original panel"); + println!(" shape: {:?}", panel.shape()); + println!(" symbols: {:?}", panel.symbols()); + println!(" index: {:?}", panel.index()); + + // ----------------------------------------------------------------------- + // Write to CSV + // ----------------------------------------------------------------------- + + let out_path = "examples/panel_output.csv"; + write_panel_csv(&panel, out_path).unwrap(); + println!("\nWrote panel to '{out_path}'"); + + // ----------------------------------------------------------------------- + // Read back and verify + // ----------------------------------------------------------------------- + + let loaded = read_panel_csv(out_path).unwrap(); + println!("\nLoaded panel from '{out_path}'"); + println!(" shape: {:?}", loaded.shape()); + println!(" symbols: {:?}", loaded.symbols()); + println!(" index: {:?}", loaded.index()); + + for sym in loaded.symbols() { + let orig = panel.get_series(sym).unwrap(); + let back = loaded.get_series(sym).unwrap(); + assert_eq!(orig.values, back.values, "round-trip mismatch for {sym}"); + println!(" {sym}: {:?}", back.values); + } + + println!("\nRound-trip OK — all values match."); + + // ----------------------------------------------------------------------- + // Read from the committed fixture + // ----------------------------------------------------------------------- + + let fixture = read_panel_csv("examples/panel_input.csv").unwrap(); + println!("\nLoaded fixture 'examples/panel_input.csv'"); + println!(" shape: {:?}", fixture.shape()); + println!( + " AAPL: {:?}", + fixture.get_series("AAPL").unwrap().values + ); + println!( + " MSFT: {:?}", + fixture.get_series("MSFT").unwrap().values + ); +} diff --git a/tests/io/mod.rs b/tests/io/mod.rs index 0edad95..acabadb 100644 --- a/tests/io/mod.rs +++ b/tests/io/mod.rs @@ -1,2 +1,4 @@ mod test_read_csv; +mod test_read_panel_csv; mod test_write_csv; +mod test_write_panel_csv; diff --git a/tests/io/test_read_panel_csv.rs b/tests/io/test_read_panel_csv.rs new file mode 100644 index 0000000..0f6bad4 --- /dev/null +++ b/tests/io/test_read_panel_csv.rs @@ -0,0 +1,116 @@ +use std::fs; +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::read_panel_csv; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel_csv__when_read_panel_csv__then_returns_correct_shape_and_symbols() { + // Given + let path = "examples/panel_input.csv"; + // When + let panel = read_panel_csv(path).unwrap(); + // Then + assert_eq!(panel.shape(), (5, 2)); + assert_eq!(panel.symbols(), &["AAPL", "MSFT"]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel_csv__when_read_panel_csv__then_values_are_correct() { + // Given + let path = "examples/panel_input.csv"; + // When + let panel = read_panel_csv(path).unwrap(); + // Then + let aapl = panel.get_series("AAPL").unwrap(); + assert_eq!(aapl.values, vec![150.0, 152.0, 149.0, 153.0, 155.0]); + let msft = panel.get_series("MSFT").unwrap(); + assert_eq!(msft.values, vec![300.0, 305.0, 298.0, 310.0, 308.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel_csv__when_read_panel_csv__then_index_is_preserved() { + // Given + let path = "examples/panel_input.csv"; + // When + let panel = read_panel_csv(path).unwrap(); + // Then + assert_eq!(panel.index(), &[1, 2, 3, 4, 5]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_non_existent_file__when_read_panel_csv__then_returns_io_error() { + // Given + let path = "non_existent_panel.csv"; + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::IoError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_single_symbol_csv__when_read_panel_csv__then_returns_one_column_panel() { + // Given + let path = "test_panel_single_symbol.csv"; + fs::write(path, "index,SPY\n1,430.0\n2,432.5\n3,428.0\n").unwrap(); + // When + let panel = read_panel_csv(path).unwrap(); + // Then + assert_eq!(panel.shape(), (3, 1)); + assert_eq!(panel.symbols(), &["SPY"]); + assert_eq!( + panel.get_series("SPY").unwrap().values, + vec![430.0, 432.5, 428.0] + ); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_malformed_value__when_read_panel_csv__then_returns_parse_error() { + // Given — second row has "not_a_number" where a f64 is expected + let path = "test_panel_malformed.csv"; + fs::write(path, "index,AAPL\n1,150.0\n2,not_a_number\n").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_empty_file__when_read_panel_csv__then_returns_parse_error() { + // Given + let path = "test_panel_empty.csv"; + fs::write(path, "").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_header_only_no_symbols__when_read_panel_csv__then_returns_parse_error() { + // Given — header row with only the index label, no symbol columns + let path = "test_panel_no_symbols.csv"; + fs::write(path, "index\n1\n2\n").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_row_with_wrong_field_count__when_read_panel_csv__then_returns_parse_error() { + // Given — data row has one more field than the header declares + let path = "test_panel_extra_field.csv"; + fs::write(path, "index,AAPL\n1,150.0,999.0\n").unwrap(); + // When + let result = read_panel_csv(path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::ParseError(_)))); +} diff --git a/tests/io/test_write_panel_csv.rs b/tests/io/test_write_panel_csv.rs new file mode 100644 index 0000000..2cf2676 --- /dev/null +++ b/tests/io/test_write_panel_csv.rs @@ -0,0 +1,108 @@ +use std::fs; +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::{read_panel_csv, write_panel_csv}; +use temporalseries::panel::Panel; + +fn sample_panel() -> Panel { + Panel::new( + vec![1, 2, 3], + vec!["AAPL".into(), "MSFT".into()], + vec![vec![150.0, 152.0, 149.0], vec![300.0, 305.0, 298.0]], + ) + .unwrap() +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_panel__when_write_panel_csv__then_writes_successfully() { + // Given + let panel = sample_panel(); + let path = "test_panel_output.csv"; + // When + let result = write_panel_csv(&panel, path); + // Then + assert!(result.is_ok()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_path__when_write_panel_csv__then_returns_io_error() { + // Given + let panel = sample_panel(); + let path = "/invalid_path/test_panel_output.csv"; + // When + let result = write_panel_csv(&panel, path); + // Then + assert!(matches!(result, Err(TemporalSeriesError::IoError(_)))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_write_then_read__then_round_trip_preserves_values() { + // Given + let original = sample_panel(); + let path = "test_panel_round_trip.csv"; + write_panel_csv(&original, path).unwrap(); + // When + let loaded = read_panel_csv(path).unwrap(); + // Then — shape, symbols, index, and values are all preserved + assert_eq!(loaded.shape(), original.shape()); + assert_eq!(loaded.symbols(), original.symbols()); + assert_eq!(loaded.index(), original.index()); + for sym in original.symbols() { + let orig_ts = original.get_series(sym).unwrap(); + let load_ts = loaded.get_series(sym).unwrap(); + assert_eq!(orig_ts.values, load_ts.values, "values mismatch for {sym}"); + } +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_write_panel_csv__then_header_matches_symbols() { + // Given + let panel = sample_panel(); + let path = "test_panel_header_check.csv"; + write_panel_csv(&panel, path).unwrap(); + // When + let content = fs::read_to_string(path).unwrap(); + let header = content.lines().next().unwrap(); + // Then + assert_eq!(header, "index,AAPL,MSFT"); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_write_panel_csv__then_row_count_equals_index_len() { + // Given + let panel = sample_panel(); + let path = "test_panel_row_count.csv"; + write_panel_csv(&panel, path).unwrap(); + // When + let content = fs::read_to_string(path).unwrap(); + let data_rows = content.lines().skip(1).filter(|l| !l.is_empty()).count(); + // Then — one data row per timestamp (3 rows for a 3-point panel) + assert_eq!(data_rows, panel.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_single_symbol_panel__when_write_then_read__then_round_trip_is_correct() { + // Given + let panel = Panel::new( + vec![10, 20, 30], + vec!["SPY".into()], + vec![vec![430.0, 432.5, 428.0]], + ) + .unwrap(); + let path = "test_panel_single_sym_rt.csv"; + write_panel_csv(&panel, path).unwrap(); + // When + let loaded = read_panel_csv(path).unwrap(); + // Then + assert_eq!(loaded.shape(), (3, 1)); + assert_eq!(loaded.symbols(), &["SPY"]); + assert_eq!( + loaded.get_series("SPY").unwrap().values, + vec![430.0, 432.5, 428.0] + ); +} From 242e8ab4505ba1738e7343d63624770b9f940e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 17:45:10 +0200 Subject: [PATCH 07/11] add NaN handling utilities: forward_fill, backward_fill, fill_with Adds three missing-value imputation methods to TimeSeries under a new NAN HANDLING section in crates/series/time_series.rs: - forward_fill: carries the last valid value forward (LOCF); leading NaNs before the first valid observation are preserved - backward_fill: carries the next valid value backward (NOCB); trailing NaNs after the last valid observation are preserved - fill_with(fill_value): replaces every NaN with a fixed scalar; passing f64::NAN is a valid no-op All three return Result and preserve the original index unchanged, consistent with the rest of the series API. Includes 18 integration tests across test_forward_fill, test_backward_fill, and test_fill_with (6 each, given/when/then convention), covering interior gaps, leading/trailing NaN edge cases, all-NaN series, no-NaN identity, and output length + index preservation. Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 2 +- crates/series/time_series.rs | 151 ++++++++++++++++++ tests/series/time_series/mod.rs | 3 + .../series/time_series/test_backward_fill.rs | 92 +++++++++++ tests/series/time_series/test_fill_with.rs | 93 +++++++++++ tests/series/time_series/test_forward_fill.rs | 92 +++++++++++ 6 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 tests/series/time_series/test_backward_fill.rs create mode 100644 tests/series/time_series/test_fill_with.rs create mode 100644 tests/series/time_series/test_forward_fill.rs diff --git a/ROADMAP.md b/ROADMAP.md index 20d6c3f..4adb964 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,7 @@ * [x] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. * [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. * [x] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. -* [ ] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries` and `TemporalSeries`; propagate to `Panel`. +* [x] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries`. * [ ] Serde support — optional `serde` feature flag; derive `Serialize`/`Deserialize` for `TimeSeries` and `Panel`. ## v0.2+ diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index 8af86ad..f4af81f 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -1103,6 +1103,157 @@ impl TimeSeries { Self::new(self.index.clone(), values) } + // NAN HANDLING ----------------------------------------------------------- + + /// Returns a new series with `NaN` values replaced by the last observed + /// non-`NaN` value (last-observation-carried-forward). + /// + /// Gaps are filled by propagating the preceding valid value to the right. + /// Leading `NaN` values — those that appear before any valid observation — + /// are left as `NaN` because there is no prior value to carry forward. + /// + /// # Examples + /// + /// Interior and trailing gaps are filled: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![1.0, f64::NAN, f64::NAN, 4.0, f64::NAN], + /// ).unwrap(); + /// + /// let filled = ts.forward_fill().unwrap(); + /// + /// assert_eq!(filled.values, vec![1.0, 1.0, 1.0, 4.0, 4.0]); + /// ``` + /// + /// Leading `NaN` values have no prior observation and are preserved: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3], + /// vec![f64::NAN, f64::NAN, 3.0], + /// ).unwrap(); + /// + /// let filled = ts.forward_fill().unwrap(); + /// + /// assert!(filled.values[0].is_nan()); + /// assert!(filled.values[1].is_nan()); + /// assert_eq!(filled.values[2], 3.0); + /// ``` + pub fn forward_fill(&self) -> Result { + let mut result = self.values.clone(); + let mut last = f64::NAN; + for v in result.iter_mut() { + if !v.is_nan() { + last = *v; + } else if !last.is_nan() { + *v = last; + } + } + Self::new(self.index.clone(), result) + } + + /// Returns a new series with `NaN` values replaced by the next observed + /// non-`NaN` value (next-observation-carried-backward). + /// + /// Gaps are filled by propagating the following valid value to the left. + /// Trailing `NaN` values — those that appear after the last valid + /// observation — are left as `NaN` because there is no future value to + /// carry backward. + /// + /// # Examples + /// + /// Interior and leading gaps are filled: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4, 5], + /// vec![f64::NAN, f64::NAN, 3.0, f64::NAN, 5.0], + /// ).unwrap(); + /// + /// let filled = ts.backward_fill().unwrap(); + /// + /// assert_eq!(filled.values, vec![3.0, 3.0, 3.0, 5.0, 5.0]); + /// ``` + /// + /// Trailing `NaN` values have no following observation and are preserved: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3], + /// vec![1.0, f64::NAN, f64::NAN], + /// ).unwrap(); + /// + /// let filled = ts.backward_fill().unwrap(); + /// + /// assert_eq!(filled.values[0], 1.0); + /// assert!(filled.values[1].is_nan()); + /// assert!(filled.values[2].is_nan()); + /// ``` + pub fn backward_fill(&self) -> Result { + let mut result = self.values.clone(); + let mut next = f64::NAN; + for v in result.iter_mut().rev() { + if !v.is_nan() { + next = *v; + } else if !next.is_nan() { + *v = next; + } + } + Self::new(self.index.clone(), result) + } + + /// Returns a new series with every `NaN` replaced by a fixed scalar. + /// + /// Non-`NaN` values are passed through unchanged. Passing `f64::NAN` as + /// `fill_value` is a valid no-op — all `NaN` positions remain `NaN`. + /// + /// # Examples + /// + /// Replace missing values with zero: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new( + /// vec![1, 2, 3, 4], + /// vec![1.0, f64::NAN, 3.0, f64::NAN], + /// ).unwrap(); + /// + /// let filled = ts.fill_with(0.0).unwrap(); + /// + /// assert_eq!(filled.values, vec![1.0, 0.0, 3.0, 0.0]); + /// ``` + /// + /// A series with no `NaN` values is returned unchanged: + /// + /// ```rust + /// use temporalseries::series::TimeSeries; + /// + /// let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + /// + /// let filled = ts.fill_with(-1.0).unwrap(); + /// + /// assert_eq!(filled.values, vec![1.0, 2.0, 3.0]); + /// ``` + pub fn fill_with(&self, fill_value: f64) -> Result { + let result: Vec = self + .values + .iter() + .map(|&v| if v.is_nan() { fill_value } else { v }) + .collect(); + Self::new(self.index.clone(), result) + } + /// Creates a [`RollingSeries`] view over this series with the given window size. /// /// The returned value is a lazy handle — no computation happens until you call diff --git a/tests/series/time_series/mod.rs b/tests/series/time_series/mod.rs index e5b180d..5ec721d 100644 --- a/tests/series/time_series/mod.rs +++ b/tests/series/time_series/mod.rs @@ -1,5 +1,8 @@ mod test_autocorrelation_function; +mod test_backward_fill; mod test_average_true_range; +mod test_fill_with; +mod test_forward_fill; mod test_borillenger_bads; mod test_crossover_signal; mod test_cumulative_return; diff --git a/tests/series/time_series/test_backward_fill.rs b/tests/series/time_series/test_backward_fill.rs new file mode 100644 index 0000000..8968883 --- /dev/null +++ b/tests/series/time_series/test_backward_fill.rs @@ -0,0 +1,92 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_interior_nans__when_backward_fill__then_propagates_next_value() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3, 4], + vec![1.0, f64::NAN, f64::NAN, 4.0], + ) + .unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 4.0, 4.0, 4.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_leading_nans__when_backward_fill__then_propagates_to_start() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3, 4], + vec![f64::NAN, f64::NAN, 3.0, 4.0], + ) + .unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![3.0, 3.0, 3.0, 4.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_trailing_nans__when_backward_fill__then_trailing_nans_are_preserved() { + // Given — no following value exists after the last non-NaN + let ts = TimeSeries::new( + vec![1, 2, 3, 4], + vec![1.0, 2.0, f64::NAN, f64::NAN], + ) + .unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values[0], 1.0); + assert_eq!(result.values[1], 2.0); + assert!(result.values[2].is_nan()); + assert!(result.values[3].is_nan()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_no_nans__when_backward_fill__then_values_are_unchanged() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![10.0, 20.0, 30.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_all_nans__when_backward_fill__then_all_remain_nan() { + // Given — no valid value to propagate in any direction + let ts = TimeSeries::new( + vec![1, 2, 3], + vec![f64::NAN, f64::NAN, f64::NAN], + ) + .unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert!(result.values.iter().all(|v| v.is_nan())); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_backward_fill__then_output_length_and_index_preserved() { + // Given + let index = vec![10_i64, 20, 30, 40]; + let ts = TimeSeries::new( + index.clone(), + vec![f64::NAN, 2.0, f64::NAN, 4.0], + ) + .unwrap(); + // When + let result = ts.backward_fill().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); + assert_eq!(result.index, index); +} diff --git a/tests/series/time_series/test_fill_with.rs b/tests/series/time_series/test_fill_with.rs new file mode 100644 index 0000000..943f02f --- /dev/null +++ b/tests/series/time_series/test_fill_with.rs @@ -0,0 +1,93 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_nans__when_fill_with_zero__then_nans_become_zero() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3, 4], + vec![1.0, f64::NAN, 3.0, f64::NAN], + ) + .unwrap(); + // When + let result = ts.fill_with(0.0).unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 0.0, 3.0, 0.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_nans__when_fill_with_custom_value__then_nans_become_that_value() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3], + vec![f64::NAN, 2.0, f64::NAN], + ) + .unwrap(); + // When + let result = ts.fill_with(-99.0).unwrap(); + // Then + assert_eq!(result.values[0], -99.0); + assert_eq!(result.values[1], 2.0); + assert_eq!(result.values[2], -99.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_no_nans__when_fill_with__then_values_are_unchanged() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, 2.0, 3.0]).unwrap(); + // When + let result = ts.fill_with(0.0).unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 2.0, 3.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_all_nans__when_fill_with__then_all_become_fill_value() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3], + vec![f64::NAN, f64::NAN, f64::NAN], + ) + .unwrap(); + // When + let result = ts.fill_with(5.0).unwrap(); + // Then + assert_eq!(result.values, vec![5.0, 5.0, 5.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_nans__when_fill_with_nan__then_nans_are_unchanged() { + // Given — filling with NaN is a valid no-op + let ts = TimeSeries::new( + vec![1, 2, 3], + vec![1.0, f64::NAN, 3.0], + ) + .unwrap(); + // When + let result = ts.fill_with(f64::NAN).unwrap(); + // Then + assert_eq!(result.values[0], 1.0); + assert!(result.values[1].is_nan()); + assert_eq!(result.values[2], 3.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_fill_with__then_output_length_and_index_preserved() { + // Given + let index = vec![10_i64, 20, 30]; + let ts = TimeSeries::new( + index.clone(), + vec![f64::NAN, 2.0, f64::NAN], + ) + .unwrap(); + // When + let result = ts.fill_with(0.0).unwrap(); + // Then + assert_eq!(result.len(), ts.len()); + assert_eq!(result.index, index); +} diff --git a/tests/series/time_series/test_forward_fill.rs b/tests/series/time_series/test_forward_fill.rs new file mode 100644 index 0000000..7b2ff89 --- /dev/null +++ b/tests/series/time_series/test_forward_fill.rs @@ -0,0 +1,92 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_interior_nans__when_forward_fill__then_propagates_last_value() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3, 4], + vec![1.0, f64::NAN, f64::NAN, 4.0], + ) + .unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 1.0, 1.0, 4.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_trailing_nans__when_forward_fill__then_propagates_to_end() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3, 4], + vec![1.0, 2.0, f64::NAN, f64::NAN], + ) + .unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![1.0, 2.0, 2.0, 2.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_leading_nans__when_forward_fill__then_leading_nans_are_preserved() { + // Given — no prior value exists before the first non-NaN + let ts = TimeSeries::new( + vec![1, 2, 3, 4], + vec![f64::NAN, f64::NAN, 3.0, 4.0], + ) + .unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert!(result.values[0].is_nan()); + assert!(result.values[1].is_nan()); + assert_eq!(result.values[2], 3.0); + assert_eq!(result.values[3], 4.0); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_no_nans__when_forward_fill__then_values_are_unchanged() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.values, vec![10.0, 20.0, 30.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_series_with_all_nans__when_forward_fill__then_all_remain_nan() { + // Given — no valid value to propagate + let ts = TimeSeries::new( + vec![1, 2, 3], + vec![f64::NAN, f64::NAN, f64::NAN], + ) + .unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert!(result.values.iter().all(|v| v.is_nan())); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_series__when_forward_fill__then_output_length_and_index_preserved() { + // Given + let index = vec![10_i64, 20, 30, 40]; + let ts = TimeSeries::new( + index.clone(), + vec![1.0, f64::NAN, 3.0, f64::NAN], + ) + .unwrap(); + // When + let result = ts.forward_fill().unwrap(); + // Then + assert_eq!(result.len(), ts.len()); + assert_eq!(result.index, index); +} From 9c784c96ed011c9cfdf97503268605807d9ebe19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 3 Jul 2026 18:03:02 +0200 Subject: [PATCH 08/11] add optional serde support for TimeSeries and Panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `serde` feature flag that derives Serialize and Deserialize for both TimeSeries and Panel: cargo add temporalseries --features serde The derives use the full-path form `serde::Serialize` / `serde::Deserialize` so no unconditional serde import is needed in library code. Panel's private fields are accessible to the derive macro because it generates code in the same module scope. JSON layout produced by serde_json: TimeSeries → {"index":[...],"values":[...]} Panel → {"index":[...],"symbols":[...],"values":[[...],...]} NaN behaviour: serde_json maps NaN to null (lossy); formats that preserve IEEE 754 special values (MessagePack, bincode) round-trip NaN cleanly. Panel deserialization bypasses Panel::new — invariants hold for any Panel serialized from a valid instance; untrusted JSON is not validated. Also adds: - serde_json dev-dependency for tests and the example - 12 integration tests (6 per type, given/when/then) under tests/serde_support/, gated via #[cfg(feature = "serde")] in tests/integration.rs: field names in JSON, index / values / symbols / shape round-trip, raw JSON deserialization, NaN → null behaviour - examples/serde_support.rs (required-features = ["serde"]) with full round-trip demo, hand-crafted JSON parsing, and NaN/null explanation Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 2 + Cargo.toml | 8 ++ ROADMAP.md | 2 +- crates/panel/core.rs | 20 +++ crates/series/time_series.rs | 17 +++ examples/serde_support.rs | 117 ++++++++++++++++++ tests/integration.rs | 2 + tests/serde_support/mod.rs | 2 + tests/serde_support/test_panel_serde.rs | 102 +++++++++++++++ tests/serde_support/test_time_series_serde.rs | 76 ++++++++++++ 10 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 examples/serde_support.rs create mode 100644 tests/serde_support/mod.rs create mode 100644 tests/serde_support/test_panel_serde.rs create mode 100644 tests/serde_support/test_time_series_serde.rs diff --git a/Cargo.lock b/Cargo.lock index bb1b94a..97c29ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,6 +547,8 @@ version = "0.1.3" dependencies = [ "chrono", "criterion", + "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a0a3d2f..aa453ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,15 +31,23 @@ harness = false [dependencies] chrono = { version = "0.4", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } [features] chrono = ["dep:chrono"] +serde = ["dep:serde"] [[example]] name = "temporal_series_with_chrono" path = "examples/temporal_series_with_chrono.rs" required-features = ["chrono"] +[[example]] +name = "serde_support" +path = "examples/serde_support.rs" +required-features = ["serde"] + [dev-dependencies] criterion = { version = "0.8.2", features = ["html_reports"] } chrono = { version = "0.4" } +serde_json = { version = "1.0" } diff --git a/ROADMAP.md b/ROADMAP.md index 4adb964..47a3bb5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ * [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. * [x] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. * [x] NaN handling utilities — `forward_fill`, `backward_fill`, `fill_with(value)` on `TimeSeries`. -* [ ] Serde support — optional `serde` feature flag; derive `Serialize`/`Deserialize` for `TimeSeries` and `Panel`. +* [x] Serde support — optional `serde` feature flag; derive `Serialize`/`Deserialize` for `TimeSeries` and `Panel`. ## v0.2+ diff --git a/crates/panel/core.rs b/crates/panel/core.rs index f69fd34..e6ad4a3 100644 --- a/crates/panel/core.rs +++ b/crates/panel/core.rs @@ -21,6 +21,25 @@ pub type Timestamp = i64; /// /// Both invariants are enforced at construction time by [`Panel::new`]. /// +/// # Serialization +/// +/// With the `serde` feature enabled, `Panel` derives [`serde::Serialize`] +/// and [`serde::Deserialize`]. The JSON representation exposes three fields: +/// +/// ```json +/// { +/// "index": [1, 2, 3], +/// "symbols": ["AAPL", "MSFT"], +/// "values": [[150.0, 152.0, 149.0], [300.0, 305.0, 298.0]] +/// } +/// ``` +/// +/// **Invariants on deserialization:** the derived `Deserialize` reconstructs +/// the struct directly without calling [`Panel::new`], so the length invariants +/// are not re-checked. A `Panel` deserialized from a valid serialized `Panel` +/// is always valid; deserializing hand-crafted or untrusted JSON with +/// mismatched lengths will produce an inconsistent `Panel`. +/// /// # Analytical methods /// /// Every analytical method available on [`TimeSeries`] is also available on @@ -48,6 +67,7 @@ pub type Timestamp = i64; /// /// assert_eq!(panel.shape(), (3, 2)); /// ``` +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Panel { index: Vec, symbols: Vec, diff --git a/crates/series/time_series.rs b/crates/series/time_series.rs index f4af81f..56d76fe 100644 --- a/crates/series/time_series.rs +++ b/crates/series/time_series.rs @@ -15,6 +15,22 @@ use crate::rolling::RollingSeries; /// sentinel for missing or undefined observations (e.g. the first element /// after a [`TimeSeries::diff`] or [`TimeSeries::shift`]). /// +/// # Serialization +/// +/// With the `serde` feature enabled, `TimeSeries` derives [`serde::Serialize`] +/// and [`serde::Deserialize`]. The JSON representation uses two fields, +/// `"index"` and `"values"`: +/// +/// ```json +/// {"index":[1,2,3],"values":[100.0,110.0,121.0]} +/// ``` +/// +/// **Note on `NaN`:** JSON has no native representation for `NaN`. When +/// serializing with `serde_json`, `NaN` values are mapped to `null`, which +/// means the round-trip is lossy — `null` cannot be deserialized back as an +/// `f64`. Formats that preserve IEEE 754 special values (e.g. MessagePack, +/// bincode) do not have this limitation. +/// /// # Example /// /// ```rust @@ -28,6 +44,7 @@ use crate::rolling::RollingSeries; /// assert_eq!(ts.len(), 3); /// ``` #[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TimeSeries { pub index: Vec, pub values: Vec, diff --git a/examples/serde_support.rs b/examples/serde_support.rs new file mode 100644 index 0000000..29405d6 --- /dev/null +++ b/examples/serde_support.rs @@ -0,0 +1,117 @@ +//! Demonstrates JSON serialization and deserialization of [`TimeSeries`] and [`Panel`]. +//! +//! The `serde` feature flag adds [`serde::Serialize`] and [`serde::Deserialize`] +//! to both types via `#[derive]`, enabling integration with any serde-compatible +//! format (JSON, MessagePack, CBOR, TOML, …). +//! +//! This example uses [`serde_json`] to show the full round-trip: +//! +//! 1. Build a [`TimeSeries`] and a [`Panel`] in memory. +//! 2. Serialize them to pretty-printed JSON strings. +//! 3. Deserialize them back and verify that index, symbols, and values are all +//! preserved. +//! 4. Demonstrate deserialization from hand-crafted JSON. +//! +//! ## JSON layout +//! +//! ```json +//! // TimeSeries +//! {"index":[1,2,3],"values":[100.0,110.0,121.0]} +//! +//! // Panel +//! {"index":[1,2,3],"symbols":["AAPL","MSFT"],"values":[[150.0,152.0,149.0],[300.0,305.0,298.0]]} +//! ``` +//! +//! ## Note on `NaN` +//! +//! JSON has no native representation for `NaN`. When serializing with +//! `serde_json`, `NaN` values are mapped to `null`, making the round-trip +//! lossy — `null` cannot be deserialized back as `f64`. Use a format that +//! preserves IEEE 754 special values — such as MessagePack or bincode — when +//! the series may contain missing-value sentinels. +//! +//! # Run +//! +//! ```bash +//! cargo run --example serde_support --features serde +//! ``` + +use temporalseries::panel::Panel; +use temporalseries::series::TimeSeries; + +fn main() { + // ----------------------------------------------------------------------- + // TimeSeries round-trip + // ----------------------------------------------------------------------- + + let ts = TimeSeries::new( + vec![1, 2, 3, 4, 5], + vec![100.0, 102.5, 99.0, 104.0, 107.5], + ) + .unwrap(); + + let ts_json = serde_json::to_string_pretty(&ts).unwrap(); + println!("TimeSeries as JSON:\n{ts_json}\n"); + + let ts_back: TimeSeries = serde_json::from_str(&ts_json).unwrap(); + assert_eq!(ts_back.index, ts.index); + assert_eq!(ts_back.values, ts.values); + println!("TimeSeries round-trip OK\n"); + + // ----------------------------------------------------------------------- + // Panel round-trip + // ----------------------------------------------------------------------- + + let panel = Panel::new( + vec![1, 2, 3], + vec!["AAPL".into(), "MSFT".into()], + vec![ + vec![150.0, 152.0, 149.0], + vec![300.0, 305.0, 298.0], + ], + ) + .unwrap(); + + let panel_json = serde_json::to_string_pretty(&panel).unwrap(); + println!("Panel as JSON:\n{panel_json}\n"); + + let panel_back: Panel = serde_json::from_str(&panel_json).unwrap(); + assert_eq!(panel_back.shape(), panel.shape()); + assert_eq!(panel_back.symbols(), panel.symbols()); + assert_eq!(panel_back.index(), panel.index()); + for sym in panel.symbols() { + let orig = panel.get_series(sym).unwrap(); + let back = panel_back.get_series(sym).unwrap(); + assert_eq!(orig.values, back.values, "value mismatch for {sym}"); + } + println!("Panel round-trip OK\n"); + + // ----------------------------------------------------------------------- + // Deserialize from hand-crafted JSON + // ----------------------------------------------------------------------- + + let raw_ts = r#"{"index":[10,20,30],"values":[1.0,2.0,3.0]}"#; + let loaded_ts: TimeSeries = serde_json::from_str(raw_ts).unwrap(); + println!( + "Loaded TimeSeries from raw JSON: index={:?} values={:?}", + loaded_ts.index, loaded_ts.values + ); + + let raw_panel = r#"{"index":[1,2],"symbols":["SPY"],"values":[[430.0,432.5]]}"#; + let loaded_panel: Panel = serde_json::from_str(raw_panel).unwrap(); + println!( + "Loaded Panel from raw JSON: shape={:?} symbols={:?}", + loaded_panel.shape(), + loaded_panel.symbols() + ); + + // ----------------------------------------------------------------------- + // NaN becomes null in JSON (lossy) + // ----------------------------------------------------------------------- + + let ts_with_nan = TimeSeries::new(vec![1, 2, 3], vec![1.0, f64::NAN, 3.0]).unwrap(); + let json_with_null = serde_json::to_string(&ts_with_nan).unwrap(); + println!("\nTimeSeries with NaN serialized as JSON:"); + println!(" {json_with_null}"); + println!(" (NaN → null; round-trip is lossy — null cannot deserialize back as f64)"); +} diff --git a/tests/integration.rs b/tests/integration.rs index e07e216..cbdc43d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -3,6 +3,8 @@ mod io; mod panel; mod rolling; mod series; +#[cfg(feature = "serde")] +mod serde_support; mod storage; #[cfg(feature = "chrono")] mod time; diff --git a/tests/serde_support/mod.rs b/tests/serde_support/mod.rs new file mode 100644 index 0000000..5f73c49 --- /dev/null +++ b/tests/serde_support/mod.rs @@ -0,0 +1,2 @@ +mod test_panel_serde; +mod test_time_series_serde; diff --git a/tests/serde_support/test_panel_serde.rs b/tests/serde_support/test_panel_serde.rs new file mode 100644 index 0000000..15429dd --- /dev/null +++ b/tests/serde_support/test_panel_serde.rs @@ -0,0 +1,102 @@ +use temporalseries::panel::Panel; + +fn sample_panel() -> Panel { + Panel::new( + vec![1, 2, 3], + vec!["AAPL".into(), "MSFT".into()], + vec![vec![150.0, 152.0, 149.0], vec![300.0, 305.0, 298.0]], + ) + .unwrap() +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_serialized_to_json__then_contains_expected_fields() { + // Given + let panel = sample_panel(); + // When + let json = serde_json::to_string(&panel).unwrap(); + // Then + assert!(json.contains("\"index\"")); + assert!(json.contains("\"symbols\"")); + assert!(json.contains("\"values\"")); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_shape_is_preserved() { + // Given + let panel = sample_panel(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.shape(), panel.shape()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_symbols_are_preserved() { + // Given + let panel = Panel::new( + vec![1, 2], + vec!["X".into(), "Y".into(), "Z".into()], + vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]], + ) + .unwrap(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.symbols(), panel.symbols()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_values_are_preserved() { + // Given + let panel = sample_panel(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + for sym in panel.symbols() { + let orig = panel.get_series(sym).unwrap(); + let back = loaded.get_series(sym).unwrap(); + assert_eq!(orig.values, back.values, "value mismatch for {sym}"); + } +} + +#[test] +#[allow(non_snake_case)] +fn test__given_panel__when_round_trip_via_json__then_index_is_preserved() { + // Given + let panel = Panel::new( + vec![100, 200, 300], + vec!["A".into()], + vec![vec![1.0, 2.0, 3.0]], + ) + .unwrap(); + // When + let json = serde_json::to_string(&panel).unwrap(); + let loaded: Panel = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.index(), panel.index()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_raw_json__when_deserialize_panel__then_produces_correct_panel() { + // Given — compact JSON as a downstream consumer might produce it + let json = r#"{"index":[1,2],"symbols":["SPY"],"values":[[430.0,432.5]]}"#; + // When + let panel: Panel = serde_json::from_str(json).unwrap(); + // Then + assert_eq!(panel.shape(), (2, 1)); + assert_eq!(panel.symbols(), &["SPY"]); + assert_eq!(panel.index(), &[1, 2]); + assert_eq!( + panel.get_series("SPY").unwrap().values, + vec![430.0, 432.5] + ); +} diff --git a/tests/serde_support/test_time_series_serde.rs b/tests/serde_support/test_time_series_serde.rs new file mode 100644 index 0000000..eed573e --- /dev/null +++ b/tests/serde_support/test_time_series_serde.rs @@ -0,0 +1,76 @@ +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_serialized_to_json__then_contains_index_and_values_fields() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + // Then + assert!(json.contains("\"index\"")); + assert!(json.contains("\"values\"")); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_round_trip_via_json__then_index_is_preserved() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30], vec![1.0, 2.0, 3.0]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + let loaded: TimeSeries = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.index, ts.index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_round_trip_via_json__then_values_are_preserved() { + // Given + let ts = TimeSeries::new( + vec![1, 2, 3, 4, 5], + vec![1.5, 2.5, 3.5, 4.5, 5.5], + ) + .unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + let loaded: TimeSeries = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.values, ts.values); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_raw_json__when_deserialize_time_series__then_produces_correct_series() { + // Given — JSON as a downstream consumer might produce it + let json = r#"{"index":[1,2,3],"values":[100.0,110.0,121.0]}"#; + // When + let ts: TimeSeries = serde_json::from_str(json).unwrap(); + // Then + assert_eq!(ts.index, vec![1, 2, 3]); + assert_eq!(ts.values, vec![100.0, 110.0, 121.0]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series_with_nan__when_serialize_to_json__then_nan_becomes_null() { + // Given — JSON has no native NaN; serde_json maps NaN → null + let ts = TimeSeries::new(vec![1, 2], vec![1.0, f64::NAN]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + // Then — the NaN position is represented as null in the output + assert!(json.contains("null")); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_round_trip_via_json__then_length_is_preserved() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![10.0, 20.0, 30.0, 40.0]).unwrap(); + // When + let json = serde_json::to_string(&ts).unwrap(); + let loaded: TimeSeries = serde_json::from_str(&json).unwrap(); + // Then + assert_eq!(loaded.len(), ts.len()); +} From 73c71fc686391eec39ca9777f5290ac3c680d144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 17 Jul 2026 16:03:37 +0200 Subject: [PATCH 09/11] expand benchmark surface: Panel analytical methods and TemporalSeries generic path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit benchmark/panel.rs — adds 6 analytical benchmarks on a 1000×10 panel, one representative per method category: - mean, std_deviation (scalar, touches all 10 columns) - simple_return, pct_change (series-returning, one pass per column) - moving_average(20), rolling_standard_deviation(20) (window-based) benchmark/temporal_series.rs (new) — 8 benchmarks pairing 4 analytical methods against both ColumnarBackend and RowBackend on n=1000: - mean: raw iteration sum; isolates backend read cost - simple_return: values_vec() collection + diff computation - moving_average(20): window scan over collected values - rolling_standard_deviation(20): heaviest pass (sqrt per window) The Columnar vs Row comparison shows that both backends pay the same computation cost after values_vec() collects from the iterator; the delta isolates per-backend iteration overhead. Registers the new target as [[bench]] name = "temporal_series" in Cargo.toml. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 5 ++ ROADMAP.md | 2 +- benchmark/panel.rs | 54 ++++++++++++++- benchmark/temporal_series.rs | 130 +++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 benchmark/temporal_series.rs diff --git a/Cargo.toml b/Cargo.toml index aa453ab..022930e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,11 @@ name = "panel" path = "benchmark/panel.rs" harness = false +[[bench]] +name = "temporal_series" +path = "benchmark/temporal_series.rs" +harness = false + [dependencies] chrono = { version = "0.4", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } diff --git a/ROADMAP.md b/ROADMAP.md index 47a3bb5..c0d27f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ ## v0.1.3 -* [ ] Expand benchmark surface — add benchmarks for Panel analytical methods and the `TemporalSeries` generic path; decide coverage for storage backends. +* [x] Expand benchmark surface — add benchmarks for Panel analytical methods and the `TemporalSeries` generic path; decide coverage for storage backends. * [ ] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. * [x] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. * [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. diff --git a/benchmark/panel.rs b/benchmark/panel.rs index cc7b451..f2236f5 100644 --- a/benchmark/panel.rs +++ b/benchmark/panel.rs @@ -52,10 +52,62 @@ fn bench_panel_shape(c: &mut Criterion) { }); } +// ----------------------------------------------------------------------- +// Analytical methods — one representative per category +// ----------------------------------------------------------------------- + +fn bench_panel_mean(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::mean 1000x10", |b| { + b.iter(|| black_box(&panel).mean()) + }); +} + +fn bench_panel_std_deviation(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::std_deviation 1000x10", |b| { + b.iter(|| black_box(&panel).std_deviation()) + }); +} + +fn bench_panel_simple_return(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::simple_return 1000x10", |b| { + b.iter(|| black_box(&panel).simple_return().unwrap()) + }); +} + +fn bench_panel_pct_change(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::pct_change 1000x10", |b| { + b.iter(|| black_box(&panel).pct_change().unwrap()) + }); +} + +fn bench_panel_moving_average(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::moving_average window=20 1000x10", |b| { + b.iter(|| black_box(&panel).moving_average(20).unwrap()) + }); +} + +fn bench_panel_rolling_standard_deviation(c: &mut Criterion) { + let panel = make_panel(); + c.bench_function("Panel::rolling_standard_deviation window=20 1000x10", |b| { + b.iter(|| black_box(&panel).rolling_standard_deviation(20).unwrap()) + }); +} + criterion_group!( benches, bench_panel_new, bench_panel_get_series, - bench_panel_shape + bench_panel_shape, + bench_panel_mean, + bench_panel_std_deviation, + bench_panel_simple_return, + bench_panel_pct_change, + bench_panel_moving_average, + bench_panel_rolling_standard_deviation, ); criterion_main!(benches); diff --git a/benchmark/temporal_series.rs b/benchmark/temporal_series.rs new file mode 100644 index 0000000..428e51f --- /dev/null +++ b/benchmark/temporal_series.rs @@ -0,0 +1,130 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; +use temporalseries::series::TemporalSeries; +use temporalseries::storage::{ColumnarBackend, RowBackend, RowRecord}; + +// Concrete type aliases for brevity. +type ColSeries = TemporalSeries>; +type RowSeries = TemporalSeries>; + +const N: usize = 1000; +const WINDOW: usize = 20; + +fn make_columnar() -> ColSeries { + let index: Vec = (0..N as i64).collect(); + let values: Vec = (0..N).map(|i| 100.0 + i as f64).collect(); + TemporalSeries::new(index, ColumnarBackend::new(values)).unwrap() +} + +fn make_row() -> RowSeries { + let index: Vec = (0..N as i64).collect(); + let records: Vec> = (0..N) + .map(|i| RowRecord { + timestamp: i as i64, + value: 100.0 + i as f64, + }) + .collect(); + TemporalSeries::new(index, RowBackend::new(records)).unwrap() +} + +// ----------------------------------------------------------------------- +// mean — pure iteration sum; shows raw read cost per backend +// ----------------------------------------------------------------------- + +fn bench_columnar_mean(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function("TemporalSeries::mean 1000", |b| { + b.iter(|| black_box(&ts).mean()) + }); +} + +fn bench_row_mean(c: &mut Criterion) { + let ts = make_row(); + c.bench_function("TemporalSeries::mean 1000", |b| { + b.iter(|| black_box(&ts).mean()) + }); +} + +// ----------------------------------------------------------------------- +// simple_return — collects values_vec then produces a new ColSeries; +// shows backend-to-vec collection cost + diff computation +// ----------------------------------------------------------------------- + +fn bench_columnar_simple_return(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function("TemporalSeries::simple_return 1000", |b| { + b.iter(|| black_box(&ts).simple_return().unwrap()) + }); +} + +fn bench_row_simple_return(c: &mut Criterion) { + let ts = make_row(); + c.bench_function("TemporalSeries::simple_return 1000", |b| { + b.iter(|| black_box(&ts).simple_return().unwrap()) + }); +} + +// ----------------------------------------------------------------------- +// moving_average — window scan over values_vec; shows window-pass cost +// ----------------------------------------------------------------------- + +fn bench_columnar_moving_average(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function( + &format!("TemporalSeries::moving_average window={WINDOW} 1000"), + |b| b.iter(|| black_box(&ts).moving_average(WINDOW).unwrap()), + ); +} + +fn bench_row_moving_average(c: &mut Criterion) { + let ts = make_row(); + c.bench_function( + &format!("TemporalSeries::moving_average window={WINDOW} 1000"), + |b| b.iter(|| black_box(&ts).moving_average(WINDOW).unwrap()), + ); +} + +// ----------------------------------------------------------------------- +// rolling_standard_deviation — heaviest analytical pass (sqrt per window) +// ----------------------------------------------------------------------- + +fn bench_columnar_rolling_std(c: &mut Criterion) { + let ts = make_columnar(); + c.bench_function( + &format!("TemporalSeries::rolling_std window={WINDOW} 1000"), + |b| { + b.iter(|| { + black_box(&ts) + .rolling_standard_deviation(WINDOW) + .unwrap() + }) + }, + ); +} + +fn bench_row_rolling_std(c: &mut Criterion) { + let ts = make_row(); + c.bench_function( + &format!("TemporalSeries::rolling_std window={WINDOW} 1000"), + |b| { + b.iter(|| { + black_box(&ts) + .rolling_standard_deviation(WINDOW) + .unwrap() + }) + }, + ); +} + +criterion_group!( + benches, + bench_columnar_mean, + bench_row_mean, + bench_columnar_simple_return, + bench_row_simple_return, + bench_columnar_moving_average, + bench_row_moving_average, + bench_columnar_rolling_std, + bench_row_rolling_std, +); +criterion_main!(benches); From 0d70078a84b61e74345e8d4b7df5543c546f2087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 17 Jul 2026 16:27:43 +0200 Subject: [PATCH 10/11] add Parquet I/O behind optional parquet feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds read_parquet / write_parquet for TimeSeries, writing a two-column Parquet file (INT64 index, DOUBLE value) using the parquet 59 column API. IEEE 754 special values (NaN, ±Inf) round-trip exactly. - crates/io/parquet.rs: write_parquet + read_parquet with full doc - crates/io/mod.rs: feature-gated re-exports - tests/io/test_write_parquet.rs + test_read_parquet.rs: 12 tests following the given/when/then convention, all behind #![cfg(feature = "parquet")] - examples/parquet_io.rs: round-trip demo including IEEE 754 specials - benchmark/io.rs: write_parquet and read_parquet criterion benchmarks - Cargo.toml: parquet = { version = "59", optional = true }, feature flag parquet = ["dep:parquet"], [[bench]] and [[example]] entries - .gitignore: exclude test and bench .parquet output files - ROADMAP.md: mark Parquet I/O as complete; all v0.1.3 items now [x] Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + Cargo.lock | 470 +++++++++++++++++++++++++++++++++ Cargo.toml | 13 + ROADMAP.md | 2 +- benchmark/io.rs | 43 +++ crates/io/mod.rs | 4 + crates/io/parquet.rs | 129 +++++++++ examples/parquet_io.rs | 79 ++++++ tests/io/mod.rs | 4 + tests/io/test_read_parquet.rs | 92 +++++++ tests/io/test_write_parquet.rs | 101 +++++++ 11 files changed, 940 insertions(+), 1 deletion(-) create mode 100644 benchmark/io.rs create mode 100644 crates/io/parquet.rs create mode 100644 examples/parquet_io.rs create mode 100644 tests/io/test_read_parquet.rs create mode 100644 tests/io/test_write_parquet.rs diff --git a/.gitignore b/.gitignore index 31de1c3..13978fb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ test_output.csv test_panel_*.csv examples/panel_output.csv +test_parquet_*.parquet +bench_parquet_*.parquet +examples/parquet_output.parquet +examples/parquet_special.parquet diff --git a/Cargo.lock b/Cargo.lock index 97c29ab..0a88c21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +31,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "alloca" version = "0.4.0" @@ -41,18 +76,134 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "arrow-array" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9486151b2f0785bafc6fa04fc5c99fcb4495455662e58787ea32eaaed33c4192" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4776577a87794bfdf0b4e90e2ea12454fa7738ea2823c4be5b9d1851da7b434" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-data" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38fe43e2e8704360f1464e6e8cc4fc381ef02cc4fb0192afa8df1aaa0115c66" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dac499fcbc6ba74ee0324057821d381929a48526a3966bd9dffb44aa06d98c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-schema" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9e4969dc350d571766247143ab36a5187d095d3d3690970408bc630d47c69e5" + +[[package]] +name = "arrow-select" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402770dba90865359d98d1ef92ef16e23d75c0cca9c2c880c8a05468b7743bf9" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cast" version = "0.3.0" @@ -66,6 +217,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -140,6 +293,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -224,6 +397,26 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -248,6 +441,40 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "half" version = "2.7.1" @@ -256,9 +483,16 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -298,6 +532,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -316,18 +560,71 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -335,6 +632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -359,12 +657,56 @@ dependencies = [ "winapi", ] +[[package]] +name = "parquet" +version = "59.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5302d4da74d6596a1f11f9928767995b53bca657cbeea1e4e8c5074f8a1157dd" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64", + "brotli", + "bytes", + "chrono", + "flate2", + "half", + "hashbrown", + "lz4_flex", + "num-bigint", + "num-integer", + "num-traits", + "paste", + "seq-macro", + "simdutf8", + "snap", + "twox-hash", + "zstd", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plotters" version = "0.3.7" @@ -411,6 +753,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rayon" version = "1.12.0" @@ -460,6 +814,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -475,6 +838,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -524,12 +899,30 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "syn" version = "2.0.117" @@ -547,10 +940,20 @@ version = "0.1.3" dependencies = [ "chrono", "criterion", + "parquet", "serde", "serde_json", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -561,12 +964,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -577,6 +992,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -731,6 +1161,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.49" @@ -751,8 +1187,42 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 022930e..e81944c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,13 +34,21 @@ name = "temporal_series" path = "benchmark/temporal_series.rs" harness = false +[[bench]] +name = "io" +path = "benchmark/io.rs" +harness = false +required-features = ["parquet"] + [dependencies] chrono = { version = "0.4", optional = true } +parquet = { version = "59", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } [features] chrono = ["dep:chrono"] serde = ["dep:serde"] +parquet = ["dep:parquet"] [[example]] name = "temporal_series_with_chrono" @@ -52,6 +60,11 @@ name = "serde_support" path = "examples/serde_support.rs" required-features = ["serde"] +[[example]] +name = "parquet_io" +path = "examples/parquet_io.rs" +required-features = ["parquet"] + [dev-dependencies] criterion = { version = "0.8.2", features = ["html_reports"] } chrono = { version = "0.4" } diff --git a/ROADMAP.md b/ROADMAP.md index c0d27f7..d5c3f74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ ## v0.1.3 * [x] Expand benchmark surface — add benchmarks for Panel analytical methods and the `TemporalSeries` generic path; decide coverage for storage backends. -* [ ] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. +* [x] Parquet I/O — `read_parquet` / `write_parquet` behind an optional `parquet` feature flag, with tests, benchmarks, and an example. * [x] `RollingSeries` method expansion — add `.sum()`, `.min()`, `.max()`, `.std()` to complement the existing `.mean()`; keep the same lazy-handle design. * [x] `ChunkedBackend` — implement the stub in `crates/storage/chunked.rs`; fixed-size chunks suited for append-heavy workloads; full test coverage mirroring `ColumnarBackend` and `RowBackend`. * [x] Panel I/O — wide-format CSV reader/writer for `Panel` (one column per symbol, shared index column), so multi-asset data can be loaded without constructing each series by hand. diff --git a/benchmark/io.rs b/benchmark/io.rs new file mode 100644 index 0000000..46f56ee --- /dev/null +++ b/benchmark/io.rs @@ -0,0 +1,43 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use std::hint::black_box; +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +const N: usize = 1_000; + +fn make_ts(n: usize) -> TimeSeries { + let index: Vec = (0..n as i64).collect(); + let values: Vec = (0..n).map(|i| 100.0 + i as f64 * 0.1).collect(); + TimeSeries::new(index, values).unwrap() +} + +// ----------------------------------------------------------------------- +// write_parquet — measures file creation + column serialization cost +// ----------------------------------------------------------------------- + +fn bench_write_parquet(c: &mut Criterion) { + let ts = make_ts(N); + c.bench_function("write_parquet 1000", |b| { + b.iter(|| { + write_parquet(black_box(&ts), "bench_parquet_write.parquet").unwrap(); + }) + }); +} + +// ----------------------------------------------------------------------- +// read_parquet — measures file open + column deserialization cost +// ----------------------------------------------------------------------- + +fn bench_read_parquet(c: &mut Criterion) { + let ts = make_ts(N); + write_parquet(&ts, "bench_parquet_read.parquet").expect("bench setup failed"); + + c.bench_function("read_parquet 1000", |b| { + b.iter(|| { + read_parquet(black_box("bench_parquet_read.parquet")).unwrap(); + }) + }); +} + +criterion_group!(benches, bench_write_parquet, bench_read_parquet); +criterion_main!(benches); diff --git a/crates/io/mod.rs b/crates/io/mod.rs index ec7b34e..46e2067 100644 --- a/crates/io/mod.rs +++ b/crates/io/mod.rs @@ -1,3 +1,7 @@ pub mod csv; +#[cfg(feature = "parquet")] +pub mod parquet; pub use csv::{read_csv, read_panel_csv, write_csv, write_panel_csv}; +#[cfg(feature = "parquet")] +pub use parquet::{read_parquet, write_parquet}; diff --git a/crates/io/parquet.rs b/crates/io/parquet.rs new file mode 100644 index 0000000..910a088 --- /dev/null +++ b/crates/io/parquet.rs @@ -0,0 +1,129 @@ +use crate::errors::TemporalSeriesError; +use crate::series::TimeSeries; +use parquet::column::reader::ColumnReader; +use parquet::data_type::{DoubleType, Int64Type}; +use parquet::file::properties::WriterProperties; +use parquet::file::reader::{FileReader, SerializedFileReader}; +use parquet::file::writer::SerializedFileWriter; +use parquet::schema::parser::parse_message_type; +use std::fs::File; +use std::sync::Arc; + +const SCHEMA: &str = " +message time_series { + REQUIRED INT64 index; + REQUIRED DOUBLE value; +} +"; + +fn map_parquet_err(e: parquet::errors::ParquetError) -> TemporalSeriesError { + TemporalSeriesError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) +} + +/// Writes a [`TimeSeries`] to a Parquet file. +/// +/// The file contains a single row group with two columns: +/// - `index` (INT64) — the timestamp index +/// - `value` (DOUBLE) — the observed values +/// +/// The resulting file can be read back without data loss via [`read_parquet`]. +/// +/// # Feature +/// +/// Requires the `parquet` feature flag. +/// +/// # Errors +/// +/// - [`TemporalSeriesError::IoError`] if the file cannot be created, written, +/// or if a Parquet serialization error occurs. +pub fn write_parquet(ts: &TimeSeries, path: &str) -> Result<(), TemporalSeriesError> { + let schema = Arc::new(parse_message_type(SCHEMA).map_err(map_parquet_err)?); + let props = Arc::new(WriterProperties::builder().build()); + let file = File::create(path)?; + let mut writer = + SerializedFileWriter::new(file, schema, props).map_err(map_parquet_err)?; + + let mut rg = writer.next_row_group().map_err(map_parquet_err)?; + + // index column (INT64) + let mut col = rg.next_column().map_err(map_parquet_err)?.unwrap(); + col.typed::() + .write_batch(&ts.index, None, None) + .map_err(map_parquet_err)?; + col.close().map_err(map_parquet_err)?; + + // value column (DOUBLE) + let mut col = rg.next_column().map_err(map_parquet_err)?.unwrap(); + col.typed::() + .write_batch(&ts.values, None, None) + .map_err(map_parquet_err)?; + col.close().map_err(map_parquet_err)?; + + rg.close().map_err(map_parquet_err)?; + writer.close().map_err(map_parquet_err)?; + Ok(()) +} + +/// Reads a Parquet file into a [`TimeSeries`]. +/// +/// Expects the file to follow the schema produced by [`write_parquet`]: two +/// columns named `index` (INT64) and `value` (DOUBLE). Multiple row groups +/// are supported and merged in order. +/// +/// # Feature +/// +/// Requires the `parquet` feature flag. +/// +/// # Errors +/// +/// - [`TemporalSeriesError::IoError`] if the file cannot be opened or if a +/// Parquet deserialization error occurs. +/// - [`TemporalSeriesError::ParseError`] if a column has an unexpected +/// physical type. +/// - [`TemporalSeriesError::LengthMismatch`] if the two columns have +/// different lengths. +pub fn read_parquet(path: &str) -> Result { + let file = File::open(path)?; + let reader = SerializedFileReader::new(file).map_err(map_parquet_err)?; + + let mut index: Vec = Vec::new(); + let mut values: Vec = Vec::new(); + + for i in 0..reader.num_row_groups() { + let rg = reader.get_row_group(i).map_err(map_parquet_err)?; + let n = rg.metadata().num_rows() as usize; + + // Read index column (column 0 — INT64) + match rg.get_column_reader(0).map_err(map_parquet_err)? { + ColumnReader::Int64ColumnReader(mut typed) => { + typed + .read_records(n, None, None, &mut index) + .map_err(map_parquet_err)?; + } + _ => { + return Err(TemporalSeriesError::ParseError( + "column 0: expected INT64 (index)".into(), + )) + } + } + + // Read value column (column 1 — DOUBLE) + match rg.get_column_reader(1).map_err(map_parquet_err)? { + ColumnReader::DoubleColumnReader(mut typed) => { + typed + .read_records(n, None, None, &mut values) + .map_err(map_parquet_err)?; + } + _ => { + return Err(TemporalSeriesError::ParseError( + "column 1: expected DOUBLE (value)".into(), + )) + } + } + } + + TimeSeries::new(index, values) +} diff --git a/examples/parquet_io.rs b/examples/parquet_io.rs new file mode 100644 index 0000000..89f041b --- /dev/null +++ b/examples/parquet_io.rs @@ -0,0 +1,79 @@ +//! Demonstrates writing and reading a [`TimeSeries`] using Parquet I/O. +//! +//! The `parquet` feature flag exposes [`write_parquet`] and [`read_parquet`], +//! which store a `TimeSeries` as a two-column Parquet file: +//! +//! | column | physical type | content | +//! |---------|---------------|----------------------| +//! | `index` | INT64 | timestamp index | +//! | `value` | DOUBLE | observed float value | +//! +//! Parquet is a columnar binary format that compresses well and preserves +//! full IEEE 754 double precision — NaN and infinite values round-trip +//! exactly, unlike JSON or CSV. +//! +//! # Run +//! +//! ```bash +//! cargo run --example parquet_io --features parquet +//! ``` + +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +fn main() { + // ----------------------------------------------------------------------- + // Build a TimeSeries in memory + // ----------------------------------------------------------------------- + + let ts = TimeSeries::new( + vec![1_000, 2_000, 3_000, 4_000, 5_000], + vec![150.25, 151.80, 149.60, 153.10, 154.50], + ) + .unwrap(); + + println!("Original TimeSeries:"); + println!(" index = {:?}", ts.index); + println!(" values = {:?}", ts.values); + + // ----------------------------------------------------------------------- + // Write to Parquet + // ----------------------------------------------------------------------- + + let path = "examples/parquet_output.parquet"; + write_parquet(&ts, path).expect("failed to write parquet file"); + println!("\nWritten to {path}"); + + // ----------------------------------------------------------------------- + // Read back and verify round-trip + // ----------------------------------------------------------------------- + + let loaded = read_parquet(path).expect("failed to read parquet file"); + + println!("\nLoaded TimeSeries:"); + println!(" index = {:?}", loaded.index); + println!(" values = {:?}", loaded.values); + + assert_eq!(loaded.index, ts.index, "index mismatch after round-trip"); + assert_eq!(loaded.values, ts.values, "values mismatch after round-trip"); + println!("\nRound-trip OK — index and values match exactly."); + + // ----------------------------------------------------------------------- + // Parquet preserves IEEE 754 special values exactly + // ----------------------------------------------------------------------- + + let ts_special = TimeSeries::new( + vec![1, 2, 3], + vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY], + ) + .unwrap(); + + let special_path = "examples/parquet_special.parquet"; + write_parquet(&ts_special, special_path).expect("failed to write special parquet"); + let loaded_special = read_parquet(special_path).expect("failed to read special parquet"); + + assert!(loaded_special.values[0].is_nan(), "NaN preserved"); + assert!(loaded_special.values[1].is_infinite() && loaded_special.values[1].is_sign_positive()); + assert!(loaded_special.values[2].is_infinite() && loaded_special.values[2].is_sign_negative()); + println!("IEEE 754 special values (NaN, +Inf, -Inf) preserved exactly."); +} diff --git a/tests/io/mod.rs b/tests/io/mod.rs index acabadb..6ac7cd2 100644 --- a/tests/io/mod.rs +++ b/tests/io/mod.rs @@ -1,4 +1,8 @@ mod test_read_csv; mod test_read_panel_csv; +#[cfg(feature = "parquet")] +mod test_read_parquet; mod test_write_csv; mod test_write_panel_csv; +#[cfg(feature = "parquet")] +mod test_write_parquet; diff --git a/tests/io/test_read_parquet.rs b/tests/io/test_read_parquet.rs new file mode 100644 index 0000000..a1ab8c0 --- /dev/null +++ b/tests/io/test_read_parquet.rs @@ -0,0 +1,92 @@ +#![cfg(feature = "parquet")] + +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_non_existent_file__when_read_parquet__then_returns_io_error() { + // Given + let path = "this_file_does_not_exist_at_all.parquet"; + // When + let result = read_parquet(path); + // Then + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), TemporalSeriesError::IoError(_))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_returns_correct_index() { + // Given + let ts = TimeSeries::new(vec![100, 200, 300], vec![1.0, 2.0, 3.0]).unwrap(); + let path = "test_parquet_read_index.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, vec![100, 200, 300]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_returns_correct_values() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![11.1, 22.2, 33.3]).unwrap(); + let path = "test_parquet_read_values.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.values, vec![11.1, 22.2, 33.3]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_returns_correct_length() { + // Given + let n = 50_usize; + let index: Vec = (0..n as i64).collect(); + let values: Vec = (0..n).map(|i| i as f64 * 0.5).collect(); + let ts = TimeSeries::new(index.clone(), values.clone()).unwrap(); + let path = "test_parquet_read_length.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index.len(), n); + assert_eq!(loaded.values.len(), n); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_written_parquet_file__when_read_parquet__then_index_and_values_same_length() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30, 40], vec![0.1, 0.2, 0.3, 0.4]).unwrap(); + let path = "test_parquet_read_lengths_match.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index.len(), loaded.values.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series_with_float_precision__when_write_then_read__then_values_are_exact() { + // Given — IEEE 754 doubles round-trip exactly through Parquet DOUBLE + let ts = TimeSeries::new( + vec![1, 2, 3, 4, 5], + vec![1.0 / 3.0, f64::MAX, f64::MIN_POSITIVE, 0.0, -1.5], + ) + .unwrap(); + let path = "test_parquet_read_precision.parquet"; + write_parquet(&ts, path).unwrap(); + // When + let loaded = read_parquet(path).unwrap(); + // Then + for (orig, got) in ts.values.iter().zip(loaded.values.iter()) { + assert_eq!(orig.to_bits(), got.to_bits(), "value mismatch: {orig} vs {got}"); + } +} diff --git a/tests/io/test_write_parquet.rs b/tests/io/test_write_parquet.rs new file mode 100644 index 0000000..fc25ffb --- /dev/null +++ b/tests/io/test_write_parquet.rs @@ -0,0 +1,101 @@ +#![cfg(feature = "parquet")] + +use temporalseries::errors::TemporalSeriesError; +use temporalseries::io::{read_parquet, write_parquet}; +use temporalseries::series::TimeSeries; + +#[test] +#[allow(non_snake_case)] +fn test__given_valid_time_series__when_write_parquet__then_creates_file_successfully() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + let path = "test_parquet_write_ok.parquet"; + // When + let result: Result<(), TemporalSeriesError> = write_parquet(&ts, path); + // Then + assert!(result.is_ok()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_invalid_path__when_write_parquet__then_returns_io_error() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3], vec![10.0, 20.0, 30.0]).unwrap(); + let path = "/invalid_path_xyz/test.parquet"; + // When + let result = write_parquet(&ts, path); + // Then + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), TemporalSeriesError::IoError(_))); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_write_then_read__then_round_trip_preserves_index() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30, 40, 50], vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap(); + let path = "test_parquet_round_trip_index.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, ts.index); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_write_then_read__then_round_trip_preserves_values() { + // Given + let ts = TimeSeries::new(vec![10, 20, 30, 40, 50], vec![1.5, 2.5, 3.5, 4.5, 5.5]).unwrap(); + let path = "test_parquet_round_trip_values.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.values, ts.values); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series__when_write_then_read__then_round_trip_preserves_length() { + // Given + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5, 6, 7], vec![7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]) + .unwrap(); + let path = "test_parquet_round_trip_len.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index.len(), ts.index.len()); + assert_eq!(loaded.values.len(), ts.values.len()); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_single_element_series__when_write_then_read__then_round_trip_preserves_data() { + // Given + let ts = TimeSeries::new(vec![42], vec![99.9]).unwrap(); + let path = "test_parquet_round_trip_single.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, vec![42]); + assert_eq!(loaded.values, vec![99.9]); +} + +#[test] +#[allow(non_snake_case)] +fn test__given_time_series_with_negative_indices__when_write_then_read__then_round_trip_preserves_data() +{ + // Given + let ts = + TimeSeries::new(vec![-3, -2, -1, 0, 1], vec![10.0, 20.0, 30.0, 40.0, 50.0]).unwrap(); + let path = "test_parquet_round_trip_neg.parquet"; + // When + write_parquet(&ts, path).unwrap(); + let loaded = read_parquet(path).unwrap(); + // Then + assert_eq!(loaded.index, ts.index); + assert_eq!(loaded.values, ts.values); +} From 192f210d866e99f2d3c61aaae1f5ceca8bed276d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Gij=C3=B3n=20Agudo?= Date: Fri, 17 Jul 2026 16:31:42 +0200 Subject: [PATCH 11/11] pass linter --- benchmark/temporal_series.rs | 16 ++-------- crates/io/csv.rs | 6 +--- crates/io/parquet.rs | 7 ++--- crates/rolling/core.rs | 4 +-- examples/panel_io.rs | 10 ++----- examples/serde_support.rs | 11 ++----- tests/integration.rs | 2 +- tests/io/test_read_parquet.rs | 11 +++++-- tests/io/test_write_parquet.rs | 17 +++++++---- tests/serde_support/test_panel_serde.rs | 5 +--- tests/serde_support/test_time_series_serde.rs | 6 +--- tests/series/time_series/mod.rs | 6 ++-- .../series/time_series/test_backward_fill.rs | 30 ++++--------------- tests/series/time_series/test_fill_with.rs | 30 ++++--------------- tests/series/time_series/test_forward_fill.rs | 30 ++++--------------- 15 files changed, 53 insertions(+), 138 deletions(-) diff --git a/benchmark/temporal_series.rs b/benchmark/temporal_series.rs index 428e51f..7daea4e 100644 --- a/benchmark/temporal_series.rs +++ b/benchmark/temporal_series.rs @@ -92,13 +92,7 @@ fn bench_columnar_rolling_std(c: &mut Criterion) { let ts = make_columnar(); c.bench_function( &format!("TemporalSeries::rolling_std window={WINDOW} 1000"), - |b| { - b.iter(|| { - black_box(&ts) - .rolling_standard_deviation(WINDOW) - .unwrap() - }) - }, + |b| b.iter(|| black_box(&ts).rolling_standard_deviation(WINDOW).unwrap()), ); } @@ -106,13 +100,7 @@ fn bench_row_rolling_std(c: &mut Criterion) { let ts = make_row(); c.bench_function( &format!("TemporalSeries::rolling_std window={WINDOW} 1000"), - |b| { - b.iter(|| { - black_box(&ts) - .rolling_standard_deviation(WINDOW) - .unwrap() - }) - }, + |b| b.iter(|| black_box(&ts).rolling_standard_deviation(WINDOW).unwrap()), ); } diff --git a/crates/io/csv.rs b/crates/io/csv.rs index 6a34447..85c5c76 100644 --- a/crates/io/csv.rs +++ b/crates/io/csv.rs @@ -148,11 +148,7 @@ pub fn read_panel_csv(path: &str) -> Result { } let idx = parts[0].trim().parse::().map_err(|e| { - TemporalSeriesError::ParseError(format!( - "line {}: invalid index — {}", - line_no + 2, - e - )) + TemporalSeriesError::ParseError(format!("line {}: invalid index — {}", line_no + 2, e)) })?; index.push(idx); diff --git a/crates/io/parquet.rs b/crates/io/parquet.rs index 910a088..d7e7032 100644 --- a/crates/io/parquet.rs +++ b/crates/io/parquet.rs @@ -43,8 +43,7 @@ pub fn write_parquet(ts: &TimeSeries, path: &str) -> Result<(), TemporalSeriesEr let schema = Arc::new(parse_message_type(SCHEMA).map_err(map_parquet_err)?); let props = Arc::new(WriterProperties::builder().build()); let file = File::create(path)?; - let mut writer = - SerializedFileWriter::new(file, schema, props).map_err(map_parquet_err)?; + let mut writer = SerializedFileWriter::new(file, schema, props).map_err(map_parquet_err)?; let mut rg = writer.next_row_group().map_err(map_parquet_err)?; @@ -106,7 +105,7 @@ pub fn read_parquet(path: &str) -> Result { _ => { return Err(TemporalSeriesError::ParseError( "column 0: expected INT64 (index)".into(), - )) + )); } } @@ -120,7 +119,7 @@ pub fn read_parquet(path: &str) -> Result { _ => { return Err(TemporalSeriesError::ParseError( "column 1: expected DOUBLE (value)".into(), - )) + )); } } } diff --git a/crates/rolling/core.rs b/crates/rolling/core.rs index f631d22..ed533c7 100644 --- a/crates/rolling/core.rs +++ b/crates/rolling/core.rs @@ -254,8 +254,8 @@ impl<'a> RollingSeries<'a> { for (i, val) in result.iter_mut().enumerate().skip(self.window - 1) { let slice = &self.series.values[i + 1 - self.window..=i]; let mean = slice.iter().sum::() / self.window as f64; - let variance = slice.iter().map(|x| (x - mean).powi(2)).sum::() - / (self.window - 1) as f64; + let variance = + slice.iter().map(|x| (x - mean).powi(2)).sum::() / (self.window - 1) as f64; *val = variance.sqrt(); } diff --git a/examples/panel_io.rs b/examples/panel_io.rs index 275e78c..88ef484 100644 --- a/examples/panel_io.rs +++ b/examples/panel_io.rs @@ -87,12 +87,6 @@ fn main() { let fixture = read_panel_csv("examples/panel_input.csv").unwrap(); println!("\nLoaded fixture 'examples/panel_input.csv'"); println!(" shape: {:?}", fixture.shape()); - println!( - " AAPL: {:?}", - fixture.get_series("AAPL").unwrap().values - ); - println!( - " MSFT: {:?}", - fixture.get_series("MSFT").unwrap().values - ); + println!(" AAPL: {:?}", fixture.get_series("AAPL").unwrap().values); + println!(" MSFT: {:?}", fixture.get_series("MSFT").unwrap().values); } diff --git a/examples/serde_support.rs b/examples/serde_support.rs index 29405d6..3704ac7 100644 --- a/examples/serde_support.rs +++ b/examples/serde_support.rs @@ -44,11 +44,7 @@ fn main() { // TimeSeries round-trip // ----------------------------------------------------------------------- - let ts = TimeSeries::new( - vec![1, 2, 3, 4, 5], - vec![100.0, 102.5, 99.0, 104.0, 107.5], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![100.0, 102.5, 99.0, 104.0, 107.5]).unwrap(); let ts_json = serde_json::to_string_pretty(&ts).unwrap(); println!("TimeSeries as JSON:\n{ts_json}\n"); @@ -65,10 +61,7 @@ fn main() { let panel = Panel::new( vec![1, 2, 3], vec!["AAPL".into(), "MSFT".into()], - vec![ - vec![150.0, 152.0, 149.0], - vec![300.0, 305.0, 298.0], - ], + vec![vec![150.0, 152.0, 149.0], vec![300.0, 305.0, 298.0]], ) .unwrap(); diff --git a/tests/integration.rs b/tests/integration.rs index cbdc43d..50be330 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,9 +2,9 @@ mod errors; mod io; mod panel; mod rolling; -mod series; #[cfg(feature = "serde")] mod serde_support; +mod series; mod storage; #[cfg(feature = "chrono")] mod time; diff --git a/tests/io/test_read_parquet.rs b/tests/io/test_read_parquet.rs index a1ab8c0..43260db 100644 --- a/tests/io/test_read_parquet.rs +++ b/tests/io/test_read_parquet.rs @@ -13,7 +13,10 @@ fn test__given_non_existent_file__when_read_parquet__then_returns_io_error() { let result = read_parquet(path); // Then assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), TemporalSeriesError::IoError(_))); + assert!(matches!( + result.unwrap_err(), + TemporalSeriesError::IoError(_) + )); } #[test] @@ -87,6 +90,10 @@ fn test__given_time_series_with_float_precision__when_write_then_read__then_valu let loaded = read_parquet(path).unwrap(); // Then for (orig, got) in ts.values.iter().zip(loaded.values.iter()) { - assert_eq!(orig.to_bits(), got.to_bits(), "value mismatch: {orig} vs {got}"); + assert_eq!( + orig.to_bits(), + got.to_bits(), + "value mismatch: {orig} vs {got}" + ); } } diff --git a/tests/io/test_write_parquet.rs b/tests/io/test_write_parquet.rs index fc25ffb..c65f850 100644 --- a/tests/io/test_write_parquet.rs +++ b/tests/io/test_write_parquet.rs @@ -26,7 +26,10 @@ fn test__given_invalid_path__when_write_parquet__then_returns_io_error() { let result = write_parquet(&ts, path); // Then assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), TemporalSeriesError::IoError(_))); + assert!(matches!( + result.unwrap_err(), + TemporalSeriesError::IoError(_) + )); } #[test] @@ -59,8 +62,11 @@ fn test__given_time_series__when_write_then_read__then_round_trip_preserves_valu #[allow(non_snake_case)] fn test__given_time_series__when_write_then_read__then_round_trip_preserves_length() { // Given - let ts = TimeSeries::new(vec![1, 2, 3, 4, 5, 6, 7], vec![7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]) - .unwrap(); + let ts = TimeSeries::new( + vec![1, 2, 3, 4, 5, 6, 7], + vec![7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0], + ) + .unwrap(); let path = "test_parquet_round_trip_len.parquet"; // When write_parquet(&ts, path).unwrap(); @@ -87,10 +93,9 @@ fn test__given_single_element_series__when_write_then_read__then_round_trip_pres #[test] #[allow(non_snake_case)] fn test__given_time_series_with_negative_indices__when_write_then_read__then_round_trip_preserves_data() -{ + { // Given - let ts = - TimeSeries::new(vec![-3, -2, -1, 0, 1], vec![10.0, 20.0, 30.0, 40.0, 50.0]).unwrap(); + let ts = TimeSeries::new(vec![-3, -2, -1, 0, 1], vec![10.0, 20.0, 30.0, 40.0, 50.0]).unwrap(); let path = "test_parquet_round_trip_neg.parquet"; // When write_parquet(&ts, path).unwrap(); diff --git a/tests/serde_support/test_panel_serde.rs b/tests/serde_support/test_panel_serde.rs index 15429dd..80fcd72 100644 --- a/tests/serde_support/test_panel_serde.rs +++ b/tests/serde_support/test_panel_serde.rs @@ -95,8 +95,5 @@ fn test__given_raw_json__when_deserialize_panel__then_produces_correct_panel() { assert_eq!(panel.shape(), (2, 1)); assert_eq!(panel.symbols(), &["SPY"]); assert_eq!(panel.index(), &[1, 2]); - assert_eq!( - panel.get_series("SPY").unwrap().values, - vec![430.0, 432.5] - ); + assert_eq!(panel.get_series("SPY").unwrap().values, vec![430.0, 432.5]); } diff --git a/tests/serde_support/test_time_series_serde.rs b/tests/serde_support/test_time_series_serde.rs index eed573e..ae8bdd4 100644 --- a/tests/serde_support/test_time_series_serde.rs +++ b/tests/serde_support/test_time_series_serde.rs @@ -28,11 +28,7 @@ fn test__given_time_series__when_round_trip_via_json__then_index_is_preserved() #[allow(non_snake_case)] fn test__given_time_series__when_round_trip_via_json__then_values_are_preserved() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3, 4, 5], - vec![1.5, 2.5, 3.5, 4.5, 5.5], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4, 5], vec![1.5, 2.5, 3.5, 4.5, 5.5]).unwrap(); // When let json = serde_json::to_string(&ts).unwrap(); let loaded: TimeSeries = serde_json::from_str(&json).unwrap(); diff --git a/tests/series/time_series/mod.rs b/tests/series/time_series/mod.rs index 5ec721d..404738d 100644 --- a/tests/series/time_series/mod.rs +++ b/tests/series/time_series/mod.rs @@ -1,14 +1,14 @@ mod test_autocorrelation_function; -mod test_backward_fill; mod test_average_true_range; -mod test_fill_with; -mod test_forward_fill; +mod test_backward_fill; mod test_borillenger_bads; mod test_crossover_signal; mod test_cumulative_return; mod test_dickey_fuller_test; mod test_excess_kurtosis; mod test_exponential_moving_average; +mod test_fill_with; +mod test_forward_fill; mod test_iqr; mod test_is_empty; mod test_jacque_bera_test; diff --git a/tests/series/time_series/test_backward_fill.rs b/tests/series/time_series/test_backward_fill.rs index 8968883..3c6e448 100644 --- a/tests/series/time_series/test_backward_fill.rs +++ b/tests/series/time_series/test_backward_fill.rs @@ -4,11 +4,7 @@ use temporalseries::series::TimeSeries; #[allow(non_snake_case)] fn test__given_series_with_interior_nans__when_backward_fill__then_propagates_next_value() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3, 4], - vec![1.0, f64::NAN, f64::NAN, 4.0], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, f64::NAN, f64::NAN, 4.0]).unwrap(); // When let result = ts.backward_fill().unwrap(); // Then @@ -19,11 +15,7 @@ fn test__given_series_with_interior_nans__when_backward_fill__then_propagates_ne #[allow(non_snake_case)] fn test__given_series_with_leading_nans__when_backward_fill__then_propagates_to_start() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3, 4], - vec![f64::NAN, f64::NAN, 3.0, 4.0], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![f64::NAN, f64::NAN, 3.0, 4.0]).unwrap(); // When let result = ts.backward_fill().unwrap(); // Then @@ -34,11 +26,7 @@ fn test__given_series_with_leading_nans__when_backward_fill__then_propagates_to_ #[allow(non_snake_case)] fn test__given_series_with_trailing_nans__when_backward_fill__then_trailing_nans_are_preserved() { // Given — no following value exists after the last non-NaN - let ts = TimeSeries::new( - vec![1, 2, 3, 4], - vec![1.0, 2.0, f64::NAN, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, f64::NAN, f64::NAN]).unwrap(); // When let result = ts.backward_fill().unwrap(); // Then @@ -63,11 +51,7 @@ fn test__given_series_with_no_nans__when_backward_fill__then_values_are_unchange #[allow(non_snake_case)] fn test__given_series_with_all_nans__when_backward_fill__then_all_remain_nan() { // Given — no valid value to propagate in any direction - let ts = TimeSeries::new( - vec![1, 2, 3], - vec![f64::NAN, f64::NAN, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, f64::NAN, f64::NAN]).unwrap(); // When let result = ts.backward_fill().unwrap(); // Then @@ -79,11 +63,7 @@ fn test__given_series_with_all_nans__when_backward_fill__then_all_remain_nan() { fn test__given_valid_series__when_backward_fill__then_output_length_and_index_preserved() { // Given let index = vec![10_i64, 20, 30, 40]; - let ts = TimeSeries::new( - index.clone(), - vec![f64::NAN, 2.0, f64::NAN, 4.0], - ) - .unwrap(); + let ts = TimeSeries::new(index.clone(), vec![f64::NAN, 2.0, f64::NAN, 4.0]).unwrap(); // When let result = ts.backward_fill().unwrap(); // Then diff --git a/tests/series/time_series/test_fill_with.rs b/tests/series/time_series/test_fill_with.rs index 943f02f..b163f28 100644 --- a/tests/series/time_series/test_fill_with.rs +++ b/tests/series/time_series/test_fill_with.rs @@ -4,11 +4,7 @@ use temporalseries::series::TimeSeries; #[allow(non_snake_case)] fn test__given_series_with_nans__when_fill_with_zero__then_nans_become_zero() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3, 4], - vec![1.0, f64::NAN, 3.0, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, f64::NAN, 3.0, f64::NAN]).unwrap(); // When let result = ts.fill_with(0.0).unwrap(); // Then @@ -19,11 +15,7 @@ fn test__given_series_with_nans__when_fill_with_zero__then_nans_become_zero() { #[allow(non_snake_case)] fn test__given_series_with_nans__when_fill_with_custom_value__then_nans_become_that_value() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3], - vec![f64::NAN, 2.0, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, 2.0, f64::NAN]).unwrap(); // When let result = ts.fill_with(-99.0).unwrap(); // Then @@ -47,11 +39,7 @@ fn test__given_series_with_no_nans__when_fill_with__then_values_are_unchanged() #[allow(non_snake_case)] fn test__given_series_with_all_nans__when_fill_with__then_all_become_fill_value() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3], - vec![f64::NAN, f64::NAN, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, f64::NAN, f64::NAN]).unwrap(); // When let result = ts.fill_with(5.0).unwrap(); // Then @@ -62,11 +50,7 @@ fn test__given_series_with_all_nans__when_fill_with__then_all_become_fill_value( #[allow(non_snake_case)] fn test__given_series_with_nans__when_fill_with_nan__then_nans_are_unchanged() { // Given — filling with NaN is a valid no-op - let ts = TimeSeries::new( - vec![1, 2, 3], - vec![1.0, f64::NAN, 3.0], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3], vec![1.0, f64::NAN, 3.0]).unwrap(); // When let result = ts.fill_with(f64::NAN).unwrap(); // Then @@ -80,11 +64,7 @@ fn test__given_series_with_nans__when_fill_with_nan__then_nans_are_unchanged() { fn test__given_valid_series__when_fill_with__then_output_length_and_index_preserved() { // Given let index = vec![10_i64, 20, 30]; - let ts = TimeSeries::new( - index.clone(), - vec![f64::NAN, 2.0, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(index.clone(), vec![f64::NAN, 2.0, f64::NAN]).unwrap(); // When let result = ts.fill_with(0.0).unwrap(); // Then diff --git a/tests/series/time_series/test_forward_fill.rs b/tests/series/time_series/test_forward_fill.rs index 7b2ff89..83d31bf 100644 --- a/tests/series/time_series/test_forward_fill.rs +++ b/tests/series/time_series/test_forward_fill.rs @@ -4,11 +4,7 @@ use temporalseries::series::TimeSeries; #[allow(non_snake_case)] fn test__given_series_with_interior_nans__when_forward_fill__then_propagates_last_value() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3, 4], - vec![1.0, f64::NAN, f64::NAN, 4.0], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, f64::NAN, f64::NAN, 4.0]).unwrap(); // When let result = ts.forward_fill().unwrap(); // Then @@ -19,11 +15,7 @@ fn test__given_series_with_interior_nans__when_forward_fill__then_propagates_las #[allow(non_snake_case)] fn test__given_series_with_trailing_nans__when_forward_fill__then_propagates_to_end() { // Given - let ts = TimeSeries::new( - vec![1, 2, 3, 4], - vec![1.0, 2.0, f64::NAN, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![1.0, 2.0, f64::NAN, f64::NAN]).unwrap(); // When let result = ts.forward_fill().unwrap(); // Then @@ -34,11 +26,7 @@ fn test__given_series_with_trailing_nans__when_forward_fill__then_propagates_to_ #[allow(non_snake_case)] fn test__given_series_with_leading_nans__when_forward_fill__then_leading_nans_are_preserved() { // Given — no prior value exists before the first non-NaN - let ts = TimeSeries::new( - vec![1, 2, 3, 4], - vec![f64::NAN, f64::NAN, 3.0, 4.0], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3, 4], vec![f64::NAN, f64::NAN, 3.0, 4.0]).unwrap(); // When let result = ts.forward_fill().unwrap(); // Then @@ -63,11 +51,7 @@ fn test__given_series_with_no_nans__when_forward_fill__then_values_are_unchanged #[allow(non_snake_case)] fn test__given_series_with_all_nans__when_forward_fill__then_all_remain_nan() { // Given — no valid value to propagate - let ts = TimeSeries::new( - vec![1, 2, 3], - vec![f64::NAN, f64::NAN, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(vec![1, 2, 3], vec![f64::NAN, f64::NAN, f64::NAN]).unwrap(); // When let result = ts.forward_fill().unwrap(); // Then @@ -79,11 +63,7 @@ fn test__given_series_with_all_nans__when_forward_fill__then_all_remain_nan() { fn test__given_valid_series__when_forward_fill__then_output_length_and_index_preserved() { // Given let index = vec![10_i64, 20, 30, 40]; - let ts = TimeSeries::new( - index.clone(), - vec![1.0, f64::NAN, 3.0, f64::NAN], - ) - .unwrap(); + let ts = TimeSeries::new(index.clone(), vec![1.0, f64::NAN, 3.0, f64::NAN]).unwrap(); // When let result = ts.forward_fill().unwrap(); // Then