mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-07-10 01:38:44 +00:00
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:
parent
51d4fdaef5
commit
f6c684aba0
9 changed files with 1303 additions and 8 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
139
docs/sdk/01-survey.md
Normal 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
169
docs/sdk/02-strategy.md
Normal 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 5–20× 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
253
docs/sdk/03-api-surface.md
Normal 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
359
docs/sdk/04-milestones.md
Normal 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).
|
||||
202
docs/sdk/05-risks-and-tradeoffs.md
Normal file
202
docs/sdk/05-risks-and-tradeoffs.md
Normal 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 286–289).
|
||||
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 5–20× 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.
|
||||
130
docs/sdk/06-decision-record.md
Normal file
130
docs/sdk/06-decision-record.md
Normal 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.9–3.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 01–06.
|
||||
- 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
46
docs/sdk/INDEX.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue