Skip to content

Commit 3c7486a

Browse files
committed
docs: rewrite README, add DESIGN.md, replace SPEC.md
README rewritten to lead with the problem statement and engineering depth rather than feature checklists. DESIGN.md replaces SPEC.md as a proper architecture document covering algorithms, data paths, and design decisions. Updated CONTRIBUTING.md with Rust build instructions.
1 parent 1e25700 commit 3c7486a

4 files changed

Lines changed: 474 additions & 639 deletions

File tree

CONTRIBUTING.md

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,62 @@
11
# Contributing to FlowState
22

3-
Thank you for your interest in contributing to FlowState! This document provides guidelines and information for contributors.
4-
53
## Development Setup
64

75
```bash
8-
# Clone the repository
9-
git clone https://github.com/flowstate-io/flowstate.git
10-
cd flowstate
11-
12-
# Create a virtual environment
13-
python -m venv .venv
14-
source .venv/bin/activate
6+
git clone https://github.com/RyanJHamby/flowstate.git && cd flowstate
157

16-
# Install in development mode with dev dependencies
8+
# Python
9+
python -m venv .venv && source .venv/bin/activate
1710
pip install -e ".[dev]"
11+
12+
# Rust core (requires Rust toolchain + maturin)
13+
cd flowstate-core && maturin develop --release && cd ..
1814
```
1915

2016
## Running Tests
2117

2218
```bash
23-
# Run all tests
19+
# Python (636 tests)
2420
python -m pytest tests/ -v
2521

26-
# Run with coverage
22+
# Python with coverage
2723
python -m pytest tests/ -v --cov=flowstate --cov-report=term-missing
24+
25+
# Rust (132 tests)
26+
cd flowstate-core && cargo test --no-default-features
27+
28+
# Rust benchmarks
29+
cargo bench --no-default-features
30+
31+
# Full-stack Python benchmarks
32+
python benchmarks/bench_full_suite.py
2833
```
2934

3035
## Code Quality
3136

3237
```bash
33-
# Linting
38+
# Python linting
3439
ruff check src/ tests/
3540

36-
# Formatting
41+
# Python formatting
3742
ruff format src/ tests/
3843

39-
# Type checking
40-
mypy src/flowstate/
44+
# Python type checking
45+
mypy src/flowstate/ --ignore-missing-imports
46+
47+
# Rust formatting
48+
cd flowstate-core && cargo fmt -- --check
49+
50+
# Rust linting
51+
cargo clippy --no-default-features -- -D warnings
4152
```
4253

4354
## Pull Request Process
4455

45-
1. Fork the repository and create your branch from `main`.
56+
1. Create your branch from `main`.
4657
2. Add tests for any new functionality.
4758
3. Ensure all tests pass and code quality checks are clean.
48-
4. Update documentation if you're changing public APIs.
59+
4. Update documentation if you are changing public APIs.
4960
5. Submit your pull request with a clear description of the changes.
5061

5162
## Commit Messages
@@ -58,6 +69,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/):
5869
- `test:` — adding or updating tests
5970
- `refactor:` — code change that neither fixes a bug nor adds a feature
6071
- `perf:` — performance improvement
72+
- `chore:` — build, CI, or tooling changes
6173

6274
## License
6375

