docs(sdk): add deep planning review for ruvector Python SDK

Seven-file design review at docs/sdk/ covering the binding strategy,
API surface, M1-M4 milestones, risks, and a one-page decision record
for shipping a Python SDK.

Recommended path: **PyO3 + maturin, single in-tree
`crates/ruvector-py/` cdylib, abi3-py39 wheel via cibuildwheel,
`pyo3-asyncio` over a singleton tokio runtime.**

Why:
- The existing `*-node` NAPI templates (e.g.
  `crates/ruvector-diskann-node/src/lib.rs`) already prove out the
  opaque-handle + `Arc<RwLock<…>>` shape PyO3 mirrors line-for-line —
  ~70% port, ~30% lifetime gymnastics.
- abi3 collapses the wheel matrix from ~25 (cpython36 × 5 platforms)
  to 5 (one wheel per platform, all py3.9+).
- Singleton tokio runtime avoids the "one runtime per call" overhead
  while remaining compatible with asyncio + uvloop.

Milestone shape (each with explicit scope + acceptance tests):

  M1 — RaBitQ-only Python wheel. Just the published
       `ruvector-rabitq` crate exposed via PyO3. Smallest possible
       useful surface. ~600 LoC, 3 weeks.
  M2 — ruLake. Async via pyo3-asyncio. Witness verify exposed.
       ~900 LoC, 4 weeks.
  M3 — Embeddings + ML helpers. Wrap consumer-facing parts of
       `ruvector-cnn` / `ruvllm`. ~700 LoC, 3 weeks.
  M4 — A2A agent client. Wrap `rvagent-a2a` so Python apps can
       dispatch tasks to A2A peers, including signed AgentCard
       discovery. ~800 LoC, 4 weeks.

Three acceptance gates that gate the whole effort:
  1. A Python user can do RAG over 1 M vectors in <5 lines.
  2. An asyncio user can stream A2A task updates without thread
     fights.
  3. `pip install ruvector` takes <10 s on a stock machine.

Top 3 risks identified:
  R1 — tokio runtime + PyO3 + asyncio/uvloop interop. Mitigation:
       single lazy runtime, `pyo3-asyncio` shim.
  R3 — wheel size. M4 budget is 22 MB; A2A deps (axum + reqwest +
       rustls) could blow it. Mitigation: feature-gate axum/reqwest
       behind `agent` extra; default install is rabitq + rulake only.
  R7 — PyPI name squat on `ruvector`. Mitigation: register placeholder
       before M1 ships.

Nuance discovered: `ruvector-rabitq` has **no** sibling `*-node` or
`*-wasm` crate — unlike most consumer crates. M1 is therefore clean
greenfield: no parity-pressure to match a flaky NAPI signature, and
it confirms rabitq alone is the right starter target rather than the
umbrella `ruvector` crate the npm package wraps.

Planning doc only; no implementation.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruvnet 2026-04-25 20:23:03 -04:00
parent 51d4fdaef5
commit f6c684aba0
9 changed files with 1303 additions and 8 deletions

View file

@ -756,10 +756,7 @@ mod tests {
// x = 3 (q = 30 with scale=0.1) → HardSwish(x) ≈ x
let x_pos3 = (lut[lut_idx(30 - zero_point)] as i32 - zero_point) as f32 * scale;
assert!(
(x_pos3 - 3.0).abs() < 0.5,
"expected ~3.0 got {x_pos3}"
); // Should be close to 3.0
assert!((x_pos3 - 3.0).abs() < 0.5, "expected ~3.0 got {x_pos3}"); // Should be close to 3.0
}
#[test]

View file

