From b377157ffa4293674a75afc5bb2a1c216a92e253 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 06:08:22 -0700 Subject: [PATCH 01/19] feat(c-api): add caller-supplied stream interface and streambuf adapter Adds svs_stream_interface_ops / svs_stream_interface, the svs_stream_ops_t / svs_stream_t / svs_stream_i typedef triple and SVS_INIT_STREAM_OPS to the public header, following the shape of the existing threadpool, allocator and id_filter interfaces. No seek callback: index load is strictly sequential and save needs only a position query, which the adapter answers from its own byte counter. The adapter in src/stream.hpp bridges the vtable to std::istream/std::ostream through a 64 KiB buffered std::streambuf. Two details are load-bearing: - tellp() reports bytes handed to the streambuf rather than bytes flushed. StreamArchiver::write_table derives cache-line padding from it, so a stale count misaligns the payload silently and would defeat a future mmap load. - Both stream wrappers set exceptions(badbit). Without it the iostream sentry converts a callback exception into a silent badbit, and a save would report success after the write callback had reported failure. validate() follows allocator.hpp, the only existing interface that honours the version and struct_size fields it declares. A null stream is an error rather than the no-op leniency IDFilterAdapter applies to a null filter. Co-Authored-By: Claude Opus 5 --- bindings/c/CMakeLists.txt | 1 + bindings/c/include/svs/c/svs_c.h | 64 +++++++++++ bindings/c/src/stream.hpp | 187 +++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 bindings/c/src/stream.hpp diff --git a/bindings/c/CMakeLists.txt b/bindings/c/CMakeLists.txt index 3a526590..d2273bd6 100644 --- a/bindings/c/CMakeLists.txt +++ b/bindings/c/CMakeLists.txt @@ -39,6 +39,7 @@ set(SVS_C_API_SOURCES src/index_builder.hpp src/leanvec_training_data.hpp src/storage.hpp + src/stream.hpp src/threadpool.hpp src/types_support.hpp diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h index 5034ecdf..dc9214c3 100644 --- a/bindings/c/include/svs/c/svs_c.h +++ b/bindings/c/include/svs/c/svs_c.h @@ -294,6 +294,66 @@ struct svs_id_filter_interface { void* self; }; +/// @brief Operations table for a caller-supplied byte stream. +/// @remarks Access is strictly sequential: the library never repositions the stream. Both +/// callbacks are invoked serially from the thread that called the streaming save or load +/// function, so no synchronization is required — unlike the thread pool, allocator and ID +/// filter interfaces. +/// @remarks Exactly one direction is required per operation: @ref svs_index_save_stream +/// needs +/// @p write, the load functions need @p read. The unused callback may be NULL. +/// @var svs_stream_interface_ops::version +/// Interface version, set by @ref SVS_INIT_STREAM_OPS. +/// @var svs_stream_interface_ops::struct_size +/// Size of this structure, set by @ref SVS_INIT_STREAM_OPS. +/// @var svs_stream_interface_ops::read +/// Reads at most @p n bytes into @p buf. +/// @param self Pointer to the stream instance. +/// @param buf Destination buffer. +/// @param n Maximum number of bytes to read. +/// @param out_err Handle to capture any error that occurs during the read. User code may +/// call svs_error_set() to set the error code and message if an error occurs. +/// @return The number of bytes read; 0 signals end of stream. A short read is not an +/// error and the library will call again. NULL for a write-only stream. +/// @var svs_stream_interface_ops::write +/// Writes exactly @p n bytes from @p buf. +/// @param self Pointer to the stream instance. +/// @param buf Source buffer. +/// @param n Number of bytes to write. +/// @param out_err Handle to capture any error that occurs during the write. User code may +/// call svs_error_set() to set the error code and message if an error occurs. +/// @return True on success. A partial write must be reported as failure. NULL for a +/// read-only stream. +struct svs_stream_interface_ops { + uint32_t version; + size_t struct_size; + size_t (*read)(void* self, void* buf, size_t n, svs_error_h out_err); + bool (*write)(void* self, const void* buf, size_t n, svs_error_h out_err); +}; + +/// @brief Macro to create a user-defined stream interface operations structure +/// @param read_func Function pointer that reads at most @p n bytes into @p buf, or NULL for +/// a write-only stream +/// @param write_func Function pointer that writes exactly @p n bytes from @p buf, or NULL +/// for a read-only stream +#define SVS_INIT_STREAM_OPS(read_func, write_func) \ + { \ + .version = SVS_C_API_VERSION, \ + .struct_size = sizeof(struct svs_stream_interface_ops), .read = (read_func), \ + .write = (write_func) \ + } + +/// @brief Structure representing a caller-supplied byte stream +/// @var svs_stream_interface::ops +/// Function pointers for the stream operations. +/// @var svs_stream_interface::self +/// Pointer to the user-defined stream instance. This pointer is passed to the function +/// pointers in @p ops when they are called. +struct svs_stream_interface { + struct svs_stream_interface_ops* ops; + void* self; +}; + /// @brief Macro to create a user-defined interface implementation structure /// @param user_ptr Pointer to the user-defined object /// @param vtable Function pointers for the interface operations @@ -498,6 +558,10 @@ typedef struct svs_id_filter_interface_ops svs_id_filter_ops_t; typedef struct svs_id_filter_interface svs_id_filter_t; typedef struct svs_id_filter_interface* svs_id_filter_i; +typedef struct svs_stream_interface_ops svs_stream_ops_t; +typedef struct svs_stream_interface svs_stream_t; +typedef struct svs_stream_interface* svs_stream_i; + typedef struct svs_search_results svs_search_results_t; typedef struct svs_memory_breakdown svs_memory_breakdown_t; diff --git a/bindings/c/src/stream.hpp b/bindings/c/src/stream.hpp new file mode 100644 index 00000000..d4e83e3d --- /dev/null +++ b/bindings/c/src/stream.hpp @@ -0,0 +1,187 @@ +/* + * Copyright 2026 Intel Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include "svs/c/svs_c.h" + +#include "error.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace svs::c_runtime { + +// Bridges svs_stream_i's read/write callbacks to std::streambuf. Buffered at 64 KiB so the +// callback amortizes across many bytes instead of firing once per byte. +class StreamBuf : public std::streambuf { + public: + static constexpr size_t buffer_size = 64 * 1024; + + static void validate(svs_stream_i stream, bool need_write) { + if (stream == nullptr) { + throw std::invalid_argument("Stream pointer cannot be null."); + } + if (stream->ops == nullptr) { + throw std::invalid_argument("Stream interface is not initialized."); + } + if (stream->ops->version > svs_get_version()) { + throw std::invalid_argument("Stream interface version is not supported."); + } + if (stream->ops->struct_size < sizeof(svs_stream_ops_t)) { + throw std::invalid_argument("Incompatible stream interface struct size."); + } + if (need_write) { + if (stream->ops->write == nullptr) { + throw std::invalid_argument("Stream interface has no write callback."); + } + } else { + if (stream->ops->read == nullptr) { + throw std::invalid_argument("Stream interface has no read callback."); + } + } + } + + // Holds a value copy of the user's ops table; `self` is referenced and only needs to + // remain valid until the streaming save/load call returns. + StreamBuf(const svs_stream_ops_t& ops, void* self) + : ops_(ops) + , self_(self) + , read_buf_(ops.read != nullptr ? buffer_size : 0) + , write_buf_(ops.write != nullptr ? buffer_size : 0) { + if (!write_buf_.empty()) { + setp(write_buf_.data(), write_buf_.data() + write_buf_.size()); + } + } + + // A destructor must never throw; callers that need to observe a final write failure + // should call pubsync() themselves before the stream goes out of scope. + ~StreamBuf() override { + if (ops_.write != nullptr) { + try { + flush_write_buffer(); + } catch (...) {} + } + } + + protected: + int_type overflow(int_type ch) override { + flush_write_buffer(); + if (!traits_type::eq_int_type(ch, traits_type::eof())) { + *pptr() = traits_type::to_char_type(ch); + pbump(1); + } + return ch; + } + + int sync() override { + flush_write_buffer(); + return 0; + } + + int_type underflow() override { + if (gptr() < egptr()) { + return traits_type::to_int_type(*gptr()); + } + // Default-initialized to SVS_OK: a 0-byte read is legitimate EOF unless the + // callback explicitly reported an error, which read()'s return value cannot encode. + svs_error_desc impl_error{}; + size_t n = ops_.read(self_, read_buf_.data(), read_buf_.size(), &impl_error); + if (n == 0) { + if (impl_error.code != SVS_OK) { + throw std::runtime_error( + "Stream read callback failed: (" + std::to_string(impl_error.code) + + ") " + impl_error.message + ); + } + return traits_type::eof(); + } + setg(read_buf_.data(), read_buf_.data(), read_buf_.data() + n); + return traits_type::to_int_type(*gptr()); + } + + pos_type seekoff( + off_type off, std::ios_base::seekdir way, std::ios_base::openmode which + ) override { + if (off == 0 && way == std::ios_base::cur && which == std::ios_base::out) { + // tellp() must count bytes handed to the streambuf, not bytes flushed to the + // callback, or the format's cache-line padding misaligns silently. + return pos_type(static_cast(written_ + (pptr() - pbase()))); + } + return pos_type(off_type(-1)); + } + + private: + void flush_write_buffer() { + auto n = static_cast(pptr() - pbase()); + if (n > 0) { + svs_error_desc impl_error{ + SVS_ERROR_UNKNOWN, "Unknown error in stream write callback"}; + if (!ops_.write(self_, pbase(), n, &impl_error)) { + throw std::runtime_error( + "Stream write callback failed: (" + std::to_string(impl_error.code) + + ") " + impl_error.message + ); + } + written_ += n; + } + setp(write_buf_.data(), write_buf_.data() + write_buf_.size()); + } + + svs_stream_ops_t ops_; + void* self_; + std::vector read_buf_; + std::vector write_buf_; + size_t written_ = 0; +}; + +namespace detail { +// Base ordering trick: a base class initializes before other bases declared after it, so +// this guarantees `buf` exists before std::istream/std::ostream stores its address. +struct StreamBufHolder { + StreamBuf buf; + StreamBufHolder(const svs_stream_ops_t& ops, void* self) + : buf(ops, self) {} +}; +} // namespace detail + +class InputStream : private detail::StreamBufHolder, public std::istream { + public: + InputStream(const svs_stream_ops_t& ops, void* self) + : detail::StreamBufHolder(ops, self) + , std::istream(&buf) { + // Without this, the sentry swallows a read-callback exception into a silent + // badbit instead of rethrowing it; EOF alone only sets eofbit/failbit, not badbit. + exceptions(std::ios_base::badbit); + } +}; + +class OutputStream : private detail::StreamBufHolder, public std::ostream { + public: + OutputStream(const svs_stream_ops_t& ops, void* self) + : detail::StreamBufHolder(ops, self) + , std::ostream(&buf) { + // Without this, the sentry swallows a write-callback exception into a silent + // badbit instead of rethrowing it, so a failed save would report success. + exceptions(std::ios_base::badbit); + } +}; + +} // namespace svs::c_runtime From 4744cc197c6cca5bc5f60e446f21ea521118fd22 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 06:08:41 -0700 Subject: [PATCH 02/19] feat(c-api): add stream-based save and load plumbing Appends a virtual save(std::ostream&) to c_runtime::Index and gives IndexBuilder load_stream and load_stream_dynamic entry points, with matching dispatcher targets registered through the existing per-file Dispatcher rather than a new one. DynamicIndex inherits the new virtual and needs no declaration of its own. The virtual is appended to the end of Index, never inserted: inserting one mid-vtable was shipped and reverted once already (9980bd26). load_stream takes std::unique_ptr&& rather than std::istream&. Ordinary load copies data out of the stream, but a future zero-copy load would hand out pointers into the stream's own buffer and must own it. Taking the unique_ptr now keeps that from becoming a signature change through every dispatcher later. This loosens coupling: the internal load path is no longer filesystem-specific. Known gap, a property of the core rather than of this layer: the stream overloads of Vamana::assemble and DynamicVamana::assemble build the graph from GraphLoader<>::return_type and accept no graph loader, so a custom graph allocator is not honoured on the stream path and a stream-loaded dynamic index gets a non-blocked graph. blocksize_bytes still reaches the data allocator. Co-Authored-By: Claude Opus 5 --- bindings/c/src/dispatcher_dynamic_vamana.cpp | 61 +++++++++++++++++++- bindings/c/src/dispatcher_dynamic_vamana.hpp | 12 ++++ bindings/c/src/dispatcher_vamana.cpp | 53 ++++++++++++++++- bindings/c/src/dispatcher_vamana.hpp | 11 ++++ bindings/c/src/index.hpp | 8 +++ bindings/c/src/index_builder.hpp | 47 +++++++++++++++ 6 files changed, 189 insertions(+), 3 deletions(-) diff --git a/bindings/c/src/dispatcher_dynamic_vamana.cpp b/bindings/c/src/dispatcher_dynamic_vamana.cpp index 365d0352..40256654 100644 --- a/bindings/c/src/dispatcher_dynamic_vamana.cpp +++ b/bindings/c/src/dispatcher_dynamic_vamana.cpp @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -103,6 +104,33 @@ svs::DynamicVamana load_dynamic_vamana_index( ); } +template +svs::DynamicVamana load_stream_dynamic_vamana_index( + const svs::index::vamana::VamanaBuildParameters& SVS_UNUSED(build_params), + std::unique_ptr stream, + DataLoader SVS_UNUSED(loader), + Distance distance, + svs::threads::ThreadPoolHandle pool, + const AllocatorBuilder& allocator_builder, + size_t blocksize_bytes +) { + svs::data::BlockingParameters block_params; + if (blocksize_bytes != 0) { + block_params.blocksize_bytes = svs::lib::prevpow2(blocksize_bytes); + } + using allocator_type = typename DataLoader::allocator_type; + using value_type = typename allocator_type::value_type; + using data_type = typename DataLoader::data_type; + auto data_allocator_handle = allocator_builder.build(); + auto allocator = allocator_type{block_params, data_allocator_handle}; + + // Data is copied out of the stream during load, so `self` need not outlive the call. + // A zero-copy load would alias the caller's buffer and must not reuse this contract. + return svs::DynamicVamana::assemble( + *stream, distance, std::move(pool), allocator + ); +} + template void register_dynamic_vamana_index_specializations(Dispatcher& dispatcher) { auto build_closure = [&dispatcher]() { @@ -111,20 +139,31 @@ void register_dynamic_vamana_index_specializations(Dispatcher& dispatcher) { auto load_closure = [&dispatcher]() { dispatcher.register_target(&load_dynamic_vamana_index); }; + auto load_stream_closure = [&dispatcher]() { + dispatcher.register_target(&load_stream_dynamic_vamana_index); + }; for_simple_specializations(build_closure); for_simple_specializations(load_closure); + for_simple_specializations(load_stream_closure); for_leanvec_specializations(build_closure); for_leanvec_specializations(load_closure); + for_leanvec_specializations(load_stream_closure); for_lvq_specializations(build_closure); for_lvq_specializations(load_closure); + for_lvq_specializations(load_stream_closure); for_sq_specializations(build_closure); for_sq_specializations(load_closure); + for_sq_specializations(load_stream_closure); } +// Third DynamicVamanaSource alternative for the stream load path; matched the same way +// the existing build/directory-load alternatives are, via the generic variant +// DispatchConverter. using DynamicVamanaSource = std::variant< std::pair, std::span>, - std::filesystem::path>; + std::filesystem::path, + std::unique_ptr>; using BuildDynamicIndexDispatcher = svs::lib::Dispatcher< svs::DynamicVamana, @@ -186,6 +225,26 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_load( ); } +svs::DynamicVamana dispatch_dynamic_vamana_index_load_stream( + const svs::index::vamana::VamanaBuildParameters& build_params, + std::unique_ptr stream, + const Storage* storage, + svs::DistanceType distance_type, + svs::threads::ThreadPoolHandle pool, + const AllocatorBuilder& allocator_builder, + size_t blocksize_bytes +) { + return build_dynamic_vamana_index_dispatcher().invoke( + build_params, + DynamicVamanaSource{std::move(stream)}, + storage, + distance_type, + std::move(pool), + allocator_builder, + blocksize_bytes + ); +} + svs::index::vamana::MemoryBreakdown dispatch_dynamic_vamana_memory_estimate( const svs::index::vamana::VamanaBuildParameters& build_params, size_t num_vectors, diff --git a/bindings/c/src/dispatcher_dynamic_vamana.hpp b/bindings/c/src/dispatcher_dynamic_vamana.hpp index 8b133772..34db408d 100644 --- a/bindings/c/src/dispatcher_dynamic_vamana.hpp +++ b/bindings/c/src/dispatcher_dynamic_vamana.hpp @@ -24,6 +24,8 @@ #include #include +#include +#include #include #include #include @@ -51,6 +53,16 @@ svs::DynamicVamana dispatch_dynamic_vamana_index_load( size_t blocksize_bytes ); +svs::DynamicVamana dispatch_dynamic_vamana_index_load_stream( + const svs::index::vamana::VamanaBuildParameters& build_params, + std::unique_ptr stream, + const Storage* storage, + svs::DistanceType distance_type, + svs::threads::ThreadPoolHandle pool, + const AllocatorBuilder& allocator_builder, + size_t blocksize_bytes +); + svs::index::vamana::MemoryBreakdown dispatch_dynamic_vamana_memory_estimate( const svs::index::vamana::VamanaBuildParameters& build_params, size_t num_vectors, diff --git a/bindings/c/src/dispatcher_vamana.cpp b/bindings/c/src/dispatcher_vamana.cpp index 2349fb72..3fa653d2 100644 --- a/bindings/c/src/dispatcher_vamana.cpp +++ b/bindings/c/src/dispatcher_vamana.cpp @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -77,6 +78,24 @@ svs::Vamana load_vamana_index( ); } +template +svs::Vamana load_stream_vamana_index( + const svs::index::vamana::VamanaBuildParameters& SVS_UNUSED(build_params), + std::unique_ptr stream, + DataLoader SVS_UNUSED(loader), + Distance distance, + svs::threads::ThreadPoolHandle pool, + const AllocatorBuilder& allocator_builder +) { + using value_type = typename DataLoader::allocator_type::value_type; + using data_type = typename DataLoader::data_type; + // Data is copied out of the stream during load, so `self` need not outlive the call. + // A zero-copy load would alias the caller's buffer and must not reuse this contract. + return svs::Vamana::assemble( + *stream, distance, std::move(pool), allocator_builder.build() + ); +} + template void register_vamana_index_specializations(Dispatcher& dispatcher) { auto build_closure = [&dispatcher]() { @@ -85,19 +104,31 @@ void register_vamana_index_specializations(Dispatcher& dispatcher) { auto load_closure = [&dispatcher]() { dispatcher.register_target(&load_vamana_index); }; + auto load_stream_closure = [&dispatcher]() { + dispatcher.register_target(&load_stream_vamana_index); + }; for_simple_specializations(build_closure); for_simple_specializations(load_closure); + for_simple_specializations(load_stream_closure); for_leanvec_specializations(build_closure); for_leanvec_specializations(load_closure); + for_leanvec_specializations(load_stream_closure); for_lvq_specializations(build_closure); for_lvq_specializations(load_closure); + for_lvq_specializations(load_stream_closure); for_sq_specializations(build_closure); for_sq_specializations(load_closure); + for_sq_specializations(load_stream_closure); } -using VamanaSource = - std::variant, std::filesystem::path>; +// Third VamanaSource alternative for the stream load path; matched the same way the +// existing build/directory-load alternatives are, via the generic variant +// DispatchConverter. +using VamanaSource = std::variant< + svs::data::ConstSimpleDataView, + std::filesystem::path, + std::unique_ptr>; using BuildIndexDispatcher = svs::lib::Dispatcher< svs::Vamana, @@ -153,6 +184,24 @@ svs::Vamana dispatch_vamana_index_load( ); } +svs::Vamana dispatch_vamana_index_load_stream( + const svs::index::vamana::VamanaBuildParameters& build_params, + std::unique_ptr stream, + const Storage* storage, + svs::DistanceType distance_type, + svs::threads::ThreadPoolHandle pool, + const AllocatorBuilder& allocator_builder +) { + return build_vamana_index_dispatcher().invoke( + build_params, + VamanaSource{std::move(stream)}, + storage, + distance_type, + std::move(pool), + allocator_builder + ); +} + svs::index::vamana::MemoryBreakdown dispatch_vamana_memory_estimate( const svs::index::vamana::VamanaBuildParameters& build_params, size_t num_vectors, diff --git a/bindings/c/src/dispatcher_vamana.hpp b/bindings/c/src/dispatcher_vamana.hpp index afb08e45..a577de12 100644 --- a/bindings/c/src/dispatcher_vamana.hpp +++ b/bindings/c/src/dispatcher_vamana.hpp @@ -27,6 +27,8 @@ #include #include +#include +#include namespace svs::c_runtime { svs::Vamana dispatch_vamana_index_build( @@ -47,6 +49,15 @@ svs::Vamana dispatch_vamana_index_load( const AllocatorBuilder& allocator_builder ); +svs::Vamana dispatch_vamana_index_load_stream( + const svs::index::vamana::VamanaBuildParameters& build_params, + std::unique_ptr stream, + const Storage* storage, + svs::DistanceType distance_type, + svs::threads::ThreadPoolHandle pool, + const AllocatorBuilder& allocator_builder +); + svs::index::vamana::MemoryBreakdown dispatch_vamana_memory_estimate( const svs::index::vamana::VamanaBuildParameters& build_params, size_t num_vectors, diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp index 1c3184b5..0953c235 100644 --- a/bindings/c/src/index.hpp +++ b/bindings/c/src/index.hpp @@ -30,6 +30,7 @@ #include #include +#include #include #include #include @@ -58,6 +59,9 @@ struct Index { virtual size_t get_num_threads() const = 0; virtual void set_num_threads(size_t num_threads) = 0; virtual svs::index::vamana::MemoryBreakdown get_memory_breakdown() const = 0; + // Appended, not inserted: a virtual inserted mid-vtable broke ABI once already (see + // commit 9980bd26). New virtuals go at the end of the list. + virtual void save(std::ostream& stream) = 0; }; struct DynamicIndex : public Index { @@ -137,6 +141,8 @@ struct IndexVamana : public Index { index.save(directory / "config", directory / "graph", directory / "data"); } + void save(std::ostream& stream) override { index.save(stream); } + size_t dimensions() const override { return index.dimensions(); } float get_distance(size_t id, std::span query) const override { @@ -253,6 +259,8 @@ struct DynamicIndexVamana : public DynamicIndex { index.save(directory / "config", directory / "graph", directory / "data"); } + void save(std::ostream& stream) override { index.save(stream); } + size_t dimensions() const override { return index.dimensions(); } size_t add_points( diff --git a/bindings/c/src/index_builder.hpp b/bindings/c/src/index_builder.hpp index a244127a..5e6f0a3c 100644 --- a/bindings/c/src/index_builder.hpp +++ b/bindings/c/src/index_builder.hpp @@ -37,6 +37,7 @@ #include #include +#include #include namespace svs::c_runtime { @@ -119,6 +120,28 @@ struct IndexBuilder { return nullptr; } + std::shared_ptr load_stream(std::unique_ptr&& stream) { + if (algorithm->type == SVS_ALGORITHM_TYPE_VAMANA) { + auto vamana_algorithm = std::static_pointer_cast(algorithm); + + auto index = std::make_shared( + vamana_algorithm, + dispatch_vamana_index_load_stream( + vamana_algorithm->build_parameters(), + std::move(stream), + storage.get(), + to_distance_type(distance_metric), + pool_builder.build(), + allocator_builder + ), + pool_builder + ); + + return index; + } + return nullptr; + } + std::shared_ptr build_dynamic( const svs::data::ConstSimpleDataView& data, std::span ids, @@ -171,6 +194,30 @@ struct IndexBuilder { return nullptr; } + std::shared_ptr + load_stream_dynamic(std::unique_ptr&& stream, size_t blocksize_bytes) { + if (algorithm->type == SVS_ALGORITHM_TYPE_VAMANA) { + auto vamana_algorithm = std::static_pointer_cast(algorithm); + + auto index = std::make_shared( + vamana_algorithm, + dispatch_dynamic_vamana_index_load_stream( + vamana_algorithm->build_parameters(), + std::move(stream), + storage.get(), + to_distance_type(distance_metric), + pool_builder.build(), + allocator_builder, + blocksize_bytes + ), + pool_builder + ); + + return index; + } + return nullptr; + } + // Estimate the memory a built static Vamana index would consume // for `num_vectors` vectors. Mirrors the accounting done by // svs::index::vamana::VamanaIndex::get_memory_breakdown(). From e5f7f0b5035aeed6fa252243881fd87e66cf8b99 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 06:08:42 -0700 Subject: [PATCH 03/19] feat(c-api): carry error codes from callbacks through wrap_exceptions Adds coded_error, holding an svs_error_code_t alongside its message, and one catch clause that reports the carried code instead of collapsing it to SVS_ERROR_RUNTIME. A callback reporting SVS_ERROR_OUT_OF_MEMORY can now surface that code rather than leaving it readable only as text inside the message. The clause must precede the std::runtime_error clause it derives from, or it becomes unreachable; the comment records that. No existing error path changes behaviour, and the threadpool path keeps its lossy reporting for now. Nothing throws coded_error yet: wiring the stream adapter's throw sites to it belongs with the public streaming functions. Co-Authored-By: Claude Opus 5 --- bindings/c/src/error.hpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bindings/c/src/error.hpp b/bindings/c/src/error.hpp index f1508678..67fb50f8 100644 --- a/bindings/c/src/error.hpp +++ b/bindings/c/src/error.hpp @@ -102,6 +102,20 @@ class out_of_memory : public std::runtime_error { using std::runtime_error::runtime_error; }; +// Carries the code a callback (e.g. a stream interface) reported, so wrap_exceptions +// can surface it verbatim instead of collapsing it to SVS_ERROR_RUNTIME. +class coded_error : public std::runtime_error { + public: + coded_error(svs_error_code_t code, const std::string& msg) + : std::runtime_error(msg) + , code_{code} {} + + svs_error_code_t code() const noexcept { return code_; } + + private: + svs_error_code_t code_; +}; + // A helper to wrap C++ exceptions and convert them to C error codes/messages. template > Result wrap_exceptions(Callable&& func, svs_error_h err, Result err_res = {}) noexcept { @@ -129,6 +143,10 @@ Result wrap_exceptions(Callable&& func, svs_error_h err, Result err_res = {}) no } catch (const std::bad_alloc& ex) { SET_ERROR(err, SVS_ERROR_OUT_OF_MEMORY, ex.what()); return err_res; + } catch (const svs::c_runtime::coded_error& ex) { + // Must precede std::runtime_error, its base class, or this clause is unreachable. + SET_ERROR(err, ex.code(), ex.what()); + return err_res; } catch (const std::runtime_error& ex) { SET_ERROR(err, SVS_ERROR_RUNTIME, ex.what()); return err_res; From 52247910c5b61cd3a139a1a858f9f18f04e06360 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 07:07:58 -0700 Subject: [PATCH 04/19] feat(c-api): add public stream-based save and load functions Add svs_index_save_stream, svs_index_load_stream and svs_index_load_stream_dynamic, delegating to the stream plumbing through the streambuf adapter. Purely additive; the directory-based functions are unchanged. Switch the two throw sites in src/stream.hpp from std::runtime_error to coded_error so a callback's svs_error_code_t survives to the public API instead of collapsing to SVS_ERROR_RUNTIME. Without this the error.hpp catch clause added earlier is dead code. Carry the Vamana-only NOT_IMPLEMENTED_IF guards on the new loaders, matching the directory-based wrappers; the IndexBuilder layer does not enforce this. svs_index_save_stream flushes explicitly after saving. The core never flushes and ~StreamBuf swallows the exception from its final write, so a write callback failing on the last partial buffer previously reported success over a truncated stream. Co-Authored-By: Claude Opus 5 --- bindings/c/include/svs/c/svs_c.h | 45 +++++++++++++++++++ bindings/c/src/stream.hpp | 10 +++-- bindings/c/src/svs_c.cpp | 76 ++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h index dc9214c3..fcde9215 100644 --- a/bindings/c/include/svs/c/svs_c.h +++ b/bindings/c/include/svs/c/svs_c.h @@ -1060,6 +1060,38 @@ SVS_API svs_index_h svs_index_load_dynamic( svs_error_h out_err /*=NULL*/ ); +/// @brief Load an index from a caller-supplied stream +/// @param builder The index builder handle (used for configuration) +/// @param stream The stream interface to read the index from +/// @param out_err An optional error handle to capture errors +/// @return A handle to the loaded index +/// @remarks The operations table is copied, but @p stream->self is retained as-is. It only +/// needs to remain valid until this function returns, because index data is copied out of +/// the stream rather than referenced. +/// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream +/// and a packed directory archive. The encoding is detected from the stream itself. +SVS_API svs_index_h svs_index_load_stream( + svs_index_builder_h builder, svs_stream_i stream, svs_error_h out_err /*=NULL*/ +); + +/// @brief Load a dynamic index from a caller-supplied stream +/// @param builder The index builder handle (used for configuration) +/// @param stream The stream interface to read the index from +/// @param blocksize_bytes The block size in bytes for dynamic index loading (0 for default) +/// @param out_err An optional error handle to capture errors +/// @return A handle to the loaded dynamic index +/// @remarks The operations table is copied, but @p stream->self is retained as-is. It only +/// needs to remain valid until this function returns, because index data is copied out of +/// the stream rather than referenced. +/// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream +/// and a packed directory archive. The encoding is detected from the stream itself. +SVS_API svs_index_h svs_index_load_stream_dynamic( + svs_index_builder_h builder, + svs_stream_i stream, + size_t blocksize_bytes /*=0*/, + svs_error_h out_err /*=NULL*/ +); + /// @brief Free the index handle /// @param index The index handle to free SVS_API void svs_index_free(svs_index_h index); @@ -1142,6 +1174,19 @@ static inline bool svs_index_search( SVS_API bool svs_index_save(svs_index_h index, const char* directory, svs_error_h out_err /*=NULL*/); +/// @brief Save the index to a caller-supplied stream +/// @param index The index handle +/// @param stream The stream interface to write the index to +/// @param out_err An optional error handle to capture errors +/// @return true on success, false on failure +/// @remarks The operations table is copied, but @p stream->self is retained as-is. It only +/// needs to remain valid until this function returns. +/// @remarks Produces the native stream encoding only. An index previously written with +/// @ref svs_index_save cannot be converted to a stream through this API. +SVS_API bool svs_index_save_stream( + svs_index_h index, svs_stream_i stream, svs_error_h out_err /*=NULL*/ +); + /// @brief Add points to a dynamic index /// @param index The dynamic index handle /// @param new_points Pointer to the new vector data (float array) diff --git a/bindings/c/src/stream.hpp b/bindings/c/src/stream.hpp index d4e83e3d..5cde033c 100644 --- a/bindings/c/src/stream.hpp +++ b/bindings/c/src/stream.hpp @@ -106,9 +106,10 @@ class StreamBuf : public std::streambuf { size_t n = ops_.read(self_, read_buf_.data(), read_buf_.size(), &impl_error); if (n == 0) { if (impl_error.code != SVS_OK) { - throw std::runtime_error( + throw coded_error( + impl_error.code, "Stream read callback failed: (" + std::to_string(impl_error.code) + - ") " + impl_error.message + ") " + impl_error.message ); } return traits_type::eof(); @@ -135,9 +136,10 @@ class StreamBuf : public std::streambuf { svs_error_desc impl_error{ SVS_ERROR_UNKNOWN, "Unknown error in stream write callback"}; if (!ops_.write(self_, pbase(), n, &impl_error)) { - throw std::runtime_error( + throw coded_error( + impl_error.code, "Stream write callback failed: (" + std::to_string(impl_error.code) + - ") " + impl_error.message + ") " + impl_error.message ); } written_ += n; diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index c8c4f953..9b943f2a 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -23,6 +23,7 @@ #include "index_builder.hpp" #include "leanvec_training_data.hpp" #include "storage.hpp" +#include "stream.hpp" #include "threadpool.hpp" #include "types_support.hpp" @@ -882,6 +883,63 @@ svs_index_load(svs_index_builder_h builder, const char* directory, svs_error_h o ); } +extern "C" svs_index_h svs_index_load_stream( + svs_index_builder_h builder, svs_stream_i stream, svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(builder); + NOT_IMPLEMENTED_IF( + (builder->impl->algorithm->type != SVS_ALGORITHM_TYPE_VAMANA), + "Only Vamana algorithm is currently supported for index loading" + ); + StreamBuf::validate(stream, /*need_write=*/false); + auto index = builder->impl->load_stream( + std::make_unique(*stream->ops, stream->self) + ); + if (index == nullptr) { + SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Index load failed"); + return svs_index_h{nullptr}; + } + auto result = new svs_index; + result->impl = index; + return result; + }, + out_err + ); +} + +extern "C" svs_index_h svs_index_load_stream_dynamic( + svs_index_builder_h builder, + svs_stream_i stream, + size_t blocksize_bytes, + svs_error_h out_err +) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(builder); + NOT_IMPLEMENTED_IF( + (builder->impl->algorithm->type != SVS_ALGORITHM_TYPE_VAMANA), + "Only Vamana algorithm is currently supported for dynamic index loading" + ); + StreamBuf::validate(stream, /*need_write=*/false); + auto index = builder->impl->load_stream_dynamic( + std::make_unique(*stream->ops, stream->self), blocksize_bytes + ); + if (index == nullptr) { + SET_ERROR(out_err, SVS_ERROR_RUNTIME, "Dynamic index load failed"); + return svs_index_h{nullptr}; + } + auto result = new svs_index; + result->impl = index; + return result; + }, + out_err + ); +} + extern "C" void svs_index_free(svs_index_h index) { delete index; } namespace { @@ -1038,6 +1096,24 @@ svs_index_save(svs_index_h index, const char* directory, svs_error_h out_err) { ); } +extern "C" bool +svs_index_save_stream(svs_index_h index, svs_stream_i stream, svs_error_h out_err) { + using namespace svs::c_runtime; + return wrap_exceptions( + [&]() { + EXPECT_ARG_NOT_NULL(index); + StreamBuf::validate(stream, /*need_write=*/true); + OutputStream os(*stream->ops, stream->self); + index->impl->save(os); + // The core never flushes and ~StreamBuf swallows the exception, so without this + // a write failure on the final partial buffer would report success. + os.flush(); + return true; + }, + out_err + ); +} + extern "C" bool svs_index_dynamic_add_points( svs_index_h index, const float* new_points, From c78b0b4a131182ed0c24c1f26dba89a4ecb7ed31 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 07:33:53 -0700 Subject: [PATCH 05/19] test(c-api): cover stream save and load Round-trips a static and a dynamic index through an in-memory stream, then exercises the interface validation paths and the callback error conventions. Two cases target defects found while implementing the feature rather than the happy path. A write callback that rejects only a sub-buffer-sized write catches the final partial flush, which the streambuf destructor used to swallow into a successful save over a truncated stream; it is guarded by a precondition that the payload does not land on a 64 KiB boundary, so it cannot pass vacuously. A read callback returning zero with an error set confirms the caller's error code survives to the public API instead of collapsing to SVS_ERROR_RUNTIME. Note that `ctest -R c_api` matches nothing: the filter is a regex over test names, which start with "C API". Use `-L c_api`. Co-Authored-By: Claude Opus 5 --- bindings/c/tests/CMakeLists.txt | 1 + bindings/c/tests/c_api_stream.cpp | 384 ++++++++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 bindings/c/tests/c_api_stream.cpp diff --git a/bindings/c/tests/CMakeLists.txt b/bindings/c/tests/CMakeLists.txt index 3b199579..4300ed3b 100644 --- a/bindings/c/tests/CMakeLists.txt +++ b/bindings/c/tests/CMakeLists.txt @@ -50,6 +50,7 @@ set(C_API_TEST_SOURCES c_api_index_builder.cpp c_api_index.cpp c_api_dynamic_index.cpp + c_api_stream.cpp ) # Create test executable diff --git a/bindings/c/tests/c_api_stream.cpp b/bindings/c/tests/c_api_stream.cpp new file mode 100644 index 00000000..23e1dd20 --- /dev/null +++ b/bindings/c/tests/c_api_stream.cpp @@ -0,0 +1,384 @@ +/* + * Copyright 2026 Intel Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// C API +#include "svs/c/svs_c.h" + +// catch2 +#include "catch2/catch_test_macros.hpp" + +// Test utilities +#include "c_api_test_utils.h" + +// Standard library +#include +#include +#include + +namespace { + +// The streambuf adapter's buffer size (bindings/c/src/stream.hpp); not part of the public +// API, so tests that depend on it hardcode the value. +constexpr size_t STREAM_BUFFER_SIZE = 64 * 1024; + +// In-memory sink/source backing the stream interface tests. write() appends to `bytes`; +// read() copies out of `bytes` starting at `pos`, capped at `max_read` per call so tests +// can force short reads. +struct MemoryStream { + std::vector bytes; + size_t pos = 0; + size_t max_read = std::numeric_limits::max(); +}; + +size_t memory_stream_read(void* self, void* buf, size_t n, svs_error_h /*out_err*/) { + auto* stream = static_cast(self); + size_t remaining = stream->bytes.size() - stream->pos; + size_t to_copy = std::min({n, remaining, stream->max_read}); + std::memcpy(buf, stream->bytes.data() + stream->pos, to_copy); + stream->pos += to_copy; + return to_copy; +} + +bool memory_stream_write(void* self, const void* buf, size_t n, svs_error_h /*out_err*/) { + auto* stream = static_cast(self); + const auto* src = static_cast(buf); + stream->bytes.insert(stream->bytes.end(), src, src + n); + return true; +} + +// Fails a write smaller than the adapter's buffer, which every write during a save is +// except the final flush of a partial one, so this deterministically targets only that +// last write regardless of how many full buffers preceded it. +bool fail_partial_write(void* self, const void* buf, size_t n, svs_error_h out_err) { + if (n < STREAM_BUFFER_SIZE) { + svs_error_set(out_err, SVS_ERROR_RUNTIME, "refusing partial write"); + return false; + } + return memory_stream_write(self, buf, n, out_err); +} + +bool always_fail_write( + void* /*self*/, const void* /*buf*/, size_t /*n*/, svs_error_h /*out_err*/ +) { + return false; +} + +bool oom_write(void* /*self*/, const void* /*buf*/, size_t /*n*/, svs_error_h out_err) { + svs_error_set(out_err, SVS_ERROR_OUT_OF_MEMORY, "simulated allocator exhaustion"); + return false; +} + +size_t oom_read(void* /*self*/, void* /*buf*/, size_t /*n*/, svs_error_h out_err) { + svs_error_set(out_err, SVS_ERROR_OUT_OF_MEMORY, "simulated allocator exhaustion"); + return 0; +} + +} // namespace + +CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { + const size_t NUM_VECTORS = 100; + const size_t DIMENSION = 32; + const size_t K = 5; + + std::vector data; + std::vector queries; + generate_test_data(data, NUM_VECTORS, DIMENSION); + generate_test_data(queries, 3, DIMENSION); + + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + // Single-threaded so a greedy search visits the same path on both indexes. + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + ); + CATCH_REQUIRE(success); + CATCH_REQUIRE(svs_error_ok(error)); + + CATCH_SECTION("Static round-trip through an in-memory stream") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + svs_search_results_t before = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + index, queries.data(), 3, K, &before, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(builder, &in_stream, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t after = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, queries.data(), 3, K, &after, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(after.num_queries == before.num_queries); + for (size_t i = 0; i < before.num_queries * K; ++i) { + CATCH_REQUIRE(after.indices[i] == before.indices[i]); + CATCH_REQUIRE(after.distances[i] == before.distances[i]); + } + + svs_search_results_free(&before); + svs_search_results_free(&after); + svs_index_free(loaded); + svs_index_free(index); + } + + CATCH_SECTION("Save fails when only the final partial flush fails") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + MemoryStream probe; + svs_stream_interface_ops probe_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface probe_stream = SVS_MAKE_INTERFACE(&probe, probe_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &probe_stream, error)); + // The failing sink below only ever rejects a write shorter than the buffer; if the + // payload happened to land exactly on a buffer boundary there would be no partial + // write left for it to catch. + CATCH_REQUIRE(probe.bytes.size() % STREAM_BUFFER_SIZE != 0); + + MemoryStream sink; + svs_stream_interface_ops fail_ops = + SVS_INIT_STREAM_OPS(nullptr, fail_partial_write); + svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(&sink, fail_ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &fail_stream, error)); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + + svs_index_free(index); + } + + CATCH_SECTION("Write callback failure aborts save") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + svs_stream_interface_ops fail_ops = SVS_INIT_STREAM_OPS(nullptr, always_fail_write); + svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(nullptr, fail_ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &fail_stream, error)); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + + svs_index_free(index); + } + + CATCH_SECTION("Write callback reports a specific error code") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + svs_stream_interface_ops fail_ops = SVS_INIT_STREAM_OPS(nullptr, oom_write); + svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(nullptr, fail_ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &fail_stream, error)); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_OUT_OF_MEMORY); + + svs_index_free(index); + } + + CATCH_SECTION("Read callback reports a specific error code") { + svs_stream_interface_ops fail_ops = SVS_INIT_STREAM_OPS(oom_read, nullptr); + svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(nullptr, fail_ops); + svs_index_h loaded = svs_index_load_stream(builder, &fail_stream, error); + CATCH_REQUIRE(loaded == nullptr); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_OUT_OF_MEMORY); + } + + CATCH_SECTION("Short reads and EOF on load") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + + // Hand back at most 3 bytes per call, forcing many short reads before the final + // 0-byte EOF. + stream.max_read = 3; + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(builder, &in_stream, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t results = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, queries.data(), 3, K, &results, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results.num_queries == 3); + + svs_search_results_free(&results); + svs_index_free(loaded); + svs_index_free(index); + } + + CATCH_SECTION("Dynamic round-trip, add_points, and search") { + std::vector ids(NUM_VECTORS); + for (size_t i = 0; i < NUM_VECTORS; ++i) { + ids[i] = i; + } + const size_t BLOCK_SIZE = 1024 * 1024; + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = + svs_index_load_stream_dynamic(builder, &in_stream, BLOCK_SIZE, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + std::vector new_data; + std::vector new_ids = {NUM_VECTORS, NUM_VECTORS + 1}; + generate_test_data(new_data, 2, DIMENSION); + size_t added_count = 0; + CATCH_REQUIRE(svs_index_dynamic_add_points( + loaded, new_data.data(), new_ids.data(), 2, &added_count, error + )); + CATCH_REQUIRE(added_count == 2); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t results = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, queries.data(), 3, K, &results, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(results.num_queries == 3); + + svs_search_results_free(&results); + svs_index_free(loaded); + svs_index_free(index); + } + + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); +} + +CATCH_TEST_CASE("C API Stream Interface Validation", "[c_api][index][stream][error]") { + const size_t NUM_VECTORS = 20; + const size_t DIMENSION = 8; + + std::vector data; + generate_test_data(data, NUM_VECTORS, DIMENSION); + + svs_error_h error = svs_error_create(); + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + + bool success = svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + ); + CATCH_REQUIRE(success); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + CATCH_SECTION("Null interface pointer is rejected") { + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, nullptr, error)); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + + CATCH_REQUIRE(svs_index_load_stream(builder, nullptr, error) == nullptr); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + } + + CATCH_SECTION("Null ops pointer is rejected") { + svs_stream_interface stream{nullptr, nullptr}; + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &stream, error)); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + + CATCH_REQUIRE(svs_index_load_stream(builder, &stream, error) == nullptr); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + } + + CATCH_SECTION("struct_size smaller than expected is rejected") { + svs_stream_interface_ops ops = + SVS_INIT_STREAM_OPS(memory_stream_read, memory_stream_write); + ops.struct_size = sizeof(uint32_t) + sizeof(size_t); + svs_stream_interface stream = SVS_MAKE_INTERFACE(nullptr, ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &stream, error)); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + + CATCH_REQUIRE(svs_index_load_stream(builder, &stream, error) == nullptr); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + } + + CATCH_SECTION("version above svs_get_version() is rejected") { + svs_stream_interface_ops ops = + SVS_INIT_STREAM_OPS(memory_stream_read, memory_stream_write); + ops.version = svs_get_version() + 1; + svs_stream_interface stream = SVS_MAKE_INTERFACE(nullptr, ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &stream, error)); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + + CATCH_REQUIRE(svs_index_load_stream(builder, &stream, error) == nullptr); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + } + + CATCH_SECTION("NULL write on save is rejected") { + svs_stream_interface_ops ops = SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface stream = SVS_MAKE_INTERFACE(nullptr, ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &stream, error)); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + } + + CATCH_SECTION("NULL read on load is rejected") { + svs_stream_interface_ops ops = SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface stream = SVS_MAKE_INTERFACE(nullptr, ops); + CATCH_REQUIRE(svs_index_load_stream(builder, &stream, error) == nullptr); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_INVALID_ARGUMENT); + } + + svs_index_free(index); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); +} From d27e2a6e1458b480a585dc9e4f8d7dd98ee5411e Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 07:34:06 -0700 Subject: [PATCH 06/19] docs(c-api): document the stream interface and add an example Adds a Stream Interface section to C_API_Design.md covering the callback contracts, the serial-invocation guarantee, the lifetime window and the encoding asymmetry, plus a runnable example that saves and loads an index through a caller-supplied in-memory stream. Two contracts were reachable only by reading the implementation. A read callback reports failure by returning zero with an error set, since the return value alone cannot distinguish that from end of stream; a caller who guessed otherwise would get a silently truncated index loaded as a success. And a supplied allocator does not reach graph memory on the stream path, which also costs a stream-loaded dynamic index its blocked growth. Both are now stated at the declarations, the allocator gap because the core fix is deferred and the header is the only place a caller will see it. Co-Authored-By: Claude Opus 5 --- bindings/c/README.md | 7 +- bindings/c/docs/C_API_Design.md | 70 ++++++- bindings/c/include/svs/c/svs_c.h | 21 ++- bindings/c/tests/README.md | 1 + examples/c/CMakeLists.txt | 2 +- examples/c/save_load_stream.c | 309 +++++++++++++++++++++++++++++++ 6 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 examples/c/save_load_stream.c diff --git a/bindings/c/README.md b/bindings/c/README.md index 72af1fe2..9c5da468 100644 --- a/bindings/c/README.md +++ b/bindings/c/README.md @@ -22,8 +22,8 @@ C applications and any language with C FFI support. The API is built around a small set of opaque handles and a builder pattern: configure an *algorithm*, optional *storage* and *thread pool*, hand them to an *index builder*, then use the resulting *index* to run TopK searches (with -optional ID filtering), save/load the index, and — for dynamic indices — add or -delete points at runtime. +optional ID filtering), save/load the index to disk or a caller-supplied stream, +and — for dynamic indices — add or delete points at runtime. For the design rationale, naming conventions, and full API reference see [docs/C_API_Design.md](docs/C_API_Design.md). @@ -190,7 +190,8 @@ Runnable sample applications live in [samples/](samples/): - [`save_load.c`](samples/save_load.c) – persisting and reloading indices from disk -Additional integration examples: [`examples/c/`](../../examples/c/). +Additional integration examples: [`examples/c/`](../../examples/c/), including +stream-based save/load via [`save_load_stream.c`](../../examples/c/save_load_stream.c). ## Further Reading diff --git a/bindings/c/docs/C_API_Design.md b/bindings/c/docs/C_API_Design.md index 597c120e..d2fd78ef 100644 --- a/bindings/c/docs/C_API_Design.md +++ b/bindings/c/docs/C_API_Design.md @@ -44,6 +44,7 @@ - [6. Allocator Configuration](#6-allocator-configuration) - [7. Search Parameters](#7-search-parameters) - [8. ID Filter (optional)](#8-id-filter-optional) + - [9. Stream Interface](#9-stream-interface) - [API Overview](#api-overview) - [Headers](#headers) - [Types](#types) @@ -198,6 +199,10 @@ one of the two optional slots is used per name: (e.g. `svs_algorithm_create_vamana` creates a Vamana algorithm; `svs_index_build_dynamic` builds a dynamic index). +When both specializations apply (e.g. a save or load variant), `_stream` and +`_stream_dynamic` are stacked at the end: `svs_index_save_stream`, +`svs_index_load_stream_dynamic`. + **Examples:** | Function | Breakdown | Description | @@ -209,6 +214,8 @@ one of the two optional slots is used per name: | `svs_index_build_dynamic()` | `svs` + `index` + `build` + `dynamic` | Build a dynamic index | | `svs_index_dynamic_add_points()` | `svs` + `index` + `dynamic` + `add_points` | Add points to a dynamic index | | `svs_index_builder_set_threadpool()` | `svs` + `index_builder` + `set_threadpool` | Configure builder thread pool | +| `svs_index_save_stream()` | `svs` + `index` + `save` + `stream` | Save index to a caller-supplied stream | +| `svs_index_load_stream_dynamic()` | `svs` + `index` + `load` + `stream_dynamic` | Load a dynamic index from a stream | ### Examples by Category @@ -225,6 +232,7 @@ typedef enum svs_error_code svs_error_code_t; // Interface pointer types typedef struct svs_threadpool_interface* svs_threadpool_i; typedef struct svs_id_filter_interface* svs_id_filter_i; +typedef struct svs_stream_interface* svs_stream_i; ``` ## Core Components @@ -500,6 +508,62 @@ Providing a non-zero `filter_rate` lets the search account for the expected selectivity; if the observed hit rate ends up lower than the reported estimate the function returns an empty result set for that query. +### 9. Stream Interface + +Enables caller-supplied byte streams for index save and load operations, eliminating +the need for intermediate disk storage. Like the thread pool and allocator, the stream +interface is a versioned ops table plus an opaque `self` pointer. + +```c +struct svs_stream_interface_ops { + uint32_t version; // Set by SVS_INIT_STREAM_OPS + size_t struct_size; // Set by SVS_INIT_STREAM_OPS + size_t (*read)(void* self, void* buf, size_t n, svs_error_h out_err); + bool (*write)(void* self, const void* buf, size_t n, svs_error_h out_err); +}; + +struct svs_stream_interface { + struct svs_stream_interface_ops* ops; + void* self; // User-defined state +}; +typedef struct svs_stream_interface* svs_stream_i; + +// Initialisation macros +static svs_stream_ops_t my_stream_ops = + SVS_INIT_STREAM_OPS(my_read_func, my_write_func); +static svs_stream_t my_stream = SVS_MAKE_INTERFACE(user_state, my_stream_ops); +``` + +**Callback contracts:** + +- **`read`** — Read at most `n` bytes into `buf`. Return the number of bytes read; return + 0 to signal end of stream. Short reads are not errors; the library will call again as + needed. To report a read error, set `out_err` via `svs_error_set()` and return 0 — this + aborts the load with that error code, unlike returning 0 with no error set, which is a + clean end of stream. This differs from `write`, which signals failure through its return + value. Required for load operations; may be NULL for write-only streams. +- **`write`** — Write exactly `n` bytes from `buf`. Return `true` on success. A partial + write must be reported as failure. Required for save operations; may be NULL for + read-only streams. + +**Threading:** Both callbacks are invoked serially from the thread that called the streaming +save or load function. No synchronization between concurrent stream operations is required. + +**Lifetime:** The operations table is copied by value; `self` is retained as a bare pointer +and only needs to remain valid until the streaming function returns. Data is copied out of +the stream during load, so the stream buffer need not persist after the call completes. + +**Encodings:** `svs_index_save_stream` produces only the native stream encoding. The load +functions accept both that encoding and a packed directory archive, detected from the stream +itself. An index written with the directory-based `svs_index_save` cannot be converted to a +stream through this API. + +**Known limitations:** When loading an index with a custom allocator via +`svs_index_load_stream` or `svs_index_load_stream_dynamic`, the graph memory comes from +`HugepageAllocator` rather than the supplied allocator. For a dynamic index, graph growth +reallocates the entire graph instead of appending a block. This limitation has a performance +consequence but does not affect correctness. + ## API Overview A concise map of the public surface. See [svs/c/svs_c.h](../include/svs/c/svs_c.h) @@ -518,8 +582,8 @@ for full signatures, parameters, and Doxygen documentation. - **Enums** (`_t`): `svs_error_code_t`, `svs_distance_metric_t`, `svs_algorithm_type_t`, `svs_data_type_t`, `svs_storage_kind_t`, `svs_threadpool_kind_t`, `svs_allocator_kind_t` -- **Custom interfaces**: `svs_threadpool_i`, `svs_allocator_i`, and `svs_id_filter_i` - (versioned ops-table + `self` pointer; build with `SVS_INIT_*_OPS()` / +- **Custom interfaces**: `svs_threadpool_i`, `svs_allocator_i`, `svs_id_filter_i`, and + `svs_stream_i` (versioned ops-table + `self` pointer; build with `SVS_INIT_*_OPS()` / `SVS_MAKE_INTERFACE()`) - **Value structs**: `svs_search_results_t` (CSR result buffer), `svs_memory_breakdown_t` @@ -535,7 +599,7 @@ for full signatures, parameters, and Doxygen documentation. | **Search params** | `svs_search_params_create_vamana`, `svs_search_params_free` | | **Builder** | `svs_index_builder_create`, `svs_index_builder_set_{storage,threadpool,threadpool_custom,allocator,allocator_custom}`, `svs_index_builder_free` | | **Memory estimation** | `svs_index_builder_estimate_memory`, `svs_index_builder_estimate_memory_dynamic`, `svs_index_builder_estimate_search_memory`, `svs_index_builder_estimate_search_memory_dynamic`, `svs_index_builder_get_default_blocksize_bytes` | -| **Index lifecycle** | `svs_index_build`, `svs_index_build_dynamic`, `svs_index_load`, `svs_index_load_dynamic`, `svs_index_save`, `svs_index_free` | +| **Index lifecycle** | `svs_index_build`, `svs_index_build_dynamic`, `svs_index_load`, `svs_index_load_dynamic`, `svs_index_load_stream`, `svs_index_load_stream_dynamic`, `svs_index_save`, `svs_index_save_stream`, `svs_index_free` | | **Dynamic ops** | `svs_index_dynamic_{add_points,delete_points,has_id,consolidate,compact}` | | **Introspection** | `svs_index_get_num_threads` / `set_num_threads`, `svs_index_get_distance`, `svs_index_reconstruct`, `svs_index_get_memory_usage`, `svs_index_get_memory_breakdown` | | **Search** | `svs_index_search_topk` (+ deprecated `svs_index_search`), `svs_search_results_free` | diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h index fcde9215..9bbbf752 100644 --- a/bindings/c/include/svs/c/svs_c.h +++ b/bindings/c/include/svs/c/svs_c.h @@ -300,8 +300,7 @@ struct svs_id_filter_interface { /// function, so no synchronization is required — unlike the thread pool, allocator and ID /// filter interfaces. /// @remarks Exactly one direction is required per operation: @ref svs_index_save_stream -/// needs -/// @p write, the load functions need @p read. The unused callback may be NULL. +/// needs @p write, the load functions need @p read. The unused callback may be NULL. /// @var svs_stream_interface_ops::version /// Interface version, set by @ref SVS_INIT_STREAM_OPS. /// @var svs_stream_interface_ops::struct_size @@ -311,10 +310,13 @@ struct svs_id_filter_interface { /// @param self Pointer to the stream instance. /// @param buf Destination buffer. /// @param n Maximum number of bytes to read. -/// @param out_err Handle to capture any error that occurs during the read. User code may -/// call svs_error_set() to set the error code and message if an error occurs. -/// @return The number of bytes read; 0 signals end of stream. A short read is not an -/// error and the library will call again. NULL for a write-only stream. +/// @param out_err Handle to capture any error that occurs during the read. Returning 0 +/// with an error set on @p out_err via svs_error_set() reports a failed read and aborts +/// the load with that error code; returning 0 without setting one is a clean end of +/// stream. This differs from @p write, which signals failure through its return value. +/// @return The number of bytes read; 0 signals end of stream unless @p out_err carries an +/// error. A short read is not an error and the library will call again. NULL for a +/// write-only stream. /// @var svs_stream_interface_ops::write /// Writes exactly @p n bytes from @p buf. /// @param self Pointer to the stream instance. @@ -1070,6 +1072,9 @@ SVS_API svs_index_h svs_index_load_dynamic( /// the stream rather than referenced. /// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream /// and a packed directory archive. The encoding is detected from the stream itself. +/// @remarks Graph memory comes from `HugepageAllocator` rather than any allocator supplied +/// through @ref svs_index_builder_set_allocator_custom. This is a performance difference, +/// not a correctness one. SVS_API svs_index_h svs_index_load_stream( svs_index_builder_h builder, svs_stream_i stream, svs_error_h out_err /*=NULL*/ ); @@ -1085,6 +1090,10 @@ SVS_API svs_index_h svs_index_load_stream( /// the stream rather than referenced. /// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream /// and a packed directory archive. The encoding is detected from the stream itself. +/// @remarks Graph memory comes from `HugepageAllocator` rather than any allocator supplied +/// through @ref svs_index_builder_set_allocator_custom, and growing the loaded index +/// reallocates the whole graph instead of appending a block. Both are a performance +/// difference, not a correctness one. SVS_API svs_index_h svs_index_load_stream_dynamic( svs_index_builder_h builder, svs_stream_i stream, diff --git a/bindings/c/tests/README.md b/bindings/c/tests/README.md index 4a6ba567..9e76f719 100644 --- a/bindings/c/tests/README.md +++ b/bindings/c/tests/README.md @@ -29,6 +29,7 @@ The tests are organized into separate files by functionality: - **c_api_index_builder.cpp**: Tests for index builder creation and configuration - **c_api_index.cpp**: Tests for index building, searching, and basic operations - **c_api_dynamic_index.cpp**: Tests for dynamic index operations (add, delete, consolidate, compact) +- **c_api_stream.cpp**: Tests for stream-based save and load operations, error handling, and round-trip validation Note: The main() function is provided by Catch2::Catch2WithMain automatically. diff --git a/examples/c/CMakeLists.txt b/examples/c/CMakeLists.txt index a1d0ebac..1315bac9 100644 --- a/examples/c/CMakeLists.txt +++ b/examples/c/CMakeLists.txt @@ -29,7 +29,7 @@ else() set(EXAMPLE_LINK_TARGET svs_c_api) endif() -foreach(EXAMPLE_NAME simple save_load dynamic) +foreach(EXAMPLE_NAME simple save_load save_load_stream dynamic) set(EXAMPLE_TARGET c_api_${EXAMPLE_NAME}) list(APPEND EXAMPLE_TARGETS ${EXAMPLE_TARGET}) add_executable(${EXAMPLE_TARGET} ${EXAMPLE_NAME}.c) diff --git a/examples/c/save_load_stream.c b/examples/c/save_load_stream.c new file mode 100644 index 00000000..079e180b --- /dev/null +++ b/examples/c/save_load_stream.c @@ -0,0 +1,309 @@ +/* + * Copyright 2026 Intel Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "svs/c/svs_c.h" +#include +#include +#include +#include + +#define NUM_VECTORS 10000 +#define NUM_QUERIES 1 +#define DIMENSION 128 +#define K 10 + +// `size` is the high-water mark reached while writing; `pos` is the read/write cursor and +// gets rewound to 0 between the save pass and the load pass. +typedef struct { + unsigned char* data; + size_t capacity; + size_t size; + size_t pos; +} memory_stream_t; + +memory_stream_t* memory_stream_create(size_t initial_capacity) { + memory_stream_t* stream = (memory_stream_t*)malloc(sizeof(memory_stream_t)); + if (!stream) { + return NULL; + } + stream->data = (unsigned char*)malloc(initial_capacity); + if (!stream->data) { + free(stream); + return NULL; + } + stream->capacity = initial_capacity; + stream->size = 0; + stream->pos = 0; + return stream; +} + +void memory_stream_free(memory_stream_t* stream) { + if (stream) { + free(stream->data); + free(stream); + } +} + +// A short read is legal here and the library calls again for the remainder; this callback +// always fills the whole request, but a caller need not. +static size_t memory_stream_read(void* self, void* buf, size_t n, svs_error_h out_err) { + (void)out_err; + memory_stream_t* stream = (memory_stream_t*)self; + size_t available = stream->size - stream->pos; + size_t to_read = (n < available) ? n : available; + if (to_read > 0) { + memcpy(buf, stream->data + stream->pos, to_read); + stream->pos += to_read; + } + return to_read; +} + +// A partial write must be reported as failure; otherwise the saved stream is silently +// truncated and the failure surfaces only much later, as a load error. +static bool +memory_stream_write(void* self, const void* buf, size_t n, svs_error_h out_err) { + (void)out_err; + memory_stream_t* stream = (memory_stream_t*)self; + while (stream->pos + n > stream->capacity) { + size_t new_capacity = stream->capacity * 2; + unsigned char* new_data = (unsigned char*)realloc(stream->data, new_capacity); + if (!new_data) { + return false; + } + stream->data = new_data; + stream->capacity = new_capacity; + } + memcpy(stream->data + stream->pos, buf, n); + stream->pos += n; + if (stream->pos > stream->size) { + stream->size = stream->pos; + } + return true; +} + +void generate_random_data(float* data, size_t count, size_t dim) { + for (size_t i = 0; i < count * dim; i++) { + data[i] = (float)rand() / RAND_MAX; + } +} + +int main() { + int ret = 0; + srand(time(NULL)); + svs_error_h error = svs_error_create(); + + float* data = NULL; + float* queries = NULL; + svs_algorithm_h algorithm = NULL; + svs_storage_h storage = NULL; + svs_index_builder_h builder = NULL; + svs_index_h index = NULL; + svs_search_results_t results = SVS_INIT_SEARCH_RESULTS(); + memory_stream_t* stream = NULL; + svs_search_results_t loaded_results = SVS_INIT_SEARCH_RESULTS(); + + // Allocate random data + data = (float*)malloc(NUM_VECTORS * DIMENSION * sizeof(float)); + queries = (float*)malloc(NUM_QUERIES * DIMENSION * sizeof(float)); + + if (!data || !queries) { + fprintf(stderr, "Failed to allocate memory\n"); + ret = 1; + goto cleanup; + } + + generate_random_data(data, NUM_VECTORS, DIMENSION); + generate_random_data(queries, NUM_QUERIES, DIMENSION); + + // Create Vamana algorithm + algorithm = svs_algorithm_create_vamana(64, 128, 100, error); + if (!algorithm) { + fprintf(stderr, "Failed to create algorithm: %s\n", svs_error_get_message(error)); + ret = 1; + goto cleanup; + } + + // Create storage (simple float32) + storage = svs_storage_create_simple(SVS_DATA_TYPE_FLOAT32, error); + if (!storage) { + fprintf(stderr, "Failed to create storage: %s\n", svs_error_get_message(error)); + ret = 1; + goto cleanup; + } + + // Create index builder + builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + if (!builder) { + fprintf( + stderr, "Failed to create index builder: %s\n", svs_error_get_message(error) + ); + ret = 1; + goto cleanup; + } + + if (!svs_index_builder_set_storage(builder, storage, error)) { + fprintf(stderr, "Failed to set storage: %s\n", svs_error_get_message(error)); + ret = 1; + goto cleanup; + } + + // Build index + printf("Building index with %d vectors of dimension %d...\n", NUM_VECTORS, DIMENSION); + index = svs_index_build(builder, data, NUM_VECTORS, error); + if (!index) { + fprintf(stderr, "Failed to build index: %s\n", svs_error_get_message(error)); + ret = 1; + goto cleanup; + } + printf("Index built successfully!\n"); + + // Search + printf("Searching %d queries for top-%d neighbors...\n", NUM_QUERIES, K); + if (!svs_index_search_topk( + index, + queries, + NUM_QUERIES, + K, + &results, + NULL /* search_params */, + NULL /* id_filter */, + error + )) { + fprintf(stderr, "Failed to search index: %s\n", svs_error_get_message(error)); + ret = 1; + goto cleanup; + } + printf("Search completed successfully!\n"); + + // Create in-memory stream for saving + stream = memory_stream_create(1024 * 1024); + if (!stream) { + fprintf(stderr, "Failed to create stream\n"); + ret = 1; + goto cleanup; + } + + // Construct stream interface and save through it + static svs_stream_ops_t stream_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, memory_stream_write); + svs_stream_t stream_iface = SVS_MAKE_INTERFACE(stream, stream_ops); + + printf("Saving index to in-memory stream...\n"); + if (!svs_index_save_stream(index, &stream_iface, error)) { + fprintf( + stderr, "Failed to save index to stream: %s\n", svs_error_get_message(error) + ); + ret = 1; + goto cleanup; + } + printf("Index saved successfully! Stream size: %zu bytes\n", stream->size); + + svs_index_free(index); + index = NULL; + + // Reset stream position for reading + stream->pos = 0; + + // Load the index from the stream + printf("Loading index from in-memory stream...\n"); + index = svs_index_load_stream(builder, &stream_iface, error); + if (!index) { + fprintf( + stderr, "Failed to load index from stream: %s\n", svs_error_get_message(error) + ); + ret = 1; + goto cleanup; + } + printf("Index loaded successfully!\n"); + + // Search the loaded index + printf( + "Searching loaded index for %d queries for top-%d neighbors...\n", NUM_QUERIES, K + ); + if (!svs_index_search_topk( + index, + queries, + NUM_QUERIES, + K, + &loaded_results, + NULL /* search_params */, + NULL /* id_filter */, + error + )) { + fprintf( + stderr, "Failed to search loaded index: %s\n", svs_error_get_message(error) + ); + ret = 1; + goto cleanup; + } + printf("Search on loaded index completed successfully!\n"); + + // Compare results + if (results.num_queries != loaded_results.num_queries) { + fprintf( + stderr, "Mismatch in number of queries between original and loaded results\n" + ); + ret = 1; + goto cleanup; + } + + size_t offset = 0; + for (size_t q = 0; q < results.num_queries; q++) { + size_t count = results.offsets[q + 1] - results.offsets[q]; + size_t loaded_count = loaded_results.offsets[q + 1] - loaded_results.offsets[q]; + if (count != loaded_count) { + fprintf(stderr, "Mismatch in number of results for query %zu\n", q); + ret = 1; + goto cleanup; + } + printf("Query %zu results:\n", q); + for (size_t i = 0; i < count; i++) { + if (results.indices[offset + i] != loaded_results.indices[offset + i]) { + fprintf( + stderr, "Mismatch in neighbor indices for query %zu, result %zu\n", q, i + ); + ret = 1; + goto cleanup; + } + printf( + " [%zu] id=%zu, distance=%.4f, diff=%.4f\n", + i, + results.indices[offset + i], + results.distances[offset + i], + results.distances[offset + i] - loaded_results.distances[offset + i] + ); + } + offset += count; + } + + printf("Done!\n"); + +cleanup: + svs_search_results_free(&results); + svs_search_results_free(&loaded_results); + svs_index_free(index); + svs_index_builder_free(builder); + svs_storage_free(storage); + svs_algorithm_free(algorithm); + free(data); + free(queries); + memory_stream_free(stream); + svs_error_free(error); + + return ret; +} From ed6f0bf5f731a57eed60641fd5c6b379aa21b1b6 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 08:09:19 -0700 Subject: [PATCH 07/19] fix(c-api): stop redelivering a failed write buffer to the stream callback `flush_write_buffer()` threw before resetting the put area, so the buffer still held the unwritten bytes. The explicit `os.flush()` in `svs_index_save_stream` rethrew, unwinding destroyed the stream, and `~StreamBuf`'s final flush handed the identical bytes to the caller's write callback a second time and then swallowed the failure. Resetting the put area before invoking the callback leaves it empty on a throw, so the destructor's flush is a no-op. Three smaller review findings, same file: - `StreamBuf` declared a destructor but no copy or move members. Since `std::streambuf`'s copy constructor is protected rather than deleted, an implicit copy constructor existed that deep-copied the buffers while shallow-copying the base's get and put pointers, so a copy's areas aliased the source's storage. Dormant, because the only holder is a private base of the non-copyable stream wrappers, but all four are now deleted. - Buffer sizing was keyed on which callbacks were non-null, so a caller supplying both directions allocated 128 KiB with half of it dead. The adapter now takes an explicit direction and allocates only what it uses. - `overflow()` returned `eof()` for the eof sentinel, which the streambuf contract reads as failure; it now returns `traits_type::not_eof(ch)`. Latent only, as no path here passes the sentinel. Co-Authored-By: Claude Opus 5 --- bindings/c/src/stream.hpp | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/bindings/c/src/stream.hpp b/bindings/c/src/stream.hpp index 5cde033c..c2dfe15b 100644 --- a/bindings/c/src/stream.hpp +++ b/bindings/c/src/stream.hpp @@ -34,6 +34,7 @@ namespace svs::c_runtime { class StreamBuf : public std::streambuf { public: static constexpr size_t buffer_size = 64 * 1024; + enum class Direction { read, write }; static void validate(svs_stream_i stream, bool need_write) { if (stream == nullptr) { @@ -61,20 +62,26 @@ class StreamBuf : public std::streambuf { // Holds a value copy of the user's ops table; `self` is referenced and only needs to // remain valid until the streaming save/load call returns. - StreamBuf(const svs_stream_ops_t& ops, void* self) + StreamBuf(const svs_stream_ops_t& ops, void* self, Direction direction) : ops_(ops) , self_(self) - , read_buf_(ops.read != nullptr ? buffer_size : 0) - , write_buf_(ops.write != nullptr ? buffer_size : 0) { - if (!write_buf_.empty()) { + , direction_(direction) + , read_buf_(direction == Direction::read ? buffer_size : 0) + , write_buf_(direction == Direction::write ? buffer_size : 0) { + if (direction_ == Direction::write) { setp(write_buf_.data(), write_buf_.data() + write_buf_.size()); } } + StreamBuf(const StreamBuf&) = delete; + StreamBuf& operator=(const StreamBuf&) = delete; + StreamBuf(StreamBuf&&) = delete; + StreamBuf& operator=(StreamBuf&&) = delete; + // A destructor must never throw; callers that need to observe a final write failure // should call pubsync() themselves before the stream goes out of scope. ~StreamBuf() override { - if (ops_.write != nullptr) { + if (direction_ == Direction::write) { try { flush_write_buffer(); } catch (...) {} @@ -88,7 +95,7 @@ class StreamBuf : public std::streambuf { *pptr() = traits_type::to_char_type(ch); pbump(1); } - return ch; + return traits_type::not_eof(ch); } int sync() override { @@ -132,10 +139,13 @@ class StreamBuf : public std::streambuf { private: void flush_write_buffer() { auto n = static_cast(pptr() - pbase()); + // Reset the put area before the callback: a throw then leaves it empty, so the + // destructor's flush is a no-op instead of redelivering the same bytes twice. + setp(write_buf_.data(), write_buf_.data() + write_buf_.size()); if (n > 0) { svs_error_desc impl_error{ SVS_ERROR_UNKNOWN, "Unknown error in stream write callback"}; - if (!ops_.write(self_, pbase(), n, &impl_error)) { + if (!ops_.write(self_, write_buf_.data(), n, &impl_error)) { throw coded_error( impl_error.code, "Stream write callback failed: (" + std::to_string(impl_error.code) + @@ -144,11 +154,11 @@ class StreamBuf : public std::streambuf { } written_ += n; } - setp(write_buf_.data(), write_buf_.data() + write_buf_.size()); } svs_stream_ops_t ops_; void* self_; + Direction direction_; std::vector read_buf_; std::vector write_buf_; size_t written_ = 0; @@ -159,15 +169,15 @@ namespace detail { // this guarantees `buf` exists before std::istream/std::ostream stores its address. struct StreamBufHolder { StreamBuf buf; - StreamBufHolder(const svs_stream_ops_t& ops, void* self) - : buf(ops, self) {} + StreamBufHolder(const svs_stream_ops_t& ops, void* self, StreamBuf::Direction direction) + : buf(ops, self, direction) {} }; } // namespace detail class InputStream : private detail::StreamBufHolder, public std::istream { public: InputStream(const svs_stream_ops_t& ops, void* self) - : detail::StreamBufHolder(ops, self) + : detail::StreamBufHolder(ops, self, StreamBuf::Direction::read) , std::istream(&buf) { // Without this, the sentry swallows a read-callback exception into a silent // badbit instead of rethrowing it; EOF alone only sets eofbit/failbit, not badbit. @@ -178,7 +188,7 @@ class InputStream : private detail::StreamBufHolder, public std::istream { class OutputStream : private detail::StreamBufHolder, public std::ostream { public: OutputStream(const svs_stream_ops_t& ops, void* self) - : detail::StreamBufHolder(ops, self) + : detail::StreamBufHolder(ops, self, StreamBuf::Direction::write) , std::ostream(&buf) { // Without this, the sentry swallows a write-callback exception into a silent // badbit instead of rethrowing it, so a failed save would report success. From e12148e6ee04ceac83e1caa29f21223273a0ff5b Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 08:09:20 -0700 Subject: [PATCH 08/19] fix(c-api): check the stream pointer before the algorithm guard The stream loaders validated the algorithm type before the stream pointer, so a non-Vamana builder passed together with a NULL stream reported `SVS_ERROR_NOT_IMPLEMENTED` where the directory loaders report `SVS_ERROR_INVALID_ARGUMENT`. `EXPECT_ARG_NOT_NULL(stream)` now precedes `NOT_IMPLEMENTED_IF` in all three streaming functions, matching the ordering the directory-based loaders already use. Co-Authored-By: Claude Opus 5 --- bindings/c/src/svs_c.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index 9b943f2a..09342f90 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -890,6 +890,7 @@ extern "C" svs_index_h svs_index_load_stream( return wrap_exceptions( [&]() { EXPECT_ARG_NOT_NULL(builder); + EXPECT_ARG_NOT_NULL(stream); NOT_IMPLEMENTED_IF( (builder->impl->algorithm->type != SVS_ALGORITHM_TYPE_VAMANA), "Only Vamana algorithm is currently supported for index loading" @@ -920,6 +921,7 @@ extern "C" svs_index_h svs_index_load_stream_dynamic( return wrap_exceptions( [&]() { EXPECT_ARG_NOT_NULL(builder); + EXPECT_ARG_NOT_NULL(stream); NOT_IMPLEMENTED_IF( (builder->impl->algorithm->type != SVS_ALGORITHM_TYPE_VAMANA), "Only Vamana algorithm is currently supported for dynamic index loading" @@ -1102,6 +1104,7 @@ svs_index_save_stream(svs_index_h index, svs_stream_i stream, svs_error_h out_er return wrap_exceptions( [&]() { EXPECT_ARG_NOT_NULL(index); + EXPECT_ARG_NOT_NULL(stream); StreamBuf::validate(stream, /*need_write=*/true); OutputStream os(*stream->ops, stream->self); index->impl->save(os); From 0180e5a9eaf08cbd101ea03ef2fd9df505ae220c Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 08:09:22 -0700 Subject: [PATCH 09/19] test(c-api): cover the multi-buffer, EOF and redelivery paths Every test built 100x32 vectors, roughly 25-30 KB, so the adapter's 64 KiB buffer never filled and `StreamBuf::overflow()` had no coverage at all. The consequence was that the test guarding the unflushed-save regression passed trivially: it proved that the only flush fails, not that the final partial flush fails after several full ones. Both round-trips now use a payload spanning more than two full buffers and assert on the measured byte count, and the partial-failure case additionally requires that at least two full-size writes succeeded first. Also: a regression test for the redelivery fix, asserting the write callback is never invoked again once it has returned false; element-by-element result comparison plus a retrievability check for an added point in the dynamic round-trip, which previously asserted only the query count; and dedicated cases for an empty stream and for one truncated partway through a valid payload. Co-Authored-By: Claude Opus 5 --- bindings/c/tests/c_api_stream.cpp | 264 +++++++++++++++++++++++++++++- 1 file changed, 257 insertions(+), 7 deletions(-) diff --git a/bindings/c/tests/c_api_stream.cpp b/bindings/c/tests/c_api_stream.cpp index 23e1dd20..7b03901d 100644 --- a/bindings/c/tests/c_api_stream.cpp +++ b/bindings/c/tests/c_api_stream.cpp @@ -86,6 +86,85 @@ size_t oom_read(void* /*self*/, void* /*buf*/, size_t /*n*/, svs_error_h out_err return 0; } +// Sized so the saved payload (data + graph) provably exceeds two StreamBuf write buffers: +// data alone is num_vectors * dimension * sizeof(float) = 200 * 700 * 4 = 560'000 bytes, +// well over 2 * STREAM_BUFFER_SIZE = 131'072, before the graph even adds its share. +constexpr size_t MULTIBUFFER_NUM_VECTORS = 200; +constexpr size_t MULTIBUFFER_DIMENSION = 700; + +// Owns the algorithm/builder/index triple built over the oversized data set above, so the +// two sections that need a multi-buffer payload (round-trip and partial-flush-failure) +// share one construction path instead of duplicating build setup. +struct MultiBufferIndex { + svs_algorithm_h algorithm = nullptr; + svs_index_builder_h builder = nullptr; + svs_index_h index = nullptr; +}; + +MultiBufferIndex build_multibuffer_index(std::vector& data, svs_error_h error) { + MultiBufferIndex result; + result.algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + result.builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, MULTIBUFFER_DIMENSION, result.algorithm, error + ); + svs_index_builder_set_threadpool( + result.builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + ); + generate_test_data(data, MULTIBUFFER_NUM_VECTORS, MULTIBUFFER_DIMENSION); + result.index = + svs_index_build(result.builder, data.data(), MULTIBUFFER_NUM_VECTORS, error); + return result; +} + +// Like fail_partial_write, but also counts the full-size writes that succeeded before the +// partial one it rejects, so a test can assert the failure was the *last* of several writes +// rather than the only one. +struct CountingFailSink { + MemoryStream stream; + size_t full_write_count = 0; +}; + +bool fail_partial_write_counted( + void* self, const void* buf, size_t n, svs_error_h out_err +) { + auto* sink = static_cast(self); + if (n < STREAM_BUFFER_SIZE) { + svs_error_set(out_err, SVS_ERROR_RUNTIME, "refusing partial write"); + return false; + } + sink->full_write_count++; + return memory_stream_write(&sink->stream, buf, n, out_err); +} + +// Fails exactly once, on the fail_at_invocation'th call, then records whether it is ever +// invoked again. Regression coverage for flush_write_buffer() resetting the put area before +// calling out, not after: previously a failed buffer's bytes were still sitting in the put +// area when ~StreamBuf tried to flush again, redelivering them to the callback a second +// time. +struct RecordingFailSink { + size_t fail_at_invocation = 0; + size_t invocation_count = 0; + size_t total_bytes = 0; + size_t invocations_after_failure = 0; + bool has_failed = false; +}; + +bool fail_after_n_write(void* self, const void* /*buf*/, size_t n, svs_error_h out_err) { + auto* sink = static_cast(self); + if (sink->has_failed) { + sink->invocations_after_failure++; + return false; + } + sink->invocation_count++; + if (sink->invocation_count == sink->fail_at_invocation) { + sink->has_failed = true; + svs_error_set(out_err, SVS_ERROR_RUNTIME, "simulated failure mid-stream"); + return false; + } + sink->total_bytes += n; + return true; +} + } // namespace CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { @@ -156,28 +235,119 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { svs_index_free(index); } + CATCH_SECTION("Round-trip through an in-memory stream spanning multiple write buffers" + ) { + std::vector big_data; + MultiBufferIndex mb = build_multibuffer_index(big_data, error); + CATCH_REQUIRE(mb.algorithm != nullptr); + CATCH_REQUIRE(mb.builder != nullptr); + CATCH_REQUIRE(mb.index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + std::vector big_queries; + generate_test_data(big_queries, 3, MULTIBUFFER_DIMENSION); + + svs_search_results_t before = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + mb.index, big_queries.data(), 3, K, &before, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(mb.index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + // Proves the write path actually flushed several full buffers and a trailing + // partial one, and the read path below refills the get area several times, rather + // than relying on the vector counts above staying big enough by construction. + CATCH_REQUIRE(stream.bytes.size() > 2 * STREAM_BUFFER_SIZE); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(mb.builder, &in_stream, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t after = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, big_queries.data(), 3, K, &after, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(after.num_queries == before.num_queries); + for (size_t i = 0; i < before.num_queries * K; ++i) { + CATCH_REQUIRE(after.indices[i] == before.indices[i]); + CATCH_REQUIRE(after.distances[i] == before.distances[i]); + } + + svs_search_results_free(&before); + svs_search_results_free(&after); + svs_index_free(loaded); + svs_index_free(mb.index); + svs_index_builder_free(mb.builder); + svs_algorithm_free(mb.algorithm); + } + CATCH_SECTION("Save fails when only the final partial flush fails") { - svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); - CATCH_REQUIRE(index != nullptr); + std::vector big_data; + MultiBufferIndex mb = build_multibuffer_index(big_data, error); + CATCH_REQUIRE(mb.index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); MemoryStream probe; svs_stream_interface_ops probe_ops = SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); svs_stream_interface probe_stream = SVS_MAKE_INTERFACE(&probe, probe_ops); - CATCH_REQUIRE(svs_index_save_stream(index, &probe_stream, error)); + CATCH_REQUIRE(svs_index_save_stream(mb.index, &probe_stream, error)); + // The payload must span at least two full buffers plus a partial remainder, or this + // test cannot distinguish "the one and only flush failed" from "the final partial + // flush failed after several successful full flushes" - the regression it targets. + CATCH_REQUIRE(probe.bytes.size() > 2 * STREAM_BUFFER_SIZE); // The failing sink below only ever rejects a write shorter than the buffer; if the // payload happened to land exactly on a buffer boundary there would be no partial // write left for it to catch. CATCH_REQUIRE(probe.bytes.size() % STREAM_BUFFER_SIZE != 0); - MemoryStream sink; + CountingFailSink sink; svs_stream_interface_ops fail_ops = - SVS_INIT_STREAM_OPS(nullptr, fail_partial_write); + SVS_INIT_STREAM_OPS(nullptr, fail_partial_write_counted); svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(&sink, fail_ops); - CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &fail_stream, error)); + CATCH_REQUIRE_FALSE(svs_index_save_stream(mb.index, &fail_stream, error)); CATCH_REQUIRE_FALSE(svs_error_ok(error)); + // At least two full-size writes must have succeeded before the failing partial one, + // or this is once again just "the single flush failed". + CATCH_REQUIRE(sink.full_write_count >= 2); - svs_index_free(index); + svs_index_free(mb.index); + svs_index_builder_free(mb.builder); + svs_algorithm_free(mb.algorithm); + } + + CATCH_SECTION("Write failure never redelivers the same bytes during destructor unwind" + ) { + std::vector big_data; + MultiBufferIndex mb = build_multibuffer_index(big_data, error); + CATCH_REQUIRE(mb.index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + // Fails on the 3rd invocation, well before the last one, so several full buffers + // must have already been accepted and there is guaranteed to be more payload left + // that a double-delivery bug would have handed to the callback a second time. + RecordingFailSink sink; + sink.fail_at_invocation = 3; + svs_stream_interface_ops fail_ops = + SVS_INIT_STREAM_OPS(nullptr, fail_after_n_write); + svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(&sink, fail_ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(mb.index, &fail_stream, error)); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + CATCH_REQUIRE(sink.invocation_count == 3); + CATCH_REQUIRE(sink.invocations_after_failure == 0); + + svs_index_free(mb.index); + svs_index_builder_free(mb.builder); + svs_algorithm_free(mb.algorithm); } CATCH_SECTION("Write callback failure aborts save") { @@ -244,6 +414,43 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { svs_index_free(index); } + CATCH_SECTION("Load fails on an empty stream instead of hanging or crashing") { + MemoryStream stream; + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(builder, &in_stream, error); + CATCH_REQUIRE(loaded == nullptr); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + } + + CATCH_SECTION("Load fails on a stream truncated partway through a valid payload") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + // Cut the valid payload in half: the read callback hands back real bytes for a + // while, then reports EOF (0 bytes) before the format is fully consumed. + CATCH_REQUIRE(stream.bytes.size() > 1); + stream.bytes.resize(stream.bytes.size() / 2); + stream.pos = 0; + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(builder, &in_stream, error); + CATCH_REQUIRE(loaded == nullptr); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + + svs_index_free(index); + } + CATCH_SECTION("Dynamic round-trip, add_points, and search") { std::vector ids(NUM_VECTORS); for (size_t i = 0; i < NUM_VECTORS; ++i) { @@ -255,6 +462,12 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { ); CATCH_REQUIRE(index != nullptr); + svs_search_results_t before = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + index, queries.data(), 3, K, &before, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + MemoryStream stream; svs_stream_interface_ops write_ops = SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); @@ -270,6 +483,22 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { CATCH_REQUIRE(loaded != nullptr); CATCH_REQUIRE(svs_error_ok(error)); + // A stream load that corrupted data or graph structure must not be able to pass + // this: compare against the pre-save index element-by-element, as the static + // round-trip does, instead of only checking the query count came back. + svs_search_results_t after = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, queries.data(), 3, K, &after, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(after.num_queries == before.num_queries); + for (size_t i = 0; i < before.num_queries * K; ++i) { + CATCH_REQUIRE(after.indices[i] == before.indices[i]); + CATCH_REQUIRE(after.distances[i] == before.distances[i]); + } + svs_search_results_free(&before); + svs_search_results_free(&after); + std::vector new_data; std::vector new_ids = {NUM_VECTORS, NUM_VECTORS + 1}; generate_test_data(new_data, 2, DIMENSION); @@ -287,7 +516,28 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results.num_queries == 3); + // Query with the exact vector of a newly added point and require its id shows up + // among the neighbors, so a load that silently dropped or corrupted the loaded + // graph/data cannot pass just because a search still returns *some* results. + std::vector probe_query( + new_data.begin(), new_data.begin() + static_cast(DIMENSION) + ); + svs_search_results_t probe_results = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, probe_query.data(), 1, K, &probe_results, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + bool found_new_id = false; + for (size_t i = 0; i < K; ++i) { + if (probe_results.indices[i] == new_ids[0]) { + found_new_id = true; + break; + } + } + CATCH_REQUIRE(found_new_id); + svs_search_results_free(&results); + svs_search_results_free(&probe_results); svs_index_free(loaded); svs_index_free(index); } From d5605b121eea4eb33d2916da751e4ae9bc9babb9 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 08:09:23 -0700 Subject: [PATCH 10/19] docs(c-api): record the stream encoding asymmetry in the design doc `C_API_Design.md` is the single source of truth for the C API, so the decision behind the save/load asymmetry belongs there rather than in a scratch document: load accepts both the native encoding and a directory archive because SVS detects which it has, while save produces only the native one. The consequence worth stating is that a directory-saved index cannot be streamed, so streaming is self-sufficient only for indexes that were themselves stream-saved. Also fixes the Samples lists in both files, which pointed at a `samples/` directory that does not exist; the examples live under `examples/c/`. Co-Authored-By: Claude Opus 5 --- bindings/c/README.md | 13 ++++++------- bindings/c/docs/C_API_Design.md | 25 ++++++++++++++----------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/bindings/c/README.md b/bindings/c/README.md index 9c5da468..f4d9fce3 100644 --- a/bindings/c/README.md +++ b/bindings/c/README.md @@ -181,17 +181,16 @@ cleanup: ## Samples -Runnable sample applications live in [samples/](samples/): +Runnable sample applications live in [`examples/c/`](../../examples/c/): -- [`simple.c`](samples/simple.c) – minimal static index build + search with a +- [`simple.c`](../../examples/c/simple.c) – minimal static index build + search with a custom thread pool -- [`dynamic.c`](samples/dynamic.c) – dynamic index with add / delete / +- [`dynamic.c`](../../examples/c/dynamic.c) – dynamic index with add / delete / consolidate -- [`save_load.c`](samples/save_load.c) – persisting and reloading indices from +- [`save_load.c`](../../examples/c/save_load.c) – persisting and reloading indices from disk - -Additional integration examples: [`examples/c/`](../../examples/c/), including -stream-based save/load via [`save_load_stream.c`](../../examples/c/save_load_stream.c). +- [`save_load_stream.c`](../../examples/c/save_load_stream.c) – stream-based index save + and load ## Further Reading diff --git a/bindings/c/docs/C_API_Design.md b/bindings/c/docs/C_API_Design.md index d2fd78ef..85aa596b 100644 --- a/bindings/c/docs/C_API_Design.md +++ b/bindings/c/docs/C_API_Design.md @@ -553,16 +553,19 @@ save or load function. No synchronization between concurrent stream operations i and only needs to remain valid until the streaming function returns. Data is copied out of the stream during load, so the stream buffer need not persist after the call completes. -**Encodings:** `svs_index_save_stream` produces only the native stream encoding. The load -functions accept both that encoding and a packed directory archive, detected from the stream -itself. An index written with the directory-based `svs_index_save` cannot be converted to a -stream through this API. +**Encodings:** SVS supports two mutually exclusive stream encodings identified by an 8-byte magic +at offset zero: the native stream encoding (`"SVS_STRM"`) and a tar-like directory archive. The load +functions accept both transparently; detection is handled by SVS internals. `svs_index_save_stream` +produces only the native encoding by design, so the save and load halves are deliberately asymmetric. +An index written to disk via `svs_index_save` cannot be streamed, because the C layer does not +expose the machinery to pack a directory archive into stream form. Streaming is therefore +self-sufficient only for indexes that were themselves stream-saved. **Known limitations:** When loading an index with a custom allocator via `svs_index_load_stream` or `svs_index_load_stream_dynamic`, the graph memory comes from `HugepageAllocator` rather than the supplied allocator. For a dynamic index, graph growth -reallocates the entire graph instead of appending a block. This limitation has a performance -consequence but does not affect correctness. +reallocates the entire graph instead of appending a block. These limitations have a performance +consequence but do not affect correctness. ## API Overview @@ -622,8 +625,8 @@ for full signatures, parameters, and Doxygen documentation. - See the top-level [../README.md](../README.md) for a quick start, build/consume instructions, and a complete end-to-end usage example. -- See [../samples/](../samples/) for runnable sample applications: - - `simple.c` – minimal static index build + search with a custom thread pool - - `dynamic.c` – dynamic index with add / delete / consolidate - - `save_load.c` – persisting and reloading indices from disk -- See [examples/c/](../../../examples/c/) for additional usage examples +- See [examples/c/](../../../examples/c/) for runnable sample applications: + - [`simple.c`](../../../examples/c/simple.c) – minimal static index build + search with a custom thread pool + - [`dynamic.c`](../../../examples/c/dynamic.c) – dynamic index with add / delete / consolidate + - [`save_load.c`](../../../examples/c/save_load.c) – persisting and reloading indices from disk + - [`save_load_stream.c`](../../../examples/c/save_load_stream.c) – stream-based index save and load From a66c55230d7ea5fe2777774ea9f53ebd2858c673 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 29 Sep 2026 08:09:24 -0700 Subject: [PATCH 11/19] style(c-api): trim two dispatcher comments to the two-line limit Both comments kept their substance: the stream alternative is matched through the generic variant `DispatchConverter`, the same way the existing build and directory-load alternatives are. Co-Authored-By: Claude Opus 5 --- bindings/c/src/dispatcher_dynamic_vamana.cpp | 5 ++--- bindings/c/src/dispatcher_vamana.cpp | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bindings/c/src/dispatcher_dynamic_vamana.cpp b/bindings/c/src/dispatcher_dynamic_vamana.cpp index 40256654..b9c25ced 100644 --- a/bindings/c/src/dispatcher_dynamic_vamana.cpp +++ b/bindings/c/src/dispatcher_dynamic_vamana.cpp @@ -157,9 +157,8 @@ void register_dynamic_vamana_index_specializations(Dispatcher& dispatcher) { for_sq_specializations(load_stream_closure); } -// Third DynamicVamanaSource alternative for the stream load path; matched the same way -// the existing build/directory-load alternatives are, via the generic variant -// DispatchConverter. +// Stream load alternative, matched via generic variant DispatchConverter like the +// existing build and directory-load alternatives. using DynamicVamanaSource = std::variant< std::pair, std::span>, std::filesystem::path, diff --git a/bindings/c/src/dispatcher_vamana.cpp b/bindings/c/src/dispatcher_vamana.cpp index 3fa653d2..e716b18e 100644 --- a/bindings/c/src/dispatcher_vamana.cpp +++ b/bindings/c/src/dispatcher_vamana.cpp @@ -122,9 +122,8 @@ void register_vamana_index_specializations(Dispatcher& dispatcher) { for_sq_specializations(load_stream_closure); } -// Third VamanaSource alternative for the stream load path; matched the same way the -// existing build/directory-load alternatives are, via the generic variant -// DispatchConverter. +// Stream load alternative, matched via generic variant DispatchConverter like the +// existing build and directory-load alternatives. using VamanaSource = std::variant< svs::data::ConstSimpleDataView, std::filesystem::path, From 05418638b28bc08ca1d32c38015065cc073baf59 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 6 Oct 2026 04:13:04 -0700 Subject: [PATCH 12/19] forward custom allocator to graph for stream i/o --- bindings/c/include/svs/c/svs_c.h | 7 - bindings/c/src/dispatcher_dynamic_vamana.cpp | 5 +- bindings/c/src/dispatcher_vamana.cpp | 6 +- bindings/c/tests/c_api_stream.cpp | 149 +++++++++++++++++-- include/svs/orchestrators/dynamic_vamana.h | 45 ++++-- include/svs/orchestrators/vamana.h | 69 +++++++-- 6 files changed, 233 insertions(+), 48 deletions(-) diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h index 9bbbf752..1206718b 100644 --- a/bindings/c/include/svs/c/svs_c.h +++ b/bindings/c/include/svs/c/svs_c.h @@ -1072,9 +1072,6 @@ SVS_API svs_index_h svs_index_load_dynamic( /// the stream rather than referenced. /// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream /// and a packed directory archive. The encoding is detected from the stream itself. -/// @remarks Graph memory comes from `HugepageAllocator` rather than any allocator supplied -/// through @ref svs_index_builder_set_allocator_custom. This is a performance difference, -/// not a correctness one. SVS_API svs_index_h svs_index_load_stream( svs_index_builder_h builder, svs_stream_i stream, svs_error_h out_err /*=NULL*/ ); @@ -1090,10 +1087,6 @@ SVS_API svs_index_h svs_index_load_stream( /// the stream rather than referenced. /// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream /// and a packed directory archive. The encoding is detected from the stream itself. -/// @remarks Graph memory comes from `HugepageAllocator` rather than any allocator supplied -/// through @ref svs_index_builder_set_allocator_custom, and growing the loaded index -/// reallocates the whole graph instead of appending a block. Both are a performance -/// difference, not a correctness one. SVS_API svs_index_h svs_index_load_stream_dynamic( svs_index_builder_h builder, svs_stream_i stream, diff --git a/bindings/c/src/dispatcher_dynamic_vamana.cpp b/bindings/c/src/dispatcher_dynamic_vamana.cpp index b9c25ced..38a990f5 100644 --- a/bindings/c/src/dispatcher_dynamic_vamana.cpp +++ b/bindings/c/src/dispatcher_dynamic_vamana.cpp @@ -124,10 +124,13 @@ svs::DynamicVamana load_stream_dynamic_vamana_index( auto data_allocator_handle = allocator_builder.build(); auto allocator = allocator_type{block_params, data_allocator_handle}; + auto graph_allocator_handle = allocator_builder.build_for_graph(); + auto graph_allocator = svs::data::Blocked{block_params, graph_allocator_handle}; + // Data is copied out of the stream during load, so `self` need not outlive the call. // A zero-copy load would alias the caller's buffer and must not reuse this contract. return svs::DynamicVamana::assemble( - *stream, distance, std::move(pool), allocator + *stream, distance, std::move(pool), allocator, graph_allocator ); } diff --git a/bindings/c/src/dispatcher_vamana.cpp b/bindings/c/src/dispatcher_vamana.cpp index e716b18e..73d928fe 100644 --- a/bindings/c/src/dispatcher_vamana.cpp +++ b/bindings/c/src/dispatcher_vamana.cpp @@ -92,7 +92,11 @@ svs::Vamana load_stream_vamana_index( // Data is copied out of the stream during load, so `self` need not outlive the call. // A zero-copy load would alias the caller's buffer and must not reuse this contract. return svs::Vamana::assemble( - *stream, distance, std::move(pool), allocator_builder.build() + *stream, + distance, + std::move(pool), + allocator_builder.build(), + allocator_builder.build_for_graph() ); } diff --git a/bindings/c/tests/c_api_stream.cpp b/bindings/c/tests/c_api_stream.cpp index 7b03901d..4e1e4e7f 100644 --- a/bindings/c/tests/c_api_stream.cpp +++ b/bindings/c/tests/c_api_stream.cpp @@ -24,8 +24,10 @@ #include "c_api_test_utils.h" // Standard library +#include #include #include +#include #include namespace { @@ -453,9 +455,7 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { CATCH_SECTION("Dynamic round-trip, add_points, and search") { std::vector ids(NUM_VECTORS); - for (size_t i = 0; i < NUM_VECTORS; ++i) { - ids[i] = i; - } + std::iota(ids.begin(), ids.end(), size_t{0}); const size_t BLOCK_SIZE = 1024 * 1024; svs_index_h index = svs_index_build_dynamic( builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error @@ -527,13 +527,11 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { loaded, probe_query.data(), 1, K, &probe_results, nullptr, nullptr, error )); CATCH_REQUIRE(svs_error_ok(error)); - bool found_new_id = false; - for (size_t i = 0; i < K; ++i) { - if (probe_results.indices[i] == new_ids[0]) { - found_new_id = true; - break; - } - } + bool found_new_id = std::any_of( + probe_results.indices, + probe_results.indices + K, + [new_id = new_ids[0]](size_t idx) { return idx == new_id; } + ); CATCH_REQUIRE(found_new_id); svs_search_results_free(&results); @@ -542,6 +540,137 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { svs_index_free(index); } + CATCH_SECTION("Dynamic Stream Load Uses Custom Allocator For Graph") { + // Assert same number of bytes are allocated during original construction and after + // a streaming I/O loop + std::vector ids(NUM_VECTORS); + std::iota(ids.begin(), ids.end(), size_t{0}); + const size_t BLOCK_SIZE = 1024 * 1024; + + TrackingAllocator build_tracker; + svs_allocator_interface_ops build_alloc_ops = SVS_INIT_ALLOCATOR_OPS( + tracking_allocator_allocate, tracking_allocator_deallocate + ); + svs_allocator_interface build_allocator = + SVS_MAKE_INTERFACE(&build_tracker, build_alloc_ops); + CATCH_REQUIRE( + svs_index_builder_set_allocator_custom(builder, &build_allocator, error) + ); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + size_t built_bytes = build_tracker.live_bytes; + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_index_builder_h load_builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(load_builder != nullptr); + CATCH_REQUIRE(svs_index_builder_set_threadpool( + load_builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + + TrackingAllocator load_tracker; + svs_allocator_interface_ops load_alloc_ops = SVS_INIT_ALLOCATOR_OPS( + tracking_allocator_allocate, tracking_allocator_deallocate + ); + svs_allocator_interface load_allocator = + SVS_MAKE_INTERFACE(&load_tracker, load_alloc_ops); + CATCH_REQUIRE( + svs_index_builder_set_allocator_custom(load_builder, &load_allocator, error) + ); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = + svs_index_load_stream_dynamic(load_builder, &in_stream, BLOCK_SIZE, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + size_t loaded_bytes = load_tracker.live_bytes; + CATCH_REQUIRE(loaded_bytes == built_bytes); + CATCH_REQUIRE(load_tracker.alloc_count > 0); + + svs_index_free(loaded); + svs_index_free(index); + svs_index_builder_free(load_builder); + } + + CATCH_SECTION("Static Stream Load Uses Custom Allocator For Graph") { + // Assert same number of bytes are allocated during original construction and after + // a streaming I/O loop + TrackingAllocator build_tracker; + svs_allocator_interface_ops build_alloc_ops = SVS_INIT_ALLOCATOR_OPS( + tracking_allocator_allocate, tracking_allocator_deallocate + ); + svs_allocator_interface build_allocator = + SVS_MAKE_INTERFACE(&build_tracker, build_alloc_ops); + CATCH_REQUIRE( + svs_index_builder_set_allocator_custom(builder, &build_allocator, error) + ); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + size_t built_bytes = build_tracker.live_bytes; + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_index_builder_h load_builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(load_builder != nullptr); + CATCH_REQUIRE(svs_index_builder_set_threadpool( + load_builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + + TrackingAllocator load_tracker; + svs_allocator_interface_ops load_alloc_ops = SVS_INIT_ALLOCATOR_OPS( + tracking_allocator_allocate, tracking_allocator_deallocate + ); + svs_allocator_interface load_allocator = + SVS_MAKE_INTERFACE(&load_tracker, load_alloc_ops); + CATCH_REQUIRE( + svs_index_builder_set_allocator_custom(load_builder, &load_allocator, error) + ); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(load_builder, &in_stream, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + size_t loaded_bytes = load_tracker.live_bytes; + CATCH_REQUIRE(loaded_bytes == built_bytes); + CATCH_REQUIRE(load_tracker.alloc_count > 0); + + svs_index_free(loaded); + svs_index_free(index); + svs_index_builder_free(load_builder); + } + svs_index_builder_free(builder); svs_algorithm_free(algorithm); svs_error_free(error); diff --git a/include/svs/orchestrators/dynamic_vamana.h b/include/svs/orchestrators/dynamic_vamana.h index a78c3437..bae443ed 100644 --- a/include/svs/orchestrators/dynamic_vamana.h +++ b/include/svs/orchestrators/dynamic_vamana.h @@ -363,22 +363,41 @@ class DynamicVamana : public manager::IndexManager { } // Assembly from stream + /// + /// @brief Assemble a DynamicVamana index from a serialized stream. + /// + /// @tparam QueryTypes The set of query element types supported by the resulting + /// index. + /// @tparam Data The dataset type to load. + /// @tparam Distance Distance functor or ``svs::DistanceType`` enum. + /// @tparam ThreadPoolProto Thread pool type or size_t. + /// @tparam DataAllocator The type of allocator used for the dataset. + /// @tparam GraphAllocator The type of allocator used for the graph. + /// + /// @param stream Stream containing the serialized index. + /// @param distance Distance functor or enum. + /// @param threadpool_proto Thread pool or number of threads to use. + /// @param data_allocator Allocator instance to use for the dataset. + /// @param graph_allocator Allocator instance to use for the graph. + /// template < manager::QueryTypeDefinition QueryTypes, typename Data, typename Distance, typename ThreadPoolProto, - typename... DataLoaderArgs> + typename DataAllocator = typename Data::allocator_type, + typename GraphAllocator = data::Blocked>> static DynamicVamana assemble( std::istream& stream, const Distance& distance, ThreadPoolProto threadpool_proto, - DataLoaderArgs&&... data_args + const DataAllocator& data_allocator = {}, + const GraphAllocator& graph_allocator = {} ) { auto deserializer = svs::lib::detail::Deserializer::build(stream); if (deserializer.is_native()) { auto threadpool = threads::as_threadpool(std::move(threadpool_proto)); - using GraphType = svs::GraphLoader<>::return_type; + using GraphType = graphs::SimpleGraph; if constexpr (std::is_same_v, DistanceType>) { auto dispatcher = DistanceDispatcher(distance); return dispatcher([&](auto distance_function) { @@ -386,12 +405,12 @@ class DynamicVamana : public manager::IndexManager { index::vamana::auto_dynamic_assemble( stream, // lazy graph loader - [&]() -> GraphType { return GraphType::load(stream); }, + [&]() -> GraphType { + return GraphType::load(stream, graph_allocator); + }, // lazy data loader [&]() -> Data { - return lib::load_from_stream( - stream, SVS_FWD(data_args)... - ); + return lib::load_from_stream(stream, data_allocator); }, distance_function, std::move(threadpool) @@ -403,12 +422,12 @@ class DynamicVamana : public manager::IndexManager { index::vamana::auto_dynamic_assemble( stream, // lazy graph loader - [&]() -> GraphType { return GraphType::load(stream); }, + [&]() -> GraphType { + return GraphType::load(stream, graph_allocator); + }, // lazy data loader [&]() -> Data { - return lib::load_from_stream( - stream, SVS_FWD(data_args)... - ); + return lib::load_from_stream(stream, data_allocator); }, distance, std::move(threadpool) @@ -439,8 +458,8 @@ class DynamicVamana : public manager::IndexManager { return assemble( config_path, - svs::GraphLoader{graph_path}, - lib::load_from_disk(data_path, SVS_FWD(data_args)...), + svs::GraphLoader{graph_path, graph_allocator}, + lib::load_from_disk(data_path, data_allocator), distance, threads::as_threadpool(std::move(threadpool_proto)), false diff --git a/include/svs/orchestrators/vamana.h b/include/svs/orchestrators/vamana.h index c79fb170..65624ff9 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -469,29 +469,48 @@ class Vamana : public manager::IndexManager { } } + /// + /// @brief Assemble a Vamana index from a stream. + /// + /// @param stream The stream to load from. See ``svs::Vamana::save``. + /// @param distance The distance functor or ``svs::DistanceType`` enum to use for + /// similarity search computations. + /// @param threadpool_proto Precursor for the thread pool to use. Can either be an + /// acceptable thread pool instance or an integer specifying the number of + /// threads to use. + /// @param data_allocator Allocator to use for the loaded data. + /// @param graph_allocator Allocator to use for the loaded graph. + /// + /// @copydoc threadpool_requirements + /// + /// @sa save, build + /// // Assembly from stream template < manager::QueryTypeDefinition QueryTypes, typename Data, typename Distance, typename ThreadPoolProto, - typename... DataLoaderArgs> + typename DataAllocator = typename Data::allocator_type, + typename GraphAllocator = std::conditional_t< + is_view_type_v, + lib::rebind_allocator_t, + HugepageAllocator>> static Vamana assemble( std::istream& stream, const Distance& distance, ThreadPoolProto threadpool_proto, - DataLoaderArgs&&... data_args + const DataAllocator& data_allocator = {}, + const GraphAllocator& graph_allocator = {} ) { auto deserializer = svs::lib::detail::Deserializer::build(stream); if (deserializer.is_native()) { auto threadpool = threads::as_threadpool(std::move(threadpool_proto)); - using GraphType = std::conditional_t< - is_view_type_v, - graphs::SimpleGraph< - uint32_t, - lib::rebind_allocator_t>, - GraphLoader<>::return_type>; + using GraphType = graphs::SimpleGraph; + // A view's allocator must bind to this specific stream; an explicit + // allocator argument would bypass that binding and break the load. + constexpr bool is_view = is_view_type_v; if constexpr (std::is_same_v) { auto dispatcher = DistanceDispatcher(distance); @@ -500,12 +519,20 @@ class Vamana : public manager::IndexManager { AssembleTag(), stream, // lazy-loader - [&]() -> GraphType { return GraphType::load(stream); }, + [&]() -> GraphType { + if constexpr (is_view) { + return GraphType::load(stream); + } else { + return GraphType::load(stream, graph_allocator); + } + }, // lazy-loader [&]() -> Data { - return lib::load_from_stream( - stream, SVS_FWD(data_args)... - ); + if constexpr (is_view) { + return lib::load_from_stream(stream); + } else { + return lib::load_from_stream(stream, data_allocator); + } }, distance_function, std::move(threadpool) @@ -516,10 +543,20 @@ class Vamana : public manager::IndexManager { AssembleTag(), stream, // lazy-loader - [&]() -> GraphType { return GraphType::load(stream); }, + [&]() -> GraphType { + if constexpr (is_view) { + return GraphType::load(stream); + } else { + return GraphType::load(stream, graph_allocator); + } + }, // lazy-loader [&]() -> Data { - return lib::load_from_stream(stream, SVS_FWD(data_args)...); + if constexpr (is_view) { + return lib::load_from_stream(stream); + } else { + return lib::load_from_stream(stream, data_allocator); + } }, distance, std::move(threadpool) @@ -549,8 +586,8 @@ class Vamana : public manager::IndexManager { return assemble( config_path, - svs::GraphLoader{graph_path}, - lib::load_from_disk(data_path, SVS_FWD(data_args)...), + svs::GraphLoader{graph_path, graph_allocator}, + lib::load_from_disk(data_path, data_allocator), distance, threads::as_threadpool(std::move(threadpool_proto)) ); From 7a6fda38731807af80a0231177d107da51999d3b Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 6 Oct 2026 04:56:49 -0700 Subject: [PATCH 13/19] cleanup --- bindings/c/include/svs/c/svs_c.h | 3 +-- bindings/c/src/dispatcher_dynamic_vamana.cpp | 4 ++-- bindings/c/src/dispatcher_vamana.cpp | 4 ++-- bindings/c/src/index.hpp | 4 +--- bindings/c/src/stream.hpp | 5 ++--- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h index 64546fdf..3466fc05 100644 --- a/bindings/c/include/svs/c/svs_c.h +++ b/bindings/c/include/svs/c/svs_c.h @@ -297,8 +297,7 @@ struct svs_id_filter_interface { /// @brief Operations table for a caller-supplied byte stream. /// @remarks Access is strictly sequential: the library never repositions the stream. Both /// callbacks are invoked serially from the thread that called the streaming save or load -/// function, so no synchronization is required — unlike the thread pool, allocator and ID -/// filter interfaces. +/// function, so no synchronization is required /// @remarks Exactly one direction is required per operation: @ref svs_index_save_stream /// needs @p write, the load functions need @p read. The unused callback may be NULL. /// @var svs_stream_interface_ops::version diff --git a/bindings/c/src/dispatcher_dynamic_vamana.cpp b/bindings/c/src/dispatcher_dynamic_vamana.cpp index dad7a619..7719ed9b 100644 --- a/bindings/c/src/dispatcher_dynamic_vamana.cpp +++ b/bindings/c/src/dispatcher_dynamic_vamana.cpp @@ -128,8 +128,8 @@ svs::DynamicVamana load_stream_dynamic_vamana_index( auto graph_allocator_handle = allocator_builder.build_for_graph(); auto graph_allocator = svs::data::Blocked{block_params, graph_allocator_handle}; - // Data is copied out of the stream during load, so `self` need not outlive the call. - // A zero-copy load would alias the caller's buffer and must not reuse this contract. + // svs_c.h lets the caller drop the stream once loading returns. That holds only while + // assemble copies data out; a view allocator here would leave the index dangling. return svs::DynamicVamana::assemble( *stream, distance, std::move(pool), allocator, graph_allocator ); diff --git a/bindings/c/src/dispatcher_vamana.cpp b/bindings/c/src/dispatcher_vamana.cpp index 5df76afd..fc92be1a 100644 --- a/bindings/c/src/dispatcher_vamana.cpp +++ b/bindings/c/src/dispatcher_vamana.cpp @@ -92,8 +92,8 @@ svs::Vamana load_stream_vamana_index( ) { using value_type = typename DataLoader::allocator_type::value_type; using data_type = typename DataLoader::data_type; - // Data is copied out of the stream during load, so `self` need not outlive the call. - // A zero-copy load would alias the caller's buffer and must not reuse this contract. + // svs_c.h lets the caller drop the stream once loading returns. That holds only while + // assemble copies data out; a view allocator here would leave the index dangling. return svs::Vamana::assemble( *stream, distance, diff --git a/bindings/c/src/index.hpp b/bindings/c/src/index.hpp index 70fb519a..dc5066db 100644 --- a/bindings/c/src/index.hpp +++ b/bindings/c/src/index.hpp @@ -49,6 +49,7 @@ struct Index { const IDFilterInterface* id_filter = nullptr ) = 0; virtual void save(const std::filesystem::path& directory) = 0; + virtual void save(std::ostream& stream) = 0; virtual size_t dimensions() const = 0; virtual float get_distance(size_t id, std::span query) const = 0; virtual void @@ -57,9 +58,6 @@ struct Index { virtual void set_num_threads(size_t num_threads) = 0; virtual svs::index::vamana::MemoryBreakdown get_memory_breakdown() const = 0; virtual size_t size() const = 0; - // Appended, not inserted: a virtual inserted mid-vtable broke ABI once already (see - // commit 9980bd26). New virtuals go at the end of the list. - virtual void save(std::ostream& stream) = 0; }; struct DynamicIndex : public Index { diff --git a/bindings/c/src/stream.hpp b/bindings/c/src/stream.hpp index c2dfe15b..a1760624 100644 --- a/bindings/c/src/stream.hpp +++ b/bindings/c/src/stream.hpp @@ -29,8 +29,7 @@ namespace svs::c_runtime { -// Bridges svs_stream_i's read/write callbacks to std::streambuf. Buffered at 64 KiB so the -// callback amortizes across many bytes instead of firing once per byte. +// Bridges svs_stream_i's read/write callbacks to std::streambuf. Buffered at 64 KiB class StreamBuf : public std::streambuf { public: static constexpr size_t buffer_size = 64 * 1024; @@ -108,7 +107,7 @@ class StreamBuf : public std::streambuf { return traits_type::to_int_type(*gptr()); } // Default-initialized to SVS_OK: a 0-byte read is legitimate EOF unless the - // callback explicitly reported an error, which read()'s return value cannot encode. + // callback explicitly reported an error svs_error_desc impl_error{}; size_t n = ops_.read(self_, read_buf_.data(), read_buf_.size(), &impl_error); if (n == 0) { From be4eb676e78dd922965c2c666dad155eed7f949d Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 6 Oct 2026 11:44:11 -0700 Subject: [PATCH 14/19] fix(orchestrators): keep pre-PR defaults for stream assemble Vamana::assemble's archive branch passed explicit allocators to view-typed data, bypassing the requires(is_view) overload that throws a clear error. Guard it like the native branch, and share the lazy loaders across both distance branches. DynamicVamana::assemble(std::istream&) defaulted its graph allocator to Blocked, committing a full block per loaded index for callers that pass no allocator. Default to the exact-size HugepageAllocator again; the C API passes Blocked explicitly. Co-Authored-By: Claude Opus 5.5 --- include/svs/orchestrators/dynamic_vamana.h | 5 +- include/svs/orchestrators/vamana.h | 80 ++++++++++------------ 2 files changed, 41 insertions(+), 44 deletions(-) diff --git a/include/svs/orchestrators/dynamic_vamana.h b/include/svs/orchestrators/dynamic_vamana.h index 43998aaa..f3512f3e 100644 --- a/include/svs/orchestrators/dynamic_vamana.h +++ b/include/svs/orchestrators/dynamic_vamana.h @@ -373,7 +373,8 @@ class DynamicVamana : public manager::IndexManager { /// @tparam Distance Distance functor or ``svs::DistanceType`` enum. /// @tparam ThreadPoolProto Thread pool type or size_t. /// @tparam DataAllocator The type of allocator used for the dataset. - /// @tparam GraphAllocator The type of allocator used for the graph. + /// @tparam GraphAllocator The type of allocator used for the graph. Defaults to an + /// exact-size ``HugepageAllocator``; a blocked default commits a full block. /// /// @param stream Stream containing the serialized index. /// @param distance Distance functor or enum. @@ -387,7 +388,7 @@ class DynamicVamana : public manager::IndexManager { typename Distance, typename ThreadPoolProto, typename DataAllocator = typename Data::allocator_type, - typename GraphAllocator = data::Blocked>> + typename GraphAllocator = HugepageAllocator> static DynamicVamana assemble( std::istream& stream, const Distance& distance, diff --git a/include/svs/orchestrators/vamana.h b/include/svs/orchestrators/vamana.h index 1faf0004..84c42353 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -510,13 +510,27 @@ class Vamana : public manager::IndexManager { const GraphAllocator& graph_allocator = {} ) { auto deserializer = svs::lib::detail::Deserializer::build(stream); + // A view's allocator must bind to this specific stream; an explicit + // allocator argument would bypass that binding and break the load. + constexpr bool is_view = is_view_type_v; if (deserializer.is_native()) { auto threadpool = threads::as_threadpool(std::move(threadpool_proto)); using GraphType = graphs::SimpleGraph; - // A view's allocator must bind to this specific stream; an explicit - // allocator argument would bypass that binding and break the load. - constexpr bool is_view = is_view_type_v; + auto load_graph = [&]() -> GraphType { + if constexpr (is_view) { + return GraphType::load(stream); + } else { + return GraphType::load(stream, graph_allocator); + } + }; + auto load_data = [&]() -> Data { + if constexpr (is_view) { + return lib::load_from_stream(stream); + } else { + return lib::load_from_stream(stream, data_allocator); + } + }; if constexpr (std::is_same_v) { auto dispatcher = DistanceDispatcher(distance); @@ -524,22 +538,8 @@ class Vamana : public manager::IndexManager { return make_vamana>( AssembleTag(), stream, - // lazy-loader - [&]() -> GraphType { - if constexpr (is_view) { - return GraphType::load(stream); - } else { - return GraphType::load(stream, graph_allocator); - } - }, - // lazy-loader - [&]() -> Data { - if constexpr (is_view) { - return lib::load_from_stream(stream); - } else { - return lib::load_from_stream(stream, data_allocator); - } - }, + load_graph, + load_data, distance_function, std::move(threadpool) ); @@ -548,22 +548,8 @@ class Vamana : public manager::IndexManager { return make_vamana>( AssembleTag(), stream, - // lazy-loader - [&]() -> GraphType { - if constexpr (is_view) { - return GraphType::load(stream); - } else { - return GraphType::load(stream, graph_allocator); - } - }, - // lazy-loader - [&]() -> Data { - if constexpr (is_view) { - return lib::load_from_stream(stream); - } else { - return lib::load_from_stream(stream, data_allocator); - } - }, + load_graph, + load_data, distance, std::move(threadpool) ); @@ -590,13 +576,23 @@ class Vamana : public manager::IndexManager { throw ANNEXCEPTION("Invalid Vamana index archive: missing data directory!"); } - return assemble( - config_path, - svs::GraphLoader{graph_path, graph_allocator}, - lib::load_from_disk(data_path, data_allocator), - distance, - threads::as_threadpool(std::move(threadpool_proto)) - ); + if constexpr (is_view) { + return assemble( + config_path, + svs::GraphLoader{graph_path}, + lib::load_from_disk(data_path), + distance, + threads::as_threadpool(std::move(threadpool_proto)) + ); + } else { + return assemble( + config_path, + svs::GraphLoader{graph_path, graph_allocator}, + lib::load_from_disk(data_path, data_allocator), + distance, + threads::as_threadpool(std::move(threadpool_proto)) + ); + } } } From 2b50a206b9204544858c434a260ca144d7614c75 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 6 Oct 2026 11:44:33 -0700 Subject: [PATCH 15/19] fix(c-api): reject truncated and misbehaving streams - Throw on failbit as well as badbit, so a stream ending inside the last graph payload fails instead of loading stale rows. - Reject a read callback that returns more bytes than requested, and honor a read error even when bytes are returned. - Report a failed write whose callback set SVS_OK as SVS_ERROR_UNKNOWN. - Drop unflushed bytes in ~StreamBuf rather than delivering a truncated chunk during unwinding. - Document the 64 KiB read-ahead, remove the stale allocator limitation, and fix the callbacks' @return text. - Add tests for each case; remove dead test helpers and trim comments. Co-Authored-By: Claude Opus 5.5 --- bindings/c/docs/C_API_Design.md | 9 +- bindings/c/include/svs/c/svs_c.h | 12 +- bindings/c/src/stream.hpp | 50 ++++---- bindings/c/src/svs_c.cpp | 4 +- bindings/c/tests/c_api_stream.cpp | 183 ++++++++++++++++++++++-------- 5 files changed, 175 insertions(+), 83 deletions(-) diff --git a/bindings/c/docs/C_API_Design.md b/bindings/c/docs/C_API_Design.md index 85aa596b..b5b6950c 100644 --- a/bindings/c/docs/C_API_Design.md +++ b/bindings/c/docs/C_API_Design.md @@ -561,11 +561,10 @@ An index written to disk via `svs_index_save` cannot be streamed, because the C expose the machinery to pack a directory archive into stream form. Streaming is therefore self-sufficient only for indexes that were themselves stream-saved. -**Known limitations:** When loading an index with a custom allocator via -`svs_index_load_stream` or `svs_index_load_stream_dynamic`, the graph memory comes from -`HugepageAllocator` rather than the supplied allocator. For a dynamic index, graph growth -reallocates the entire graph instead of appending a block. These limitations have a performance -consequence but do not affect correctness. +**Read-ahead behavior:** The native stream encoding carries no total length. During load, the +library may read up to 64 KiB past the logical end of the index data, so callers embedding an +index inside a larger stream must frame the payload themselves (e.g. with a length prefix) and +bound the stream reads to that frame. ## API Overview diff --git a/bindings/c/include/svs/c/svs_c.h b/bindings/c/include/svs/c/svs_c.h index 3466fc05..f9c23f54 100644 --- a/bindings/c/include/svs/c/svs_c.h +++ b/bindings/c/include/svs/c/svs_c.h @@ -314,8 +314,7 @@ struct svs_id_filter_interface { /// the load with that error code; returning 0 without setting one is a clean end of /// stream. This differs from @p write, which signals failure through its return value. /// @return The number of bytes read; 0 signals end of stream unless @p out_err carries an -/// error. A short read is not an error and the library will call again. NULL for a -/// write-only stream. +/// error. A short read is not an error and the library will call again. /// @var svs_stream_interface_ops::write /// Writes exactly @p n bytes from @p buf. /// @param self Pointer to the stream instance. @@ -323,8 +322,7 @@ struct svs_id_filter_interface { /// @param n Number of bytes to write. /// @param out_err Handle to capture any error that occurs during the write. User code may /// call svs_error_set() to set the error code and message if an error occurs. -/// @return True on success. A partial write must be reported as failure. NULL for a -/// read-only stream. +/// @return True on success. A partial write must be reported as failure. struct svs_stream_interface_ops { uint32_t version; size_t struct_size; @@ -1071,6 +1069,9 @@ SVS_API svs_index_h svs_index_load_dynamic( /// the stream rather than referenced. /// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream /// and a packed directory archive. The encoding is detected from the stream itself. +/// @remarks The native encoding carries no total length, so a load may read up to 64 KiB +/// past the end of the index. Callers embedding it in a larger stream must frame the +/// payload (e.g. with a length prefix) and bound reads to that frame. SVS_API svs_index_h svs_index_load_stream( svs_index_builder_h builder, svs_stream_i stream, svs_error_h out_err /*=NULL*/ ); @@ -1086,6 +1087,9 @@ SVS_API svs_index_h svs_index_load_stream( /// the stream rather than referenced. /// @remarks Accepts both the native stream encoding produced by @ref svs_index_save_stream /// and a packed directory archive. The encoding is detected from the stream itself. +/// @remarks The native encoding carries no total length, so a load may read up to 64 KiB +/// past the end of the index. Callers embedding it in a larger stream must frame the +/// payload (e.g. with a length prefix) and bound reads to that frame. SVS_API svs_index_h svs_index_load_stream_dynamic( svs_index_builder_h builder, svs_stream_i stream, diff --git a/bindings/c/src/stream.hpp b/bindings/c/src/stream.hpp index a1760624..b7371a5a 100644 --- a/bindings/c/src/stream.hpp +++ b/bindings/c/src/stream.hpp @@ -64,10 +64,9 @@ class StreamBuf : public std::streambuf { StreamBuf(const svs_stream_ops_t& ops, void* self, Direction direction) : ops_(ops) , self_(self) - , direction_(direction) , read_buf_(direction == Direction::read ? buffer_size : 0) , write_buf_(direction == Direction::write ? buffer_size : 0) { - if (direction_ == Direction::write) { + if (direction == Direction::write) { setp(write_buf_.data(), write_buf_.data() + write_buf_.size()); } } @@ -77,15 +76,9 @@ class StreamBuf : public std::streambuf { StreamBuf(StreamBuf&&) = delete; StreamBuf& operator=(StreamBuf&&) = delete; - // A destructor must never throw; callers that need to observe a final write failure - // should call pubsync() themselves before the stream goes out of scope. - ~StreamBuf() override { - if (direction_ == Direction::write) { - try { - flush_write_buffer(); - } catch (...) {} - } - } + // Unflushed bytes are dropped on destruction, never delivered: a flush during unwinding + // would hand the callback a truncated chunk. Writers must flush explicitly. + ~StreamBuf() override = default; protected: int_type overflow(int_type ch) override { @@ -107,17 +100,22 @@ class StreamBuf : public std::streambuf { return traits_type::to_int_type(*gptr()); } // Default-initialized to SVS_OK: a 0-byte read is legitimate EOF unless the - // callback explicitly reported an error + // callback explicitly reported an error. svs_error_desc impl_error{}; size_t n = ops_.read(self_, read_buf_.data(), read_buf_.size(), &impl_error); + if (n > read_buf_.size()) { + throw std::invalid_argument( + "Stream read callback returned more bytes than the buffer it was given." + ); + } + if (impl_error.code != SVS_OK) { + throw coded_error( + impl_error.code, + "Stream read callback failed: (" + std::to_string(impl_error.code) + ") " + + impl_error.message + ); + } if (n == 0) { - if (impl_error.code != SVS_OK) { - throw coded_error( - impl_error.code, - "Stream read callback failed: (" + std::to_string(impl_error.code) + - ") " + impl_error.message - ); - } return traits_type::eof(); } setg(read_buf_.data(), read_buf_.data(), read_buf_.data() + n); @@ -138,13 +136,16 @@ class StreamBuf : public std::streambuf { private: void flush_write_buffer() { auto n = static_cast(pptr() - pbase()); - // Reset the put area before the callback: a throw then leaves it empty, so the - // destructor's flush is a no-op instead of redelivering the same bytes twice. + // Reset the put area before the callback: a throw then leaves it empty, so a + // retried flush cannot redeliver the same bytes twice. setp(write_buf_.data(), write_buf_.data() + write_buf_.size()); if (n > 0) { svs_error_desc impl_error{ SVS_ERROR_UNKNOWN, "Unknown error in stream write callback"}; if (!ops_.write(self_, write_buf_.data(), n, &impl_error)) { + if (impl_error.code == SVS_OK) { + impl_error.code = SVS_ERROR_UNKNOWN; + } throw coded_error( impl_error.code, "Stream write callback failed: (" + std::to_string(impl_error.code) + @@ -157,7 +158,6 @@ class StreamBuf : public std::streambuf { svs_stream_ops_t ops_; void* self_; - Direction direction_; std::vector read_buf_; std::vector write_buf_; size_t written_ = 0; @@ -178,9 +178,9 @@ class InputStream : private detail::StreamBufHolder, public std::istream { InputStream(const svs_stream_ops_t& ops, void* self) : detail::StreamBufHolder(ops, self, StreamBuf::Direction::read) , std::istream(&buf) { - // Without this, the sentry swallows a read-callback exception into a silent - // badbit instead of rethrowing it; EOF alone only sets eofbit/failbit, not badbit. - exceptions(std::ios_base::badbit); + // badbit alone lets a read ending inside a payload fail silently; failbit joins the + // mask, with eofbit excluded so plain EOF is not an exception. + exceptions(std::ios_base::badbit | std::ios_base::failbit); } }; diff --git a/bindings/c/src/svs_c.cpp b/bindings/c/src/svs_c.cpp index 8e045bff..1a63d6e9 100644 --- a/bindings/c/src/svs_c.cpp +++ b/bindings/c/src/svs_c.cpp @@ -1179,8 +1179,8 @@ svs_index_save_stream(svs_index_h index, svs_stream_i stream, svs_error_h out_er StreamBuf::validate(stream, /*need_write=*/true); OutputStream os(*stream->ops, stream->self); index->impl->save(os); - // The core never flushes and ~StreamBuf swallows the exception, so without this - // a write failure on the final partial buffer would report success. + // The core never flushes and ~StreamBuf drops unflushed bytes, so without this + // the final partial buffer would be lost while the save reports success. os.flush(); return true; }, diff --git a/bindings/c/tests/c_api_stream.cpp b/bindings/c/tests/c_api_stream.cpp index 4e1e4e7f..83ceb8d2 100644 --- a/bindings/c/tests/c_api_stream.cpp +++ b/bindings/c/tests/c_api_stream.cpp @@ -36,9 +36,8 @@ namespace { // API, so tests that depend on it hardcode the value. constexpr size_t STREAM_BUFFER_SIZE = 64 * 1024; -// In-memory sink/source backing the stream interface tests. write() appends to `bytes`; -// read() copies out of `bytes` starting at `pos`, capped at `max_read` per call so tests -// can force short reads. +// In-memory sink/source backing the stream interface tests, capped at `max_read` per call +// so tests can force short reads. struct MemoryStream { std::vector bytes; size_t pos = 0; @@ -61,17 +60,6 @@ bool memory_stream_write(void* self, const void* buf, size_t n, svs_error_h /*ou return true; } -// Fails a write smaller than the adapter's buffer, which every write during a save is -// except the final flush of a partial one, so this deterministically targets only that -// last write regardless of how many full buffers preceded it. -bool fail_partial_write(void* self, const void* buf, size_t n, svs_error_h out_err) { - if (n < STREAM_BUFFER_SIZE) { - svs_error_set(out_err, SVS_ERROR_RUNTIME, "refusing partial write"); - return false; - } - return memory_stream_write(self, buf, n, out_err); -} - bool always_fail_write( void* /*self*/, const void* /*buf*/, size_t /*n*/, svs_error_h /*out_err*/ ) { @@ -88,15 +76,30 @@ size_t oom_read(void* /*self*/, void* /*buf*/, size_t /*n*/, svs_error_h out_err return 0; } +size_t over_report_read(void* /*self*/, void* /*buf*/, size_t n, svs_error_h /*out_err*/) { + return n + 1; +} + +size_t error_with_bytes_read(void* self, void* buf, size_t n, svs_error_h out_err) { + size_t copied = memory_stream_read(self, buf, n, out_err); + svs_error_set(out_err, SVS_ERROR_RUNTIME, "error reported alongside returned bytes"); + return copied; +} + +bool fail_write_with_ok_error( + void* /*self*/, const void* /*buf*/, size_t /*n*/, svs_error_h out_err +) { + svs_error_set(out_err, SVS_OK, "no error, yet still failing"); + return false; +} + // Sized so the saved payload (data + graph) provably exceeds two StreamBuf write buffers: -// data alone is num_vectors * dimension * sizeof(float) = 200 * 700 * 4 = 560'000 bytes, -// well over 2 * STREAM_BUFFER_SIZE = 131'072, before the graph even adds its share. +// data alone is 200 * 700 * 4 = 560'000 bytes, well over 2 * STREAM_BUFFER_SIZE = 131'072. constexpr size_t MULTIBUFFER_NUM_VECTORS = 200; constexpr size_t MULTIBUFFER_DIMENSION = 700; -// Owns the algorithm/builder/index triple built over the oversized data set above, so the -// two sections that need a multi-buffer payload (round-trip and partial-flush-failure) -// share one construction path instead of duplicating build setup. +// Owns the algorithm/builder/index triple built over the oversized data set above, shared +// by the sections that need a multi-buffer payload instead of duplicating build setup. struct MultiBufferIndex { svs_algorithm_h algorithm = nullptr; svs_index_builder_h builder = nullptr; @@ -118,9 +121,8 @@ MultiBufferIndex build_multibuffer_index(std::vector& data, svs_error_h e return result; } -// Like fail_partial_write, but also counts the full-size writes that succeeded before the -// partial one it rejects, so a test can assert the failure was the *last* of several writes -// rather than the only one. +// Rejects a write smaller than the adapter's buffer and counts the full-size writes that +// succeeded before it, so a test can assert the failure was the *last* of several writes. struct CountingFailSink { MemoryStream stream; size_t full_write_count = 0; @@ -138,15 +140,11 @@ bool fail_partial_write_counted( return memory_stream_write(&sink->stream, buf, n, out_err); } -// Fails exactly once, on the fail_at_invocation'th call, then records whether it is ever -// invoked again. Regression coverage for flush_write_buffer() resetting the put area before -// calling out, not after: previously a failed buffer's bytes were still sitting in the put -// area when ~StreamBuf tried to flush again, redelivering them to the callback a second -// time. +// Fails once, on the fail_at_invocation'th call, then records any later call: a +// redelivery after failure means a rejected chunk reached the callback again. struct RecordingFailSink { size_t fail_at_invocation = 0; size_t invocation_count = 0; - size_t total_bytes = 0; size_t invocations_after_failure = 0; bool has_failed = false; }; @@ -163,7 +161,6 @@ bool fail_after_n_write(void* self, const void* /*buf*/, size_t n, svs_error_h o svs_error_set(out_err, SVS_ERROR_RUNTIME, "simulated failure mid-stream"); return false; } - sink->total_bytes += n; return true; } @@ -261,9 +258,8 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); CATCH_REQUIRE(svs_index_save_stream(mb.index, &out_stream, error)); CATCH_REQUIRE(svs_error_ok(error)); - // Proves the write path actually flushed several full buffers and a trailing - // partial one, and the read path below refills the get area several times, rather - // than relying on the vector counts above staying big enough by construction. + // Proves the write path flushed several full buffers and a trailing partial one, + // and the read path below refills the get area several times. CATCH_REQUIRE(stream.bytes.size() > 2 * STREAM_BUFFER_SIZE); svs_stream_interface_ops read_ops = @@ -303,13 +299,11 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); svs_stream_interface probe_stream = SVS_MAKE_INTERFACE(&probe, probe_ops); CATCH_REQUIRE(svs_index_save_stream(mb.index, &probe_stream, error)); - // The payload must span at least two full buffers plus a partial remainder, or this - // test cannot distinguish "the one and only flush failed" from "the final partial - // flush failed after several successful full flushes" - the regression it targets. + // Needs two full buffers plus a partial one; otherwise the only flush failing + // looks the same as the final one failing. CATCH_REQUIRE(probe.bytes.size() > 2 * STREAM_BUFFER_SIZE); - // The failing sink below only ever rejects a write shorter than the buffer; if the - // payload happened to land exactly on a buffer boundary there would be no partial - // write left for it to catch. + // The sink rejects only writes shorter than the buffer; a payload ending exactly + // on a buffer boundary leaves nothing for it to catch. CATCH_REQUIRE(probe.bytes.size() % STREAM_BUFFER_SIZE != 0); CountingFailSink sink; @@ -334,9 +328,8 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { CATCH_REQUIRE(mb.index != nullptr); CATCH_REQUIRE(svs_error_ok(error)); - // Fails on the 3rd invocation, well before the last one, so several full buffers - // must have already been accepted and there is guaranteed to be more payload left - // that a double-delivery bug would have handed to the callback a second time. + // Fails on the 3rd of many calls, so bytes remain that a double-delivery bug + // would hand to the callback again. RecordingFailSink sink; sink.fail_at_invocation = 3; svs_stream_interface_ops fail_ops = @@ -453,6 +446,104 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { svs_index_free(index); } + CATCH_SECTION("Load fails when the last 16 bytes of a valid payload are missing") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(stream.bytes.size() > 16); + stream.bytes.resize(stream.bytes.size() - 16); + stream.pos = 0; + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(builder, &in_stream, error); + CATCH_REQUIRE(loaded == nullptr); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + + svs_index_free(index); + } + + CATCH_SECTION("Dynamic load fails when the last 16 bytes of a valid payload are missing" + ) { + std::vector ids(NUM_VECTORS); + std::iota(ids.begin(), ids.end(), size_t{0}); + const size_t BLOCK_SIZE = 1024 * 1024; + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(stream.bytes.size() > 16); + stream.bytes.resize(stream.bytes.size() - 16); + stream.pos = 0; + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = + svs_index_load_stream_dynamic(builder, &in_stream, BLOCK_SIZE, error); + CATCH_REQUIRE(loaded == nullptr); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + + svs_index_free(index); + } + + CATCH_SECTION("Read callback returning more bytes than requested fails the load") { + svs_stream_interface_ops fail_ops = SVS_INIT_STREAM_OPS(over_report_read, nullptr); + svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(nullptr, fail_ops); + svs_index_h loaded = svs_index_load_stream(builder, &fail_stream, error); + CATCH_REQUIRE(loaded == nullptr); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + } + + CATCH_SECTION("Read callback error is honored even when it also returns bytes") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(error_with_bytes_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(builder, &in_stream, error); + CATCH_REQUIRE(loaded == nullptr); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_RUNTIME); + + svs_index_free(index); + } + + CATCH_SECTION("Write callback failure with SVS_OK error is not reported as success") { + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + + svs_stream_interface_ops fail_ops = + SVS_INIT_STREAM_OPS(nullptr, fail_write_with_ok_error); + svs_stream_interface fail_stream = SVS_MAKE_INTERFACE(nullptr, fail_ops); + CATCH_REQUIRE_FALSE(svs_index_save_stream(index, &fail_stream, error)); + CATCH_REQUIRE_FALSE(svs_error_ok(error)); + CATCH_REQUIRE(svs_error_get_code(error) == SVS_ERROR_UNKNOWN); + + svs_index_free(index); + } + CATCH_SECTION("Dynamic round-trip, add_points, and search") { std::vector ids(NUM_VECTORS); std::iota(ids.begin(), ids.end(), size_t{0}); @@ -483,9 +574,8 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { CATCH_REQUIRE(loaded != nullptr); CATCH_REQUIRE(svs_error_ok(error)); - // A stream load that corrupted data or graph structure must not be able to pass - // this: compare against the pre-save index element-by-element, as the static - // round-trip does, instead of only checking the query count came back. + // Compares element-by-element against the pre-save index, so a load that + // corrupts data or graph cannot pass just by returning. svs_search_results_t after = SVS_INIT_SEARCH_RESULTS(); CATCH_REQUIRE(svs_index_search_topk( loaded, queries.data(), 3, K, &after, nullptr, nullptr, error @@ -516,9 +606,8 @@ CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { CATCH_REQUIRE(svs_error_ok(error)); CATCH_REQUIRE(results.num_queries == 3); - // Query with the exact vector of a newly added point and require its id shows up - // among the neighbors, so a load that silently dropped or corrupted the loaded - // graph/data cannot pass just because a search still returns *some* results. + // Queries with a newly added point's exact vector and requires its id in the + // result, so a load that dropped or corrupted the graph cannot pass on count alone. std::vector probe_query( new_data.begin(), new_data.begin() + static_cast(DIMENSION) ); From 4b12552b257a05d01d54adaf2eef56075e947c0a Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 7 Oct 2026 00:48:49 -0700 Subject: [PATCH 16/19] fix(vamana): split stream assemble into view and owning overloads The directory-archive branch instantiated GraphLoader with the stream-bound view allocator, which has no disk load and broke the build. Views cannot be backed by a temporary directory, so the view overload now accepts only the native stream format and takes no allocators. Passing allocators with a view type is now a compile error instead of being silently ignored. Co-Authored-By: Claude Opus 5.5 --- include/svs/orchestrators/vamana.h | 157 ++++++++++++++++++----------- 1 file changed, 98 insertions(+), 59 deletions(-) diff --git a/include/svs/orchestrators/vamana.h b/include/svs/orchestrators/vamana.h index 84c42353..705afe65 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -475,6 +475,51 @@ class Vamana : public manager::IndexManager { } } + /// + /// @brief Assemble a Vamana index from an in-memory, view-backed stream. + /// + /// @param stream The stream to load from. See ``svs::Vamana::save``. + /// @param distance The distance functor or ``svs::DistanceType`` enum to use for + /// similarity search computations. + /// @param threadpool_proto Precursor for the thread pool to use. Can either be an + /// acceptable thread pool instance or an integer specifying the number of + /// threads to use. + /// + /// The stream must be an in-memory stream in native format; the returned index views + /// its buffer directly, so the stream must outlive the index. + /// + /// @copydoc threadpool_requirements + /// + /// @sa save, build + /// + template < + manager::QueryTypeDefinition QueryTypes, + typename Data, + typename Distance, + typename ThreadPoolProto> + requires is_view_type_v + static Vamana assemble( + std::istream& stream, const Distance& distance, ThreadPoolProto threadpool_proto + ) { + auto deserializer = svs::lib::detail::Deserializer::build(stream); + if (!deserializer.is_native()) { + throw ANNEXCEPTION( + "Cannot load a view-backed Vamana index from a directory archive. " + "Directory archives are unpacked to a temporary directory and cannot " + "back a view; use the native stream format instead." + ); + } + + using Allocator = lib::rebind_allocator_t; + using GraphType = graphs::SimpleGraph; + auto load_graph = [&]() -> GraphType { return GraphType::load(stream); }; + auto load_data = [&]() -> Data { return lib::load_from_stream(stream); }; + + return assemble_native( + stream, load_graph, load_data, distance, std::move(threadpool_proto) + ); + } + /// /// @brief Assemble a Vamana index from a stream. /// @@ -491,17 +536,14 @@ class Vamana : public manager::IndexManager { /// /// @sa save, build /// - // Assembly from stream template < manager::QueryTypeDefinition QueryTypes, typename Data, typename Distance, typename ThreadPoolProto, typename DataAllocator = typename Data::allocator_type, - typename GraphAllocator = std::conditional_t< - is_view_type_v, - lib::rebind_allocator_t, - HugepageAllocator>> + typename GraphAllocator = HugepageAllocator> + requires(!is_view_type_v) static Vamana assemble( std::istream& stream, const Distance& distance, @@ -510,50 +552,18 @@ class Vamana : public manager::IndexManager { const GraphAllocator& graph_allocator = {} ) { auto deserializer = svs::lib::detail::Deserializer::build(stream); - // A view's allocator must bind to this specific stream; an explicit - // allocator argument would bypass that binding and break the load. - constexpr bool is_view = is_view_type_v; if (deserializer.is_native()) { - auto threadpool = threads::as_threadpool(std::move(threadpool_proto)); - using GraphType = graphs::SimpleGraph; auto load_graph = [&]() -> GraphType { - if constexpr (is_view) { - return GraphType::load(stream); - } else { - return GraphType::load(stream, graph_allocator); - } + return GraphType::load(stream, graph_allocator); }; auto load_data = [&]() -> Data { - if constexpr (is_view) { - return lib::load_from_stream(stream); - } else { - return lib::load_from_stream(stream, data_allocator); - } + return lib::load_from_stream(stream, data_allocator); }; - if constexpr (std::is_same_v) { - auto dispatcher = DistanceDispatcher(distance); - return dispatcher([&](auto distance_function) { - return make_vamana>( - AssembleTag(), - stream, - load_graph, - load_data, - distance_function, - std::move(threadpool) - ); - }); - } else { - return make_vamana>( - AssembleTag(), - stream, - load_graph, - load_data, - distance, - std::move(threadpool) - ); - } + return assemble_native( + stream, load_graph, load_data, distance, std::move(threadpool_proto) + ); } else { namespace fs = std::filesystem; lib::UniqueTempDirectory tempdir{"svs_vamana_load"}; @@ -576,23 +586,13 @@ class Vamana : public manager::IndexManager { throw ANNEXCEPTION("Invalid Vamana index archive: missing data directory!"); } - if constexpr (is_view) { - return assemble( - config_path, - svs::GraphLoader{graph_path}, - lib::load_from_disk(data_path), - distance, - threads::as_threadpool(std::move(threadpool_proto)) - ); - } else { - return assemble( - config_path, - svs::GraphLoader{graph_path, graph_allocator}, - lib::load_from_disk(data_path, data_allocator), - distance, - threads::as_threadpool(std::move(threadpool_proto)) - ); - } + return assemble( + config_path, + svs::GraphLoader{graph_path, graph_allocator}, + lib::load_from_disk(data_path, data_allocator), + distance, + threads::as_threadpool(std::move(threadpool_proto)) + ); } } @@ -739,6 +739,45 @@ class Vamana : public manager::IndexManager { svs::index::vamana::VamanaIndexParameters parameters() const { return impl_->parameters(); } + + private: + template < + manager::QueryTypeDefinition QueryTypes, + typename GraphLoaderFn, + typename DataLoaderFn, + typename Distance, + typename ThreadPoolProto> + static Vamana assemble_native( + std::istream& stream, + const GraphLoaderFn& load_graph, + const DataLoaderFn& load_data, + const Distance& distance, + ThreadPoolProto threadpool_proto + ) { + auto threadpool = threads::as_threadpool(std::move(threadpool_proto)); + if constexpr (std::is_same_v) { + auto dispatcher = DistanceDispatcher(distance); + return dispatcher([&](auto distance_function) { + return make_vamana>( + AssembleTag(), + stream, + load_graph, + load_data, + distance_function, + std::move(threadpool) + ); + }); + } else { + return make_vamana>( + AssembleTag(), + stream, + load_graph, + load_data, + distance, + std::move(threadpool) + ); + } + } }; /// From fbfab2febcbdd00b554ea7b1bed2f3dd591f183a Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 7 Oct 2026 04:31:55 -0700 Subject: [PATCH 17/19] fix(vamana): forward data loader arguments in view stream assemble --- include/svs/orchestrators/vamana.h | 14 +++++++-- tests/svs/index/vamana/index.cpp | 47 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/include/svs/orchestrators/vamana.h b/include/svs/orchestrators/vamana.h index 705afe65..08d3a80d 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -484,6 +484,8 @@ class Vamana : public manager::IndexManager { /// @param threadpool_proto Precursor for the thread pool to use. Can either be an /// acceptable thread pool instance or an integer specifying the number of /// threads to use. + /// @param data_args Forwarded to the dataset loader. An allocator passed here must be + /// bound to ``stream``. /// /// The stream must be an in-memory stream in native format; the returned index views /// its buffer directly, so the stream must outlive the index. @@ -496,10 +498,14 @@ class Vamana : public manager::IndexManager { manager::QueryTypeDefinition QueryTypes, typename Data, typename Distance, - typename ThreadPoolProto> + typename ThreadPoolProto, + typename... DataLoaderArgs> requires is_view_type_v static Vamana assemble( - std::istream& stream, const Distance& distance, ThreadPoolProto threadpool_proto + std::istream& stream, + const Distance& distance, + ThreadPoolProto threadpool_proto, + DataLoaderArgs&&... data_args ) { auto deserializer = svs::lib::detail::Deserializer::build(stream); if (!deserializer.is_native()) { @@ -513,7 +519,9 @@ class Vamana : public manager::IndexManager { using Allocator = lib::rebind_allocator_t; using GraphType = graphs::SimpleGraph; auto load_graph = [&]() -> GraphType { return GraphType::load(stream); }; - auto load_data = [&]() -> Data { return lib::load_from_stream(stream); }; + auto load_data = [&]() -> Data { + return lib::load_from_stream(stream, SVS_FWD(data_args)...); + }; return assemble_native( stream, load_graph, load_data, distance, std::move(threadpool_proto) diff --git a/tests/svs/index/vamana/index.cpp b/tests/svs/index/vamana/index.cpp index 284bc68d..d6c9b67f 100644 --- a/tests/svs/index/vamana/index.cpp +++ b/tests/svs/index/vamana/index.cpp @@ -423,6 +423,53 @@ CATCH_TEST_CASE("Vamana Index Save and Load", "[vamana][index][saveload]") { CATCH_REQUIRE(modified_distance == Catch::Approx(0.0).epsilon(1e-5)); } + CATCH_SECTION("Load view with explicit stream-bound allocator") { + using ViewData_t = + svs::data::SimpleData>; + + // Save the full index to a stringstream. + auto ss = std::stringstream{}; + index.save(ss); + + // Load the Vamana index from the stream, passing the allocator explicitly. + ss.seekg(0); + auto loaded_index = svs::Vamana::assemble( + ss, + distance_function, + svs::threads::DefaultThreadPool(1), + svs::io::MemoryStreamAllocator{ss} + ); + + CATCH_REQUIRE(loaded_index.size() == index.size()); + CATCH_REQUIRE(loaded_index.dimensions() == index.dimensions()); + } + + CATCH_SECTION("Load view from directory archive throws") { + using ViewData_t = + svs::data::SimpleData>; + + std::stringstream ss; + { + svs::lib::UniqueTempDirectory tempdir{"svs_vamana_save"}; + const auto config_dir = tempdir.get() / "config"; + const auto graph_dir = tempdir.get() / "graph"; + const auto data_dir = tempdir.get() / "data"; + std::filesystem::create_directories(config_dir); + std::filesystem::create_directories(graph_dir); + std::filesystem::create_directories(data_dir); + index.save(config_dir, graph_dir, data_dir); + svs::lib::DirectoryArchiver::pack(tempdir, ss); + } + + // A directory archive cannot back a view-backed Data; assemble must throw. + CATCH_REQUIRE_THROWS_AS( + (svs::Vamana::assemble( + ss, distance_function, svs::threads::DefaultThreadPool(1) + )), + svs::ANNException + ); + } + CATCH_SECTION("Load with SimpleDataView pointing to memory mapped file") { // We will load the Vamana index's data as a SimpleDataView directly from the // stream, without copying. From 397a5c864b491f29a9fdb3c4a7b9d804c13cd5c9 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 7 Oct 2026 11:51:04 -0700 Subject: [PATCH 18/19] final review comments --- bindings/c/CMakeLists.txt | 2 +- bindings/c/src/stream.hpp | 6 +- bindings/c/tests/c_api_stream.cpp | 265 ++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 4 deletions(-) diff --git a/bindings/c/CMakeLists.txt b/bindings/c/CMakeLists.txt index dbbcc2a3..4feea412 100644 --- a/bindings/c/CMakeLists.txt +++ b/bindings/c/CMakeLists.txt @@ -145,7 +145,7 @@ if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) else() # Links to LTO-enabled static library, requires GCC/G++ 11.2 if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL "11.2" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "11.3") - set(SVS_URL "https://github.com/intel/ScalableVectorSearch/releases/download/nightly/svs-shared-library-lto-nightly-2026-10-06-1613.tar.gz" + set(SVS_URL "https://github.com/intel/ScalableVectorSearch/releases/download/nightly/svs-shared-library-lto-nightly-2026-10-07-1413.tar.gz" CACHE STRING "URL to download SVS shared library") else() # The fallback is correct but slower, so nothing downstream fails and CI diff --git a/bindings/c/src/stream.hpp b/bindings/c/src/stream.hpp index b7371a5a..bb6ca2f0 100644 --- a/bindings/c/src/stream.hpp +++ b/bindings/c/src/stream.hpp @@ -189,9 +189,9 @@ class OutputStream : private detail::StreamBufHolder, public std::ostream { OutputStream(const svs_stream_ops_t& ops, void* self) : detail::StreamBufHolder(ops, self, StreamBuf::Direction::write) , std::ostream(&buf) { - // Without this, the sentry swallows a write-callback exception into a silent - // badbit instead of rethrowing it, so a failed save would report success. - exceptions(std::ios_base::badbit); + // A write-callback throw surfaces as badbit from the sentry and as failbit from + // the streambuf inserter in write_table; a missing bit loses the callback's code. + exceptions(std::ios_base::badbit | std::ios_base::failbit); } }; diff --git a/bindings/c/tests/c_api_stream.cpp b/bindings/c/tests/c_api_stream.cpp index 83ceb8d2..5fc00039 100644 --- a/bindings/c/tests/c_api_stream.cpp +++ b/bindings/c/tests/c_api_stream.cpp @@ -25,6 +25,8 @@ // Standard library #include +#include +#include #include #include #include @@ -164,6 +166,51 @@ bool fail_after_n_write(void* self, const void* /*buf*/, size_t n, svs_error_h o return true; } +enum class StorageKind { Float16, ScalarQuantization, Lvq, LeanVec }; + +constexpr std::array STORAGE_KINDS = { + StorageKind::Float16, + StorageKind::ScalarQuantization, + StorageKind::Lvq, + StorageKind::LeanVec, +}; + +const char* storage_kind_name(StorageKind kind) { + switch (kind) { + case StorageKind::Float16: + return "float16"; + case StorageKind::ScalarQuantization: + return "scalar quantization (int8)"; + case StorageKind::Lvq: + return "LVQ (int4 primary, int8 residual)"; + case StorageKind::LeanVec: + return "LeanVec (int4 primary, int8 secondary)"; + } + return "unknown"; +} + +svs_storage_h create_storage_case(StorageKind kind, size_t dimension, svs_error_h error) { + switch (kind) { + case StorageKind::Float16: + return svs_storage_create_simple(SVS_DATA_TYPE_FLOAT16, error); + case StorageKind::ScalarQuantization: + return svs_storage_create_sq(SVS_DATA_TYPE_INT8, error); + case StorageKind::Lvq: + return svs_storage_create_lvq(SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error); + case StorageKind::LeanVec: + return svs_storage_create_leanvec( + dimension / 2, SVS_DATA_TYPE_INT4, SVS_DATA_TYPE_INT8, error + ); + } + return nullptr; +} + +// Compressed storages need not reproduce pre-save distances bit for bit. +bool distance_within_tolerance(float reloaded, float original) { + float scale = std::max({std::fabs(reloaded), std::fabs(original), 1.0f}); + return std::fabs(reloaded - original) <= 1e-3f * scale; +} + } // namespace CATCH_TEST_CASE("C API Stream Save and Load", "[c_api][index][stream]") { @@ -850,3 +897,221 @@ CATCH_TEST_CASE("C API Stream Interface Validation", "[c_api][index][stream][err svs_algorithm_free(algorithm); svs_error_free(error); } + +CATCH_TEST_CASE("C API Stream Storage Round Trips", "[c_api][index][stream][storage]") { + const size_t NUM_VECTORS = 100; + const size_t DIMENSION = 32; + const size_t K = 5; + + std::vector data; + std::vector queries; + generate_test_data(data, NUM_VECTORS, DIMENSION); + generate_test_data(queries, 3, DIMENSION); + + CATCH_SECTION("Static round trip per storage kind") { + for (StorageKind kind : STORAGE_KINDS) { + CATCH_INFO("storage kind: " << storage_kind_name(kind)); + svs_error_h error = svs_error_create(); + + svs_storage_h build_storage = create_storage_case(kind, DIMENSION, error); + CATCH_REQUIRE(check_storage_support(build_storage, error)); + if (!storage_usable(build_storage)) { + svs_storage_free(build_storage); + svs_error_free(error); + continue; + } + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + CATCH_REQUIRE(svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + )); + CATCH_REQUIRE(svs_index_builder_set_storage(builder, build_storage, error)); + svs_storage_free(build_storage); + + svs_index_h index = svs_index_build(builder, data.data(), NUM_VECTORS, error); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t before = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + index, queries.data(), 3, K, &before, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + // Load dispatch follows the builder's storage, not the stream's metadata, so + // the load builder must use the kind that saved it. + svs_storage_h load_storage = create_storage_case(kind, DIMENSION, error); + CATCH_REQUIRE(storage_usable(load_storage)); + svs_index_builder_h load_builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(load_builder != nullptr); + CATCH_REQUIRE(svs_index_builder_set_threadpool( + load_builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + )); + CATCH_REQUIRE(svs_index_builder_set_storage(load_builder, load_storage, error)); + svs_storage_free(load_storage); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = svs_index_load_stream(load_builder, &in_stream, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t after = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, queries.data(), 3, K, &after, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(after.num_queries == before.num_queries); + for (size_t i = 0; i < before.num_queries * K; ++i) { + CATCH_REQUIRE(after.indices[i] == before.indices[i]); + CATCH_REQUIRE( + distance_within_tolerance(after.distances[i], before.distances[i]) + ); + } + + svs_search_results_free(&before); + svs_search_results_free(&after); + svs_index_free(loaded); + svs_index_free(index); + svs_index_builder_free(load_builder); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + } + + CATCH_SECTION("Dynamic round trip, add_points, and search per storage kind") { + std::vector ids(NUM_VECTORS); + std::iota(ids.begin(), ids.end(), size_t{0}); + const size_t BLOCK_SIZE = 1024 * 1024; + + for (StorageKind kind : STORAGE_KINDS) { + CATCH_INFO("storage kind: " << storage_kind_name(kind)); + svs_error_h error = svs_error_create(); + + svs_storage_h build_storage = create_storage_case(kind, DIMENSION, error); + CATCH_REQUIRE(check_storage_support(build_storage, error)); + if (!storage_usable(build_storage)) { + svs_storage_free(build_storage); + svs_error_free(error); + continue; + } + + svs_algorithm_h algorithm = svs_algorithm_create_vamana(16, 32, 50, error); + CATCH_REQUIRE(algorithm != nullptr); + svs_index_builder_h builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(builder != nullptr); + CATCH_REQUIRE(svs_index_builder_set_threadpool( + builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + )); + CATCH_REQUIRE(svs_index_builder_set_storage(builder, build_storage, error)); + svs_storage_free(build_storage); + + svs_index_h index = svs_index_build_dynamic( + builder, data.data(), ids.data(), NUM_VECTORS, BLOCK_SIZE, error + ); + CATCH_REQUIRE(index != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t before = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + index, queries.data(), 3, K, &before, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + + MemoryStream stream; + svs_stream_interface_ops write_ops = + SVS_INIT_STREAM_OPS(nullptr, memory_stream_write); + svs_stream_interface out_stream = SVS_MAKE_INTERFACE(&stream, write_ops); + CATCH_REQUIRE(svs_index_save_stream(index, &out_stream, error)); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_storage_h load_storage = create_storage_case(kind, DIMENSION, error); + CATCH_REQUIRE(storage_usable(load_storage)); + svs_index_builder_h load_builder = svs_index_builder_create( + SVS_DISTANCE_METRIC_EUCLIDEAN, DIMENSION, algorithm, error + ); + CATCH_REQUIRE(load_builder != nullptr); + CATCH_REQUIRE(svs_index_builder_set_threadpool( + load_builder, SVS_THREADPOOL_KIND_SINGLE_THREAD, 1, error + )); + CATCH_REQUIRE(svs_index_builder_set_storage(load_builder, load_storage, error)); + svs_storage_free(load_storage); + + svs_stream_interface_ops read_ops = + SVS_INIT_STREAM_OPS(memory_stream_read, nullptr); + svs_stream_interface in_stream = SVS_MAKE_INTERFACE(&stream, read_ops); + svs_index_h loaded = + svs_index_load_stream_dynamic(load_builder, &in_stream, BLOCK_SIZE, error); + CATCH_REQUIRE(loaded != nullptr); + CATCH_REQUIRE(svs_error_ok(error)); + + svs_search_results_t after = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, queries.data(), 3, K, &after, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + CATCH_REQUIRE(after.num_queries == before.num_queries); + for (size_t i = 0; i < before.num_queries * K; ++i) { + CATCH_REQUIRE(after.indices[i] == before.indices[i]); + CATCH_REQUIRE( + distance_within_tolerance(after.distances[i], before.distances[i]) + ); + } + svs_search_results_free(&before); + svs_search_results_free(&after); + + std::vector new_data; + std::vector new_ids = {NUM_VECTORS, NUM_VECTORS + 1}; + generate_test_data(new_data, 2, DIMENSION); + size_t added_count = 0; + CATCH_REQUIRE(svs_index_dynamic_add_points( + loaded, new_data.data(), new_ids.data(), 2, &added_count, error + )); + CATCH_REQUIRE(added_count == 2); + CATCH_REQUIRE(svs_error_ok(error)); + + // An exact-vector probe must return the new id; a corrupted graph would still + // pass a count check. + std::vector probe_query( + new_data.begin(), new_data.begin() + static_cast(DIMENSION) + ); + svs_search_results_t probe_results = SVS_INIT_SEARCH_RESULTS(); + CATCH_REQUIRE(svs_index_search_topk( + loaded, probe_query.data(), 1, K, &probe_results, nullptr, nullptr, error + )); + CATCH_REQUIRE(svs_error_ok(error)); + bool found_new_id = std::any_of( + probe_results.indices, + probe_results.indices + K, + [new_id = new_ids[0]](size_t idx) { return idx == new_id; } + ); + CATCH_REQUIRE(found_new_id); + + svs_search_results_free(&probe_results); + svs_index_free(loaded); + svs_index_free(index); + svs_index_builder_free(load_builder); + svs_index_builder_free(builder); + svs_algorithm_free(algorithm); + svs_error_free(error); + } + } +} From 10903d86cb122f87c854dee72b03e75448c03c2c Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Thu, 8 Oct 2026 00:33:24 -0700 Subject: [PATCH 19/19] build(c-api): bump LTO shared library to include stream assemble --- bindings/c/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/c/CMakeLists.txt b/bindings/c/CMakeLists.txt index 4feea412..334b8470 100644 --- a/bindings/c/CMakeLists.txt +++ b/bindings/c/CMakeLists.txt @@ -145,7 +145,7 @@ if (SVS_RUNTIME_ENABLE_LVQ_LEANVEC) else() # Links to LTO-enabled static library, requires GCC/G++ 11.2 if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL "11.2" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "11.3") - set(SVS_URL "https://github.com/intel/ScalableVectorSearch/releases/download/nightly/svs-shared-library-lto-nightly-2026-10-07-1413.tar.gz" + set(SVS_URL "https://github.com/intel/ScalableVectorSearch/releases/download/nightly/svs-shared-library-lto-nightly-2026-10-08-1629.tar.gz" CACHE STRING "URL to download SVS shared library") else() # The fallback is correct but slower, so nothing downstream fails and CI