DESIGN.md

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# FlowState: System Design
2+
3+
This document describes the architecture, algorithms, and design decisions behind FlowState. It is intended for engineers evaluating the system's internals.
4+
5+
---
6+
7+
## 1. Problem Statement
8+
9+
Quantitative trading systems require joining heterogeneous time-series streams into feature matrices for model training. The core operation is the **as-of join**: for each row in a primary stream (e.g., trades), find the most recent corresponding row in a secondary stream (e.g., quotes) as of that timestamp. This must be:
10+
11+
- **Point-in-time correct.** A backward join at timestamp `T` may only match rows at `T' <= T`. Violating this constraint introduces look-ahead bias, which invalidates any downstream backtest or model evaluation.
12+
- **Performant at scale.** Production datasets contain billions of rows across thousands of symbols. The join must complete in seconds, not minutes.
13+
- **Streaming-capable.** Research uses batch replay over historical data. Production uses incremental alignment on live feeds, where data arrives out-of-order and completeness is determined by watermarks.
14+
- **Zero-copy from storage to GPU.** The aligned output feeds into PyTorch or JAX training loops. Every intermediate copy between disk and GPU VRAM is wasted latency and memory bandwidth.
15+
16+
General-purpose DataFrame libraries (Polars, pandas, DuckDB) treat as-of joins as one operation among hundreds. FlowState treats it as the only operation and exploits every invariant that implies.
17+
18+
---
19+
20+
## 2. Architecture Overview
21+
22+
```
23+
┌─────────────────────────────────────────────────────────────────────────┐
24+
│ Python API Layer │
25+
│ TemporalAligner · StreamingAligner · FeatureStore · ReplayEngine │
26+
│ 7,800 lines · Full type annotations · PEP 561 typed │
27+
└───────────────────────────────┬─────────────────────────────────────────┘
28+
│ Arrow PyCapsule Interface (zero-copy)
29+
┌───────────────────────────────▼─────────────────────────────────────────┐
30+
│ Rust Core (PyO3) │
31+
│ As-of join kernels · Streaming join · Arrow IPC I/O │
32+
│ 6,400 lines · 132 tests (121 unit + 11 proptest) │
33+
└───────────────────────────────┬─────────────────────────────────────────┘
34+
35+
┌───────────────────┬───────┴───────┬───────────────────┐
36+
▼ ▼ ▼ ▼
37+
Storage Alignment Streaming Data Feeding
38+
Hive partitions Merge-scan Watermarks Pinned memory
39+
NVMe LRU cache Rayon parallel Late data policy kvikio GDS
40+
S3/GCS/Azure Multi-stream Batch coalescing CUDA streams
41+
```
42+
43+
### Data path
44+
45+
```
46+
PyArrow Table
47+
→ Arrow PyCapsule Interface (pyo3-arrow 0.17)
48+
→ arrow-rs RecordBatch (zero-copy, shared memory)
49+
→ Rust computation (Rayon parallel)
50+
→ Arrow PyCapsule Interface
51+
→ PyArrow Table
52+
```
53+
54+
No serialization. No copying. The Rust kernel operates directly on the same memory buffers that Python allocated. This is the critical design decision that makes the system practical — a serialization boundary would dominate the runtime at these data volumes.
55+
56+
---
57+
58+
## 3. As-Of Join Engine
59+
60+
### 3.1 Core algorithm: sorted linear merge-scan
61+
62+
The as-of join kernel uses an O(n+m) sorted merge-scan, identical in complexity class to Polars. For two sorted timestamp arrays `left[0..n]` and `right[0..m]`:
63+
64+
```
65+
backward_scan(left, right) → indices:
66+
j = 0
67+
for i in 0..n:
68+
while j+1 < m and right[j+1] <= left[i]:
69+
j += 1
70+
if j < m and right[j] <= left[i]:
71+
indices[i] = j
72+
else:
73+
indices[i] = NULL
74+
```
75+
76+
Three scan variants exist: `backward` (match `<=`), `forward` (match `>=`), and `nearest` (minimum absolute distance, ties broken backward). All three are implemented in `flowstate-core/src/asof/scan.rs` as branchless inner loops for pipeline-friendly execution.
77+
78+
### 3.2 Why we outperform Polars
79+
80+
FlowState is faster on grouped temporal joins because it exploits domain-specific invariants that a general-purpose engine cannot assume:
81+
82+
1. **Skip sort verification.** Market data arrives pre-sorted by timestamp from exchanges. FlowState detects sortedness with a single branchless pass and skips the O(n log n) sort that Polars must defensively perform.
83+
84+
2. **ahash grouping with borrowed keys.** Symbol grouping uses `ahash` (the same hash Polars uses internally) but borrows `&str` keys directly from Arrow string arrays — zero `String` allocation during the group-build phase. The hash map maps `&str → Vec<u32>` indices.
85+
86+
3. **Rayon parallel per-group joins.** Each symbol group is independent. After grouping, Rayon's work-stealing scheduler distributes groups across cores with no coordination. At 1,000 symbols, this parallelizes cleanly across 8–16 cores.
87+
88+
4. **Parallel chunked scan.** For ungrouped joins on large inputs (5M+ rows), the left array is split into chunks. Binary search finds the starting right-cursor position for each chunk. Chunks execute in parallel with disjoint writes to the output index array via `UnsafeCell` (sound because write ranges are provably non-overlapping).
89+
90+
5. **Tolerance early termination.** When a tolerance window is configured (common in production — e.g., "stale quotes older than 5 seconds are invalid"), the scan breaks early once the distance exceeds the tolerance, avoiding unnecessary comparisons.
91+
92+
### 3.3 Multi-stream alignment
93+
94+
Joining N secondary streams against one primary is a common operation (e.g., align trades with quotes, bars, signals, and reference data simultaneously). Polars requires N sequential `join_asof` calls. FlowState does this in one call via `align_streams`, which dispatches N independent joins to Rayon's `par_iter`. At 8 streams this is 15% faster than sequential Polars — the only library that offers this as a single operation.
95+
96+
### 3.4 Parallel column gather
97+
98+
After computing join indices, the output table must be assembled by gathering columns from the right table at the computed positions. FlowState parallelizes this via Arrow's `take()` kernel dispatched per-column through Rayon. For wide right tables (20+ columns), this provides measurable speedup over sequential gather.
99+
100+
---
101+
102+
## 4. Streaming Join Engine
103+
104+
The streaming join engine (`src/asof/streaming.rs`, 900 lines) handles incremental alignment on live data with watermark semantics. This is the capability that no general-purpose engine provides.
105+
106+
### 4.1 Design
107+
108+
```
109+
push_left(batch) ──→ ┌──────────────┐
110+
│ Left Buffer │ (sorted by timestamp, grouped by symbol)
111+
push_right(batch) ──→ │ Right Buffer │
112+
└──────┬───────┘
113+
114+
advance_watermark(ts) ──→ ▼
115+
┌──────────────┐
116+
│ Scan & Emit │ All left rows with ts < watermark are sealed
117+
└──────┬───────┘
118+
119+
emit() ◄──┘ Returns joined pa.Table or None
120+
```
121+
122+
- **Watermark semantics.** A watermark at time `W` declares: "no more data will arrive with timestamp < W." Left rows below the watermark are sealed — their join result is final and can be emitted. This decouples output latency from input completeness.
123+
- **Configurable lateness.** `lateness_ns` extends the watermark by a tolerance window, allowing late-arriving data to still participate in joins before being dropped.
124+
- **Right-side pruning.** `prune_right_before(ts)` garbage-collects right-side rows that can no longer match any future left row, bounding memory usage for long-running streams.
125+
- **Per-symbol state.** Each symbol group maintains independent left/right buffers and watermark positions. Cross-symbol isolation prevents one slow symbol from blocking emission for others.
126+
127+
### 4.2 Python wrapper
128+
129+
`StreamingAligner` (Python) wraps the Rust `StreamingJoin` kernel. If the Rust extension is not installed, it falls back to a pure-Python implementation using `bisect`-based matching on sorted per-symbol buffers. The Python fallback produces identical results at lower throughput.
130+
131+
---
132+
133+
## 5. Storage Layer
134+
135+
### 5.1 Hive partitioning
136+
137+
Data is stored in Hive-partitioned Parquet with deterministic bucket assignment:
138+
139+
```
140+
data/
141+
└── type=trade/
142+
└── date=2024-01-15/
143+
├── bucket=0000/data.parquet
144+
├── bucket=0001/data.parquet
145+
└── ...
146+
```
147+
148+
Bucket assignment uses `xxhash` on the symbol string, which provides uniform distribution and symbol affinity — the same symbol always maps to the same bucket, enabling per-bucket parallelism without cross-bucket coordination.
149+
150+
### 5.2 Three-level pruning
151+
152+
The replay engine eliminates unnecessary I/O at three levels:
153+
154+
1. **Partition pruning.** Hive partition keys (type, date) are matched against the replay filter before any file is opened.
155+
2. **Row-group pushdown.** Parquet row-group statistics (min/max timestamp) are compared against the requested time range. Row groups outside the range are skipped entirely.
156+
3. **Column projection.** Only requested columns are read from disk. For a 20-column Parquet file where the query needs 3 columns, this reduces I/O by 85%.
157+
158+
### 5.3 Caching
159+
160+
An NVMe LRU cache tier sits between cloud object storage (S3/GCS/Azure via fsspec) and the replay engine. Files are cached locally on first access. Cache eviction is LRU by total byte size, with configurable max capacity.
161+
162+
---
163+
164+
## 6. GPU Data Feeding
165+
166+
### 6.1 Pipeline
167+
168+
```
169+
Parquet on disk
170+
→ Replay engine (partition-pruned, projected)
171+
→ Temporal alignment (Rust kernel)
172+
→ Batch coalescer (target row count)
173+
→ Pinned memory pool (cudaMallocHost or page-aligned mmap)
174+
→ CUDA stream async H2D transfer
175+
→ PyTorch / JAX tensor
176+
```
177+
178+
### 6.2 Pinned memory pool
179+
180+
`PinnedBufferPool` pre-allocates CUDA pinned memory (`cudaMallocHost`) at startup. Pinned memory enables async DMA transfers — the GPU can read from host memory while the CPU prepares the next batch. If CUDA is not available, the pool falls back to page-aligned CPU memory via `mmap`, maintaining the same API.
181+
182+
### 6.3 GPUDirect Storage
183+
184+
On systems with NVMe SSDs and NVIDIA GPUDirect Storage support, `GPUDirectReader` uses kvikio `CuFile` to DMA data directly from NVMe to GPU VRAM, bypassing the CPU entirely. This eliminates two memory copies (NVMe→CPU, CPU→GPU) from the data path.
185+
186+
### 6.4 Double-buffered prefetch
187+
188+
`PrefetchPipeline` runs a background thread that fills buffer N+1 while the GPU processes buffer N. Configurable prefetch depth (default 2) keeps the GPU pipeline saturated. Backpressure prevents the prefetcher from running ahead of a slow consumer.
189+
190+
---
191+
192+
## 7. Lock-Free Infrastructure
193+
194+
### 7.1 SPSC ring buffer
195+
196+
`spsc.rs` implements a single-producer single-consumer ring buffer using `AtomicU64` with Acquire/Release ordering. The ring is cache-line padded (128 bytes) to prevent false sharing between producer and consumer threads. Throughput: 82M elements/second single-threaded, 20M elements/second cross-thread.
197+
198+
### 7.2 Streaming pipeline
199+
200+
`pipeline.rs` composes SPSC rings into a streaming pipeline:
201+
202+
```
203+
Input SPSC → StreamingJoin → BatchCoalescer → Output SPSC
204+
```
205+
206+
Each stage runs on its own thread. The `BatchCoalescer` accumulates small output batches into larger ones (configurable target row count) to amortize downstream overhead. Pipeline latency is tracked via an HDR histogram with log-linear bucketing and CAS-based concurrent recording.
207+
208+
### 7.3 Bloom filter
209+
210+
`bloom.rs` implements a lock-free Bloom filter with double-hashing and auto-tuned false positive rate. Used for fast negative lookups in partition pruning — if a symbol is definitely not in a partition's Bloom filter, the partition can be skipped without reading its Parquet metadata.
211+
212+
### 7.4 Slab buffer pool
213+
214+
`pool.rs` provides a pre-allocated slab pool with automatic return-on-drop and zero-on-return semantics. Used by the streaming pipeline to avoid allocation in the hot path.
215+
216+
---
217+
218+
## 8. Temporal Feature Store
219+
220+
The feature store provides a catalog → materialize → serve workflow for managing aligned feature sets:
221+
222+
- **Catalog** (`catalog.py`): Versioned feature definitions with dependency DAG, status lifecycle (active/deprecated/archived), tag-based filtering, and JSON persistence.
223+
- **Materializer** (`materializer.py`): Reads catalog definitions, runs `align_streams()`, writes results to Arrow IPC files. Handles dependency ordering — features are materialized in topological order.
224+
- **Server** (`server.py`): Reads materialized IPC files with column projection and symbol filtering. Provides feature metadata (schema, row count, byte size) for operational monitoring.
225+
226+
---
227+
228+
## 9. Correctness Guarantees
229+
230+
- **No look-ahead bias by default.** Backward joins are the default direction. Every matched right-side timestamp is `<=` the left-side timestamp. This is verified by dedicated integration tests.
231+
- **Property-based testing.** 11 proptest tests in Rust generate random sorted timestamp arrays and verify kernel output against a brute-force O(n*m) reference implementation. Properties tested include: backward match is rightmost, forward match is leftmost, nearest minimizes distance, tolerance is enforced, output length equals left length.
232+
- **Streaming-batch parity.** Integration tests verify that streaming alignment on complete data produces identical results to batch alignment.
233+
- **End-to-end pipeline tests.** 14 integration tests validate that data flows correctly from raw generation through partitioned storage, replay, alignment, materialization, and serving with no row loss or corruption.
234+
235+
---
236+
237+
## 10. Key Design Decisions
238+
239+
| Decision | Rationale |
240+
|---|---|
241+
| **O(n+m) merge-scan, not O(n log m) binary search** | Market data is pre-sorted. Linear scan has better cache locality and constant factors than binary search for sorted inputs. Same complexity class as Polars. |
242+
| **ahash with borrowed `&str` keys** | Zero `String` allocation during grouping. `ahash` is the fastest non-cryptographic hash for short keys. Borrowed keys avoid copying Arrow string buffers. |
243+
| **`UnsafeCell` for parallel index writes** | Chunked parallel scan writes to disjoint ranges of the output index array. `UnsafeCell` avoids the overhead of per-element atomic operations. Soundness relies on provably non-overlapping chunk boundaries. |
244+
| **`TimestampSlice` enum for zero-copy ts access** | Borrows the Arrow buffer directly when data is already sorted, avoiding a copy into a separate `Vec<i64>`. Falls back to a sorted copy only when input is unsorted. |
245+
| **Feature-gated PyO3** | `#[cfg(feature = "python")]` allows all Rust code to compile and test without Python. CI runs `cargo test --no-default-features` for pure-Rust validation. |
246+
| **Pure Python fallback** | Every Rust-accelerated operation has a Python fallback using NumPy and bisect. The Rust kernel is a transparent accelerator imported at runtime — `pip install flowstate` works without a Rust toolchain. |
247+
| **Arrow PyCapsule Interface (pyo3-arrow 0.17)** | Zero-copy data exchange between Python and Rust. No serialization, no intermediate buffers. The Rust kernel operates on the same physical memory that PyArrow allocated. |
248+
| **Watermark-based streaming emission** | Borrowed from Apache Flink's event-time processing model. Watermarks decouple output completeness from input ordering, which is essential for real-time feeds where data arrives out-of-order. |
249+
| **Deterministic xxhash bucketing** | Same symbol always maps to the same partition bucket regardless of timestamp or data type. Enables symbol-affinity sharding for distributed replay without runtime coordination. |
250+
251+
---
252+
253+
## 11. Test Matrix
254+
255+
| Layer | Count | Tool | What it covers |
256+
|---|---|---|---|
257+
| Rust unit tests | 121 | `cargo test` | Scan kernels, streaming join, SPSC, HDR, Bloom, coalescer, pool, IPC, pinned memory |
258+
| Rust property tests | 11 | proptest | Random-input correctness against brute-force reference for all three join directions |
259+
| Python unit tests | 611 | pytest | All Python modules: alignment, replay, storage, streaming, GPU, feature store, schemas, microstructure |
260+
| Integration tests | 14 | pytest | End-to-end pipeline, point-in-time correctness, streaming-batch parity, cache, partitioning, distributed replay |
261+
| Criterion benchmarks | 8 | `cargo bench` | Rust kernel throughput: sequential vs parallel, tolerance, all directions, SPSC ring |
262+
| Python benchmarks | 10 | bench_full_suite.py | Full-stack: as-of join, multi-stream, streaming, IPC, replay, alignment, feature store, cache, partitioning, prefetcher |

0 commit comments

Comments
 (0)