ZVec is Alibaba's open-source, in-process vector database — the "SQLite of Vector DBs" — built on the battle-tested Proxima search engine. Written in C++, it delivers sub-millisecond HNSW search, hybrid scalar+vector filtering, full-text search, WAL durability, and memory-mapped I/O with zero network overhead.
ZVec.NET is the .NET NuGet package that wraps ZVec's C++ core via the official
zvec_c_apiand exposes an idiomatic C# API (sync + async) with zero-allocation vector pipelines. Public identity is ZVec.NET only (PackageId / namespace / assembly) — no company prefix.
- Project Vision & Goals
- ZVec API Surface Catalog
- Architecture Overview
- Detailed Design
- Native C-API Bridge Specification
- P/Invoke & Marshalling Layer
- Public SDK API Design
- Memory & Performance Strategy
- Cross-Platform NuGet Packaging (incl. §9.3 Versioning & §9.4 Announcing to Upstream)
- Testing Strategy
- Benchmark Strategy
- Work Breakdown Structure (WBS)
- CI/CD Pipeline
- Risk Register
- Timeline & Milestones
- Appendix A — ZVec Enum Reference
- Appendix B — ZVec Method Signature Reference
Deliver the definitive .NET SDK for ZVec — the same raw performance as the C++ core, with the ergonomics .NET developers expect. The SDK should feel like it was written by Microsoft, not by a C++ engineer who learned C# last week.
| # | Goal | Success Metric |
|---|---|---|
| G1 | Idiomatic C# API | Sync + async entry points; .NET naming guidelines; ValueTask for async; IAsyncEnumerable for streaming where useful |
| G2 | Zero-allocation vector pipeline | ReadOnlySpan<float> / ReadOnlyMemory<float> on vector hot paths; no float[] copies. Scalar Fields may box (documented) |
| G3 | Sub-millisecond overhead | Sync P/Invoke marshalling overhead < 50 µs on a 768-dim vector query (verified by BenchmarkDotNet); async APIs are cancellation-aware sync wrappers (no thread-pool offload) |
| G4 | Cross-platform single NuGet | One .nupkg (ZVec.NET) with native binaries for win/linux/osx × x64/arm64 plus Android / iOS / Mac Catalyst when CI builds succeed |
| G5 | Edge-ready / MAUI-compatible | Memory-mapped I/O exposed; resource governance knobs exposed; no server dependency; MAUI sample wires natives for Windows/Android/iOS/MacCatalyst |
| G6 | Full in-process DB / C++ wrap | 100% of the Vector Database API that zvec_c_api / C++ exposes, with managed shapes aligned to Python/Node DB docs in llms-full (collections, schema, CRUD, query modes, indexes, schema evolution, config). Not 100% of AI Integration packages (embeddings, MCP, skills, model rerankers) |
| G7 | SafeHandle guarantees | Every native pointer wrapped in SafeHandle; consumers must Dispose / await using; finalizer is a safety net only |
| G8 | Comprehensive test & benchmark suite | ≥90% line coverage; BenchmarkDotNet comparison vs. raw P/Invoke baseline |
- Embedding generation (dense/sparse) — AI Integration; left to user or separate package
- MCP server integration — AI Integration; not part of
zvec_c_api - Skills / agent tooling packages — AI Integration
- Model-based / API rerankers (e.g. Qwen, local cross-encoder) — AI Integration; in-DB fusion rerankers (
Rrf/Weightedvia multi-query C API) are in scope - Custom
IQueryable/ LINQ provider over the engine (LINQ applies to query results only) - Blazor WebAssembly hosting (no native
zvec_c_apiRID for WASM in v1; Blazor Server is supported)
G6 excludes AI Integration. Coverage is the C++ DB wrap + Vector Database docs parity.
All DB index types are in scope for v1 (HNSW, Flat, IVF, HNSW-RaBitQ, DiskANN, Vamana, Invert, FTS). Platform caveats (RaBitQ = x86_64/AVX2; DiskANN = Linux-only, libaio optional via dlopen) are runtime warnings, not scope cuts. C API caveat: HNSW-RaBitQ create/setters are incomplete in
c_apitoday — managed SDK throws until upstream fixes.
| Decision | Choice | Rationale |
|---|---|---|
| Public identity | Namespace, assembly, PackageId = ZVec.NET |
ZVec (wrapped product) + .NET (framework). Consumers import only this name |
| Inner project path | src/Core/... for layout only |
Folder/project may use Core internally; never ship a public *.Core namespace or PackageId |
| .NET Target | net8.0 – net10.0 (pack all three lib/ TFMs) |
LTS baseline net8.0; ship net9/net10 assemblies in the same package; never encode TFM in the SemVer string |
| C++ Standard | C++17 | Matches ZVec upstream |
| License | Apache-2.0 via <PackageLicenseExpression> |
Matches upstream zvec; see NOTICE |
| Strong-naming | Yes, open signing key (.snk committed to repo) |
Enterprise / strong-named consumer compatibility. The .snk is an identity key (not a security secret) — like SQLitePCLRaw's approach. Generated once via sn -k ZVec.NET.snk and placed in build/. It lets strong-named consumers reference our assembly |
| Test Framework | xUnit + FluentAssertions | User decision |
| Test Strategy | Real native + Skip when DLL missing; SetMockLibrary only for missing-path failure tests |
Mock C++ project retired (E17 canceled) |
| NuGet Layout | Single .nupkg with runtimes/{rid}/native/ |
Runtime resolves RID natives; no custom build/*.props for RID packing |
| P/Invoke Style | [LibraryImport] (source generator) |
Compile-time marshalling; faster than [DllImport] |
| Public API style | DI-first (IZvecFactory / IZvecCollection / IZvecCollection<T>) + builders + typed ODM |
Hostable in ASP.NET Core, MAUI, Blazor Server; typed recommended, ZVecDoc escape hatch |
| Factory / Builder | Factory for open/create; builders for schema + filters; From<T>() for typed schema |
Immutable configs vs native resource lifecycle |
| C++ type surface (v1) | Full wrap — every type.h enum + every index-param class |
Match C++ library; align with llms-full.txt DB sections |
| Docs audit source | Fresh snapshot docs/llms-full.txt |
Re-audit when upstream docs change |
| LINQ | On results only; typed expression filters → native filter strings | No custom IQueryable provider; ZVecFilterBuilder remains for dynamic filters |
| Typed ODM (E23) | ZVec.NET.Mapping + IZvecCollection<T> + AddZVecCollection<T> |
One mapper/expression engine; E24 = future separate ZVec.NET.VectorData package |
| Public API shape | Sync + async entry points | Sync = lowest-latency P/Invoke on caller thread; async = cancellation-aware ValueTask wrappers (same thread; no offload). Bounded offload is a future optional epic only if ASP.NET hosting benchmarks prove need |
| Concurrency gates | Interlocked for lifecycle state; native C++ owns operation-level thread safety | No managed-side RW lock — Interlocked.CompareExchange/Exchange for factory/collection state transitions; native std::atomic for its own global config. The planned AsyncReaderWriterLock was canceled (redundant with native guarantees, correctness concerns) |
| Native version gate | Prefer zvec_check_version / int major/minor/patch; string version is diagnostics only |
Fail fast on ABI mismatch; never free static version string |
| Close vs Destroy | Dispose/ReleaseHandle → close only; Destroy → destroy then close |
Close releases handle (data on disk); Destroy permanently deletes collection |
| Factory shutdown | IZvecFactory : IAsyncDisposable + Shutdown/ShutdownAsync → zvec_shutdown |
DI singleton dispose tears down process-wide native state |
| First-init-wins | First successful Initialize wins; later calls no-op |
Log warning if subsequent ZVecOptions differ (incl. MemoryLimitMb) |
| Enum / ABI source of truth | Upstream zvec/db/type.h + c_api.h only |
Prefer type.h when C macros lag; never invent parallel headers or numeric values |
| Bool marshalling | [MarshalAs(UnmanagedType.U1)] for C99 bool |
Not UnmanagedType.Bool (4-byte Win32 BOOL) |
For all current and future development tasks, the following strict rules apply:
- Developer-Friendly Documentation: Check created classes and enums against
c_api.handdocs/llms-full.txt. All C# code must include explicitly written XML documentation (///) reflecting the original C++ docs so it is highly accessible to developers. - Centralized Defaults: Use a public
Defaultsclass (or similar structural mechanism) that groups many static classes/defaults by related functionality instead of scattering magic values. - No Magic Strings: All strings must be defined as constants,
nameof(), or strongly typed enums. - No Magic Default Values: All default values must be sourced from the centralized
Defaultsclass or clearly defined constants.
This catalog covers the Vector Database surface that maps to our submodule (zvec_c_api / C++). It is aligned to the DB sections of llms-full.txt / llms.txt. AI Integration (Embedding, MCP, Skills, model rerankers) is out of scope for this package — see §1.3.
Every DB item below that has a C API (or type.h) counterpart must have a corresponding C# wrapper. Features documented for Python/Node but missing from c_api.h are listed as binding gaps (use type.h numeric values where possible; do not invent P/Invoke).
Full audit completed: Cross-checked
c_api.h(4,144 lines) ×type.h(146 lines). Only 2 binding gaps remain (HNSW_RABITQ=4,RABITQ=4), both resolved viatype.hvalues. All other DB features have matching C API functions.
Audit basis: remote https://zvec.org/llms-full.txt saved as docs/llms-full.txt (2026-07-14, ~715 KB), cross-checked against c_api.h + type.h and this plan §7.
| DB feature (llms-full / llms.txt) | c_api.h / type.h |
Plan §7 / catalog | Status |
|---|---|---|---|
| Global config / init | zvec_initialize, config/log APIs, zvec_get_version* |
IZvecFactory.Initialize / InitializeAsync, first-init-wins, ZVecOptions, version gate |
Covered |
| Global shutdown | zvec_shutdown |
Shutdown / ShutdownAsync, factory IAsyncDisposable |
Covered |
| Create and open | zvec_collection_create_and_open |
CreateAndOpen / CreateAndOpenAsync |
Covered |
| Open / close / destroy | zvec_collection_open / close / destroy |
Open / OpenAsync; Dispose→close only; Destroy→destroy+close |
Covered |
| Inspect (schema/stats/options/path) | zvec_collection_get_schema / stats / options |
Collection properties; Open/OpenAsync call zvec_collection_get_schema to bind managed Schema/FieldTypeMap for unmarshalling |
Covered |
| Optimize | zvec_collection_optimize |
Optimize / OptimizeAsync |
Covered |
| Schema + field/vector indexes | schema + zvec_index_params_* |
SchemaBuilder + IndexParams | Covered |
| Schema evolution (add/alter/drop column, create/drop index) | zvec_collection_add_column, schema alter/drop, index add/drop |
DDL methods on IZvecCollection / typed IZvecCollection<T> (sync + async). Native add_column and typed EnsureSchema add nullable numeric columns only — put string/array fields in the create-time schema. |
Covered |
| Insert / Upsert / Update / Delete / DeleteByFilter | matching zvec_collection_* |
Sync + *Async CRUD |
Covered |
| Fetch | zvec_collection_fetch |
Fetch / FetchAsync (single → ZVecDoc?; batch → dictionary) |
Covered |
| Query single / multi / filter / hybrid | zvec_collection_query, multi-query APIs |
Query / QueryAsync |
Covered |
| Full-text query | FTS query params + collection query | ZVecFtsQuery |
Covered |
| Group query | zvec_group_by_vector_query_* builders; no zvec_collection_group_by_query in C API |
QueryGroupBy throws; builders bound; Python uses pybind → Collection::GroupByQuery |
C API gap (execution) — awaiting C export mirroring pybind |
| In-DB RRF / Weighted fusion | zvec_multi_query_set_rerank_rrf / _weighted |
ZVecRrfReranker / ZVecWeightedReranker |
Covered |
| Indexes: HNSW, Flat, IVF, Invert, FTS | index type macros + params APIs | IndexParams + Appendix A | Covered |
| Indexes: DiskANN, Vamana | types + query/index params in C API | ZVecDiskAnnIndexParam / ZVecVamanaIndexParam |
Covered (DiskANN: Linux-only per docs) |
| Index: HNSW-RaBitQ | type.h = 4; no #define in c_api.h |
ZVecHnswRabitqIndexParam |
Binding gap — pass value 4 from type.h until upstream adds macro |
Quantize RABITQ |
type.h = 4; C quantize macros stop at INT4 |
ZVecQuantizeType.Rabitq |
Binding gap — same as above |
All DataType ABI values |
ZVEC_DATA_TYPE_* |
Appendix A | Covered |
| Embedding models / MCP / Skills / model rerankers | Not in zvec_c_api |
§1.3 non-goals | Out of scope (AI) |
| Python | Node.js | Description |
|---|---|---|
zvec.init(log_type, log_level, query_threads) |
ZVecInitialize({ logType, logLevel, queryThreads }) |
Global configuration; call once at startup |
zvec.create_and_open(path, schema, option?) |
ZVecCreateAndOpen(path, schema, options?) |
Create a new collection and open it |
zvec.open(path, option?) |
ZVecOpen(path, options?) |
Open an existing collection |
Managed wrappers must expose the full C++ set from type.h (see Appendix A). Public docs (llms-full.txt) list a product-facing subset; ABI-only members remain required for a complete wrap.
| Enum | Values (docs + ABI) |
|---|---|
DataType |
Docs: STRING, BOOL, INT32, INT64, UINT32, UINT64, FLOAT, DOUBLE, ARRAY_*, VECTOR_FP16/FP32/INT8, SPARSE_VECTOR_FP16/FP32. ABI also: BINARY, VECTOR_BINARY32/64, VECTOR_FP64, VECTOR_INT4/INT16, ARRAY_BINARY, UNDEFINED |
MetricType |
UNDEFINED, L2, IP, COSINE, MIPSL2 |
LogType |
CONSOLE, FILE |
LogLevel |
DEBUG, INFO, WARN, ERROR, FATAL |
IndexType |
UNDEFINED, HNSW, IVF, FLAT, HNSW_RABITQ, DISKANN, VAMANA, INVERT, FTS |
QuantizeType |
UNDEFINED, FP16, INT8, INT4, RABITQ |
| Type | Properties |
|---|---|
CollectionSchema |
name: string, fields: list[FieldSchema], vectors: list[VectorSchema], max_doc_count_per_segment: int |
FieldSchema |
name: string, data_type: DataType, nullable: bool, index_param: InvertIndexParam? |
VectorSchema |
name: string, data_type: DataType, dimension: int, index_param: HnswIndexParam? or HnswRabitqIndexParam? or IvfIndexParam? or FlatIndexParam? or DiskAnnIndexParam? or VamanaIndexParam? or FtsIndexParam? |
InvertIndexParam |
enable_range_optimization: bool, enable_extended_wildcard: bool |
HnswIndexParam |
metric_type: MetricType, m: int, ef_construction: int, quantize_type: QuantizeType |
HnswRabitqIndexParam |
metric_type: MetricType, total_bits: int, num_clusters: int, m: int, ef_construction: int, sample_count: int |
IvfIndexParam |
metric_type: MetricType, centroids_num: int, nlist: int, nprobe: int, quantize_type: QuantizeType |
FlatIndexParam |
metric_type: MetricType, quantize_type: QuantizeType |
DiskAnnIndexParam |
metric_type: MetricType, max_degree: int, list_size: int, pq_chunk_num: int, quantize_type: QuantizeType |
VamanaIndexParam |
metric_type: MetricType, max_degree: int, search_list_size: int, alpha: float, saturate_graph: bool, use_contiguous_memory: bool, use_id_map: bool, quantize_type: QuantizeType |
FtsIndexParam |
Python: tokenizer_name/filters/extra_params strings → .NET: ZVecFtsTokenizer / IReadOnlyList<ZVecFtsTokenFilter> / ZVecFtsExtraParams? |
CollectionOption |
read_only: bool, enable_mmap: bool |
| Type | Properties |
|---|---|
Doc |
id: string, vectors: dict[string, float[] or dict[int,float]], fields: dict[string, object], score: float (read-only, query result) |
| Method | Signature (Python) | Returns |
|---|---|---|
insert |
collection.insert(doc or list[doc]) |
Status or list[Status] |
upsert |
collection.upsert(doc or list[doc]) |
Status or list[Status] |
update |
collection.update(doc or list[doc]) |
Status or list[Status] |
delete |
collection.delete(ids: str or list[str]) |
Status or list[Status] |
delete_by_filter |
collection.delete_by_filter(filter: str) |
Status |
fetch |
collection.fetch(ids: str or list[str]) |
dict[str, Doc] |
query |
collection.query(queries, topk, filter?, reranker?) |
list[Doc] |
optimize |
collection.optimize() |
void |
destroy |
collection.destroy() |
void |
add_column |
collection.add_column(field_schema, expression) |
void |
drop_column |
collection.drop_column(field_name) |
void |
alter_column |
collection.alter_column(old_name?, new_name?, field_schema?) |
void |
create_index |
collection.create_index(field_name, index_param) |
void |
drop_index |
collection.drop_index(field_name) |
void |
| Property | Type | Description |
|---|---|---|
schema |
CollectionSchema |
Current collection schema |
stats |
CollectionStats |
{ doc_count, index_completeness } |
option |
CollectionOption |
Runtime options (enable_mmap, read_only) |
path |
string |
Filesystem path |
Query modes documented in llms-full.txt — all must be wrapped: single vector, multi vector, filter, hybrid (dense + sparse), FTS, group.
| Type | Properties |
|---|---|
Query |
field_name: string, vector?: float[] or dict[int,float], id?: string, fts?: Fts, params?: dict |
Fts |
match_string?: string, query_string?: string, default_operator?: string |
GroupBy / grouped query |
Group field + per-group topk / limit (see docs “Group” query); C# ZVecGroupByQuery |
| Type | Properties |
|---|---|
WeightedReRanker |
topn: int, metric: MetricType, weights: dict[str,float] |
RrfReRanker |
topn: int, rank_constant: float |
ZVec uses string-based filter expressions:
| Operator | Example |
|---|---|
=, != |
category = "fiction" |
>, <, >=, <= |
publish_year > 1936 |
IN |
category IN ("fiction", "romance") |
LIKE |
title LIKE "Wireless%" |
CONTAIN_ANY |
tags CONTAIN_ANY ["sport", "music"] |
CONTAIN_ALL |
permissions CONTAIN_ALL ["read", "write"] |
AND, OR, NOT |
publish_year > 1936 AND category = "fiction" |
┌──────────────────────────────────────────────────────────────┐
│ Consumer (ASP.NET Core / MAUI / Blazor Server) │
│ DI: AddZVec / AddZVecCollection │
│ using ZVec.NET; │
└───────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ ZVec.NET Public SDK │
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌─────────────────┐ │
│ │ IZvecFactory │ │ IZvecCollection│ │ SchemaBuilder + │ │
│ │ (open/create)│ │ sync + async │ │ FilterBuilder │ │
│ └──────┬───────┘ └───────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ ┌──────┴──────────────────┴─────────────────────┴────────┐ │
│ │ DTO Layer (ZVecDoc, ZVecStatus, schemas, index params) │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┴─────────────────────────────┐ │
│ │ Interlocked lifecycle gating + process-wide native cap │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┴─────────────────────────────┐ │
│ │ SafeHandle Layer (SafeZvecHandle, etc.) │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┴─────────────────────────────┐ │
│ │ P/Invoke Layer ([LibraryImport] source-gen) │ │
│ │ NativeMethods.cs │ │
│ └──────────────────────────┬─────────────────────────────┘ │
└─────────────────────────────┼────────────────────────────────┘
│ P/Invoke (flat C ABI)
▼
┌──────────────────────────────────────────────────────────────┐
│ zvec_c_api (official Alibaba C bindings) │
│ │
│ zvec_initialize / zvec_get_version / zvec_check_version │
│ zvec_collection_create_and_open / open / close / destroy │
│ zvec_collection_insert / query / fetch / delete / optimize │
│ zvec_get_last_error / zvec_get_last_error_details │
└──────────────────────────┬───────────────────────────────────┘
│ C++ function calls
▼
┌──────────────────────────────────────────────────────────────┐
│ ZVec C++ Core (Proxima Engine) │
└──────────────────────────────────────────────────────────────┘
| Layer | Responsibility | Key Design Principle |
|---|---|---|
| Public SDK | Idiomatic sync + async C# API + DI; hides unsafe code | Code against IZvecFactory / IZvecCollection; never expose IntPtr; namespace ZVec.NET |
| Builders | ZVecCollectionSchemaBuilder + fluent ZVecFilterBuilder |
Immutable outputs; no native calls |
| DTO Layer | Strongly-typed data transfer objects | init properties; ReadOnlyMemory<float> for vectors |
| Concurrency gates | Interlocked lifecycle gating + process-wide native cap |
Factory/collection state transitions via Interlocked.CompareExchange/Exchange; no managed-side RW lock (canceled — native C++ handles operation-level safety) |
| SafeHandle Layer | Wraps native pointers; guarantees cleanup | Prefer explicit Dispose; finalizer is last resort (see §8.2) |
| P/Invoke Layer | [LibraryImport] from upstream c_api.h |
Blittable spans where applicable; C99 bool as U1 |
| C-API Bridge | Upstream official zvec_c_api (Alibaba C bindings) |
C-linkage; zvec_error_code_t + last-error APIs — do not invent headers |
| ZVec Core | Upstream C++ library (git submodule) | Consumed via CMake add_subdirectory; not forked for API surface |
ZVec.NET/ # repo / product root
├── src/
│ ├── Native/
│ │ └── ZVec.Native/ # CMake wrapper → upstream zvec_c_api
│ │ ├── CMakeLists.txt # Forces BUILD_C_BINDINGS=ON; add_subdirectory(external/zvec)
│ │ ├── steps.md # Windows operator build guide
│ │ └── external/zvec/ # Git submodule (alibaba/zvec)
│ │ ├── src/include/zvec/c_api.h # Official C API (P/Invoke source of truth)
│ │ └── src/binding/c/ # Builds fat zvec_c_api shared library
│ │
│ ├── Core/ # INTERNAL layout only — not a public package name
│ │ └── ZVec.NET/ # Published assembly / PackageId: ZVec.NET
│ │ ├── ZVec.NET.csproj
│ │ ├── Abstractions/
│ │ │ ├── IZvecFactory.cs # Open/create collections (sync + async)
│ │ │ ├── IZvecCollection.cs # Sync + async CRUD + query + DDL (dynamic)
│ │ │ └── IZvecCollectionOfT.cs # Typed ODM façade IZvecCollection<T>
│ │ ├── Mapping/ # ZVec.NET.Mapping — attrs, TypeModel, Mapper, ExpressionFilter
│ │ ├── DependencyInjection/
│ │ │ ├── ZVecServiceCollectionExtensions.cs # AddZVec / AddZVecCollection / AddZVecCollection<T>
│ │ │ ├── ZVecOptions.cs # Global init (incl. MemoryLimitMb)
│ │ │ └── ZVecCollectionRegistrationOptions.cs
│ │ ├── Builders/
│ │ │ └── ZVecCollectionSchemaBuilder.cs # + From<T>()
│ │ ├── Interop/
│ │ │ ├── NativeMethods.cs # [LibraryImport] from c_api.h
│ │ │ ├── SafeZvecHandle.cs
│ │ │ ├── SafeZvecQueryHandle.cs
│ │ │ ├── SafeZvecSchemaHandle.cs
│ │ │ └── NativeLibraryResolver.cs
│ │ ├── Internal/
│ │ ├── Models/
│ │ │ ├── ZVecDoc.cs
│ │ │ ├── ZVecStatus.cs
│ │ │ ├── ZVecCollectionSchema.cs
│ │ │ ├── ZVecFieldSchema.cs
│ │ │ ├── ZVecVectorSchema.cs
│ │ │ ├── ZVecCollectionOptions.cs
│ │ │ ├── ZVecCollectionStats.cs
│ │ │ └── Enums/ # Values MUST match type.h / c_api.h (Appendix A)
│ │ ├── IndexParams/
│ │ ├── Query/
│ │ │ ├── ZVecQuery.cs
│ │ │ ├── ZVecQueryParams.cs # Typed index-specific query params
│ │ │ ├── ZVecFtsQuery.cs
│ │ │ ├── ZVecGroupByQuery.cs
│ │ │ ├── ZVecFilterBuilder.cs
│ │ │ └── ZVecReranker.cs
│ │ ├── ZVecFactory.cs
│ │ └── ZVecCollection.cs
│ │
│
├── testing/
│ ├── ZVec.NET.Tests/
│ │ ├── ZVec.NET.Tests.csproj
│ │ ├── Unit/
│ │ ├── Integration/
│ │ └── Memory/
│ │
│ └── ZVec.NET.Benchmarks/
│ └── ZVec.NET.Benchmarks.csproj
│
├── samples/ # USER DEMOS ONLY — never packed (Epic E25)
│ ├── Directory.Build.props # IsPackable=false; sample TFMs; no SignAssembly
│ ├── ZVec.NET.Samples.slnx # Preferred entrypoint for sample work
│ ├── README.md # Prerequisites, LM Studio, MB datasets, E21 isolation
│ ├── datasets/ # T0 fixtures committed; T1 cache gitignored (≤ ~100 MB/pack)
│ ├── ZVec.NET.Samples.Shared/ # RAG/search/recommend helpers + LM Studio clients (.NET 10)
│ ├── ZVec.NET.Samples.Console/ # net10.0
│ ├── ZVec.NET.Samples.AspNet/ # net10.0
│ └── ZVec.NET.Samples.Maui/ # Flagship offline/edge Blazor Hybrid RAG (net10.0-*)
│
├── build/
│ ├── ZVec.NET.snk # Strong-name identity key (not a secret)
│ └── ci/
│ ├── build-native.yml
│ ├── build-managed.yml
│ └── publish-nuget.yml
│
├── ZVec.NET.slnx # Solution at repo root (VS .slnx) — core + tests + benchmarks; samples in samples/*.slnx only
├── Directory.Build.props # Auto-imported by MSBuild (must be at root / ancestor of projects)
├── Directory.Packages.props # Central Package Management (must be at root)
└── README.md
Consumer rule: all imports are
using ZVec.NET;(plus.DependencyInjection/.Queryas needed). TheCore/folder is repo layout only — it must not appear in PackageId, assembly name, or public namespaces.Samples rule:
samples/is for users learning the SDK. Projects areIsPackable=falseand must not appear in NuGet packages or block E21 CI when packaging is added.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Multi-target for local builds; NuGet may ship a single lib/net8.0 if no TFM #ifs -->
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>ZVec.NET</RootNamespace>
<AssemblyName>ZVec.NET</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- SignAssembly + AssemblyOriginatorKeyFile live in root Directory.Build.props
(key file: build/ZVec.NET.snk) -->
</PropertyGroup>
</Project>Implementation rule: ZVec.NET consumes Alibaba’s official C API at
src/Native/ZVec.Native/external/zvec/src/include/zvec/c_api.h(built bysrc/binding/caszvec_c_api). Do not invent a parallel custom header, stub.cpp, enum table, or error-code table. P/Invoke signatures, enums, and error codes must be derived fromc_api.h+type.h. Numeric values live in Appendix A only.
- Flat C linkage only — no C++ name mangling, no exceptions across boundary
- Error code return pattern — upstream returns
zvec_error_code_t; details viazvec_get_last_error/zvec_get_last_error_details(managed layer maps to exceptions / status DTOs). There is noZvecStatus { int code; char message[512]; }struct in upstream. - Opaque handles — C++ objects exposed as opaque pointers (
zvec_collection_t*, etc.) - No heap allocations in the managed vector hot path — native allocations stay in ZVec core; C# uses spans / pooling for vectors
- Version gate — call
zvec_check_version/zvec_get_version*duringInitializeand fail withZVecAbiMismatchExceptionon mismatch - Lifecycle names —
Dispose/ finalizer →zvec_collection_closeonly.Destroy→zvec_collection_destroythenzvec_collection_close. Do not inventzvec_destroy/zvec_destroy_collection.
| Artifact | Path / API |
|---|---|
| C API header | external/zvec/src/include/zvec/c_api.h |
| Enum / type ABI | external/zvec/src/include/zvec/db/type.h (+ macros in c_api.h) |
| Error codes | zvec_error_code_t — see Appendix A (ZVecErrorCode) |
| Version | zvec_get_version, zvec_get_version_{major,minor,patch}, zvec_check_version |
| Init / shutdown | zvec_initialize(const zvec_config_data_t*), zvec_shutdown |
| Memory limit | zvec_config_data_set_memory_limit (global config, not collection options) |
Before Phase 1 coding starts: audit every §2.5 method against c_api.h and record any remaining binding gaps in §2.0.
// NativeMethods.cs — namespace ZVec.NET.Interop (internal)
internal static partial class NativeMethods
{
private const string LibraryName = "zvec_c_api";
// Expected native SemVer pinned at build time (also embedded in NuGet +zvec metadata)
internal const int ExpectedMajor = /* pin */;
internal const int ExpectedMinor = /* pin */;
internal const int ExpectedPatch = /* pin */;
// Version gate: prefer ints / zvec_check_version. String is library-owned static memory —
// MUST NOT use LPUTF8Str return (that CoTaskMemFrees the pointer → crash).
[LibraryImport(LibraryName)]
internal static partial IntPtr zvec_get_version(); // const char* — do not free
internal static string GetVersionString()
{
IntPtr ptr = zvec_get_version();
return Marshal.PtrToStringUTF8(ptr) ?? string.Empty;
}
[LibraryImport(LibraryName)]
[return: MarshalAs(UnmanagedType.U1)]
internal static partial bool zvec_check_version(int major, int minor, int patch);
[LibraryImport(LibraryName)]
internal static partial int zvec_get_version_major();
[LibraryImport(LibraryName)]
internal static partial int zvec_get_version_minor();
[LibraryImport(LibraryName)]
internal static partial int zvec_get_version_patch();
// Returns zvec_error_code_t (int). Map via Appendix A — not a custom struct.
[LibraryImport(LibraryName)]
internal static partial int zvec_initialize(IntPtr configData);
[LibraryImport(LibraryName)]
internal static partial int zvec_shutdown();
// error_msg is malloc'd by native code — free with libc free(), NOT Marshal.FreeCoTaskMem.
// Copy with PtrToStringUTF8, then NativeMemory.Free / P/Invoke free(ptr).
[LibraryImport(LibraryName)]
internal static partial int zvec_get_last_error(out IntPtr errorMsg); // char**; caller frees
// Prefer zvec_get_last_error_details when you only need code/message pointers without owning allocation.
// Note: zvec_error_details_t is a struct, not an opaque pointer — define as managed struct with explicit layout.
[LibraryImport(LibraryName)]
internal static partial int zvec_get_last_error_details(ref ZvecErrorDetails errorDetails);
// Error details struct — maps to zvec_error_details_t in c_api.h
[StructLayout(LayoutKind.Sequential)]
internal struct ZvecErrorDetails
{
public ZVecErrorCode Code; // zvec_error_code_t
public IntPtr Message; // const char* — do not free (owned by library)
public IntPtr File; // const char* — do not free
public int Line;
public IntPtr Function; // const char* — do not free
}
// Free memory allocated by the native library (e.g., from zvec_get_last_error).
// Use this instead of Marshal.FreeCoTaskMem — the native library uses its own allocator.
[LibraryImport(LibraryName)]
internal static partial void zvec_free(IntPtr ptr);
[LibraryImport(LibraryName)]
internal static partial int zvec_collection_create_and_open(
[MarshalAs(UnmanagedType.LPUTF8Str)] string path,
IntPtr schema,
IntPtr collectionOptions,
out IntPtr outCollection);
[LibraryImport(LibraryName)]
internal static partial int zvec_collection_open(
[MarshalAs(UnmanagedType.LPUTF8Str)] string path,
IntPtr collectionOptions,
out IntPtr outCollection);
// C99 bool params — MUST use U1 (1 byte), not UnmanagedType.Bool (4-byte Win32 BOOL)
[LibraryImport(LibraryName)]
internal static partial int zvec_collection_options_set_read_only(
IntPtr options,
[MarshalAs(UnmanagedType.U1)] bool readOnly);
[LibraryImport(LibraryName)]
internal static partial int zvec_collection_options_set_enable_mmap(
IntPtr options,
[MarshalAs(UnmanagedType.U1)] bool enableMmap);
[LibraryImport(LibraryName)]
internal static partial int zvec_collection_close(IntPtr collection);
[LibraryImport(LibraryName)]
internal static partial int zvec_collection_destroy(IntPtr collection);
// Document/query paths use upstream zvec_doc_t / query builders — not a flat invented zvec_insert.
// Pin ReadOnlyMemory<float> in managed code and pass IntPtr / unsafe float* into the
// matching upstream doc/vector setter APIs. [LibraryImport] supports ReadOnlySpan<T> for
// blittable T on net8+ when the C signature truly takes a pointer+length pair.
}DllImport resolver (clear RID-miss errors):
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, (name, assembly, searchPath) =>
{
if (name != "zvec_c_api")
return IntPtr.Zero;
if (NativeLibrary.TryLoad(name, assembly, searchPath, out var handle))
return handle;
throw new DllNotFoundException(
$"ZVec native library not found for RID '{RuntimeInformation.RuntimeIdentifier}'. " +
"Ensure the ZVec.NET NuGet package supports your platform.");
});internal sealed class SafeZvecHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeZvecHandle() : base(ownsHandle: true) { }
public SafeZvecHandle(IntPtr handle) : base(ownsHandle: true)
{
SetHandle(handle);
}
protected override bool ReleaseHandle()
{
// Finalizer / Dispose safety net: CLOSE ONLY.
// Never call zvec_collection_destroy here — that permanently deletes on-disk data.
// Verify close is safe from any thread (incl. finalizer).
if (!IsInvalid)
{
_ = NativeMethods.zvec_collection_close(handle);
}
return true;
}
}Close vs Destroy (upstream semantics):
| Operation | Native calls | Effect |
|---|---|---|
Dispose / DisposeAsync / ReleaseHandle |
zvec_collection_close only |
Free C handle (shared_ptr* delete); data remains on disk |
Destroy / DestroyAsync |
zvec_collection_destroy then zvec_collection_close |
Permanent disk delete (Collection::Destroy), then free handle |
Note: zvec_collection_destroy does not free the C handle wrapper — close is still required after destroy. After destroy, mark the SafeHandle invalid so Dispose does not double-close.
internal static class VectorMarshaller
{
/// <summary>
/// Pins a ReadOnlyMemory<float> and returns a memory handle + pointer.
/// The caller MUST keep the MemoryHandle alive for the duration of the P/Invoke call.
/// </summary>
internal static (IntPtr ptr, MemoryHandle pin) PinVector(ReadOnlyMemory<float> vector)
{
var pin = vector.Pin();
return ((IntPtr)pin.Pointer, pin);
}
internal static void SerializeSparseVector(
IReadOnlyDictionary<int, float> sparse,
out int[] indices, out float[] values, out int count)
{
count = sparse.Count;
indices = ArrayPool<int>.Shared.Rent(count);
values = ArrayPool<float>.Shared.Rent(count);
int i = 0;
foreach (var kvp in sparse.OrderBy(kv => kv.Key))
{
indices[i] = kvp.Key;
values[i] = kvp.Value;
i++;
}
}
internal static void ReturnSparseArrays(int[] indices, float[] values)
{
ArrayPool<int>.Shared.Return(indices);
ArrayPool<float>.Shared.Return(values);
}
}Primary surface is DI + interfaces under namespace
ZVec.NET. All consumer imports use this root (plus.DependencyInjection/.Query). A thin static façade (scripting/console) may be added later; it must not be the only entry point.Sync + async: Every mutating/querying operation exposes both. Sync invokes P/Invoke on the caller thread (lowest latency). Async APIs are cancellation-aware sync wrappers (
ValueTask) — they complete on the caller thread after the native call; they are not thread-pool offloads. Never add unboundedTask.Runaround native calls (worsens ASP.NET thread-pool starvation). When optional gates are enabled (MaxConcurrentNativeCalls/MaxConcurrentReads> 0), async paths awaitSemaphoreSlim.WaitAsync(gate wait only); mid-P/Invoke cancel remains best-effort. Bounded offload is a future optional epic, not the shipped model.
namespace ZVec.NET;
public interface IZvecFactory : IAsyncDisposable, IDisposable
{
/// <summary>
/// Ensure global native init + ABI version gate have run.
/// <b>First successful init wins</b> — subsequent calls are no-ops even if
/// <see cref="ZVecOptions"/> differ; log a warning when options diverge.
/// </summary>
void Initialize();
ValueTask InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>Process-wide teardown → <c>zvec_shutdown</c>. Idempotent. Prefer DI container dispose.</summary>
void Shutdown();
ValueTask ShutdownAsync(CancellationToken cancellationToken = default);
IZvecCollection CreateAndOpen(
string path,
ZVecCollectionSchema schema,
ZVecCollectionOptions? options = null);
ValueTask<IZvecCollection> CreateAndOpenAsync(
string path,
ZVecCollectionSchema schema,
ZVecCollectionOptions? options = null,
CancellationToken cancellationToken = default);
IZvecCollection Open(string path, ZVecCollectionOptions? options = null);
ValueTask<IZvecCollection> OpenAsync(
string path,
ZVecCollectionOptions? options = null,
CancellationToken cancellationToken = default);
}On first Initialize / InitializeAsync:
- Resolve native library via
NativeLibrary.SetDllImportResolver - Call
zvec_check_version(ExpectedMajor, ExpectedMinor, ExpectedPatch)(prefer ints; useGetVersionString()only for exception messages) - On mismatch → throw
ZVecAbiMismatchExceptionwith expected vs found versions - Build
zvec_config_datafromZVecOptions(log, query threads, MemoryLimitMb) and callzvec_initialize - Remember applied options; later
Initialize*calls return immediately (first-init-wins). If new options differ, log a warning — do not re-apply.
Dispose / DisposeAsync on the factory call Shutdown / ShutdownAsync once (Interlocked.Exchange). DI registers the factory as a singleton so host teardown runs shutdown.
namespace ZVec.NET.DependencyInjection;
services.AddZVec(options =>
{
options.LogType = ZVecLogType.Console;
options.LogLevel = ZVecLogLevel.Warn;
options.QueryThreads = -1;
options.MaxConcurrentNativeCalls = 0; // 0 = unlimited (no SemaphoreSlim)
options.MemoryLimitMb = 512; // global — maps to zvec_config_data_set_memory_limit
});
// Recommended — typed ODM (schema defaults to From<Product>())
services.AddZVecCollection<Product>(options =>
{
options.Path = path;
options.EnableMmap = true;
options.MaxConcurrentReads = 0; // 0 = unlimited; set > 0 only to throttle reads
});
// Advanced — dynamic / string-keyed (explicit schema)
services.AddZVecCollection("products", options =>
{
options.Path = path;
options.Schema = schema;
options.EnableMmap = true;
options.MaxConcurrentReads = 0; // 0 = unlimited
});Host guidance: ASP.NET Core / Blazor Server / MAUI — factory and named collections as singletons. Prefer injecting IZvecCollection<T>; use .Untyped or keyed IZvecCollection for the dynamic escape hatch. When the host stops, disposing the factory singleton runs zvec_shutdown. Blazor WASM is out of scope for v1.
namespace ZVec.NET;
public interface IZvecCollection : IDisposable, IAsyncDisposable
{
ZVecCollectionSchema Schema { get; }
ZVecCollectionStats Stats { get; }
ZVecCollectionOptions Options { get; }
string Path { get; }
// --- Insert ---
ZVecStatus Insert(ZVecDoc doc);
IReadOnlyList<ZVecStatus> Insert(IReadOnlyList<ZVecDoc> docs);
ValueTask<ZVecStatus> InsertAsync(ZVecDoc doc, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ZVecStatus>> InsertAsync(IReadOnlyList<ZVecDoc> docs, CancellationToken cancellationToken = default);
// --- Upsert / Update (same sync+async pattern) ---
ZVecStatus Upsert(ZVecDoc doc);
IReadOnlyList<ZVecStatus> Upsert(IReadOnlyList<ZVecDoc> docs);
ValueTask<ZVecStatus> UpsertAsync(ZVecDoc doc, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ZVecStatus>> UpsertAsync(IReadOnlyList<ZVecDoc> docs, CancellationToken cancellationToken = default);
ZVecStatus Update(ZVecDoc doc);
IReadOnlyList<ZVecStatus> Update(IReadOnlyList<ZVecDoc> docs);
ValueTask<ZVecStatus> UpdateAsync(ZVecDoc doc, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ZVecStatus>> UpdateAsync(IReadOnlyList<ZVecDoc> docs, CancellationToken cancellationToken = default);
// --- Delete ---
ZVecStatus Delete(string id);
IReadOnlyList<ZVecStatus> Delete(IReadOnlyList<string> ids);
ZVecStatus DeleteByFilter(string filter);
ValueTask<ZVecStatus> DeleteAsync(string id, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ZVecStatus>> DeleteAsync(IReadOnlyList<string> ids, CancellationToken cancellationToken = default);
ValueTask<ZVecStatus> DeleteByFilterAsync(string filter, CancellationToken cancellationToken = default);
// --- Fetch: single ID returns ZVecDoc?; batch returns dictionary ---
ZVecDoc? Fetch(string id);
IReadOnlyDictionary<string, ZVecDoc> Fetch(IReadOnlyList<string> ids);
ValueTask<ZVecDoc?> FetchAsync(string id, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyDictionary<string, ZVecDoc>> FetchAsync(IReadOnlyList<string> ids, CancellationToken cancellationToken = default);
// --- Query ---
IReadOnlyList<ZVecDoc> Query(ZVecQuery query, int topk = 10, string? filter = null);
IReadOnlyList<ZVecDoc> Query(IReadOnlyList<ZVecQuery> queries, int topk = 10, string? filter = null, ZVecReranker? reranker = null);
ValueTask<IReadOnlyList<ZVecDoc>> QueryAsync(ZVecQuery query, int topk = 10, string? filter = null, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ZVecDoc>> QueryAsync(IReadOnlyList<ZVecQuery> queries, int topk = 10, string? filter = null, ZVecReranker? reranker = null, CancellationToken cancellationToken = default);
IReadOnlyList<ZVecDoc> QueryGroupBy(ZVecGroupByQuery groupQuery);
ValueTask<IReadOnlyList<ZVecDoc>> QueryGroupByAsync(ZVecGroupByQuery groupQuery, CancellationToken cancellationToken = default);
void Optimize();
ValueTask OptimizeAsync(CancellationToken cancellationToken = default);
void Destroy();
ValueTask DestroyAsync(CancellationToken cancellationToken = default);
void AddColumn(ZVecFieldSchema fieldSchema, string defaultExpression);
void DropColumn(string fieldName);
void AlterColumnRename(string oldName, string newName);
void AlterColumnType(string fieldName, ZVecDataType newDataType);
void CreateIndex(string fieldName, ZVecIndexParam indexParam);
void DropIndex(string fieldName);
ValueTask AddColumnAsync(ZVecFieldSchema fieldSchema, string defaultExpression, CancellationToken cancellationToken = default);
ValueTask DropColumnAsync(string fieldName, CancellationToken cancellationToken = default);
ValueTask AlterColumnRenameAsync(string oldName, string newName, CancellationToken cancellationToken = default);
ValueTask AlterColumnTypeAsync(string fieldName, ZVecDataType newDataType, CancellationToken cancellationToken = default);
ValueTask CreateIndexAsync(string fieldName, ZVecIndexParam indexParam, CancellationToken cancellationToken = default);
ValueTask DropIndexAsync(string fieldName, CancellationToken cancellationToken = default);
}Concrete public class ZVecCollection implements this interface (~40 members: sync+async CRUD/query/DDL is intentional for v1; no separate IZvecSchemaManagement split). Callers may use BCL LINQ on returned IReadOnlyList<ZVecDoc>. There is no custom IQueryable provider over the engine.
Dispose vs Destroy:
Dispose/DisposeAsync→zvec_collection_closeonly (data stays on disk). Both must be idempotent and mutually exclusive — useInterlocked.Exchangeso cleanup runs exactly once.Destroy/DestroyAsync→zvec_collection_destroythenzvec_collection_close(permanent delete). After success, invalidate the handle so a later Dispose is a no-op.
namespace ZVec.NET;
public sealed class ZVecDoc
{
public required string Id { get; init; }
public IReadOnlyDictionary<string, ReadOnlyMemory<float>> DenseVectors { get; init; }
public IReadOnlyDictionary<string, IReadOnlyDictionary<int, float>> SparseVectors { get; init; }
/// <summary>
/// Scalar fields. Values are boxed (<c>object</c>) for Python/Node parity.
/// Zero-allocation goal (G2) applies to <b>vector</b> paths only — iterating
/// Fields on a hot query path allocates. Prefer vector-only access when measuring GC.
/// </summary>
public IReadOnlyDictionary<string, object> Fields { get; init; }
public float Score { get; init; }
public static ZVecDoc Create(
string id,
IReadOnlyDictionary<string, ReadOnlyMemory<float>>? denseVectors = null,
IReadOnlyDictionary<string, IReadOnlyDictionary<int, float>>? sparseVectors = null,
IReadOnlyDictionary<string, object>? fields = null)
{
// Runtime validation: reject null/whitespace — required alone is compile-time only.
ArgumentException.ThrowIfNullOrWhiteSpace(id);
// ...
}
}namespace ZVec.NET;
public sealed class ZVecQuery
{
public required string FieldName { get; init; }
public ReadOnlyMemory<float>? Vector { get; init; }
public IReadOnlyDictionary<int, float>? SparseVector { get; init; }
public string? DocumentId { get; init; }
public ZVecFtsQuery? Fts { get; init; }
/// <summary>Prefer typed <see cref="QueryParams"/> over opaque dictionaries.</summary>
public ZVecQueryParams? QueryParams { get; init; }
}
/// <summary>Index-specific search params (ef_search, nprobe, etc.). Subclass per index family.</summary>
public abstract class ZVecQueryParams { }
public sealed class ZVecHnswQueryParams : ZVecQueryParams
{
public int? EfSearch { get; init; }
}
public sealed class ZVecIvfQueryParams : ZVecQueryParams
{
public int? Nprobe { get; init; }
}
// Flat / DiskANN / Vamana / FTS query-param types are added when the matching
// setters are confirmed in upstream c_api.h (same binding-gap pattern as §2.0).
public sealed class ZVecFtsQuery
{
public string? MatchString { get; init; }
public string? QueryString { get; init; }
public ZVecFtsDefaultOperator DefaultOperator { get; init; } = ZVecFtsDefaultOperator.Or;
}
public enum ZVecFtsDefaultOperator { Or, And }
public sealed class ZVecGroupByQuery
{
public required ZVecQuery Query { get; init; }
public required string GroupByField { get; init; }
public int GroupSize { get; init; } = 1;
public int Topk { get; init; } = 10;
public string? Filter { get; init; }
}namespace ZVec.NET.Query;
public sealed class ZVecFilterBuilder
{
public static ZVecFilterBuilder Create() => new();
public ZVecFilterBuilder Where(string fieldName, ZVecCompareOp op, int value);
public ZVecFilterBuilder Where(string fieldName, ZVecCompareOp op, long value);
public ZVecFilterBuilder Where(string fieldName, ZVecCompareOp op, float value);
public ZVecFilterBuilder Where(string fieldName, ZVecCompareOp op, double value);
public ZVecFilterBuilder Where(string fieldName, ZVecCompareOp op, string value);
public ZVecFilterBuilder Where(string fieldName, ZVecCompareOp op, bool value);
public ZVecFilterBuilder And(ZVecFilterBuilder inner);
public ZVecFilterBuilder Or(ZVecFilterBuilder inner);
public ZVecFilterBuilder Not(ZVecFilterBuilder inner);
public ZVecFilterBuilder In(string fieldName, params object[] values); // boxes; cold path — typed overloads deferred
public ZVecFilterBuilder Like(string fieldName, string pattern);
public ZVecFilterBuilder ContainAny(string fieldName, params object[] values); // boxes; cold path
public ZVecFilterBuilder ContainAll(string fieldName, params object[] values); // boxes; cold path
/// <summary>
/// Generates the ZVec filter expression. String values are escaped
/// (quotes / backslashes) so literals like O'Brien cannot break the expression.
/// </summary>
public override string ToString();
}
// Usage:
// var filter = ZVecFilterBuilder.Create()
// .Where("publish_year", ZVecCompareOp.Gt, 1936)
// .And(ZVecFilterBuilder.Create().ContainAny("category", "fiction", "romance"));
// collection.Query(query, topk: 10, filter: filter.ToString());namespace ZVec.NET;
public sealed class ZVecCollectionSchemaBuilder
{
public ZVecCollectionSchemaBuilder(string name);
public ZVecCollectionSchemaBuilder WithMaxDocCountPerSegment(int value);
public ZVecCollectionSchemaBuilder AddField(ZVecFieldSchema field);
public ZVecCollectionSchemaBuilder AddField(string name, ZVecDataType dataType, bool nullable = false, ZVecInvertIndexParam? index = null);
public ZVecCollectionSchemaBuilder AddVector(ZVecVectorSchema vector);
public ZVecCollectionSchemaBuilder AddVector(string name, ZVecDataType dataType, int dimension, ZVecIndexParam? index = null);
public ZVecCollectionSchema Build();
}namespace ZVec.NET;
public abstract class ZVecReranker { }
public sealed class ZVecWeightedReranker : ZVecReranker
{
public int TopN { get; init; }
public ZVecMetricType Metric { get; init; }
public IReadOnlyDictionary<string, float> Weights { get; init; }
}
public sealed class ZVecRrfReranker : ZVecReranker
{
public int TopN { get; init; }
public int RankConstant { get; init; } = ZVecDefaults.Rerank.RankConstant; // native int, default 60
}namespace ZVec.NET;
public sealed class ZVecCollectionSchema
{
public required string Name { get; init; }
public IReadOnlyList<ZVecFieldSchema> Fields { get; init; }
public IReadOnlyList<ZVecVectorSchema> Vectors { get; init; }
public int MaxDocCountPerSegment { get; init; } = 10_000_000;
}
public sealed class ZVecFieldSchema
{
public required string Name { get; init; }
public ZVecDataType DataType { get; init; }
public bool Nullable { get; init; }
public ZVecInvertIndexParam? IndexParam { get; init; }
}
public sealed class ZVecVectorSchema
{
public required string Name { get; init; }
public ZVecDataType DataType { get; init; }
public int Dimension { get; init; }
public ZVecIndexParam? IndexParam { get; init; }
}namespace ZVec.NET;
public abstract class ZVecIndexParam { }
public sealed class ZVecHnswIndexParam : ZVecIndexParam
{
public ZVecMetricType MetricType { get; init; } = ZVecMetricType.Cosine;
public int M { get; init; } = 16;
public int EfConstruction { get; init; } = 200;
public ZVecQuantizeType QuantizeType { get; init; } = ZVecQuantizeType.Undefined;
}
/// <summary>
/// HNSW + RaBitQ. Maps to <c>IndexType::HNSW_RABITQ = 4</c> in <c>type.h</c>.
/// <c>c_api.h</c> may omit the macro — pass value 4 until upstream adds it. x86_64/AVX2 only.
/// </summary>
public sealed class ZVecHnswRabitqIndexParam : ZVecIndexParam
{
public ZVecMetricType MetricType { get; init; } = ZVecMetricType.Cosine;
public int M { get; init; } = 16;
public int EfConstruction { get; init; } = 200;
public int TotalBits { get; init; } = 7;
public int NumClusters { get; init; } = 16;
public int SampleCount { get; init; } = 0;
}
public sealed class ZVecIvfIndexParam : ZVecIndexParam
{
public ZVecMetricType MetricType { get; init; } = ZVecMetricType.L2;
public int CentroidsNum { get; init; } = 256;
public int Nlist { get; init; } = 16;
public int Nprobe { get; init; } = 8;
public ZVecQuantizeType QuantizeType { get; init; } = ZVecQuantizeType.Undefined;
}
public sealed class ZVecFlatIndexParam : ZVecIndexParam
{
public ZVecMetricType MetricType { get; init; } = ZVecMetricType.L2;
public ZVecQuantizeType QuantizeType { get; init; } = ZVecQuantizeType.Undefined;
}
public sealed class ZVecDiskAnnIndexParam : ZVecIndexParam
{
public ZVecMetricType MetricType { get; init; } = ZVecMetricType.L2;
public int MaxDegree { get; init; } = 100;
public int ListSize { get; init; } = 50;
public int PqChunkNum { get; init; } = 0;
public ZVecQuantizeType QuantizeType { get; init; } = ZVecQuantizeType.Undefined;
}
public sealed class ZVecVamanaIndexParam : ZVecIndexParam
{
public ZVecMetricType MetricType { get; init; } = ZVecMetricType.L2;
public int MaxDegree { get; init; } = 64;
public int SearchListSize { get; init; } = 100;
public float Alpha { get; init; } = 1.2f;
public bool SaturateGraph { get; init; } = false;
public bool UseContiguousMemory { get; init; } = false;
public bool UseIdMap { get; init; } = false;
public ZVecQuantizeType QuantizeType { get; init; } = ZVecQuantizeType.Undefined;
}
public sealed class ZVecFtsIndexParam : ZVecIndexParam
{
public ZVecFtsTokenizer Tokenizer { get; init; } = ZVecFtsTokenizer.Standard;
public IReadOnlyList<ZVecFtsTokenFilter> Filters { get; init; } = [ZVecFtsTokenFilter.Lowercase];
public ZVecFtsExtraParams? ExtraParams { get; init; }
}
public sealed class ZVecInvertIndexParam : ZVecIndexParam
{
public bool EnableRangeOptimization { get; init; } = false;
public bool EnableExtendedWildcard { get; init; } = false;
}User Code: ReadOnlyMemory<float> vec = embeddingModel.Embed("query");
│
▼
ZVecDoc: DenseVectors["embedding"] = vec; // No copy — stored as Memory<T>
│
▼
ZVecCollection.Query() / QueryAsync():
var (ptr, pin) = VectorMarshaller.PinVector(query.Vector);
try {
// Upstream query/doc APIs with pinned pointer
} finally {
pin.Dispose();
}
Key guarantees:
ReadOnlyMemory<float>is stored inZVecDoc— nofloat[]copy on the vector pathMemory.Pin()for the native call duration only; dispose infinally- G2 zero-allocation applies to vectors; scalar
Fieldsmay box (see §7.3)
Every native pointer is wrapped in a SafeHandle subclass:
- Consumers must call
Dispose()/await using— that is the primary cleanup path - If forgotten, the
SafeHandlefinalizer frees on GC — safety net only ReleaseHandlemay run on the finalizer thread; P/Invoke there can deadlock if native code uses TLS or thread-affine locks. Verifyzvec_collection_closeis safe from any thread; log a diagnostic if release is detected from a finalizer (missing Dispose)ReleaseHandle/Disposecallzvec_collection_closeonly — neverdestroy(that would wipe on-disk data)DisposeandDisposeAsynconZVecCollectionmust be idempotent and mutually exclusive (Interlocked.Exchange— cleanup once)- Do not rely on finalizers for hot-path resource turnover
internal static class ResultBufferPool
{
// Intermediate buffers for the native call only.
// After P/Invoke returns, copy into ZVecDoc objects, then Return to the pool.
// This is NOT zero-allocation for the final managed result list.
internal static (int[] ids, float[] scores, int bufferSize) RentResultBuffer(int topk)
{
var ids = ArrayPool<int>.Shared.Rent(topk);
var scores = ArrayPool<float>.Shared.Rent(topk);
return (ids, scores, topk);
}
internal static void ReturnResultBuffer(int[] ids, float[] scores)
{
ArrayPool<int>.Shared.Return(ids, clearArray: true);
ArrayPool<float>.Shared.Return(scores, clearArray: true);
}
}The upstream C API is synchronous/blocking. The managed SDK uses a minimalist concurrency model — no custom managed-side RW lock — because:
- ZVec's native
GlobalConfigusesstd::atomic<bool>for its own thread-safe init (config.h:197) - Factory/collection lifecycle uses
Interlocked.CompareExchange/Interlocked.Exchange - The C++ engine is responsible for its own operation-level thread safety
- The planned
AsyncReaderWriterLockwas canceled (redundant with native guarantees; CoreReview flagged correctness issues: AsyncLocal leakage, cancellation races)
┌──────────────┐ ┌──────────────────┐
│ Query / Fetch│ │ Insert / DDL │
│ (sync|async) │ │ (sync|async) │
└──────┬───────┘ └──────┬───────────┘
│ │
└──────────┬──────────┘
▼
┌──────────────────────────────────────┐
│ Optional MaxConcurrentNativeCalls │ ← per-factory SemaphoreSlim when > 0
│ + optional MaxConcurrentReads │ ← per-collection; 0 = unlimited
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Sync: P/Invoke on caller thread │
│ Async: same P/Invoke after gate │ ← WaitAsync when gates enabled;
│ → zvec_c_api │ else sync-completing ValueTask
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ ZVec C++ Core │
│ (owns its own thread safety) │
│ + QueryThreads intra-query pool │
└──────────────────────────────────────┘
Rules:
- No managed-side RW lock — The native C++ engine handles concurrent reads and exclusive writes internally. Managed code uses
Interlockedfor lifecycle state transitions only. - Sync path — P/Invoke on caller thread. Lowest latency (console, batch jobs, MAUI background).
- Async path — Cancellation-aware sync wrappers (
ValueTask). When optional gates are enabled, awaitWaitAsyncfor the gate only; then P/Invoke still runs on the continuation thread. Never unboundedTask.Runper request (worsens ASP.NET thread-pool starvation for sync native I/O). Bounded offload is deferred as a future optional epic. - Optional throttle —
MaxConcurrentNativeCalls(per factory) andMaxConcurrentReads(per collection) useSemaphoreSlimwhen > 0;0= unlimited. - Native query threads —
ZVecOptions.QueryThreadsis orthogonal (intra-query parallelism). - Dispose/Destroy safety —
Interlocked.Exchangeon_disposed/_destroyedflags ensures lifecycle operations run exactly once, even under concurrent calls. - Batch over chatty P/Invoke — Prefer list overloads.
- Zero-copy vectors — Pin only for the native call duration.
- Cancellation — Honored while waiting on optional gates (
WaitAsync+ linked operation/shutdown tokens) and before entering native. Mid-P/Invoke cancel is best-effort — document that limitation.
namespace ZVec.NET.DependencyInjection;
public sealed class ZVecOptions
{
public ZVecLogType LogType { get; set; } = ZVecLogType.Console;
public ZVecLogLevel LogLevel { get; set; } = ZVecLogLevel.Warn;
public int QueryThreads { get; set; } = -1;
public int MaxConcurrentNativeCalls { get; set; } = 0;
/// <summary>
/// Process-wide memory limit (MB). Maps to zvec_config_data_set_memory_limit
/// on global init — not a per-collection option.
/// </summary>
public int? MemoryLimitMb { get; set; }
}
public sealed class ZVecCollectionOptions
{
public bool ReadOnly { get; init; } = false;
public bool EnableMmap { get; init; } = true;
public int MaxConcurrentReads { get; init; } = 0; // 0 = unlimited
}Ship lib/net8.0 + lib/net9.0 + lib/net10.0 in one package (same SemVer). Natives are assembled from CI artifacts into runtimes/{rid}/native/.
ZVec.NET.nupkg/
├── lib/
│ ├── net8.0/ZVec.NET.dll
│ ├── net9.0/ZVec.NET.dll
│ └── net10.0/ZVec.NET.dll
├── runtimes/
│ ├── win-x64|win-arm64/native/zvec_c_api.dll
│ ├── linux-x64|linux-arm64/native/libzvec_c_api.so
│ ├── osx-x64|osx-arm64/native/libzvec_c_api.dylib
│ ├── android-arm64|android-x64/native/libzvec_c_api.so
│ └── ios-arm64|iossimulator-arm64|maccatalyst-arm64/native/libzvec_c_api.dylib
├── README.md
└── (optional) ZVec.NET.snupkg alongside
No build/*.props for RID native packing — the .NET runtime resolves runtimes/{rid}/native/ automatically. Soft size gate in CI: 500 MiB (desktop+mobile natives are large). Package owner on nuget.org: AdamSystems; PackageId remains ZVec.NET.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<PackageId>ZVec.NET</PackageId>
<AssemblyName>ZVec.NET</AssemblyName>
<RootNamespace>ZVec.NET</RootNamespace>
<!-- Version = our SemVer + build metadata for pinned native ZVec release.
Example: 1.0.0-alpha.1+zvec.1.2.3 means our SDK 1.0.0-alpha.1 wrapping ZVec C++ 1.2.3.
The .NET target is NOT in the version — it lives in TargetFrameworks + lib/ folder.
NuGet already shows "net8.0" as the dependency framework. -->
<Version>1.0.0-alpha.1+zvec.1.2.3</Version>
<Authors>Ahmed Samir https://github.com/ahmedSamir50</Authors>
<Company>Adam Systems EGY</Company>
<Description>High-performance .NET SDK for Alibaba ZVec — the "SQLite of Vector DBs". Zero-allocation vector pipelines (ReadOnlyMemory<float>), sync + async APIs, DI-first design. Wraps the official zvec_c_api C++ core with idiomatic C#: SafeHandle guarantees, HNSW/IVF/Flat/DiskANN/Vamana/FTS indexes, hybrid search, schema evolution, and cross-platform native binaries (win/linux/mac, x64/arm64).</Description>
<PackageTags>zvec;vector-database;embeddings;HNSW;semantic-search;RAG;dotnet;alibaba;similarity-search;ann</PackageTags>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>Wraps ZVec C++ 1.2.3; targets .NET 8.0+ (LTS baseline)</PackageReleaseNotes>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<RepositoryUrl>https://github.com/AdamSystems/ZVec.NET</RepositoryUrl>
<!-- SignAssembly + AssemblyOriginatorKeyFile come from root Directory.Build.props
(key file: build/ZVec.NET.snk) -->
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<Content Include="runtimes\**\*" Pack="true" PackagePath="runtimes" />
</ItemGroup>
</Project>The version encodes three pieces of information across different NuGet metadata fields:
| What | Where it goes | Format | Example |
|---|---|---|---|
| Our SDK version | <Version> (SemVer prefix) |
major.minor.patch[-prerelease] |
1.0.0-alpha.1 |
| Pinned ZVec native version | <Version> build metadata (after +) |
+zvec.{major}.{minor}.{patch} |
+zvec.1.2.3 |
| Supported .NET target | <TargetFrameworks> + lib/net8.0/ folder |
TFM | net8.0 (LTS baseline) |
Combined NuGet version: <Version>1.0.0-alpha.1+zvec.1.2.3</Version>
Why .NET target is NOT in the version string: Per SemVer and NuGet conventions, the target framework is expressed via the TFM and the lib/ folder structure, not the version. NuGet already displays "net8.0" as a dependency framework in the package listing. Putting it in the version would be non-standard and break tooling.
<PackageReleaseNotes> carries a human-readable summary: "Wraps ZVec C++ 1.2.3; targets .NET 8.0+ (LTS baseline)"
If managed adds P/Invoke for new APIs before native ships those symbols, bump the required native pin and fail the version gate clearly.
Once the alpha NuGet package is published:
- GitHub Issue — Open a "Community SDK" issue on
alibaba/zvecwith NuGethttps://www.nuget.org/packages/ZVec.NETand sourcehttps://github.com/ahmedSamir50/AdamSystems.ZVec.NET(maintainer Adam Systems EGY; PackageIdZVec.NET) - Docs PR — Submit a PR to ZVec's docs adding
ZVec.NETto their SDK list (alongside Python/Node) - Community channels — Announce in ZVec's Discord/Slack channels
- NuGet discoverability —
<PackageTags>withzvec,vector-database,alibabaensures developers searching NuGet for vector DBs find it
| Category | Scope | Native Library | Count (est.) |
|---|---|---|---|
| Unit | DTO serialization, filter building, enum mapping, SafeHandle lifecycle, ABI/platform gates | None (pure managed) | ~60 |
| Integration | Full CRUD lifecycle, query accuracy, FTS, hybrid search, schema evolution | Real (Skip if missing) | ~40 |
| Memory | Zero-allocation vector verification, SafeHandle leak detection, ArrayPool recycling | Real (Skip if missing) | ~15 |
| Concurrency | Multi-threaded read/write; SemaphoreSlim when MaxConcurrent* > 0 | Real (Skip if missing) | ~10 |
Canceled with Epic E17. The C++ mock project is retired. Historical design notes below are archive-only. Prefer real native + Skip; use
SetMockLibraryonly for missing-path failure tests.
Implements the entire— canceledc_api.hsurfaceBuilds as— canceledzvec_c_apishared library- Unit tests are pure managed; integration tests link against real
NativeLibrary.SetDllImportResolver/SetMockLibraryremain for failure-path tests only
// zvec_c_api_mock.cpp — namespace ::mock (internal linkage)
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#include <cmath>
#include <algorithm>
#include <cstring>
#include "zvec/c_api.h" // Include upstream header to ensure signature compatibility
namespace mock {
// Thread-safe global state
static std::mutex g_mutex;
static bool g_initialized = false;
static zvec_config_data_t* g_config = nullptr;
static int g_version_major = 0, g_version_minor = 3, g_version_patch = 0;
// Per-collection store
struct MockDocument {
std::string pk;
std::unordered_map<std::string, std::string> string_fields;
std::unordered_map<std::string, std::vector<float>> vector_fields;
float score = 0.0f;
};
struct MockCollection {
std::string path;
std::string name;
bool is_open = true;
std::unordered_map<std::string, MockDocument> documents; // pk -> doc
};
static std::unordered_map<std::string, MockCollection> g_collections; // path -> collection
// Error state (thread-local to match upstream behavior)
static thread_local zvec_error_code_t t_last_error = ZVEC_OK;
static thread_local std::string t_last_error_msg;
} // namespace mock| Category | # Functions | Mock Depth | Notes |
|---|---|---|---|
| Version | 4 | Full | Return hardcoded version matching ExpectedMajor/Minor/Patch |
| Error handling | 3 | Full | Thread-local storage; zvec_get_last_error allocates via zvec_malloc |
| String/array memory | 15 | Full | Simple alloc/free with std::vector backing |
| Config + Init/Shutdown | 20+ | Full | Parse config; track initialized state |
| Index params | 30+ | Stub | zvec_index_params_create stores type; getters return defaults; setters accept and store values |
| Field schema | 15+ | Full | Store name, type, dimension, nullable, index params |
| Collection schema | 15+ | Full | Store name, fields list; add_field/alter_field/drop_field/add_index/drop_index mutate |
| Collection options | 5 | Full | Store mmap, read_only, max_buffer_size |
| Collection lifecycle | 6 | Full | create_and_open makes directory + in-memory store; open loads existing; close/destroy as expected |
| Query params (6 types) | 30+ | Stub | Store params; getters return stored values |
| Vector query | 10+ | Full | Store field_name, vector data (copy), filter, topk, query_params |
| FTS query | 5 | Stub | Store query_string/match_string; mock search returns all docs with matching fields |
| Group-by query | 8 | Partial | Store params; group results by specified field |
| Multi-query + sub-query | 10+ | Full | Store sub-queries; mock reranking merges results |
| Doc create/destroy/get/set | 15+ | Full | Store pk, fields, vectors; getters return stored values |
| DML (insert/upsert/update/delete) | 10 | Full | Mutate MockCollection::documents; return success/error counts |
| DQL (query/multi_query/fetch) | 3 | Core logic | See §10.2.4 |
| DDL (add_column/drop_column/alter_column) | 3 | Full | Mutate schema fields |
| Stats | 3 | Full | Return doc_count, index_count from store |
| Utility (to_string converters) | 4 | Full | Switch on enum value, return static strings |
This is the most important mock logic — it must produce plausible ranked results:
// Simplified brute-force cosine similarity search
extern "C" ZVEC_EXPORT zvec_error_code_t ZVEC_CALL
zvec_collection_query(const zvec_collection_t* collection,
const zvec_vector_query_t* query,
zvec_doc_t*** results, size_t* result_count)
{
auto* col = reinterpret_cast<mock::MockCollection*>(collection);
auto* q = reinterpret_cast<mock::MockQueryState*>(query);
std::lock_guard<std::mutex> lock(mock::g_mutex);
// Get query vector from stored state
const float* query_vec = q->query_vector_data;
size_t query_dim = q->query_vector_size / sizeof(float);
// Score every document
struct ScoredDoc {
mock::MockDocument* doc;
float score;
};
std::vector<ScoredDoc> scored;
for (auto& [pk, doc] : col->documents) {
auto it = doc.vector_fields.find(q->field_name);
if (it == doc.vector_fields.end()) continue;
const auto& vec = it->second;
if (vec.size() != query_dim) continue;
// Cosine similarity
float dot = 0, normA = 0, normB = 0;
for (size_t i = 0; i < query_dim; i++) {
dot += query_vec[i] * vec[i];
normA += query_vec[i] * query_vec[i];
normB += vec[i] * vec[i];
}
float sim = (normA > 0 && normB > 0) ? dot / (std::sqrt(normA) * std::sqrt(normB)) : 0;
scored.push_back({&doc, sim});
}
// Sort by score descending
std::sort(scored.begin(), scored.end(),
[](const auto& a, const auto& b) { return a.score > b.score; });
// Apply topk
int topk = q->topk > 0 ? q->topk : 10;
size_t count = std::min(static_cast<size_t>(topk), scored.size());
// Allocate result array
*results = static_cast<zvec_doc_t**>(mock_alloc(sizeof(zvec_doc_t*) * count));
for (size_t i = 0; i < count; i++) {
(*results)[i] = create_mock_doc_from_document(scored[i].doc, scored[i].score);
}
*result_count = count;
return ZVEC_OK;
}# src/Mock/ZVec.Native.Mock/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(zvec_c_api_mock CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(zvec_c_api_mock SHARED
src/zvec_c_api_mock.cpp
)
target_include_directories(zvec_c_api_mock PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../../Native/ZVec.Native/external/zvec/src/include
)
# Output with the same name as the real library
set_target_properties(zvec_c_api_mock PROPERTIES
OUTPUT_NAME "zvec_c_api" # Same name for DllImportResolver switching
WINDOWS_EXPORT_ALL_SYMBOLS ON
)// NativeLibraryResolver.cs
internal static class NativeLibraryResolver
{
private static string? _mockLibraryPath;
private static bool _useMock;
internal static void UseMockLibrary(string mockPath)
{
_mockLibraryPath = mockPath;
_useMock = true;
}
internal static void UseRealLibrary() => _useMock = false;
internal static void EnsureRegistered()
{
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve);
}
private static IntPtr Resolve(string name, Assembly assembly, DllImportSearchPath? searchPath)
{
if (name != "zvec_c_api") return IntPtr.Zero;
if (_useMock && _mockLibraryPath is not null)
{
if (NativeLibrary.TryLoad(_mockLibraryPath, out var handle))
return handle;
}
// Fall back to real library from runtimes/{rid}/native/
if (NativeLibrary.TryLoad(name, assembly, searchPath, out var realHandle))
return realHandle;
throw new DllNotFoundException(
$"ZVec native library not found for RID '{RuntimeInformation.RuntimeIdentifier}'.");
}
}Advisory alternative: a pure managed mock that returns function pointers through SetDllImportResolver (no C++ mock project) — optional later; v1 keeps the C++ mock.
No external concurrency testing framework is required — raw Task + xUnit assertions + Interlocked + Barrier are sufficient. The .NET BCL provides everything:
Task.WhenAllfor concurrent launchSemaphoreSlimfor throttlingCancellationTokenSourcefor cancellationInterlockedfor thread-safe countersxUnit[Fact]withasync Taskfor async tests
For systematic concurrency testing, add:
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<!-- No special testing package — use raw Task + xUnit assertions -->🗑️ Canceled — This subsection described correctness + stress + linearizability tests for the
AsyncReaderWriterLock component, which was removed from the codebase. The native C++ engine
handles its own thread safety; no managed-side RW lock exists to test.
These test the ZVecCollection's use of the lock with real (or mock) native calls:
| # | Test | Scenario |
|---|---|---|
| 1 | ConcurrentReads_DontBlock |
10 QueryAsync calls complete concurrently |
| 2 | WriteBlocksReads |
InsertAsync blocks QueryAsync until complete |
| 3 | MixedReadWrite_NoCorruption |
5 readers + 2 writers, all complete without exception |
| 4 | Cancellation_DuringQuery |
Cancel QueryAsync while waiting on lock |
| 5 | DisposeDuringActiveRead |
DisposeAsync waits for active reads to finish |
| 6 | DestroyDuringActiveRead |
DestroyAsync waits for active reads, then destroys |
| 7 | MaxConcurrentReads_Throttles |
MaxConcurrentReads=4 limits to 4 concurrent native calls |
| 8 | GlobalThrottle_LimitsNativeCalls |
MaxConcurrentNativeCalls=8 caps concurrent P/Invokes |
// Test helpers for deterministic concurrency testing
internal static class ConcurrencyTestHelper
{
/// <summary>
/// Runs an action on multiple threads simultaneously, with a barrier
/// to ensure all threads start at the same time.
/// </summary>
internal static async Task RunConcurrently(int threadCount, Func<int, Task> action)
{
var barrier = new Barrier(threadCount);
var tasks = Enumerable.Range(0, threadCount)
.Select(i => Task.Run(async () =>
{
barrier.SignalAndWait();
await action(i);
}));
await Task.WhenAll(tasks);
}
/// <summary>
/// Verifies no deadlock by timing out after a specified duration.
/// </summary>
internal static async Task VerifyNoDeadlock(Func<Task> action, TimeSpan timeout)
{
var task = action();
var completed = await Task.WhenAny(task, Task.Delay(timeout));
if (completed != task)
throw new TimeoutException($"Deadlock suspected: operation did not complete within {timeout}");
await task; // Propagate exceptions
}
}using ZVec.NET;
using ZVec.NET.Query;
// Unit: Filter builder
[Fact]
public void FilterBuilder_CompoundExpression_GeneratesCorrectString()
{
var filter = ZVecFilterBuilder.Create()
.Where("publish_year", ZVecCompareOp.Gt, 1936)
.And(ZVecFilterBuilder.Create().ContainAny("category", "fiction", "romance"));
filter.ToString().Should().Be("publish_year > 1936 AND category CONTAIN_ANY [\"fiction\", \"romance\"]");
}
// Integration: CRUD lifecycle (sync path)
[Fact]
public void Collection_InsertFetchDelete_Lifecycle()
{
using var factory = /* resolve IZvecFactory */;
factory.Initialize();
using var col = factory.CreateAndOpen(tempPath, schema);
var doc = ZVecDoc.Create("doc1",
denseVectors: new Dictionary<string, ReadOnlyMemory<float>>
{
["embedding"] = new float[] { 0.1f, 0.2f, 0.3f, 0.4f }
},
fields: new Dictionary<string, object> { ["title"] = "Test" });
var insertResult = col.Insert(doc);
insertResult.Code.Should().Be(ZVecErrorCode.Ok);
var fetched = col.Fetch("doc1");
fetched.Should().NotBeNull();
fetched!.Fields["title"].Should().Be("Test");
var deleteResult = col.Delete("doc1");
deleteResult.Code.Should().Be(ZVecErrorCode.Ok);
}
// Integration: async path
[Fact]
public async Task Collection_QueryAsync_ReturnsHits()
{
await factory.InitializeAsync();
await using var col = await factory.OpenAsync(tempPath);
var results = await col.QueryAsync(
new ZVecQuery { FieldName = "embedding", Vector = memory },
topk: 10);
results.Should().NotBeEmpty();
}
// Memory: vector path — prefer BenchmarkDotNet [MemoryDiagnoser] for accuracy.
// GC.GetAllocatedBytesForCurrentThread includes framework noise; keep threshold tight.
[Fact]
public void Query_WithPinnedMemory_DoesNotAllocateFloatArrayCopy()
{
var vector = new float[768];
var memory = new ReadOnlyMemory<float>(vector);
long before = GC.GetAllocatedBytesForCurrentThread();
_ = collection.Query(new ZVecQuery { FieldName = "embedding", Vector = memory }, topk: 10);
long after = GC.GetAllocatedBytesForCurrentThread();
// Must not copy 768*4 bytes; allow small overhead only (prefer BDN for CI gates)
(after - before).Should().BeLessThan(256);
}| Benchmark | Measures | Comparison |
|---|---|---|
VectorMarshallingBench |
Time to pass 768-dim vector through P/Invoke | vs. raw DllImport with float[] |
QueryThroughputBench |
QPS for single-vector search (sync path) | vs. Python ZVec SDK |
InsertThroughputBench |
Docs/sec for batch insert | vs. Python ZVec SDK |
MemoryDiagnosisBench |
GC allocations per operation | Near-zero on vector query path |
FilterParsingBench |
Filter builder string generation | Must be < 1 µs |
using ZVec.NET;
[MemoryDiagnoser]
[RankColumn]
public class QueryThroughputBench
{
private IZvecCollection _collection = null!;
private ReadOnlyMemory<float> _queryVector;
private float[] _queryArray;
[GlobalSetup]
public void Setup()
{
var factory = /* build IZvecFactory */;
factory.Initialize();
_collection = factory.Open("/bench_collection");
_queryVector = new float[768];
_queryArray = new float[768];
}
[Benchmark(Baseline = true)]
public IReadOnlyList<ZVecDoc> Query_ReadOnlyMemory()
{
return _collection.Query(
new ZVecQuery { FieldName = "embedding", Vector = _queryVector },
topk: 10);
}
/// <summary>
/// Forces a managed copy so GC allocation differs from the zero-copy baseline.
/// Note: passing float[] alone does NOT copy — it wraps as ReadOnlyMemory without alloc.
/// </summary>
[Benchmark]
public IReadOnlyList<ZVecDoc> Query_ExplicitCopy()
{
float[] copy = _queryArray.ToArray();
return _collection.Query(
new ZVecQuery { FieldName = "embedding", Vector = copy },
topk: 10);
}
[Benchmark]
public IReadOnlyList<ZVecDoc> Query_WithFilter()
{
return _collection.Query(
new ZVecQuery { FieldName = "embedding", Vector = _queryVector },
topk: 10,
filter: "publish_year > 2000");
}
}| Metric | Target | Baseline |
|---|---|---|
| Sync P/Invoke overhead (768-dim vector) | < 50 µs | DllImport float[] ≈ 30 µs |
| Single-vector query (10k docs, topk=10) | < 1 ms | Python ZVec ≈ 1.2 ms |
| Batch insert (1000 docs) | > 50k docs/sec | Python ZVec ≈ 40k docs/sec |
| GC allocation per query (vector path) | < 256 B intermediate + result objects | float[] copy ≈ 3 KB |
| Filter builder string generation | < 1 µs | String concatenation ≈ 0.5 µs |
- Epic E14 (Schema DDL): Fully implemented DDL methods in
ZVecCollection(AddColumn,DropColumn,AlterColumn, etc.) and validated with tests. - Epic E16 (Dependency Injection): Integrated
Microsoft.Extensions.DependencyInjectionwithAddZVecandAddZVecCollection/AddZVecCollection<T>using keyed singletons and DI options. - Epic E18 (Integration Tests) / Epic E19 (Memory & Concurrency): Real-native coverage with Skip; ABI min+same-major (
ZVecNativeAbi); platform gates; SemaphoreSlim throttles. - Epic E23 (Typed ODM):
ZVec.NET.Mapping,IZvecCollection<T>,From<T>(), expression filters, typed DDL + additiveEnsureSchema,AddZVecCollection<T>. DynamicZVecDoc/ string filters remain as escape hatch. E24 (ZVec.NET.VectorData) deferred as a future separate package.
- Epic E17 (Mock Native Library): 🗑️ Canceled — mock C++ project retired; real native + Skip is the test strategy.
| ID | Task | Owner | Depends On | Est. |
|---|---|---|---|---|
| 1.1 | Submodule + CMake wrapper for upstream zvec_c_api |
C++ Lead | — | 2d |
| 1.2 | Audit §2.5 vs c_api.h; freeze gap table |
C++ Lead | 1.1 | 1d |
| 1.3 | Align P/Invoke surface with upstream c_api.h |
C++ Lead | 1.2 | 1d |
| 1.4 | Wire global init + version APIs + schema construction | C++ Lead | 1.3 | 2d |
| 1.5 | Wire collection lifecycle (create_and_open, open, close, destroy) | C++ Lead | 1.4 | 2d |
| 1.6 | Wire insert/upsert/update/delete | C++ Lead | 1.4 | 3d |
| 1.7 | Wire query/fetch | C++ Lead | 1.4 | 3d |
| 1.8 | Wire optimize + schema DDL | C++ Lead | 1.4 | 2d |
| 1.9 | Compile test libraries (win-x64, linux-x64) | C++ Lead | 1.6-1.8 | 1d |
| 1.10 | Cross-compile macOS + ARM64 | C++ Lead | 1.9 | 2d |
| 1.11 | Implement mock C-API library | C++ Dev | 1.3 | 5–8d |
| ID | Task | Owner | Depends On | Est. |
|---|---|---|---|---|
| 2.1 | Create ZVec.NET class library | .NET Lead | — | 0.5d |
| 2.2 | Define C# enums matching Appendix A | .NET Lead | 1.2 | 0.5d |
| 2.3 | Implement [LibraryImport] (U1 bools, version APIs) |
.NET Lead | 2.1, 1.3 | 2d |
| 2.4 | SafeHandles + finalizer/Dispose guidance | .NET Lead | 2.3 | 1d |
| 2.5 | VectorMarshaller + ArrayPool | .NET Lead | 2.3 | 2d |
| 2.6 | NativeLibraryResolver + clear DllNotFound | .NET Lead | 2.3 | 0.5d |
| 2.7 | Unit tests for SafeHandle + enum mapping | .NET Dev | 2.4, 2.2 | 2d |
| ID | Task | Owner | Depends On | Est. |
|---|---|---|---|---|
| 3.0 | IZvecFactory / IZvecCollection sync+async + DI + Shutdown |
.NET Lead | 2.2 | 1.5d |
| 3.1 | DTOs + SchemaBuilder + typed query params | .NET Lead | 2.2 | 2d |
| 3.2 | ZVecFactory (init, first-init-wins, version gate, create/open, shutdown) |
.NET Lead | 2.3, 3.0, 3.1 | 2d |
| 3.3 | Internal.AsyncReaderWriterLock + unit tests |
— | — | — |
| 3.4 | ZVecCollection CRUD sync+async + RW lock + close-vs-destroy |
.NET Lead | 3.2, 3.3 | 4d |
| 3.5 | Query sync+async (single + multi-vector) | .NET Lead | 3.4 | 3d |
| 3.6 | Optimize / Destroy sync+async (destroy then close) | .NET Lead | 3.2 | 0.5d |
| 3.7 | Schema DDL sync+async | .NET Lead | 3.2 | 2d |
| 3.8 | ZVecFilterBuilder (ZVecCompareOp + escaping) |
.NET Lead | 3.1 | 1.5d |
| 3.9 | Reranker types | .NET Lead | 3.1 | 1d |
| 3.10 | Collection properties | .NET Lead | 3.2 | 1d |
| 3.11 | Integration tests | .NET Dev | 3.4-3.10 | 5–7d |
| 3.12 | Memory/concurrency tests | .NET Dev | 3.3-3.10 | 3d |
| ID | Task | Owner | Depends On | Est. |
|---|---|---|---|---|
| 4.1 | GitHub Actions: C++ matrix (6 RIDs) | DevOps | 1.9 | 3–5d |
| 4.2 | GitHub Actions: .NET build + test | DevOps | 2.1 | 1d |
| 4.3 | NuGet pack (PackageLicenseExpression, snupkg, README, runtimes/) |
.NET Lead | 4.1, 4.2 | 2–3d |
| 4.4 | BenchmarkDotNet suite | .NET Dev | 3.11 | 3d |
| 4.5 | Validate performance targets | .NET Dev | 4.4 | 1d |
| 4.6 | Publish alpha NuGet | .NET Lead | 4.3 | 0.5d |
| 4.7 | README + getting-started | .NET Lead | 4.6 | 1d |
Optimistic calendar floor remains ~8 weeks if native/CI goes smoothly; plan to 10–12 weeks for cross-platform RIDs, packaging, and integration depth.
Workflows live under .github/workflows/ (not only build/ci/):
build-native.yml— desktop: win-x64, win-arm64 (compile-only), linux-x64, linux-arm64 (cross), osx-arm64 (macos-latest), osx-x64 (macos-13)build-native-mobile.yml— android-arm64 / android-x64 (NDK); ios-arm64 / iossimulator-arm64 / maccatalyst-arm64 (Xcode)build-managed.yml— restore/build/test core + tests only (not samples). Push/PR: integration Skip without native. Pack calls withrequire_nativeafter desktop natives.pack.yml— desktop natives → managed-with-RID → assemble artifacts →dotnet pack→ consumer smoke (mobile soft-fail in parallel)publish-nuget.yml— tagv*→ nuget.org (Trusted Publishing OIDC preferred)
Scripts: build/ci/deploy-native.sh|.ps1, build-android.sh, build-ios.sh, validate-consumer.sh.
- Build Native (desktop) — Compile
zvec_c_apifor 6 desktop RIDs; uploadzvec-native-{rid} - Build Native (mobile) — Android NDK + Apple mobile RIDs (parallel; soft-fail OK for pack)
- Build Managed (Pack) — Download RID for the runner →
dotnet build/dotnet testcore + tests (integration must run) - Build Managed (push/PR) — Same projects without native download; integration Skip if binary missing
- Pack — Assemble
runtimes/+dotnet pack+ symbols - Publish — Push to nuget.org (release tag only; commit on
release/*)
You don't own ARM64 devices. Building native ARM64 binaries requires cross-compilation. Testing them requires emulation.
| RID | Build Host | Cross-Compile Method | Test Method |
|---|---|---|---|
| win-x64 | windows-latest |
Native build (MSVC) | Run directly |
| win-arm64 | windows-latest |
MSVC with -A ARM64 |
No run test — compile-only gate |
| linux-x64 | ubuntu-latest |
Native build (GCC) | Run directly |
| linux-arm64 | ubuntu-latest |
aarch64-linux-gnu-gcc cross-compiler |
QEMU user-mode (qemu-aarch64) |
| osx-x64 | macos-latest (Intel runner) |
Native build (Clang) | Run directly |
| osx-arm64 | macos-latest (M-series runner) |
Native build (Clang) | Run directly (GitHub now has M1 runners) |
# .github/workflows/build-native.yml — linux-arm64 job
build-linux-arm64:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install cross-compilation toolchain
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user
- name: Configure CMake for ARM64
run: |
cmake -B build-arm64 \
-DCMAKE_SYSTEM_NAME=Linux \
-DCMAKE_SYSTEM_PROCESSOR=aarch64 \
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
-DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ \
-S src/Native/ZVec.Native
- name: Build
run: cmake --build build-arm64 --config Release
- name: Test with QEMU
run: |
# Run a simple smoke test binary that exercises the built library
qemu-aarch64 -L /usr/aarch64-linux-gnu \
./build-arm64/smoke_test_zvecWindows ARM64 is the hardest because QEMU can't run Windows binaries. Strategy:
- Compile-only gate: Build with MSVC
-A ARM64in CI. This verifies the code compiles correctly for ARM64. - No run test in CI — document this as a known gap.
- Community testing: Add a GitHub Issue template: "ARM64 Windows Testing Report" where users can report success/failure.
- Alternative: Use Windows ARM64 runners when GitHub makes them generally available (currently in preview).
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
rid: win-x64
cmake_extra: ""
test: true
- os: windows-latest
rid: win-arm64
cmake_extra: "-A ARM64"
test: false # compile-only
- os: ubuntu-latest
rid: linux-x64
cmake_extra: ""
test: true
- os: ubuntu-latest
rid: linux-arm64
cmake_extra: >
-DCMAKE_SYSTEM_NAME=Linux
-DCMAKE_SYSTEM_PROCESSOR=aarch64
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc
-DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++
test: true # via QEMU
cross: true
- os: macos-latest
rid: osx-x64
cmake_extra: ""
test: true
- os: macos-latest
rid: osx-arm64
cmake_extra: "-DCMAKE_OSX_ARCHITECTURES=arm64"
test: true # M-series runnerFor developers without ARM64 hardware:
- Docker + QEMU: Run
docker run --platform linux/arm64 ...with QEMU emulation. - CI is the gate: Don't build ARM64 locally. Push to CI and let the matrix handle it.
- Unit tests are managed-only (no mock C++ library). ARM64/mobile device runs are integration/community QA.
| # | Risk | Impact | Probability | Status |
|---|---|---|---|---|
| R1 | ZVec C++ ABI changes between versions | High | Medium | ✅ Mitigated |
| R2 | P/Invoke marshalling overhead exceeds 50µs (sync path) | High | Low | ✅ Mitigated |
| R3 | Cross-compilation fails for ARM64 | Medium | Medium | ✅ Mitigated (§13.3) |
| R4 | NuGet package size too large (>50MB) | Medium | Low | ✅ Mitigated |
| R5 | GC pauses / Field boxing on hot paths | Medium | Medium | ✅ Mitigated |
| R6 | Filter expression / escaping bugs | Medium | Medium | ✅ Mitigated |
| R7 | .NET multi-target / SDK churn | Medium | Low | ✅ Mitigated |
| R8 | SafeHandle finalizer-thread P/Invoke deadlock | High | Low | ✅ Mitigated |
| R9 | Dual-semaphore race if RW lock regresses | High | Low | ✅ Mitigated |
| R10 | Accidental on-disk delete via Dispose | High | Low | ✅ Mitigated |
| R11 | LPUTF8Str / wrong free on version or error strings |
High | Low | ✅ Mitigated |
| R12 | Upstream license change (non-permissive) | High | Low | ✅ Mitigated |
| R13 | Binding gaps (missing C API for documented DB features) | Medium | Medium | ✅ Closed (§2.0 audit) |
| R14 | Native DLL name conflict with another package | Medium | Low | ✅ Mitigated |
The version gate already mitigates this, but we add a compile-time lock:
// NativeMethods.cs — pinned at build time
internal const int ExpectedMajor = 0; // TODO: set from zvec submodule tag
internal const int ExpectedMinor = 3;
internal const int ExpectedPatch = 0;
// During Initialize:
if (!NativeMethods.zvec_check_version(ExpectedMajor, ExpectedMinor, ExpectedPatch))
throw new ZVecAbiMismatchException(...);CI enforcement: GitHub Action checks that the submodule tag matches ExpectedMajor/Minor/Patch constants. If someone bumps the submodule without updating the constants, CI fails.
NuGet metadata: <Version>{sdk-semver}+zvec.{major}.{minor}.{patch}</Version> makes the pinned version visible to consumers.
Already using [LibraryImport] (source-generated, no runtime reflection). Additional mitigation:
// Use unsafe pointers for vector hot path instead of managed arrays:
[LibraryImport(LibraryName)]
internal static partial int zvec_vector_query_set_query_vector(
zvec_vector_query_t* query, void* data, UIntPtr size);Benchmark gate in CI: BenchmarkDotNet test runs on every PR targeting the dev branch. If sync P/Invoke overhead exceeds 50µs on a 768-dim vector, the PR is blocked.
See §13.3 for the dedicated cross-compilation strategy. Key points: CI-first with QEMU emulation for linux-arm64, compile-only gate for win-arm64, native runners for macOS.
-
Build native libraries with
CMAKE_BUILD_TYPE=Releaseand strip debug symbols:set(CMAKE_BUILD_TYPE Release) set(CMAKE_CXX_FLAGS_RELEASE "-O2 -DNDEBUG") if(NOT WIN32) add_link_options(-s) # strip symbols endif()
-
Size budget per RID:
RID Expected Size Stripped? win-x64 ~8 MB Yes (.pdb separate) win-arm64 ~7 MB Yes linux-x64 ~6 MB Yes (strip) linux-arm64 ~5 MB Yes (strip) osx-x64 ~6 MB Yes (strip) osx-arm64 ~5 MB Yes (strip) Total ~37 MB -
CI gate:
dotnet packfails if.nupkgexceeds 50 MB. -
Fallback: If total exceeds 50 MB, split into RID-specific packages (
ZVec.NET.runtime.win-x64, etc.) with a meta-package.
- Vector path is already zero-allocation (
ReadOnlyMemory<float>+Memory.Pin()). - Document that scalar
Fieldsboxing is intentional and acceptable:/// <summary> /// Scalar fields. Values are boxed (object) for Python/Node parity. /// Zero-allocation goal (G2) applies to VECTOR paths only — iterating /// Fields on a hot query path allocates. Prefer vector-only access when measuring GC. /// </summary> public IReadOnlyDictionary<string, object> Fields { get; init; }
- Add
ZVecQuery.OutputFields(maps tozvec_vector_query_set_output_fields) so consumers can request only needed fields, reducing boxing.
ZVecFilterBuilderescapes single quotes, backslashes, and special characters:private static string EscapeString(string value) => value.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\"", "\\\"");
- Integration test gate: Every filter builder test has a mirror integration test that runs the generated filter against the real ZVec engine. This catches escaping bugs that unit tests miss.
- Fuzz testing: Generate random filter expressions with special characters and verify no crashes.
- Ship a single
lib/net8.0/asset when no TFM-specific#ifcode exists. net9/net10 consumers fall back automatically. - Pin
<LangVersion>latest</LangVersion>but avoid preview features. - CI tests against net8.0 and net9.0 only — net10.0 is optional until GA.
ReleaseHandlecallszvec_collection_closeonly — verified thread-safe by upstream (usesshared_ptrwith atomic refcount, no TLS).- Add diagnostic logging when finalizer runs:
protected override bool ReleaseHandle() { if (!IsInvalid) { if (!Environment.HasShutdownStarted && Thread.CurrentThread.IsThreadPoolThread) Debug.WriteLine("[ZVec] WARNING: SafeZvecHandle released from finalizer — missing Dispose()"); _ = NativeMethods.zvec_collection_close(handle); } return true; }
- Integration test: Force GC +
WaitForPendingFinalizersafter abandoning a collection, verify no deadlock within 5 seconds.
🗑️ No longer applicable — The AsyncReaderWriterLock was canceled; there is no custom
managed-side lock to regress against. Native C++ owns all operation-level thread safety.
Managed code uses Interlocked for lifecycle state only — the SemaphoreSlim/ReaderWriterLockSlim
restriction does not apply.
Dispose/ReleaseHandlecallszvec_collection_closeONLY — neverdestroy.Destroy/DestroyAsyncis a separate explicit method that callszvec_collection_destroythenzvec_collection_close.- After
Destroy, mark theSafeHandleinvalid so subsequentDisposeis a no-op. - Integration test:
[Fact] public void Dispose_DoesNotDeleteOnDiskData() { using var col = factory.CreateAndOpen(tempPath, schema); col.Insert(doc); col.Dispose(); // Re-open and verify data persists using var col2 = factory.Open(tempPath); var fetched = col2.Fetch("doc1"); fetched.Should().NotBeNull(); }
zvec_get_version()returnsconst char*— library-owned static memory. P/Invoke asIntPtr, read withMarshal.PtrToStringUTF8, never free.zvec_get_last_error()returnschar**— caller must free withfree()(libc). P/Invoke asout IntPtr, read withMarshal.PtrToStringUTF8, then free withzvec_free()(the library's own allocator):internal static string GetLastErrorMessage() { int rc = NativeMethods.zvec_get_last_error(out IntPtr msgPtr); if (msgPtr == IntPtr.Zero) return string.Empty; string msg = Marshal.PtrToStringUTF8(msgPtr) ?? string.Empty; NativeMethods.zvec_free(msgPtr); // Use library's free(), NOT Marshal.FreeCoTaskMem return msg; }
zvec_freeP/Invoke declaration:[LibraryImport(LibraryName)] internal static partial void zvec_free(IntPtr ptr);
- CI step checks the LICENSE file in the submodule:
- name: Verify upstream license run: | head -1 src/Native/ZVec.Native/external/zvec/LICENSE | grep -q "Apache License"
- If license changes, CI fails and blocks the build.
- NuGet metadata:
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>— same as upstream zvec. See root NOTICE for attribution.
This is now closed — see §2.0 audit. Only 2 gaps exist (HNSW_RABITQ=4, RABITQ=4), both have concrete resolutions using type.h values.
- The native library is named
zvec_c_api(upstream's choice). If another NuGet package also shipszvec_c_api.dll, the .NET runtime loads only one. - Mitigation: Use
NativeLibrary.SetDllImportResolverto load from our package'sruntimes/path explicitly. - Future option: If conflict occurs, ship a uniquely named native binary and add a
DllImportResolveralias. This is a breaking change and only done if a real conflict is reported.
| Milestone | Date (Target) | Deliverable |
|---|---|---|
| M1: Native MVP | Week 3 | Upstream c_api.h wired; win-x64 + linux-x64 binaries; mock library started |
| M2: P/Invoke Layer | Week 5 | NativeMethods + SafeHandles + version gate + VectorMarshaller; unit tests |
| M3: Public SDK Alpha | Week 8–9 | DI + sync/async CRUD/Query/FTS/DDL + RW lock; integration tests |
| M4: Cross-Platform Pack | Week 10–11 | CI builds 6 RIDs; NuGet pack (license expression, snupkg, README) |
| M5: Benchmark & Release | Week 11–12 | Performance targets validated; alpha ZVec.NET published |
Sole numeric enum / error-code table in this document. Do not reintroduce parallel enum lists in §5/§6.
Source of truth for numeric values:
src/Native/ZVec.Native/external/zvec/src/include/zvec/db/type.h(and matching macros inc_api.hwhen present). Prefertype.hif the C header lags (notablyHNSW_RABITQ = 4is defined intype.hbut currently missing fromc_api.h).Public docs subset: zvec.org Data Types / llms-full.txt (local audit snapshot:
docs/llms-full.txt) document the product-facing types (STRING…DOUBLE,ARRAY_*,VECTOR_FP16/FP32/INT8,SPARSE_VECTOR_*). The full ABI enum below is a superset (includesBINARY,VECTOR_FP64,VECTOR_INT4/INT16,ARRAY_BINARY, etc.) for P/Invoke fidelity — nothing from the public docs set was removed.
| Value | Name | C# Member | In public docs? |
|---|---|---|---|
| 0 | UNDEFINED |
ZVecDataType.Undefined |
— |
| 1 | BINARY |
ZVecDataType.Binary |
ABI-only |
| 2 | STRING |
ZVecDataType.String |
yes |
| 3 | BOOL |
ZVecDataType.Bool |
yes |
| 4 | INT32 |
ZVecDataType.Int32 |
yes |
| 5 | INT64 |
ZVecDataType.Int64 |
yes |
| 6 | UINT32 |
ZVecDataType.UInt32 |
yes |
| 7 | UINT64 |
ZVecDataType.UInt64 |
yes |
| 8 | FLOAT |
ZVecDataType.Float |
yes |
| 9 | DOUBLE |
ZVecDataType.Double |
yes |
| 20 | VECTOR_BINARY32 |
ZVecDataType.VectorBinary32 |
ABI-only |
| 21 | VECTOR_BINARY64 |
ZVecDataType.VectorBinary64 |
ABI-only |
| 22 | VECTOR_FP16 |
ZVecDataType.VectorFp16 |
yes |
| 23 | VECTOR_FP32 |
ZVecDataType.VectorFp32 |
yes |
| 24 | VECTOR_FP64 |
ZVecDataType.VectorFp64 |
ABI-only |
| 25 | VECTOR_INT4 |
ZVecDataType.VectorInt4 |
ABI-only |
| 26 | VECTOR_INT8 |
ZVecDataType.VectorInt8 |
yes |
| 27 | VECTOR_INT16 |
ZVecDataType.VectorInt16 |
ABI-only |
| 30 | SPARSE_VECTOR_FP16 |
ZVecDataType.SparseVectorFp16 |
yes |
| 31 | SPARSE_VECTOR_FP32 |
ZVecDataType.SparseVectorFp32 |
yes |
| 40 | ARRAY_BINARY |
ZVecDataType.ArrayBinary |
ABI-only |
| 41 | ARRAY_STRING |
ZVecDataType.ArrayString |
yes |
| 42 | ARRAY_BOOL |
ZVecDataType.ArrayBool |
yes |
| 43 | ARRAY_INT32 |
ZVecDataType.ArrayInt32 |
yes |
| 44 | ARRAY_INT64 |
ZVecDataType.ArrayInt64 |
yes |
| 45 | ARRAY_UINT32 |
ZVecDataType.ArrayUInt32 |
yes |
| 46 | ARRAY_UINT64 |
ZVecDataType.ArrayUInt64 |
yes |
| 47 | ARRAY_FLOAT |
ZVecDataType.ArrayFloat |
yes |
| 48 | ARRAY_DOUBLE |
ZVecDataType.ArrayDouble |
yes |
| Value | Name | C# Member |
|---|---|---|
| 0 | UNDEFINED |
ZVecMetricType.Undefined |
| 1 | L2 |
ZVecMetricType.L2 |
| 2 | IP |
ZVecMetricType.Ip |
| 3 | COSINE |
ZVecMetricType.Cosine |
| 4 | MIPSL2 |
ZVecMetricType.MipsL2 |
| Value | Name | C# Member | Notes |
|---|---|---|---|
| 0 | UNDEFINED |
ZVecIndexType.Undefined |
|
| 1 | HNSW |
ZVecIndexType.Hnsw |
|
| 2 | IVF |
ZVecIndexType.Ivf |
First-class in v1 |
| 3 | FLAT |
ZVecIndexType.Flat |
|
| 4 | HNSW_RABITQ |
ZVecIndexType.HnswRabitq |
In type.h; missing #define in c_api.h — use 4. x86_64/AVX2 only |
| 5 | DISKANN |
ZVecIndexType.DiskAnn |
|
| 6 | VAMANA |
ZVecIndexType.Vamana |
|
| 10 | INVERT |
ZVecIndexType.Invert |
|
| 11 | FTS |
ZVecIndexType.Fts |
| Value | Name | C# Member |
|---|---|---|
| 0 | UNDEFINED |
ZVecQuantizeType.Undefined |
| 1 | FP16 |
ZVecQuantizeType.Fp16 |
| 2 | INT8 |
ZVecQuantizeType.Int8 |
| 3 | INT4 |
ZVecQuantizeType.Int4 |
| 4 | RABITQ |
ZVecQuantizeType.Rabitq |
| Value | Name | C# Member |
|---|---|---|
| 0 | ZVEC_OK |
ZVecErrorCode.Ok |
| 1 | NOT_FOUND |
ZVecErrorCode.NotFound |
| 2 | ALREADY_EXISTS |
ZVecErrorCode.AlreadyExists |
| 3 | INVALID_ARGUMENT |
ZVecErrorCode.InvalidArgument |
| 4 | PERMISSION_DENIED |
ZVecErrorCode.PermissionDenied |
| 5 | FAILED_PRECONDITION |
ZVecErrorCode.FailedPrecondition |
| 6 | RESOURCE_EXHAUSTED |
ZVecErrorCode.ResourceExhausted |
| 7 | UNAVAILABLE |
ZVecErrorCode.Unavailable |
| 8 | INTERNAL_ERROR |
ZVecErrorCode.InternalError |
| 9 | NOT_SUPPORTED |
ZVecErrorCode.NotSupported |
| 10 | UNKNOWN |
ZVecErrorCode.Unknown |
Match zvec_log_level_t / zvec_log_type_t in c_api.h: Debug=0 … Fatal=4; Console=0 / File=1.
Match ZVEC_QUANTIZE_TYPE_* / QuantizeType in type.h: Undefined=0, Fp16=1, Int8=2, Int4=3, Rabitq=4.
| Value | Name | C# Member |
|---|---|---|
| 0 | INSERT |
ZVecOperator.Insert |
| 1 | UPSERT |
ZVecOperator.Upsert |
| 2 | UPDATE |
ZVecOperator.Update |
| 3 | DELETE |
ZVecOperator.Delete |
| Value | Name | C# Member |
|---|---|---|
| 0 | NONE |
ZVecCompareOp.None |
| 1 | EQ |
ZVecCompareOp.Eq |
| 2 | NE |
ZVecCompareOp.Ne |
| 3 | LT |
ZVecCompareOp.Lt |
| 4 | LE |
ZVecCompareOp.Le |
| 5 | GT |
ZVecCompareOp.Gt |
| 6 | GE |
ZVecCompareOp.Ge |
| 7 | LIKE |
ZVecCompareOp.Like |
| 8 | CONTAIN_ALL |
ZVecCompareOp.ContainAll |
| 9 | CONTAIN_ANY |
ZVecCompareOp.ContainAny |
| 10 | NOT_CONTAIN_ALL |
ZVecCompareOp.NotContainAll |
| 11 | NOT_CONTAIN_ANY |
ZVecCompareOp.NotContainAny |
| 12 | IS_NULL |
ZVecCompareOp.IsNull |
| 13 | IS_NOT_NULL |
ZVecCompareOp.IsNotNull |
| 14 | HAS_PREFIX |
ZVecCompareOp.HasPrefix |
| 15 | HAS_SUFFIX |
ZVecCompareOp.HasSuffix |
| Value | Name | C# Member |
|---|---|---|
| 0 | NONE |
ZVecRelationOp.None |
| 1 | AND |
ZVecRelationOp.And |
| 2 | OR |
ZVecRelationOp.Or |
| Value | Name | C# Member |
|---|---|---|
| 0 | UNDEFINED |
ZVecColumnOp.Undefined |
| 1 | ADD |
ZVecColumnOp.Add |
| 2 | ALTER |
ZVecColumnOp.Alter |
| 3 | DROP |
ZVecColumnOp.Drop |
| Value | Name | C# Member |
|---|---|---|
| 0 | UNDEFINED |
ZVecBlockType.Undefined |
| 1 | SCALAR |
ZVecBlockType.Scalar |
| 2 | SCALAR_INDEX |
ZVecBlockType.ScalarIndex |
| 3 | VECTOR_INDEX |
ZVecBlockType.VectorIndex |
| 4 | VECTOR_INDEX_QUANTIZE |
ZVecBlockType.VectorIndexQuantize |
| 5 | FTS_INDEX |
ZVecBlockType.FtsIndex |
| Value | Name | C# Member |
|---|---|---|
| 0 | UNKNOWN |
ZVecFileFormat.Unknown |
| 1 | IPC |
ZVecFileFormat.Ipc |
| 2 | PARQUET |
ZVecFileFormat.Parquet |
Complete method signatures extracted from the official Python and Node.js SDK documentation, serving as the ground truth for C# wrapper implementation.
zvec.init(log_type: LogType, log_level: LogLevel, query_threads: int)ZVecInitialize({ logType: ZVecLogType, logLevel: ZVecLogLevel, queryThreads: number })zvec.create_and_open(path: str, schema: CollectionSchema, option: CollectionOption = None) -> CollectionZVecCreateAndOpen(path: string, schema: ZVecCollectionSchema, options?: ZVecCollectionOptions): ZVecCollectionzvec.open(path: str, option: CollectionOption = None) -> CollectionZVecOpen(path: string, options?: ZVecCollectionOptions): ZVecCollectioncollection.insert(doc: Doc | list[Doc]) -> Status | list[Status]collection.insertSync(doc: ZVecDocInput | ZVecDocInput[]): ZVecStatus | ZVecStatus[]collection.upsert(doc: Doc | list[Doc]) -> Status | list[Status]collection.upsertSync(doc: ZVecDocInput | ZVecDocInput[]): ZVecStatus | ZVecStatus[]collection.update(doc: Doc | list[Doc]) -> Status | list[Status]collection.updateSync(doc: ZVecDocInput | ZVecDocInput[]): ZVecStatus | ZVecStatus[]collection.delete(ids: str | list[str]) -> Status | list[Status]
collection.delete_by_filter(filter: str) -> Statuscollection.deleteSync(ids: string | string[]): ZVecStatus | ZVecStatus[]
collection.deleteByFilterSync(filter: string): ZVecStatuscollection.fetch(ids: str | list[str]) -> dict[str, Doc]collection.fetchSync(ids: string | string[]): Record<string, ZVecDoc>collection.query(
queries: Query | list[Query],
topk: int = 10,
filter: str | None = None,
reranker: ReRanker | None = None,
) -> list[Doc]collection.querySync(params: {
fieldName: string,
vector?: number[],
fts?: { matchString?: string, queryString?: string, defaultOperator?: string },
topk?: number,
filter?: string
}): ZVecDoc[]collection.optimize() -> Nonecollection.optimizeSync(): void
collection.optimize(): Promise<void>collection.destroy() -> Nonecollection.destroySync(): voidcollection.add_column(field_schema: FieldSchema, expression: str) -> None
collection.drop_column(field_name: str) -> None
collection.alter_column(old_name: str = None, new_name: str = None, field_schema: FieldSchema = None) -> None
collection.create_index(field_name: str, index_param: IndexParam) -> None
collection.drop_index(field_name: str) -> Nonecollection.schema -> CollectionSchema
collection.stats -> { doc_count: int, index_completeness: dict[str, float] }
collection.option -> { enable_mmap: int, read_only: int }
collection.path -> strQuery(
field_name: str,
vector: list[float] | dict[int, float] | None = None,
id: str | None = None,
fts: Fts | None = None,
params: dict | None = None,
)
Fts(
match_string: str | None = None,
query_string: str | None = None,
default_operator: str | None = None,
)WeightedReRanker(topn: int, metric: MetricType, weights: dict[str, float])
RrfReRanker(topn: int, rank_constant: float = 60.0)End of ZVec.NET Project Plan