@ -730,10 +730,10 @@ fn test_activation_with_special_values() {
assert!(output[0].is_infinite() && output[0] > 0.0); // inf stays inf
assert_eq!(output[1], 0.0); // -inf becomes 0
// NaN handling depends on backend: AVX2 `_mm256_max_ps(NaN, 0)` returns
// the second operand (0.0) per Intel's unordered-comparison semantics,
// while a scalar `f32::max` propagates NaN. Both behaviors are
// legitimate ReLU implementations, so accept either.
// NaN handling depends on backend: AVX2 `_mm256_max_ps(NaN, 0)` returns
// the second operand (0.0) per Intel's unordered-comparison semantics,
// while a scalar `f32::max` propagates NaN. Both behaviors are
// legitimate ReLU implementations, so accept either.
assert!(
output[2].is_nan() || output[2] == 0.0,
"expected NaN or 0.0 for ReLU(NaN), got {}",

139
docs/sdk/01-survey.md Normal file
View file

@ -0,0 +1,139 @@
# 01 — Survey: What ruvector Ships Today
Snapshot taken 2026-04-25 against `main` at commit `2e68f0c9f`.
## Workspace shape
- `crates/` contains ~110 directories. The workspace `Cargo.toml` has 96
active `members =` entries (rest are `exclude`d for env-specific build
reasons — `ruvector-postgres` needs `pgrx`, `mcp-brain-server` is private,
the hyperbolic-hnsw pair is intentionally out of the default workspace).
- Workspace version pin is `2.2.0` for first-party `ruvector-*` crates;
`rvAgent/*` crates are independently versioned at `0.1.0`.
- The two crates that have actual `[package].description` text indicating a
consumer-facing v1 are:
- `ruvector-rabitq` — *"RaBitQ: rotation-based 1-bit quantization for
ultra-fast approximate nearest-neighbor search with theoretical error
bounds."* No NAPI/wasm sibling crate. Pure Rust, 9 source files,
~3,700 LoC, the trait surface is `AnnIndex` over four index variants
(`FlatF32Index`, `RabitqIndex`, `RabitqPlusIndex`, `RabitqAsymIndex`).
Already published on crates.io at `2.2.0` per the workspace version.
- `ruvector-rulake` — *"ruLake — vector-native federation intermediary
over heterogeneous backends (ADR-155)."* Depends on `ruvector-rabitq`.
7 source files, ~3,100 LoC. Public surface is `RuLake`,
`BackendAdapter`, `LocalBackend`, `FsBackend`, `VectorCache`,
`RuLakeBundle`. Methods on `RuLake` include `search_one`,
`search_federated`, `search_batch`, `publish_bundle`,
`refresh_from_bundle_dir`, `save_cache_to_dir`, `warm_from_dir`. All
sync (no `async`).
These are the obvious starter targets — they're recent, they're small,
they're the ones the ADR pair (ADR-154 + ADR-155) is shipping behind, and
they're the only crates whose names appear in the workspace member list
ahead of `ruvector-core`.
## Existing FFI surfaces (the templates we copy)
### NAPI-RS bindings (Node.js)
The workspace has 14 `*-node` crates wired through `napi-derive` 2.16. The
cleanest minimal template is `crates/ruvector-diskann-node/src/lib.rs`
one file, ~250 LoC, wraps `ruvector-diskann` with:
- `#[napi(object)]` config struct (`DiskAnnOptions`).
- `#[napi]` result struct (`DiskAnnSearchResult`).
- `#[napi]` opaque handle holding `Arc<RwLock<CoreIndex>>`.
- Sync methods (`insert`, `insert_batch`, `search`).
- Async methods via `tokio::task::spawn_blocking` + `.await` on the
JoinHandle (`build_async`).
This shape — opaque handle, `Arc<RwLock<inner>>`, sync + spawn_blocking
async pair — is the existing house style. PyO3 bindings should mirror
it module-for-module so reviewers can diff them against each other and so
behaviour is identical across language clients.
### wasm-bindgen modules (browser / Node)
There are ~30 `*-wasm` crates. They use `wasm-bindgen` 0.2 + `js-sys` 0.3
+ a `getrandom` shim (`features = ["wasm_js"]`) that's the workspace
default. Pattern is identical: opaque handle, sync methods only (WASM
has no real threads in stable browsers without SharedArrayBuffer
gymnastics).
WASM is *relevant* to the SDK strategy as an alternative-not-taken
(see 02-strategy), not as a code-share opportunity.
### Raw cbindgen / FFI
`crates/ruvector-router-ffi` is the only `-ffi` crate. C ABI. We do not
use it. Mentioning here because someone will ask.
## What's published
- `ruvector-rabitq` and `ruvector-rulake` — both at workspace version
`2.2.0`. These are the v1 consumer-facing crates.
- npm packages: `npm/packages/` has 57 directories. The flagship
`ruvector` npm package is at `0.2.23` and pulls in `@ruvector/core`
(0.1.25), `@ruvector/attention` (0.1.3), `@ruvector/gnn` (0.1.22),
`@ruvector/sona` (0.1.4) — i.e. the JS/TS story is **fragmented**:
one umbrella package over four core sub-packages, each backed by a
`*-node` crate. The umbrella also bundles a CLI (`bin/cli.js`),
WASM artifacts (`wasm/`), and an MCP server (`@modelcontextprotocol/sdk`
is a runtime dep).
## What the JS/TS SDK actually covers (anchor for parity)
Reading `npm/packages/ruvector/package.json` keywords + dependencies:
- HNSW search, hybrid search, RaBitQ ("turboquant" appears),
Graph RAG, FlashAttention-3, ColBERT, Mamba, hyperbolic geometry,
ONNX MiniLM (semantic embeddings), SONA / LoRA / EWC adaptive
learning, MCP server, Pi-Brain identity ("pi-key").
The Python SDK does **not** need to chase parity. The JS package is
the everything-bagel; the Python package should be narrow and
deliberate (see 02-strategy and 04-milestones).
## Examples that map to Python notebooks
`examples/` has 60+ directories. The ones that translate naturally:
- `examples/refrag-pipeline/` — RAG pipeline using `compress.rs` /
`expand.rs` / `sense.rs`. Becomes the M1 hello-world notebook
(`01_rag_in_5_lines.ipynb`).
- `examples/onnx-embeddings/` — MiniLM ONNX embedder. Backs the M3
embedding tutorial.
- `examples/a2a-swarm/` — multi-peer A2A demo. Backs the M4
agent tutorial. Lives at the workspace top level, was added with
ADR-159.
- `crates/ruvector-rulake/examples/sidecar_daemon.rs` and
`warm_restart.rs` — the "production deployment" patterns. Become
the M2 ops notebook.
The notebooks are tracked under `04-milestones.md` per milestone, not
checked in here.
## What we are deliberately ignoring
These crates exist, are interesting, and will not be in the Python SDK
roadmap:
- The 30+ `*-wasm` browser crates. Not Python's market.
- `ruvix/` (cognition kernel, bare-metal AArch64). Out of scope for
any host-language SDK.
- `mcp-*` crates. MCP is a coordination protocol; if a Python user
wants MCP they use the official MCP SDK.
- `examples/*-consciousness`, `examples/*-boundary-discovery`,
`examples/seti-*`, `examples/seizure-*`, etc. — research demos,
not API surfaces.
- `crates/ruQu*`, `crates/ruvix/*`, `crates/cognitum-*`,
`crates/prime-radiant`, `crates/thermorust`. Internal R&D.
## Net assessment
There is no existing Python work — confirmed by exhaustive search. This
is a clean room. The four crates that matter for v1 of a Python SDK are,
in order: `ruvector-rabitq`, `ruvector-rulake`, the embedder
(`ruvector-cnn` + ONNX glue), and `rvagent-a2a`. The NAPI template at
`crates/ruvector-diskann-node/src/lib.rs` is the structural exemplar to
follow for every PyO3 module we write.

169
docs/sdk/02-strategy.md Normal file
View file

@ -0,0 +1,169 @@
# 02 — Binding Strategy
## Decision
**PyO3 + maturin, single extension module, abi3-py39, with `pyo3-asyncio`
for async bridging and a hand-written `.pyi` stub.** Built and distributed
via `cibuildwheel` in CI, published to PyPI as `ruvector`. The crate lives
in-tree at `crates/ruvector-py/`.
The rest of this document defends that choice against the four alternatives
considered, and locks in the supporting decisions (asyncio, GIL, wheels,
stubs).
## The choice space
| Option | Idea | Why we are not picking it |
|---|---|---|
| **A. PyO3 + maturin** *(chosen)* | Native Rust extension exposed as a CPython C-API module via `pyo3`, built with `maturin`. | — |
| B. CFFI over a Rust `cdylib` | Hand-roll a C ABI in `ruvector-py/` (or reuse `ruvector-router-ffi`) and let Python call it via `cffi`. | Loses the rich type story PyO3 gives for free (NumPy buffers, `Vec<T>` <-> `list`, `Result<T,E>` <-> exception, `async fn` <-> awaitable). Forces us to maintain a C header. We already maintain NAPI bindings; CFFI is a strictly worse parallel surface. |
| C. ctypes over cbindgen | Same as B, but using the stdlib `ctypes` module instead of `cffi`. | Same loss; less ergonomic; no installer to declare a build dep on; users hit a `ctypes.CDLL` import error if they pip-install on a platform without a wheel. |
| D. wasmtime-py over the existing `*-wasm` crates | Reuse `ruvector-rabitq` via a new `ruvector-rabitq-wasm` crate, run the WASM in `wasmtime-py`. | Requires writing the missing `*-wasm` crate first (rabitq has none; rulake has none). Loses 520× perf vs native (no SIMD escape hatch). Tokio doesn't run inside `wasm32-wasi`. Adds a 6 MB+ wasmtime runtime to every wheel. The whole point of going native is to *match* the Rust numbers, not lose half of them at the boundary. |
| E. gRPC / OpenAPI server with thin Python client | Stand up `ruvector-server` over HTTP/gRPC, ship a Python client that hits localhost. | Two-process architecture is the wrong default for a library — the user gets to deal with port allocation, server lifecycle, and serialization cost on every call. This is the right shape for a Python *service* SDK, but a vector index isn't a service; it's a data structure. |
## Why PyO3 specifically
1. **Surface area parity with NAPI is automatic.** PyO3's `#[pyclass]`
maps onto an opaque handle the same way `#[napi]` does, and
`#[pymethods]` maps onto `#[napi]` impl blocks. Anyone who maintains
`crates/ruvector-diskann-node` can read and review the PyO3 module
in `crates/ruvector-py` line-for-line.
2. **NumPy zero-copy.** `pyo3` + `numpy` (the `rust-numpy` crate) lets
us accept `np.ndarray` and read it as `&[f32]` without a copy when
the array is contiguous and `dtype=float32`. RaBitQ search loops on
`&[f32]` already; this is a thin wrap.
3. **abi3 wheels.** PyO3 supports the stable ABI (`abi3-py39`), which
means **one wheel covers Python 3.9 / 3.10 / 3.11 / 3.12 / 3.13 /
3.14**. We do not need to ship a wheel per Python version.
This collapses the matrix from ~25 wheels (5 versions × 5 platforms)
to 5 wheels.
4. **Mature async.** `pyo3-asyncio` (or its successor `pyo3-async-runtimes`,
which we should track) lets a Rust `async fn` return a Python
`awaitable` that `asyncio.run` awaits without spawning a thread per
call. This is the only practical way to bridge tokio without
double-runtime-fights.
5. **Maturin is the de-facto Rust-Python build tool.** Used by polars,
pydantic-core, cryptography (in part), tokenizers. We are not
pioneering anything; we are taking the well-trodden path.
## Async story
**Native asyncio via `pyo3-asyncio`.** Every Rust `async fn` we expose
becomes an `async def` in Python by way of a `pyo3_asyncio::tokio::future_into_py`
wrapper. There is exactly one tokio runtime in the process: a multi-thread
runtime owned by the extension module, lazily initialized on first use,
sized to `min(8, os.cpu_count())` worker threads. We do **not** create a
runtime per call.
We do **not** use `asyncio.to_thread` or `run_in_executor` to wrap a sync
API. That works but breaks cancellation propagation and tracing context.
The main async surfaces are:
- `RuLake.search_async` (M2)
- `A2aClient.send_task` / `stream_task` (M4)
- `Embedder.embed_batch_async` (M3, optional — sync is fine for CPU work)
Sync siblings are kept for every async method (e.g. `search` and
`search_async`). Synchronous calls release the GIL via
`Python::allow_threads`; async calls return immediately and block the
tokio runtime, not the calling Python thread.
Compatibility: tested against CPython's default asyncio + uvloop. We do
not pin uvloop. We do not invent our own loop policy.
## GIL story
Every CPU-bound entry point that takes more than ~50 µs releases the
GIL via `py.allow_threads(|| { ... })` around the inner Rust call. The
list as of M3:
| Surface | Releases GIL? | Why |
|---|---|---|
| `RabitqIndex.build` | yes | dominant cost is rotation + popcount, all Rust |
| `RabitqIndex.search` | yes | scan loop, no Python interaction |
| `RabitqIndex.add` | no | one vector per call, overhead < release cost |
| `RuLake.search_*` | yes | scan + cache lookup, all Rust |
| `Embedder.embed` | yes | tensor ops |
| `A2aClient.send_task` | n/a (async) | tokio runs without holding the GIL |
This is the same calculus polars and tokenizers use. Documenting it
explicitly so the next person who adds a method knows the rule.
## Wheel distribution matrix
We ship five wheels for each release, all `abi3-py39` (works on Python
3.9+):
| Platform | Triple | Built on | Notes |
|---|---|---|---|
| Linux x86_64 | `manylinux_2_28_x86_64` | GitHub Actions ubuntu-latest | AVX2 baseline; runtime detect AVX-512 |
| Linux aarch64 | `manylinux_2_28_aarch64` | GHA ARM runners or QEMU via cibuildwheel | NEON baseline |
| macOS x86_64 | `macosx_10_15_x86_64` | GHA macos-13 | AVX2 baseline; bottlenecking on M-series users is fine, they have an arm64 wheel |
| macOS aarch64 | `macosx_11_0_arm64` | GHA macos-14 | NEON baseline |
| Windows x86_64 | `win_amd64` | GHA windows-latest | AVX2 baseline; runtime detect AVX-512 |
We **drop** musllinux, Windows arm64, and 32-bit anything. cibuildwheel
configures via `[tool.cibuildwheel]` in `pyproject.toml`. A 32-bit user
gets `pip install` falling back to sdist, which fails to build, which
is the correct outcome.
SIMD is **runtime-detected**, not compiled per-platform. ruvector-rabitq
is pure Rust without explicit AVX-512 paths today (the `kernel.rs`
`VectorKernel` trait is the extension point). We ship one binary per
platform; if/when we add an AVX-512 kernel it lives behind a runtime
CPU-feature check.
## Type stubs
**Hand-written `.pyi` stubs**, checked in at
`crates/ruvector-py/python/ruvector/__init__.pyi`. Reasons:
- `pyo3-stub-gen` is real and improving but generates noisy stubs that
need editing anyway (it overstates `Any`, doesn't infer `Optional[...]`
from `Option<T>` cleanly).
- The stub surface is small enough (≤ 4 modules × ≤ 40 methods) that
hand-writing is feasible.
- We control the user-visible API shape, e.g. we want NumPy types in
signatures (`np.ndarray[np.float32]`), not `list[float]`.
A CI job runs `mypy --strict tests/` and `pyright tests/` against an
`import ruvector` to catch stub regressions.
## Source layout
```
crates/ruvector-py/
Cargo.toml # crate-type = ["cdylib"], pyo3 + numpy + pyo3-asyncio
pyproject.toml # maturin backend; cibuildwheel config; project metadata
README.md # short — links to docs/sdk
src/
lib.rs # PyModule init, re-exports each submodule
rabitq.rs # M1
rulake.rs # M2
embed.rs # M3
a2a.rs # M4
error.rs # exception hierarchy
runtime.rs # the singleton tokio runtime
python/ruvector/
__init__.py # re-exports from the compiled module + small pure-Py helpers
__init__.pyi # hand-written stubs
py.typed # marker so mypy/pyright recognize stubs
tests/ # pytest, runs against the installed wheel
benches/ # asv (airspeed-velocity) over identical workloads to Rust criterion
```
The `python/ruvector/__init__.py` re-export pattern lets us add pure-Python
helpers (e.g. dataclasses for config) without forcing them through the
extension boundary.
## What this strategy explicitly does NOT do
- Does not wrap every workspace crate. We pick four crates over four
milestones; everything else stays Rust-only.
- Does not try to be a Pythonic vector DB framework (chromadb, weaviate,
qdrant). We are a thin, fast, typed binding to a specific Rust stack.
- Does not vendor models. The embedder downloads weights from
HuggingFace at first use, the same way `ruvector-cnn` does in Rust.
- Does not provide an asyncio-only API. Sync siblings always exist
for non-network calls.

253
docs/sdk/03-api-surface.md Normal file
View file

@ -0,0 +1,253 @@
# 03 — Python API Surface
The user-visible Python API across all four milestones. Everything in this
document is what gets typed at a REPL or in a notebook. Implementation
details (PyO3 attributes, GIL handling) are in 02-strategy.
## Top-level layout
```python
import ruvector
# Vector indexes (M1) — backed by ruvector-rabitq
ruvector.FlatF32Index
ruvector.RabitqIndex
ruvector.RabitqPlusIndex
ruvector.RabitqAsymIndex
# Cache-first execution fabric (M2) — backed by ruvector-rulake
ruvector.RuLake
ruvector.LocalBackend
ruvector.FsBackend
ruvector.Consistency # enum: FRESH | EVENTUAL | STALE
ruvector.RuLakeBundle
# Embedding (M3) — backed by ruvector-cnn + ONNX glue
ruvector.Embedder
# Agent peer protocol (M4) — backed by rvagent-a2a
ruvector.A2aClient
ruvector.AgentCard
ruvector.TaskSpec
ruvector.Task
# Cross-cutting
ruvector.RuVectorError # base exception
ruvector.__version__ # mirrors Cargo workspace version
ruvector.cpu_features() # runtime SIMD probe
```
Every public name above is exported from the compiled extension and
re-exported by `python/ruvector/__init__.py`.
## M1 — RaBitQ vector index
```python
import numpy as np
import ruvector
# Build from an (n, d) float32 array. Dtype is enforced; mismatch raises.
vectors = np.random.randn(100_000, 768).astype(np.float32)
idx = ruvector.RabitqPlusIndex.build(
vectors,
seed=42,
rerank_factor=20, # ADR-154 recommended for 100% recall@10 at D=128
)
# Search a single query — returns a list of (id, score) named tuples.
query = np.random.randn(768).astype(np.float32)
hits = idx.search(query, k=10)
for h in hits:
print(h.id, h.score)
# Pythonic conveniences
len(idx) # n vectors
idx.dim # 768
idx.memory_bytes # honest accounting (matches AnnIndex::memory_bytes)
idx.save("index.rbpx")
idx2 = ruvector.RabitqPlusIndex.load("index.rbpx")
# Add after build (mirrors AnnIndex::add — appends, must match dim).
idx.add(id=100_001, vector=np.random.randn(768).astype(np.float32))
```
`build()` is a classmethod, takes `np.ndarray` directly (no list copy),
releases the GIL, runs in parallel via rayon. Ergonomic but not magic:
non-contiguous, non-`float32` arrays raise immediately with a clear
message rather than silently copying.
The four index types share an `AnnIndex`-shaped Python protocol but we
do **not** expose a Python ABC; the four classes are concrete.
`isinstance(idx, ruvector.AnyIndex)` works via a runtime-checkable
`Protocol` in the stub.
## M2 — RuLake (cache-first vector fabric)
```python
import ruvector
import asyncio
# Builder pattern mirrors RuLake::new + with_*.
lake = (
ruvector.RuLake.builder()
.rerank_factor(20)
.rotation_seed(42)
.max_cache_entries(1_000_000)
.consistency(ruvector.Consistency.FRESH)
.build()
)
# Backends are first-class Python objects.
backend = ruvector.LocalBackend(name="hot-shard")
backend.upsert("docs", ids=[1, 2, 3], vectors=np.random.randn(3, 768).astype(np.float32))
lake.register_backend(backend)
# Sync search.
hits = lake.search_one(collection="docs", query=query, k=10)
# Async search — no thread fight; runs on the extension's tokio runtime.
async def main():
hits = await lake.search_one_async(collection="docs", query=query, k=10)
print([(h.backend, h.id, h.score) for h in hits])
asyncio.run(main())
# Federated search across all backends — fanout + merge by score.
hits = lake.search_federated(collection="docs", query=query, k=10)
# Bundle witness operations — surfaces the SHA3 witness from RuLake::publish_bundle.
witness = lake.publish_bundle("docs", out_dir="/tmp/bundle/")
result = lake.refresh_from_bundle_dir(key=("local", "docs"), dir="/tmp/bundle/")
assert result == ruvector.RefreshResult.UP_TO_DATE # or INVALIDATED, BUNDLE_MISSING
```
The `(backend_id, collection)` tuple that Rust uses as a `CacheKey` is
exposed as a Python tuple — no custom class, no surprise.
`Consistency` is `enum.Enum`-like (actually `pyo3` int enum) with values
`FRESH`, `EVENTUAL`, `STALE`. We do **not** accept string consistency
levels; the type system catches typos.
## M3 — Embeddings
```python
import ruvector
emb = ruvector.Embedder.from_pretrained("all-MiniLM-L6-v2") # downloads once, caches
vec = emb.embed("hello world") # np.ndarray, shape (384,)
batch = emb.embed_batch(["hello", "world", "foo bar"]) # shape (3, 384)
emb.dim # 384
# CNN-image embedder (ADR-013). Same shape; takes (H, W, 3) uint8.
img_emb = ruvector.Embedder.from_pretrained("mobilenetv3-small")
v = img_emb.embed_image(np.zeros((224, 224, 3), dtype=np.uint8)) # (576,)
```
One `Embedder` class, two factory paths (`from_pretrained` for text,
same name for image — distinguished by model identifier prefix). All
results are `np.ndarray[np.float32]` ready to feed into a `RabitqIndex`
or `RuLake`. This is the contract that makes "RAG in 5 lines" possible
(see acceptance gate G1 in 06-decision-record).
## M4 — A2A client
```python
import ruvector
import asyncio
# Discover a peer (verifies signature per ADR-159 r2 identity).
async def main():
client = await ruvector.A2aClient.connect("https://peer.example.com")
print(client.card.skills) # list[AgentSkill]
print(client.card.agent_id) # SHAKE-256(pubkey) per ADR-159
# Send a task.
spec = ruvector.TaskSpec(
skill="rag.query",
input="What is RaBitQ?",
policy=ruvector.TaskPolicy(
max_tokens=4_000,
max_cost_usd=0.10,
max_duration_ms=30_000,
),
)
task = await client.send_task(spec)
print(task.status, task.id)
# Stream task updates (SSE under the hood).
async for update in client.stream_task(task.id):
if update.kind == "artifact":
print("artifact:", update.artifact)
elif update.kind == "status":
print("status:", update.status)
# Cancel.
await client.cancel_task(task.id)
asyncio.run(main())
```
`stream_task` returns an `AsyncIterator[TaskUpdate]`. `TaskUpdate` is a
tagged union exposed as a discriminated dataclass-like Python type
(`kind` field).
We do **not** expose the A2A *server* in v1 — Python users embed an
rvAgent server via the Rust binary; the Python SDK is client-only. This
keeps the wheel small and avoids dragging axum + tower into Python's
process.
## Error hierarchy
A single root, with subclasses that map onto the Rust error variants:
```
ruvector.RuVectorError # root, Exception subclass
├── ruvector.IndexError # ruvector_rabitq::RabitqError
│ ├── ruvector.DimensionMismatch # vector dim != index dim
│ ├── ruvector.EmptyIndex # search on n=0
│ └── ruvector.PersistError # save/load IO + format errors
├── ruvector.LakeError # ruvector_rulake::RuLakeError
│ ├── ruvector.BackendError # adapter failure, bubbles backend id
│ ├── ruvector.CacheMissError # consistency=STRICT and miss happened
│ └── ruvector.WitnessMismatch # bundle witness != cache witness
├── ruvector.A2aError # rvagent_a2a::A2aError
│ ├── ruvector.CardSignatureInvalid # ADR-159 r2 verify-on-discover failure
│ ├── ruvector.PolicyViolation # TaskPolicy guard fired
│ ├── ruvector.BudgetExceeded # GlobalBudget gate fired
│ └── ruvector.TransportError # HTTP / SSE plumbing
└── ruvector.EmbedError # model download / inference failures
```
Names are stable across milestones. `RuVectorError` is what users put
in their `except` blocks if they don't care which subsystem failed.
## Pythonic conveniences
| Operation | Behavior |
|---|---|
| `len(idx)` | n vectors |
| `idx[id]` | returns the original f32 vector if `RabitqPlusIndex` (which keeps originals); raises `LookupError` for `RabitqIndex` (which doesn't) |
| `for v in idx` | iterates `(id, vector)` pairs, only on indexes that retain originals |
| `idx in lake` | `__contains__` checks if a `RabitqPlusIndex` is currently primed in a `RuLake` cache (used for "did my warmup work?") |
| `np.asarray(idx)` | only on indexes that retain originals; returns the (n, d) float32 matrix without a copy |
| `with lake.session() as s` | optional context manager for batched ops; commits caches on exit |
| `repr(idx)` | shows variant, n, d, memory_bytes — diagnostic-friendly |
| `idx == idx2` | structural equality if both come from same data + seed (matches the determinism guarantee in `ruvector-rabitq/src/lib.rs` §Guarantees) |
## NumPy interop is a first-class contract
- Every vector input accepts `np.ndarray[np.float32]` directly.
- `list[float]` / `tuple[float, ...]` / Python sequences are accepted
for ergonomic one-shot calls but copy through a NumPy buffer
internally (documented as slower).
- Outputs are `np.ndarray[np.float32]` for vectors and Python `int` /
`float` scalars for ids and scores.
- We do not invent a `Vector` class. NumPy is the lingua franca of
Python ML.
## Versioning
`ruvector.__version__` mirrors the Cargo workspace version; the PyPI
release is cut at the same time as the Rust 2.x.y release. We use
trailing `.postN` for Python-only fixes (e.g. stub corrections) without
a Rust source change.

359
docs/sdk/04-milestones.md Normal file
View file

@ -0,0 +1,359 @@
# 04 — Milestones
Same shape as ADR-159's milestone plan (`docs/adr/ADR-159-rvagent-a2a-protocol.md`
§ "Implementation plan"). Each milestone has explicit scope, a file list,
a LoC budget, an acceptance test set, the wheel platforms shipped, and the
docs that must land.
The crate `crates/ruvector-py/` is created in M1 and grows by one source
module per milestone.
---
## M1 — RaBitQ-only Python wheel
**Scope.**
- Create the new workspace crate `crates/ruvector-py/` with
`crate-type = ["cdylib"]`, `pyo3 = "0.22"`, `numpy = "0.22"`,
`pyo3-asyncio = "0.22"` (or successor `pyo3-async-runtimes` if pinned),
`maturin` as the build backend in `pyproject.toml`.
- Bind exactly the four index types from `crates/ruvector-rabitq/src/index.rs`:
`FlatF32Index`, `RabitqIndex`, `RabitqPlusIndex`, `RabitqAsymIndex`.
All four implement the `AnnIndex` trait there.
- Bind `BinaryCode` accessors for advanced users (`ids`, `norms`, `packed`)
even though most users will never touch them — they're cheap to expose
and the test suite uses them.
- Bind `RandomRotation` only as an opaque handle returned by
`idx.rotation()` — no public constructor in v1.
- Persistence: bind `crates/ruvector-rabitq/src/persist.rs` so
`idx.save(path)` and `Index.load(path)` work for `RabitqIndex` and
`RabitqPlusIndex` (the `.rbpx` format from `lake.rs` `PERSISTED_INDEX_FILENAME`).
- Hand-write `python/ruvector/__init__.pyi` with full stubs for the M1
surface.
- Set up `pyproject.toml` with cibuildwheel configured for the five
platform wheels listed in `02-strategy.md` § "Wheel distribution
matrix", abi3-py39.
- CI: GitHub Actions workflow `python-wheels.yml` that builds + tests
+ uploads to TestPyPI on every PR, PyPI on tag.
- Pure-Python helpers in `python/ruvector/`: `_version.py` (mirrors Cargo
version), `_typing.py` (the `AnyIndex` runtime-checkable Protocol).
**File list.**
```
crates/ruvector-py/
Cargo.toml # ~30 LoC
pyproject.toml # ~80 LoC (cibuildwheel matrix, project meta)
README.md # ~50 LoC, links docs/sdk
src/
lib.rs # ~80 LoC — PyModule init, re-exports
rabitq.rs # ~450 LoC — four index types
error.rs # ~80 LoC — exception hierarchy root + IndexError tree
numpy_util.rs # ~60 LoC — view & dtype enforcement helpers
python/ruvector/
__init__.py # ~40 LoC — re-exports
__init__.pyi # ~200 LoC — typed stubs
py.typed # 0 LoC marker
_version.py # ~5 LoC
_typing.py # ~30 LoC — AnyIndex Protocol
tests/
test_rabitq_basic.py # ~120 LoC
test_rabitq_persist.py # ~80 LoC
test_numpy_interop.py # ~80 LoC
test_errors.py # ~60 LoC
.github/workflows/
python-wheels.yml # ~120 LoC
docs/sdk/notebooks/
01_rag_in_5_lines.ipynb # uses M1 index over toy data
```
**LoC budget.** ~700 Rust + ~340 Python tests + ~200 stubs + ~120 CI YAML.
**Acceptance tests.**
1. `pip install ruvector` from TestPyPI on each of the five platforms
in `02-strategy.md`'s matrix succeeds in ≤ 10 s on a stock
GitHub-hosted runner with a warm pip cache.
2. `test_rabitq_basic.py::test_100k_search_under_10ms`: build a
`RabitqPlusIndex` over 100,000 random D=128 vectors with
`rerank_factor=20`, run 100 single-query searches, assert
p99 latency < 10 ms (mirrors `ruvector-rabitq/BENCHMARK.md` baseline
of 1.05 ms/query for `RabitqPlus rerank×20` with headroom for
Python overhead).
3. `test_numpy_interop.py::test_zero_copy_search`: build an index,
pass a contiguous `np.ndarray[np.float32]` query, assert the call
produces no copy via a memory-tracker fixture.
4. `test_rabitq_persist.py::test_roundtrip`: save → load → search,
assert bit-identical results to a search before save.
5. `test_errors.py::test_dim_mismatch`: query of wrong dim raises
`ruvector.DimensionMismatch` and the message names both expected
and got dim.
6. `mypy --strict` on `tests/` finds no errors.
7. Wheel size ≤ 8 MB on linux x86_64, ≤ 12 MB on macOS arm64.
**Wheels shipped.** All five platforms in `02-strategy.md` § "Wheel
distribution matrix". M1 ships nothing else.
**Docs.**
- `docs/sdk/notebooks/01_rag_in_5_lines.ipynb` — derived from
`examples/refrag-pipeline/`. Uses the M1 surface only (no embedder yet
— uses pre-computed vectors from a fixture).
- Sphinx rST scaffold under `docs/sdk/api/` is created but only the
RaBitQ section is filled.
- Top-level `crates/ruvector-py/README.md`.
---
## M2 — ruLake bindings
**Scope.**
- Add `crates/ruvector-py/src/rulake.rs`. Bind:
- `RuLake` with builder (`RuLake::new` + chained `with_*` mapped to a
Python builder pattern).
- `BackendAdapter` exposed as a Python ABC for users to implement;
bridges into Rust via a `PyBackendAdapter` impl that calls back into
the Python class. (This is the only place we need GIL re-acquisition
in M2.)
- `LocalBackend` and `FsBackend` as concrete classes.
- `Consistency` as an int-enum.
- `RuLakeBundle`, `RefreshResult`, `SearchResult`, `CacheStats`,
`PerBackendStats`.
- All `RuLake` methods listed in `crates/ruvector-py-survey` (i.e.
`register_backend`, `search_one`, `search_federated`,
`search_batch`, `publish_bundle`, `refresh_from_bundle_dir`,
`save_cache_to_dir`, `warm_from_dir`, `cache_stats*`,
`cache_witness_of`, `invalidate_cache`).
- Add `_async` siblings for `search_one` / `search_federated` /
`search_batch` using `pyo3_asyncio::tokio::future_into_py`. The
underlying Rust calls are sync today (per `lake.rs`); async siblings
exist so we don't have to break the surface when the Rust `async`
refactor lands.
- Initialize the singleton tokio runtime here in M2 (M1 doesn't need it).
- Extend `error.rs` with the `LakeError` subtree.
**File list.**
```
crates/ruvector-py/src/
rulake.rs # ~600 LoC
runtime.rs # ~80 LoC — singleton tokio runtime
crates/ruvector-py/python/ruvector/
__init__.pyi # +180 LoC for RuLake surface
crates/ruvector-py/tests/
test_rulake_local.py # ~150 LoC
test_rulake_fs_backend.py # ~120 LoC
test_rulake_async.py # ~100 LoC
test_rulake_witness.py # ~80 LoC
docs/sdk/notebooks/
02_warm_restart_with_witness.ipynb
```
**LoC budget.** ~680 Rust + ~450 Python tests + ~180 stub additions.
**Acceptance tests.**
1. `test_rulake_local.py::test_register_search_local`: register a
`LocalBackend` with 50,000 D=128 vectors, run `search_one`,
assert results match a direct `RabitqPlusIndex` search.
2. `test_rulake_async.py::test_search_one_async_in_event_loop`: run
100 concurrent `await lake.search_one_async(...)` calls inside a
single asyncio event loop, assert they complete in less than
10× the sync time (no thread-fight regression).
3. `test_rulake_witness.py::test_publish_refresh_roundtrip`: publish
bundle, mutate underlying data, re-publish, refresh, assert
`RefreshResult.INVALIDATED`. Mirrors `lake.rs` `refresh_from_bundle_dir`
contract.
4. `test_rulake_fs_backend.py::test_warm_restart`: prime cache, save to
disk, kill process, start a fresh `RuLake`, `warm_from_dir`, assert
first search after warmup is < 1.5× steady-state latency.
5. `test_rulake_local.py::test_python_backend_adapter`: a user-defined
Python class subclasses `ruvector.BackendAdapter`, registers, gets
called back by ruLake on cache miss. (This is the GIL re-acquisition
round-trip.)
**Wheels shipped.** Same five platforms. Wheel size budget bumps to
≤ 12 MB linux / ≤ 16 MB macOS arm64 (tokio adds ~3 MB).
**Docs.**
- `docs/sdk/notebooks/02_warm_restart_with_witness.ipynb` — derived from
`crates/ruvector-rulake/examples/warm_restart.rs`.
- Sphinx page for `ruLake` reference complete.
---
## M3 — Embeddings + ML helpers
**Scope.**
- Add `crates/ruvector-py/src/embed.rs`. Bind a single `Embedder` class
with two factory functions:
- `Embedder.from_pretrained(name)` for text. Implementation calls
into `crates/ruvector-cnn/` for image and into a new tiny
`crates/ruvector-py/src/onnx_embed.rs` helper for text (ONNX
Runtime via `ort` 2.x). Text models: `all-MiniLM-L6-v2` first;
`bge-small-en-v1.5` second.
- `Embedder.from_pretrained` with a `mobilenetv3-*` prefix routes to
`ruvector-cnn`'s `MobileNetEmbedder` (gated on the `backbone`
feature in `ruvector-cnn/Cargo.toml`).
- Model weights: download once on first use into the standard
`~/.cache/ruvector/models/` directory, verify a SHA-256 digest, cache.
No bundled weights — the wheel stays small.
- Sync `embed`, sync `embed_batch`, async `embed_batch_async`. Async
exists so a notebook user can interleave embedding with ruLake
ingestion in the same event loop.
- Extend `error.rs` with `EmbedError`.
**File list.**
```
crates/ruvector-py/src/
embed.rs # ~350 LoC
onnx_embed.rs # ~250 LoC — ort wrapper, model registry
crates/ruvector-py/python/ruvector/
__init__.pyi # +120 LoC for Embedder
_models.py # ~80 LoC — model registry, download paths
crates/ruvector-py/tests/
test_embed_text.py # ~120 LoC
test_embed_image.py # ~100 LoC
test_embed_to_index.py # ~80 LoC — end-to-end RAG
docs/sdk/notebooks/
03_text_to_search.ipynb # full RAG: text → embed → RabitqPlus → search
```
**LoC budget.** ~600 Rust + ~300 Python tests + ~200 helpers/stubs.
**Acceptance tests.**
1. `test_embed_text.py::test_minilm_dim`: embed 100 strings, assert
shape `(100, 384)` and dtype `float32`.
2. `test_embed_text.py::test_first_use_downloads`: in a fresh cache
dir, `from_pretrained("all-MiniLM-L6-v2")` downloads, verifies
SHA-256, caches; second call is no-network.
3. `test_embed_image.py::test_mobilenetv3_small_dim`: embed a
(224, 224, 3) image, assert shape `(576,)` (matches
`ruvector-cnn` MobileNetV3-Small dim).
4. `test_embed_to_index.py::test_e2e_rag_under_5_lines`: file is the
acceptance gate G1 in 06-decision-record. Full pipeline, ≤ 5
significant lines of user code, completes < 30 s on a stock laptop
with warm model cache. (Subject to network for the *first* run only.)
5. ONNX Runtime is optional: the wheel ships without `ort` bundled in;
image-only users `pip install ruvector` and skip the text path.
Importing `Embedder.from_pretrained("all-MiniLM-...")` without `ort`
raises `EmbedError("install ruvector[text]")`.
**Wheels shipped.** Same five platforms; the `ruvector` wheel does
**not** bundle `ort`. We ship a `ruvector[text]` extra that adds
`onnxruntime` as a Python-side dep (so wheel size of `ruvector` itself
stays ≤ 14 MB).
**Docs.**
- `docs/sdk/notebooks/03_text_to_search.ipynb`.
- Sphinx page for `Embedder`.
- README example block updated.
---
## M4 — A2A client
**Scope.**
- Add `crates/ruvector-py/src/a2a.rs`. Bind from
`crates/rvAgent/rvagent-a2a/src/`:
- `A2aClient` with `connect`, `send_task`, `get_task`, `cancel_task`,
`stream_task`. All async (the underlying Rust API is async via
reqwest). Sync siblings via `pyo3_asyncio::tokio::run_until_complete`
for non-async users.
- `AgentCard`, `AgentCapabilities`, `AgentSkill`, `AgentProvider`,
`AuthScheme`, `Task`, `TaskSpec`, `TaskState`, `TaskStatus`,
`Message`, `Part`, `Role`, `Artifact`,
`TaskArtifactUpdateEvent`, `TaskStatusUpdateEvent`
all from `rvagent-a2a/src/types.rs` and `lib.rs` re-exports.
- `TaskPolicy` from `rvagent-a2a/src/policy.rs`. Construction-only
on the Python side; not modifiable post-send.
- `TaskUpdate` discriminated dataclass returned by `stream_task`.
- Verify-on-discover (ADR-159 r2) enabled by default; `strict_verify=False`
is exposed but documented as for-test-only.
- We **do not** bind the A2A server. Server-side rvAgent stays Rust-only
in v1.
- Extend `error.rs` with `A2aError` subtree
(`CardSignatureInvalid`, `PolicyViolation`, `BudgetExceeded`,
`TransportError`).
**File list.**
```
crates/ruvector-py/src/
a2a.rs # ~700 LoC
a2a_types.rs # ~250 LoC — type conversions for AgentCard, Task, Artifact
crates/ruvector-py/python/ruvector/
__init__.pyi # +220 LoC for the A2A surface
crates/ruvector-py/tests/
test_a2a_card.py # ~120 LoC
test_a2a_send_task.py # ~150 LoC
test_a2a_stream.py # ~150 LoC
test_a2a_policy.py # ~80 LoC
docs/sdk/notebooks/
04_dispatch_to_python_peer.ipynb
```
A test fixture stands up an in-process rvAgent A2A server (using
`tokio::test`-equivalent in pytest via a test-only Rust binary
launched in a `subprocess.Popen`). The server lives in
`crates/ruvector-py/tests/a2a_test_server/` and is built once per
test session.
**LoC budget.** ~950 Rust + ~500 Python tests + ~220 stub additions.
**Acceptance tests.**
1. `test_a2a_card.py::test_fetch_signed_card`: connect to the test
server, fetch the AgentCard, assert signature verifies and
`agent_id` matches `SHAKE-256(pubkey)`.
2. `test_a2a_card.py::test_tampered_card_rejected`: redirect the
client to a tampered `/.well-known/agent.json`, assert
`CardSignatureInvalid`.
3. `test_a2a_send_task.py::test_lifecycle`: send a task, poll until
`completed`, assert artifacts present.
4. `test_a2a_stream.py::test_stream_no_thread_fight` (acceptance gate
G2): consume an SSE stream of 1,000 status updates inside a single
asyncio event loop alongside two other concurrent ruLake
`search_one_async` calls; assert no event-loop-blocked warnings,
no thread-stuck warnings, total time < 1.2 × the maximum of the
three workloads in isolation.
5. `test_a2a_policy.py::test_budget_exceeded`: send a task that
violates `max_cost_usd`, assert `PolicyViolation` raised before
any work begins.
**Wheels shipped.** Same five platforms. Wheel size budget tops out at
≤ 22 MB linux / ≤ 28 MB macOS arm64 (reqwest + rustls + axum-deps).
This is the size red line; if we trip it we ship the A2A bits as a
`ruvector[a2a]` extra with a separate wheel `ruvector-a2a`.
**Docs.**
- `docs/sdk/notebooks/04_dispatch_to_python_peer.ipynb` — derived from
`examples/a2a-swarm/`.
- Sphinx page for `A2aClient`.
- README updated with end-to-end "Python app dispatches to rvAgent"
walkthrough.
---
## Total sizing
| Milestone | Rust LoC | Python LoC | Tests LoC | Cum. wheel size | Calendar weeks |
|---|---:|---:|---:|---:|---:|
| M1 | ~700 | ~75 | ~340 | ≤ 8 MB | 2 |
| M2 | ~680 | ~30 | ~450 | ≤ 12 MB | 3 |
| M3 | ~600 | ~80 | ~300 | ≤ 14 MB | 2.5 |
| M4 | ~950 | ~30 | ~500 | ≤ 22 MB | 3.5 |
| **Total** | **~2,930** | **~215** | **~1,590** | **≤ 22 MB** | **~11 weeks** |
Calendar weeks assume one engineer with PyO3 experience working full-time;
double if pair-programmed; halve if not done in series (M1 and M3 can
parallelize after M1's CI is green).

View file

@ -0,0 +1,202 @@
# 05 — Risks and Tradeoffs
The honest reservations. Each item lists the risk, the mitigation we
plan to apply, and the unmitigated remainder we accept.
## R1 — Tokio runtime in a PyO3 extension
**Risk.** PyO3 extensions are loaded into the host CPython process. If
each method call spins up a tokio runtime, we leak threads and contend
for cores; if we share a runtime with the user's `asyncio` loop, the two
runtimes deadlock on each other. uvloop adds a third loop into the mix.
**Mitigation.** One singleton tokio multi-thread runtime per process,
lazily initialized on first async call (in `crates/ruvector-py/src/runtime.rs`
landing in M2). Sized `min(8, os.cpu_count())`. Bridged to asyncio via
`pyo3_asyncio::tokio::future_into_py`, which schedules the future onto
tokio and resolves a Python `Future` on the asyncio loop — no busy
waiting, no second loop in the same event-driven context.
For the rare interleave-heavy workload (acceptance gate G2 in M4), we
test with both default asyncio and uvloop. We do not pin uvloop and we
do not invent our own loop policy.
**Unmitigated.** A user who launches multiple `ruvector.A2aClient`
operations in a thread that does *not* have an asyncio event loop will
see a clean `RuntimeError`, not magical behaviour. We take that as
correct and document it.
## R2 — GIL releases for batched ops
**Risk.** A `RabitqPlusIndex.build` over 1M vectors in the GIL-held
state freezes the whole CPython process. Same for batched search.
**Mitigation.** Every CPU-bound entry point that takes more than ~50 µs
calls `py.allow_threads(|| inner)`. The list is enumerated in
`02-strategy.md` § "GIL story" and is part of the M1 review checklist.
A regression test in `tests/test_concurrency.py` runs an
in-asyncio search alongside a CPU-bound `numpy` op on the main thread
and asserts wall time matches `max(t_search, t_numpy)`, not their sum.
**Unmitigated.** Single-vector `add` is not GIL-released because the
release/reacquire cost dominates the work. Users hot-looping `add` in
Python instead of `add_batch` will not parallelize. Documented.
## R3 — Wheel size
**Risk.** PyO3 + reqwest + rustls + tokio + axum-deps + ort can push
wheels past 50 MB.
**Mitigation (in priority order).**
1. abi3-py39 collapses ~25 wheels to 5.
2. `strip = true` and `lto = "fat"` already set in workspace
`[profile.release]` (`Cargo.toml` lines 286289).
3. `ort` is **not bundled**; users opt in via `ruvector[text]` which
pulls `onnxruntime` as a Python wheel (Microsoft already ships those).
4. M1 budget ≤ 8 MB; M4 budget ≤ 22 MB (per `04-milestones.md`).
5. **Hard line:** if M4 trips 22 MB on any platform, A2A bindings
ship as a separate wheel `ruvector-a2a` with `ruvector` as a dep.
6. We do not vendor model weights.
**Unmitigated.** macOS arm64 wheels are systematically larger
because the Mach-O format compresses worse than ELF. We accept ~30%
overhead there.
## R4 — SIMD: NEON, AVX2, AVX-512 cross-platform
**Risk.** Building one wheel that performs on a Ryzen, an Ice Lake
Xeon, an M3 Pro, and a Graviton2 means choosing what SIMD to compile.
Compile to AVX-512 baseline → users on AVX2-only crash on `SIGILL`.
Compile to SSE2 baseline → we leave 520× perf on AVX-512 hardware.
**Mitigation.** **Runtime CPU feature detection.** ruvector-rabitq's
`kernel.rs` already exposes a `VectorKernel` trait + `CpuKernel` impl
+ `KernelCaps` capability struct (`crates/ruvector-rabitq/src/kernel.rs`).
The Python wheel ships *one* binary per platform, compiled with AVX2
+ NEON baseline (the `manylinux_2_28` and `macosx_11_0_arm64` floors).
At init time we probe via `is_x86_feature_detected!` / `cpufeatures`
crate and pick the best kernel.
A future AVX-512 kernel slots in via the same trait. No wheel-matrix
explosion. ARM SVE is similarly handled when/if added.
**Unmitigated.** Users on pre-Haswell x86 (no AVX2) will get
`Illegal instruction` on Linux x86_64. We document
`manylinux_2_28_x86_64` requires AVX2 and let `pip install` fall back
to sdist (which fails to build on a sufficiently old machine — correct).
## R5 — abi3 stability
**Risk.** abi3-py39 covers the stable ABI but excludes private CPython
APIs. If we ever need one (rare for vector code), we drop abi3 and the
wheel matrix re-explodes.
**Mitigation.** PyO3's macros emit only stable-ABI code under the
`abi3-py39` feature. Code review explicitly bans direct CPython
private-API calls. We have not identified any need today.
**Unmitigated.** abi3 is a one-way door. If we ever want
free-threaded Python (PEP 703) optimizations that require post-3.13
APIs, we'll need a 3.13+ specific wheel alongside abi3. Cross that
bridge in 2027.
## R6 — Tokio multi-runtime risk
**Risk.** A user imports `ruvector` in an application that already
embeds tokio via a different extension (e.g. another Rust-Python lib),
and the two each call `Runtime::new()`. Result: two independent
runtimes competing for the same cores, neither reachable from the
other's `tokio::spawn`.
**Mitigation.** Each runtime is owned by its respective extension
module. We do not hand out runtime references. Async work submitted
through `ruvector` always lands on `ruvector`'s runtime; nothing is
shared. For inter-library async coordination, users go through
asyncio (the lingua franca).
**Unmitigated.** Total OS-thread count goes up linearly in number of
loaded Rust extensions. On a low-core box this matters. We size the
runtime conservatively (`min(8, cpu)`) to avoid being the worst
offender.
## R7 — Symbol clash on PyPI
**Risk.** `ruvector` on PyPI may already be squatted or claimed by
another project. As of plan-write the author has not checked.
**Mitigation.** Before M1 starts, register `ruvector` (and
`ruvector-rabitq`, `ruvector-rulake`, `ruvector-a2a` for safety even
if we don't ship them as separate distributions today) on PyPI under
the org account. Park empty 0.0.0 placeholder packages with a single
README pointing at this repo. Cost: ~10 minutes. If any name is
already taken, we negotiate or fall back to `ruvector-py` and rename
the import in the docs accordingly.
**Unmitigated.** A determined squatter who refuses transfer would
force a rename. Open question O1 in `06-decision-record.md`.
## R8 — Repo location: monorepo vs separate
**Risk.** The Python SDK could live in `crates/ruvector-py/` (in this
monorepo) or in a separate `ruvnet/ruvector-py` repo.
**Decision.** Monorepo. `crates/ruvector-py/`.
**Why.** Every binding crate already lives here (`*-node`, `*-wasm`,
`router-ffi`). Following the precedent means:
- Reviewers diff Rust changes against their bindings in one PR.
- Workspace `Cargo.toml` pins ensure the Python wheel's ruvector-rabitq
is bit-identical to the Rust crate's; with two repos we'd have to
cut a tagged release on every change.
- `cibuildwheel` triggers off `crates/ruvector-py/**` path filter on
PRs; doesn't run when only `crates/ruvix/` changes.
- Single CHANGELOG.
**Cost we accept.** Python contributors who never touch Rust have to
clone a 2-GB repo to push a 5-line stub fix. We accept that.
## R9 — CI maintenance
**Risk.** `python-wheels.yml` is the largest non-CLI workflow in the
repo (~120 LoC YAML, 5 platforms × build × test × upload). It will
break.
**Mitigation.** Use `cibuildwheel` (which is opinionated and
maintained) rather than rolling our own per-platform setup. Pin
`cibuildwheel` to a major version, dependabot-bump weekly. The
workflow is not in the critical path of Rust development — Rust CI
runs without Python.
**Unmitigated.** When PyO3 releases a major (e.g. 0.22 → 0.23), we
re-do the bindings. Major PyO3 bumps come ~2/year; the breaking
changes are mechanical. Expect ~1 person-day per bump.
## R10 — User confusion: which index?
**Risk.** Four index types (`FlatF32Index`, `RabitqIndex`,
`RabitqPlusIndex`, `RabitqAsymIndex`) are exposed in M1. A first-time
Python user picks the wrong one and concludes ruvector is slow / has
bad recall.
**Mitigation.** The README hello-world uses `RabitqPlusIndex`
(rerank=20) — the one with 100% recall@10 in the benchmark table. The
docstring on each class names the tradeoff in one sentence. We add a
top-level `ruvector.recommend(n, dim, recall_target)` helper in M1
that returns the right class for the workload, modeled on
`crates/ruvector-rabitq/BENCHMARK.md`'s recommendations.
**Unmitigated.** `FlatF32Index` users on n=10M will be sad. The
docstring tells them why.
## What we are NOT worried about
- **PyO3 itself.** Mature, used by polars + pydantic-core +
cryptography. Not a risk.
- **maturin.** Same.
- **NumPy compat.** `rust-numpy` 0.22 covers what we need. The pin
moves with PyO3 in lockstep.
- **Build determinism.** Workspace already pins everything; the only
Python-side variable is the wheel build platform, which CI
controls.

View file

@ -0,0 +1,130 @@
# 06 — Decision Record (one-page summary)
## The chosen strategy
**A new in-tree workspace crate `crates/ruvector-py/` exposes the
Rust SDK through PyO3, built and distributed as a single abi3-py39
wheel via maturin + cibuildwheel.** Async surfaces use `pyo3-asyncio`
over a singleton tokio runtime; vector inputs are accepted as
zero-copy `np.ndarray[np.float32]`; type stubs are hand-written and
shipped with `py.typed`.
## Roadmap
| M | Scope | Rust LoC | Wheel cap | Calendar |
|---|---|---:|---:|---:|
| **M1** | RaBitQ index (`FlatF32`, `Rabitq`, `RabitqPlus`, `RabitqAsym`); persistence; CI publishing pipeline. | ~700 | 8 MB | 2 wk |
| **M2** | ruLake (`RuLake` builder, `LocalBackend` / `FsBackend` / Python `BackendAdapter` ABC); witness operations; sync + async search; tokio runtime singleton. | ~680 | 12 MB | 3 wk |
| **M3** | Embeddings (`Embedder.from_pretrained` for MiniLM-text and MobileNetV3-image); HF model cache + SHA-256 verification. | ~600 | 14 MB | 2.5 wk |
| **M4** | A2A client (`A2aClient.connect/send_task/stream_task/cancel_task`); typed AgentCard / Task / Artifact; signed card verify-on-discover. | ~950 | 22 MB | 3.5 wk |
| **Total** | — | **~2,930** | **22 MB** | **~11 wk** |
(One full-time engineer with PyO3 experience. Sequenceable; M3 may
parallelize after M1 ships.)
## Three acceptance gates that gate the whole effort
**G1 — RAG in 5 lines.** A user types ≤ 5 significant lines of Python
to embed a corpus, build an index, and query it with sub-10-ms p99
latency on 100k D=128 vectors. Concretely:
```python
import ruvector, numpy as np
emb = ruvector.Embedder.from_pretrained("all-MiniLM-L6-v2")
idx = ruvector.RabitqPlusIndex.build(emb.embed_batch(corpus), seed=42, rerank_factor=20)
hits = idx.search(emb.embed("my query"), k=10)
print([(h.id, h.score) for h in hits])
```
This gate clears at the end of M3.
**G2 — asyncio without thread fights.** A user awaits an A2A SSE
stream of 1,000 status updates concurrently with two ruLake
`search_one_async` calls inside a single asyncio event loop, with no
event-loop-blocked warnings, no thread-stuck warnings, and total
wall time within 1.2× of the maximum of the three workloads in
isolation.
This gate clears at the end of M4 and is enforced by
`tests/test_a2a_stream.py::test_stream_no_thread_fight`.
**G3 — `pip install ruvector` is instant.** On a stock Linux x86_64
GitHub Actions runner with a warm pip cache, `pip install ruvector`
from PyPI completes in ≤ 10 s. This is the "we ship a binary wheel,
not a sdist" gate. Enforced as a CI step that fails the release if
the timing regresses.
This gate clears at the end of M1 and stays clear forever.
## Open questions for stakeholders before M1
**O1 — PyPI name.** Is `ruvector` available on PyPI? If not, do we
negotiate transfer, register `ruvector-py`, or pick something else?
Owner: project lead. Resolution required before M1 PR is opened.
**O2 — Python version floor.** abi3-py39 covers Python 3.93.14+.
Are we comfortable dropping support for 3.8 (which is EOL but still
deployed)? This document assumes yes. Owner: product.
**O3 — Tokio runtime sizing default.** This document picks
`min(8, os.cpu_count())`. Is that right for the typical ruvector user?
A serving deployment on a 96-core box might want more. Decision can
slide post-M2 (env var override is cheap to add) but the default
needs to be picked once. Owner: performance engineer.
**O4 — `ort` (ONNX Runtime) coupling for M3.** The plan is to **not**
bundle `ort` and instead expose `ruvector[text]` as a Python extra
that pulls `onnxruntime` from PyPI. Confirm this is acceptable from a
"works out of the box" UX perspective. Owner: product.
**O5 — Where does the Python A2A *server* live?** Plan deliberately
ships only the client in M4. If/when a Python user wants to host an
A2A peer from inside their Python process, do they (a) embed the
Rust server via PyO3, (b) run an external rvAgent binary, or (c)
re-implement the server in Python? This document says (b). Owner:
rvAgent maintainer.
**O6 — Stable-ABI commitment.** abi3-py39 is a forward commitment:
once published, downgrading to "version-specific" wheels is a
breaking change for users on niche Python builds. Confirm we're
willing to make that commitment. Owner: maintainer.
## What "done" looks like
When M4 ships:
- `pip install ruvector` works on Linux x86_64/arm64, macOS
x86_64/arm64, Windows x86_64.
- `import ruvector` exposes vector indexes, ruLake, embedders, and
the A2A client.
- 100% of the public surface has hand-written type stubs.
- CI gates all three acceptance gates G1, G2, G3 on every PR.
- Four notebooks (`docs/sdk/notebooks/01..04`) walk a new user from
hello-world to multi-agent dispatch.
- A single PyO3 crate at `crates/ruvector-py/` is the only place
Python-related Rust code lives.
## Rejected alternatives (one-liners)
- **CFFI** — strictly worse than PyO3 for this code.
- **wasmtime-py** — loses native perf, requires writing missing
WASM crates first, drags 6 MB runtime.
- **gRPC service + thin client** — wrong architectural shape for a
vector index.
- **One-wheel-per-Python-version** — abi3 collapses the matrix.
- **Separate `ruvnet/ruvector-py` repo** — breaks the single-PR
cross-binding diff workflow that NAPI bindings already enjoy.
## Source pointers
- This plan: `docs/sdk/INDEX.md` and siblings 0106.
- Survey of existing ruvector code: `docs/sdk/01-survey.md`.
- Strategy defense: `docs/sdk/02-strategy.md`.
- API sketch: `docs/sdk/03-api-surface.md`.
- Milestone breakdown: `docs/sdk/04-milestones.md`.
- Risks: `docs/sdk/05-risks-and-tradeoffs.md`.
- Reference Rust APIs: `crates/ruvector-rabitq/src/lib.rs`,
`crates/ruvector-rulake/src/lib.rs`, `crates/rvAgent/rvagent-a2a/src/lib.rs`.
- NAPI binding template (mirror this style in PyO3):
`crates/ruvector-diskann-node/src/lib.rs`.
- Anchor ADRs: ADR-154 (RaBitQ), ADR-155 (ruLake), ADR-159 (A2A).

46
docs/sdk/INDEX.md Normal file
View file

@ -0,0 +1,46 @@
# ruvector Python SDK — Planning Index
This directory contains the design review for a first-party Python SDK over the
ruvector workspace. It is a planning artifact, not source code. No `pyproject.toml`,
`*-py` crate, or PyO3 dependency exists in the workspace today (verified
2026-04-25 by searching for `pyo3`/`maturin` in every `Cargo.toml` and for
`pyproject.toml`/`*.pyi` outside `target/` and `node_modules/`). Everything
below is greenfield.
## Documents
- **[01-survey.md](./01-survey.md)** — What ruvector ships today: which crates
are realistic SDK targets vs internal-only, what FFI surfaces already exist
(NAPI-RS templates, wasm-bindgen modules, raw cbindgen consumers), the
shape of the JS/TS distribution, and which `examples/` are good Python
notebook material.
- **[02-strategy.md](./02-strategy.md)** — The binding-approach decision.
Reviews PyO3 + maturin, CFFI, ctypes-over-cbindgen, wasmtime-py over the
WASM crates, and gRPC-server-with-Python-client. Picks PyO3 + maturin and
defends the choice. Covers the asyncio story, the GIL story, the wheel
matrix, and the type-stub plan.
- **[03-api-surface.md](./03-api-surface.md)** — A concrete sketch of the
Python API the user types: `ruvector.RabitqIndex.build(...)`,
`ruvector.RuLake.builder()...build()`, `ruvector.A2aClient(...)`. Locks
in the error hierarchy, sync-vs-async signatures per call, NumPy interop,
and the Pythonic conveniences (`len(idx)`, `idx[i]`, context managers).
- **[04-milestones.md](./04-milestones.md)** — Four buildable milestones
with explicit scope, file lists, LoC budgets, and acceptance tests in
the same shape as ADR-159's milestone plan. M1 is RaBitQ-only. M2 adds
ruLake. M3 adds embeddings. M4 wraps `rvagent-a2a`.
- **[05-risks-and-tradeoffs.md](./05-risks-and-tradeoffs.md)** — The honest
reservations: tokio runtime in a PyO3 extension, GIL for batched ops,
wheel size, NEON/AVX-512 build-time-vs-runtime detection, abi3 vs
version-specific wheels, the `ruvector` PyPI squat question, and where
this code lives in the repo (a new `crates/ruvector-py/` member, not a
separate repo).
- **[06-decision-record.md](./06-decision-record.md)** — One-page summary
with the chosen strategy, the 4-milestone roadmap, three acceptance
gates that gate the whole effort, and the open questions for stakeholders
to answer before M1 starts.
## How to read this
Read `06` first if you want the call-to-action. Read `02` first if you want
to argue with the binding strategy. Read `01` first if you've never opened
this codebase before.