This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-vector-index.git


The following commit(s) were added to refs/heads/main by this push:
     new 81f5f8d  Expand release test coverage (#41)
81f5f8d is described below

commit 81f5f8d1cb0053eae55eef337cd0689564775a09
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Jun 12 11:36:00 2026 +0800

    Expand release test coverage (#41)
---
 .github/workflows/ci.yml                           | 106 ++++++++
 README.md                                          |  17 ++
 c/test_vindex.c                                    | 231 ++++++++++++++--
 core/Cargo.toml                                    |   4 +
 core/benches/ivfhnswsq_filter_bench.rs             | 293 +++++++++++++++++++++
 core/tests/storage_format_fixtures.rs              |  31 +++
 cpp/test_vindex.cpp                                | 140 ++++++++--
 .../vector/VectorIndexNativeValidationTest.java    | 167 +++++++++++-
 8 files changed, 931 insertions(+), 58 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07bb7b4..d1e5275 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -210,3 +210,109 @@ jobs:
           pytest -v
         env:
           PAIMON_VINDEX_LIB_PATH: ${{ github.workspace }}/target/release
+
+  python-wheel:
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Rust Cache
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.cargo/registry
+            ~/.cargo/git
+            target
+          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-cargo-
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.12'
+
+      - name: Build FFI library
+        run: cargo build --release -p paimon-vindex-ffi
+
+      - name: Build wheel
+        working-directory: python
+        run: |
+          python -m pip install --upgrade pip build
+          python -m build --wheel
+
+      - name: Test wheel
+        if: runner.os != 'Windows'
+        working-directory: python
+        run: |
+          python -m pip install dist/*.whl
+          cd ..
+          python - <<'PY'
+          import io
+          import numpy as np
+          from paimon_vindex import VectorIndexReader, VectorIndexWriter
+
+          class Input:
+              def __init__(self, data):
+                  self.data = data
+
+              def pread_many(self, ranges):
+                  return [self.data[pos : pos + length] for pos, length in 
ranges]
+
+          data = np.array([[0.0, 0.0], [0.1, 0.0], [10.0, 10.0], [10.1, 
10.0]], dtype=np.float32)
+          ids = np.array([1, 2, 3, 4], dtype=np.int64)
+          output = io.BytesIO()
+          writer = VectorIndexWriter({"index.type": "ivf_flat", "dimension": 
"2", "nlist": "2", "metric": "l2"})
+          writer.train(data)
+          writer.add_vectors(ids, data)
+          writer.write(output)
+          writer.close()
+
+          reader = VectorIndexReader(Input(output.getvalue()))
+          reader.optimize_for_search()
+          result_ids, distances = reader.search(data[0], top_k=2, nprobe=2)
+          assert result_ids[0] == 1, result_ids
+          assert np.isfinite(distances[0]), distances
+          reader.close()
+          PY
+
+      - name: Test wheel
+        if: runner.os == 'Windows'
+        working-directory: python
+        shell: pwsh
+        run: |
+          $wheel = Get-ChildItem dist/*.whl | Select-Object -First 1
+          python -m pip install $wheel.FullName
+          Set-Location ..
+          python -c @'
+          import io
+          import numpy as np
+          from paimon_vindex import VectorIndexReader, VectorIndexWriter
+
+          class Input:
+              def __init__(self, data):
+                  self.data = data
+
+              def pread_many(self, ranges):
+                  return [self.data[pos : pos + length] for pos, length in 
ranges]
+
+          data = np.array([[0.0, 0.0], [0.1, 0.0], [10.0, 10.0], [10.1, 
10.0]], dtype=np.float32)
+          ids = np.array([1, 2, 3, 4], dtype=np.int64)
+          output = io.BytesIO()
+          writer = VectorIndexWriter({"index.type": "ivf_flat", "dimension": 
"2", "nlist": "2", "metric": "l2"})
+          writer.train(data)
+          writer.add_vectors(ids, data)
+          writer.write(output)
+          writer.close()
+
+          reader = VectorIndexReader(Input(output.getvalue()))
+          reader.optimize_for_search()
+          result_ids, distances = reader.search(data[0], top_k=2, nprobe=2)
+          assert result_ids[0] == 1, result_ids
+          assert np.isfinite(distances[0]), distances
+          reader.close()
+          '@
diff --git a/README.md b/README.md
index 3ea1782..82bd6f1 100644
--- a/README.md
+++ b/README.md
@@ -320,6 +320,23 @@ cargo bench -p paimon-vindex-core --bench ann_bench -- 
--nocapture
 Benchmark rows report `disk_scope=index_bytes`, which is the serialized vector
 index file.
 
+`IVF_HNSW_SQ` filtered-search fallback can be profiled separately with a
+filter-heavy benchmark. It compares filtered batch search before and after
+`optimize_for_search` and verifies that both paths return the same results:
+
+```bash
+cargo bench -p paimon-vindex-core --bench ivfhnswsq_filter_bench -- --nocapture
+```
+
+Useful knobs include:
+
+```bash
+FILTER_BENCH_N=50000 FILTER_BENCH_NQ=500 FILTER_BENCH_D=128 \
+FILTER_BENCH_NLIST=64 FILTER_BENCH_NPROBE=32 FILTER_BENCH_EF_SEARCH=80 \
+FILTER_BENCH_FILTER_STRIDES=1,4,16,64 \
+cargo bench -p paimon-vindex-core --bench ivfhnswsq_filter_bench -- --nocapture
+```
+
 ## Development
 
 Common Rust commands:
diff --git a/c/test_vindex.c b/c/test_vindex.c
index 916889e..b08660e 100644
--- a/c/test_vindex.c
+++ b/c/test_vindex.c
@@ -49,12 +49,33 @@ struct MemBuffer {
     size_t pos;
 };
 
+enum {
+    ROUNDTRIP_DIMENSION = 2,
+    ROUNDTRIP_NLIST = 4,
+    ROUNDTRIP_PER_LIST = 128,
+    ROUNDTRIP_VECTOR_COUNT = ROUNDTRIP_NLIST * ROUNDTRIP_PER_LIST,
+};
+
 static void fail_ffi(const char *message) {
     const char *err = paimon_vindex_last_error();
     fprintf(stderr, "%s: %s\n", message, err == NULL ? "(no error)" : err);
     abort();
 }
 
+static void assert_last_error_contains(const char *needle) {
+    const char *err = paimon_vindex_last_error();
+    if (err == NULL || strstr(err, needle) == NULL) {
+        fprintf(
+            stderr,
+            "FAIL %s:%d: last error should contain '%s', got '%s'\n",
+            __FILE__,
+            __LINE__,
+            needle,
+            err == NULL ? "(null)" : err);
+        abort();
+    }
+}
+
 static int mem_write(void *ctx, const uint8_t *data, uintptr_t len) {
     struct MemBuffer *buf = (struct MemBuffer *)ctx;
     if (len > SIZE_MAX - buf->len) {
@@ -106,10 +127,57 @@ static int mem_read_at(void *ctx, uint64_t offset, 
uint8_t *dst, uintptr_t len)
     return 0;
 }
 
-static void test_basic_roundtrip(void) {
-    const char *keys[] = {"index.type", "dimension", "nlist", "metric"};
-    const char *values[] = {"ivf_flat", "2", "2", "l2"};
-    PaimonVindexWriterHandle *writer = paimon_vindex_writer_open(keys, values, 
4);
+static int failing_write(void *ctx, const uint8_t *data, uintptr_t len) {
+    (void)ctx;
+    (void)data;
+    (void)len;
+    return -1;
+}
+
+static int failing_flush(void *ctx) {
+    (void)ctx;
+    return -1;
+}
+
+static int failing_read_at(void *ctx, uint64_t offset, uint8_t *dst, uintptr_t 
len) {
+    (void)ctx;
+    (void)offset;
+    (void)dst;
+    (void)len;
+    return -1;
+}
+
+static int64_t cluster_base_id(size_t cluster) {
+    return (int64_t)((cluster + 1) * 100000);
+}
+
+static void fill_roundtrip_data(float *data, int64_t *ids) {
+    for (size_t i = 0; i < ROUNDTRIP_VECTOR_COUNT; i++) {
+        size_t cluster = i / ROUNDTRIP_PER_LIST;
+        size_t local = i % ROUNDTRIP_PER_LIST;
+        float center = (float)cluster * 20.0f;
+        data[i * ROUNDTRIP_DIMENSION] = center + (float)(local % 16) * 0.001f;
+        data[i * ROUNDTRIP_DIMENSION + 1] = center + (float)(local / 16) * 
0.001f;
+        ids[i] = cluster_base_id(cluster) + (int64_t)local;
+    }
+}
+
+static void assert_id_in_cluster(int64_t id, size_t cluster) {
+    int64_t base = cluster_base_id(cluster);
+    ASSERT_TRUE(id >= base);
+    ASSERT_TRUE(id < base + ROUNDTRIP_PER_LIST);
+}
+
+static void run_roundtrip(
+        const char *name,
+        const char *const *keys,
+        const char *const *values,
+        uintptr_t num_options,
+        uint32_t expected_index_type,
+        uintptr_t expected_pq_m,
+        uintptr_t expected_hnsw_m) {
+    PaimonVindexWriterHandle *writer =
+        paimon_vindex_writer_open(keys, values, num_options);
     if (writer == NULL) {
         fail_ffi("writer open failed");
     }
@@ -120,17 +188,16 @@ static void test_basic_roundtrip(void) {
     }
     ASSERT_EQ_I64(dimension, 2);
 
-    const float data[] = {
-        0.0f, 0.0f,
-        1.0f, 0.0f,
-        10.0f, 10.0f,
-        11.0f, 10.0f,
-    };
-    const int64_t ids[] = {100, 101, 200, 201};
-    if (paimon_vindex_writer_train(writer, data, 4) != 0) {
+    float *data = (float *)malloc(sizeof(float) * ROUNDTRIP_VECTOR_COUNT * 
ROUNDTRIP_DIMENSION);
+    int64_t *ids = (int64_t *)malloc(sizeof(int64_t) * ROUNDTRIP_VECTOR_COUNT);
+    ASSERT_TRUE(data != NULL);
+    ASSERT_TRUE(ids != NULL);
+    fill_roundtrip_data(data, ids);
+
+    if (paimon_vindex_writer_train(writer, data, ROUNDTRIP_VECTOR_COUNT) != 0) 
{
         fail_ffi("writer train failed");
     }
-    if (paimon_vindex_writer_add_vectors(writer, ids, data, 4) != 0) {
+    if (paimon_vindex_writer_add_vectors(writer, ids, data, 
ROUNDTRIP_VECTOR_COUNT) != 0) {
         fail_ffi("writer add failed");
     }
 
@@ -160,11 +227,13 @@ static void test_basic_roundtrip(void) {
     if (paimon_vindex_reader_metadata(reader, &metadata) != 0) {
         fail_ffi("reader metadata failed");
     }
-    ASSERT_EQ_I64(metadata.index_type, PAIMON_VINDEX_INDEX_TYPE_IVF_FLAT);
+    ASSERT_EQ_I64(metadata.index_type, expected_index_type);
     ASSERT_EQ_I64(metadata.metric, PAIMON_VINDEX_METRIC_L2);
     ASSERT_EQ_I64(metadata.dimension, 2);
-    ASSERT_EQ_I64(metadata.nlist, 2);
-    ASSERT_EQ_I64(metadata.total_vectors, 4);
+    ASSERT_EQ_I64(metadata.nlist, 4);
+    ASSERT_EQ_I64(metadata.total_vectors, ROUNDTRIP_VECTOR_COUNT);
+    ASSERT_EQ_I64(metadata.pq_m, expected_pq_m);
+    ASSERT_EQ_I64(metadata.hnsw_m, expected_hnsw_m);
 
     if (paimon_vindex_reader_optimize_for_search(reader) != 0) {
         fail_ffi("reader optimize_for_search failed");
@@ -174,28 +243,142 @@ static void test_basic_roundtrip(void) {
     int64_t result_ids[2] = {0};
     float result_distances[2] = {0};
     if (paimon_vindex_reader_search(
-            reader, query, 2, 2, 0, result_ids, result_distances, 2) != 0) {
+            reader, query, 2, 4, 16, result_ids, result_distances, 2) != 0) {
         fail_ffi("reader search failed");
     }
-    ASSERT_EQ_I64(result_ids[0], 100);
+    assert_id_in_cluster(result_ids[0], 0);
     ASSERT_TRUE(isfinite(result_distances[0]));
 
-    const float queries[] = {0.0f, 0.0f, 10.0f, 10.0f};
+    const float queries[] = {0.0f, 0.0f, 20.0f, 20.0f};
     int64_t batch_ids[2] = {0};
     float batch_distances[2] = {0};
     if (paimon_vindex_reader_search_batch(
-            reader, queries, 2, 1, 2, 0, batch_ids, batch_distances, 2) != 0) {
+            reader, queries, 2, 1, 4, 16, batch_ids, batch_distances, 2) != 0) 
{
         fail_ffi("reader search batch failed");
     }
-    ASSERT_EQ_I64(batch_ids[0], 100);
-    ASSERT_EQ_I64(batch_ids[1], 200);
+    assert_id_in_cluster(batch_ids[0], 0);
+    assert_id_in_cluster(batch_ids[1], 1);
 
     paimon_vindex_reader_free(reader);
     free(buf.data);
-    printf("PASS test_basic_roundtrip\n");
+    free(data);
+    free(ids);
+    printf("PASS %s\n", name);
+}
+
+static PaimonVindexWriterHandle *new_trained_flat_writer(void) {
+    const char *keys[] = {"index.type", "dimension", "nlist", "metric"};
+    const char *values[] = {"ivf_flat", "1", "1", "l2"};
+    PaimonVindexWriterHandle *writer = paimon_vindex_writer_open(keys, values, 
4);
+    if (writer == NULL) {
+        fail_ffi("writer open failed");
+    }
+
+    const float data[] = {0.0f, 1.0f};
+    const int64_t ids[] = {1, 2};
+    if (paimon_vindex_writer_train(writer, data, 2) != 0) {
+        fail_ffi("writer train failed");
+    }
+    if (paimon_vindex_writer_add_vectors(writer, ids, data, 2) != 0) {
+        fail_ffi("writer add failed");
+    }
+    return writer;
+}
+
+static void test_output_write_callback_error_propagates(void) {
+    PaimonVindexWriterHandle *writer = new_trained_flat_writer();
+    struct PaimonVindexOutputFile output = {
+        .ctx = NULL,
+        .write_fn = failing_write,
+        .flush_fn = mem_flush,
+        .get_pos_fn = NULL,
+    };
+
+    ASSERT_TRUE(paimon_vindex_writer_write_index(writer, output) != 0);
+    assert_last_error_contains("write callback failed");
+    paimon_vindex_writer_free(writer);
+    printf("PASS output_write_callback_error_propagates\n");
+}
+
+static void test_output_flush_callback_error_propagates(void) {
+    PaimonVindexWriterHandle *writer = new_trained_flat_writer();
+    struct MemBuffer buf = {0};
+    struct PaimonVindexOutputFile output = {
+        .ctx = &buf,
+        .write_fn = mem_write,
+        .flush_fn = failing_flush,
+        .get_pos_fn = mem_pos,
+    };
+
+    ASSERT_TRUE(paimon_vindex_writer_write_index(writer, output) != 0);
+    assert_last_error_contains("flush callback failed");
+    paimon_vindex_writer_free(writer);
+    free(buf.data);
+    printf("PASS output_flush_callback_error_propagates\n");
+}
+
+static void test_input_read_callback_error_propagates(void) {
+    struct PaimonVindexInputFile input = {
+        .ctx = NULL,
+        .read_at_fn = failing_read_at,
+    };
+
+    PaimonVindexReaderHandle *reader = paimon_vindex_reader_open(input);
+    ASSERT_TRUE(reader == NULL);
+    assert_last_error_contains("read_at callback failed");
+    printf("PASS input_read_callback_error_propagates\n");
+}
+
+static void test_supported_index_roundtrips(void) {
+    const char *flat_keys[] = {"index.type", "dimension", "nlist", "metric"};
+    const char *flat_values[] = {"ivf_flat", "2", "4", "l2"};
+    run_roundtrip(
+        "ivf_flat_roundtrip",
+        flat_keys,
+        flat_values,
+        4,
+        PAIMON_VINDEX_INDEX_TYPE_IVF_FLAT,
+        0,
+        0);
+
+    const char *pq_keys[] = {"index.type", "dimension", "nlist", "metric", 
"pq.m"};
+    const char *pq_values[] = {"ivf_pq", "2", "4", "l2", "1"};
+    run_roundtrip(
+        "ivf_pq_roundtrip",
+        pq_keys,
+        pq_values,
+        5,
+        PAIMON_VINDEX_INDEX_TYPE_IVF_PQ,
+        1,
+        0);
+
+    const char *hnsw_flat_keys[] = {"index.type", "dimension", "nlist", 
"metric", "hnsw.m"};
+    const char *hnsw_flat_values[] = {"ivf_hnsw_flat", "2", "4", "l2", "4"};
+    run_roundtrip(
+        "ivf_hnsw_flat_roundtrip",
+        hnsw_flat_keys,
+        hnsw_flat_values,
+        5,
+        PAIMON_VINDEX_INDEX_TYPE_IVF_HNSW_FLAT,
+        0,
+        4);
+
+    const char *hnsw_sq_keys[] = {"index.type", "dimension", "nlist", 
"metric", "hnsw.m"};
+    const char *hnsw_sq_values[] = {"ivf_hnsw_sq", "2", "4", "l2", "4"};
+    run_roundtrip(
+        "ivf_hnsw_sq_roundtrip",
+        hnsw_sq_keys,
+        hnsw_sq_values,
+        5,
+        PAIMON_VINDEX_INDEX_TYPE_IVF_HNSW_SQ,
+        0,
+        4);
 }
 
 int main(void) {
-    test_basic_roundtrip();
+    test_supported_index_roundtrips();
+    test_output_write_callback_error_propagates();
+    test_output_flush_callback_error_propagates();
+    test_input_read_callback_error_propagates();
     return 0;
 }
diff --git a/core/Cargo.toml b/core/Cargo.toml
index f331ef7..82f67c0 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -42,3 +42,7 @@ harness = false
 [[bench]]
 name = "ann_bench"
 harness = false
+
+[[bench]]
+name = "ivfhnswsq_filter_bench"
+harness = false
diff --git a/core/benches/ivfhnswsq_filter_bench.rs 
b/core/benches/ivfhnswsq_filter_bench.rs
new file mode 100644
index 0000000..4738280
--- /dev/null
+++ b/core/benches/ivfhnswsq_filter_bench.rs
@@ -0,0 +1,293 @@
+use paimon_vindex_core::distance::MetricType;
+use paimon_vindex_core::hnsw::HnswBuildParams;
+use paimon_vindex_core::index::{
+    VectorIndexConfig, VectorIndexReader, VectorIndexWriter, 
VectorSearchParams,
+};
+use paimon_vindex_core::io::PosWriter;
+use roaring::RoaringTreemap;
+use std::env;
+use std::io::{self, Cursor};
+use std::time::{Duration, Instant};
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let cfg = Config::from_env()?;
+    cfg.validate()?;
+
+    let (data, queries) = generate_clustered_data(cfg.n, cfg.nq, cfg.d, 
cfg.clusters, cfg.seed);
+    let ids: Vec<i64> = (0..cfg.n as i64).collect();
+
+    let start = Instant::now();
+    let mut writer = VectorIndexWriter::new(VectorIndexConfig::IvfHnswSq {
+        dimension: cfg.d,
+        nlist: cfg.nlist,
+        metric: MetricType::L2,
+        hnsw: cfg.hnsw_params(),
+    })?;
+    writer.train(&data, cfg.n)?;
+    writer.add_vectors(&ids, &data, cfg.n)?;
+    let mut index_bytes = Vec::new();
+    writer.write(&mut PosWriter::new(&mut index_bytes))?;
+    let build = start.elapsed();
+
+    println!(
+        
"n={},nq={},d={},k={},nlist={},nprobe={},ef_search={},index_bytes={},build_ms={}",
+        cfg.n,
+        cfg.nq,
+        cfg.d,
+        cfg.k,
+        cfg.nlist,
+        cfg.nprobe,
+        cfg.ef_search,
+        index_bytes.len(),
+        build.as_millis()
+    );
+    
println!("filter_stride,allowed,optimized,warmup_ms,search_ms,us_per_query,qps");
+
+    for stride in &cfg.filter_strides {
+        let filter_bytes = filter_bytes(cfg.n, *stride)?;
+        let allowed = cfg.n.div_ceil(*stride);
+        let baseline = run_case(&cfg, &index_bytes, &queries, &filter_bytes, 
false)?;
+        let optimized = run_case(&cfg, &index_bytes, &queries, &filter_bytes, 
true)?;
+        assert_same_results(*stride, &baseline.result, &optimized.result);
+        print_row(
+            *stride,
+            allowed,
+            false,
+            baseline.warmup,
+            baseline.search,
+            cfg.nq,
+        );
+        print_row(
+            *stride,
+            allowed,
+            true,
+            optimized.warmup,
+            optimized.search,
+            cfg.nq,
+        );
+    }
+
+    Ok(())
+}
+
+struct Config {
+    n: usize,
+    nq: usize,
+    d: usize,
+    k: usize,
+    nlist: usize,
+    nprobe: usize,
+    ef_search: usize,
+    hnsw_m: usize,
+    hnsw_ef_construction: usize,
+    hnsw_max_level: usize,
+    clusters: usize,
+    seed: u64,
+    filter_strides: Vec<usize>,
+}
+
+impl Config {
+    fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
+        Ok(Self {
+            n: read_env("FILTER_BENCH_N", 50_000)?,
+            nq: read_env("FILTER_BENCH_NQ", 500)?,
+            d: read_env("FILTER_BENCH_D", 128)?,
+            k: read_env("FILTER_BENCH_K", 10)?,
+            nlist: read_env("FILTER_BENCH_NLIST", 64)?,
+            nprobe: read_env("FILTER_BENCH_NPROBE", 32)?,
+            ef_search: read_env("FILTER_BENCH_EF_SEARCH", 80)?,
+            hnsw_m: read_env("FILTER_BENCH_HNSW_M", 20)?,
+            hnsw_ef_construction: 
read_env("FILTER_BENCH_HNSW_EF_CONSTRUCTION", 150)?,
+            hnsw_max_level: read_env("FILTER_BENCH_HNSW_MAX_LEVEL", 7)?,
+            clusters: read_env("FILTER_BENCH_CLUSTERS", 32)?,
+            seed: read_env("FILTER_BENCH_SEED", 42)?,
+            filter_strides: read_strides("FILTER_BENCH_FILTER_STRIDES", &[1, 
4, 16, 64])?,
+        })
+    }
+
+    fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
+        if self.n == 0 || self.nq == 0 || self.d == 0 || self.k == 0 {
+            return Err("FILTER_BENCH_N, NQ, D, and K must be greater than 
0".into());
+        }
+        if self.nlist == 0 || self.nprobe == 0 || self.nprobe > self.nlist || 
self.nlist > self.n {
+            return Err("FILTER_BENCH_NLIST/NPROBE must satisfy 0 < nprobe <= 
nlist <= n".into());
+        }
+        if self.clusters == 0 {
+            return Err("FILTER_BENCH_CLUSTERS must be greater than 0".into());
+        }
+        if self.filter_strides.is_empty() || self.filter_strides.contains(&0) {
+            return Err("FILTER_BENCH_FILTER_STRIDES must contain positive 
integers".into());
+        }
+        Ok(())
+    }
+
+    fn hnsw_params(&self) -> HnswBuildParams {
+        HnswBuildParams {
+            m: self.hnsw_m,
+            ef_construction: self.hnsw_ef_construction,
+            max_level: self.hnsw_max_level,
+        }
+        .sanitized()
+    }
+}
+
+struct CaseResult {
+    warmup: Duration,
+    search: Duration,
+    result: (Vec<i64>, Vec<f32>),
+}
+
+fn run_case(
+    cfg: &Config,
+    index_bytes: &[u8],
+    queries: &[f32],
+    filter_bytes: &[u8],
+    optimized: bool,
+) -> io::Result<CaseResult> {
+    let mut reader = 
VectorIndexReader::open(Cursor::new(index_bytes.to_vec()))?;
+    let warmup_start = Instant::now();
+    if optimized {
+        reader.optimize_for_search()?;
+    }
+    let warmup = warmup_start.elapsed();
+
+    let params = VectorSearchParams::with_ef_search(cfg.k, cfg.nprobe, 
cfg.ef_search);
+    let _ = reader.search_batch_with_roaring_filter(queries, cfg.nq, params, 
filter_bytes)?;
+
+    let start = Instant::now();
+    let result = reader.search_batch_with_roaring_filter(queries, cfg.nq, 
params, filter_bytes)?;
+    let search = start.elapsed();
+    Ok(CaseResult {
+        warmup,
+        search,
+        result,
+    })
+}
+
+fn filter_bytes(n: usize, stride: usize) -> io::Result<Vec<u8>> {
+    let mut filter = RoaringTreemap::new();
+    for id in (0..n as u64).step_by(stride) {
+        filter.insert(id);
+    }
+    let mut bytes = Vec::new();
+    filter.serialize_into(&mut bytes)?;
+    Ok(bytes)
+}
+
+fn assert_same_results(
+    stride: usize,
+    expected: &(Vec<i64>, Vec<f32>),
+    actual: &(Vec<i64>, Vec<f32>),
+) {
+    assert_eq!(
+        actual.0, expected.0,
+        "ids should match for stride {}",
+        stride
+    );
+    assert_eq!(
+        actual.1.len(),
+        expected.1.len(),
+        "distance count should match for stride {}",
+        stride
+    );
+    for (actual, expected) in actual.1.iter().zip(expected.1.iter()) {
+        assert!(
+            (actual - expected).abs() < 1e-4,
+            "distance {} should match {} for stride {}",
+            actual,
+            expected,
+            stride
+        );
+    }
+}
+
+fn print_row(
+    filter_stride: usize,
+    allowed: usize,
+    optimized: bool,
+    warmup: Duration,
+    search: Duration,
+    nq: usize,
+) {
+    println!(
+        "{},{},{},{},{},{:.2},{:.2}",
+        filter_stride,
+        allowed,
+        optimized,
+        warmup.as_millis(),
+        search.as_millis(),
+        search.as_secs_f64() * 1_000_000.0 / nq as f64,
+        nq as f64 / search.as_secs_f64(),
+    );
+}
+
+fn generate_clustered_data(
+    n: usize,
+    nq: usize,
+    d: usize,
+    clusters: usize,
+    seed: u64,
+) -> (Vec<f32>, Vec<f32>) {
+    let mut rng = Lcg::new(seed);
+    let mut centers = vec![0.0f32; clusters * d];
+    for value in &mut centers {
+        *value = rng.next_f32() * 30.0;
+    }
+
+    let mut data = vec![0.0f32; n * d];
+    for i in 0..n {
+        let cluster = i % clusters;
+        for j in 0..d {
+            data[i * d + j] = centers[cluster * d + j] + rng.next_f32();
+        }
+    }
+
+    let mut queries = vec![0.0f32; nq * d];
+    for qi in 0..nq {
+        let source = (qi * 9973) % n;
+        queries[qi * d..(qi + 1) * d].copy_from_slice(&data[source * 
d..(source + 1) * d]);
+    }
+    (data, queries)
+}
+
+fn read_env<T>(name: &str, default: T) -> Result<T, Box<dyn std::error::Error>>
+where
+    T: std::str::FromStr,
+    T::Err: std::error::Error + 'static,
+{
+    match env::var(name) {
+        Ok(value) => Ok(value.parse()?),
+        Err(env::VarError::NotPresent) => Ok(default),
+        Err(err) => Err(Box::new(err)),
+    }
+}
+
+fn read_strides(name: &str, default: &[usize]) -> Result<Vec<usize>, Box<dyn 
std::error::Error>> {
+    match env::var(name) {
+        Ok(value) => value
+            .split(',')
+            .map(|part| {
+                part.trim()
+                    .parse::<usize>()
+                    .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })
+            })
+            .collect(),
+        Err(env::VarError::NotPresent) => Ok(default.to_vec()),
+        Err(err) => Err(Box::new(err)),
+    }
+}
+
+struct Lcg {
+    state: u64,
+}
+
+impl Lcg {
+    fn new(seed: u64) -> Self {
+        Self { state: seed }
+    }
+
+    fn next_f32(&mut self) -> f32 {
+        self.state = 
self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
+        ((self.state >> 33) as f32) / (u32::MAX as f32) * 2.0 - 1.0
+    }
+}
diff --git a/core/tests/storage_format_fixtures.rs 
b/core/tests/storage_format_fixtures.rs
index bf5e453..fea6da3 100644
--- a/core/tests/storage_format_fixtures.rs
+++ b/core/tests/storage_format_fixtures.rs
@@ -84,6 +84,37 @@ fn 
storage_format_v1_golden_fixtures_match_current_writers_and_readers() {
     }
 }
 
+#[test]
+fn storage_format_v1_golden_fixtures_support_search_warmup() {
+    for case in fixture_cases() {
+        let fixture = hex_to_bytes(case.fixture_hex);
+
+        let mut baseline = 
VectorIndexReader::open(Cursor::new(fixture.clone())).unwrap();
+        let expected = baseline.search(&case.query, case.params).unwrap();
+
+        let mut optimized = 
VectorIndexReader::open(Cursor::new(fixture)).unwrap();
+        optimized.optimize_for_search().unwrap();
+        let actual = optimized.search(&case.query, case.params).unwrap();
+
+        assert_eq!(actual.0, expected.0, "{} optimized ids", case.name);
+        assert_eq!(
+            actual.1.len(),
+            expected.1.len(),
+            "{} optimized distance count",
+            case.name
+        );
+        for (actual, expected) in actual.1.iter().zip(expected.1.iter()) {
+            assert!(
+                (actual - expected).abs() < 1e-4,
+                "{} optimized distance {} should match {}",
+                case.name,
+                actual,
+                expected
+            );
+        }
+    }
+}
+
 #[test]
 #[ignore]
 fn print_storage_format_v1_fixture_hex() {
diff --git a/cpp/test_vindex.cpp b/cpp/test_vindex.cpp
index c1eeb04..96bd2dd 100644
--- a/cpp/test_vindex.cpp
+++ b/cpp/test_vindex.cpp
@@ -45,6 +45,11 @@ struct MemBuffer {
     size_t pos = 0;
 };
 
+constexpr size_t kRoundtripDimension = 2;
+constexpr size_t kRoundtripNlist = 4;
+constexpr size_t kRoundtripPerList = 128;
+constexpr size_t kRoundtripVectorCount = kRoundtripNlist * kRoundtripPerList;
+
 static paimon::vindex::OutputFile make_output(MemBuffer& buf) {
     paimon::vindex::OutputFile out;
     out.write_fn = [&buf](const uint8_t* data, size_t len) -> int {
@@ -67,25 +72,51 @@ static paimon::vindex::InputFile make_input(const 
MemBuffer& buf) {
     return in;
 }
 
-static void test_basic_roundtrip() {
-    std::vector<std::pair<std::string, std::string>> options = {
-        {"index.type", "ivf_flat"},
-        {"dimension", "2"},
-        {"nlist", "2"},
-        {"metric", "l2"},
-    };
+static int64_t cluster_base_id(size_t cluster) {
+    return static_cast<int64_t>((cluster + 1) * 100000);
+}
+
+static std::vector<float> roundtrip_data() {
+    std::vector<float> data(kRoundtripVectorCount * kRoundtripDimension);
+    for (size_t i = 0; i < kRoundtripVectorCount; i++) {
+        size_t cluster = i / kRoundtripPerList;
+        size_t local = i % kRoundtripPerList;
+        float center = static_cast<float>(cluster) * 20.0f;
+        data[i * kRoundtripDimension] = center + static_cast<float>(local % 
16) * 0.001f;
+        data[i * kRoundtripDimension + 1] = center + static_cast<float>(local 
/ 16) * 0.001f;
+    }
+    return data;
+}
+
+static std::vector<int64_t> roundtrip_ids() {
+    std::vector<int64_t> ids(kRoundtripVectorCount);
+    for (size_t i = 0; i < kRoundtripVectorCount; i++) {
+        size_t cluster = i / kRoundtripPerList;
+        size_t local = i % kRoundtripPerList;
+        ids[i] = cluster_base_id(cluster) + static_cast<int64_t>(local);
+    }
+    return ids;
+}
+
+static void assert_id_in_cluster(int64_t id, size_t cluster) {
+    int64_t base = cluster_base_id(cluster);
+    ASSERT_TRUE(id >= base);
+    ASSERT_TRUE(id < base + static_cast<int64_t>(kRoundtripPerList));
+}
+
+static void run_roundtrip(
+        const char* name,
+        const std::vector<std::pair<std::string, std::string>>& options,
+        uint32_t expected_index_type,
+        size_t expected_pq_m,
+        size_t expected_hnsw_m) {
     paimon::vindex::Writer writer(options);
     ASSERT_EQ(writer.dimension(), 2);
 
-    std::vector<float> data = {
-        0.0f, 0.0f,
-        1.0f, 0.0f,
-        10.0f, 10.0f,
-        11.0f, 10.0f,
-    };
-    std::vector<int64_t> ids = {100, 101, 200, 201};
-    writer.train(data.data(), 4);
-    writer.add_vectors(ids.data(), data.data(), 4);
+    std::vector<float> data = roundtrip_data();
+    std::vector<int64_t> ids = roundtrip_ids();
+    writer.train(data.data(), kRoundtripVectorCount);
+    writer.add_vectors(ids.data(), data.data(), kRoundtripVectorCount);
 
     MemBuffer buf;
     writer.write_index(make_output(buf));
@@ -93,29 +124,84 @@ static void test_basic_roundtrip() {
 
     paimon::vindex::Reader reader(make_input(buf));
     auto metadata = reader.metadata();
-    ASSERT_EQ(metadata.index_type, PAIMON_VINDEX_INDEX_TYPE_IVF_FLAT);
+    ASSERT_EQ(metadata.index_type, expected_index_type);
     ASSERT_EQ(metadata.dimension, 2);
-    ASSERT_EQ(metadata.nlist, 2);
+    ASSERT_EQ(metadata.nlist, 4);
     ASSERT_EQ(metadata.metric, PAIMON_VINDEX_METRIC_L2);
-    ASSERT_EQ(metadata.total_vectors, 4);
+    ASSERT_EQ(metadata.total_vectors, kRoundtripVectorCount);
+    ASSERT_EQ(metadata.pq_m, expected_pq_m);
+    ASSERT_EQ(metadata.hnsw_m, expected_hnsw_m);
 
     reader.optimize_for_search();
 
     const float query[] = {0.0f, 0.0f};
-    auto result = reader.search(query, 2, 2);
+    auto result = reader.search(query, 2, 4, 16);
     ASSERT_EQ(result.ids.size(), 2);
-    ASSERT_EQ(result.ids[0], 100);
+    assert_id_in_cluster(result.ids[0], 0);
     ASSERT_TRUE(std::isfinite(result.distances[0]));
 
-    const float queries[] = {0.0f, 0.0f, 10.0f, 10.0f};
-    auto batch = reader.search_batch(queries, 2, 1, 2);
+    const float queries[] = {0.0f, 0.0f, 20.0f, 20.0f};
+    auto batch = reader.search_batch(queries, 2, 1, 4, 16);
     ASSERT_EQ(batch.ids.size(), 2);
-    ASSERT_EQ(batch.ids[0], 100);
-    ASSERT_EQ(batch.ids[1], 200);
-    printf("PASS test_basic_roundtrip\n");
+    assert_id_in_cluster(batch.ids[0], 0);
+    assert_id_in_cluster(batch.ids[1], 1);
+    printf("PASS %s\n", name);
+}
+
+static void test_supported_index_roundtrips() {
+    run_roundtrip(
+        "ivf_flat_roundtrip",
+        {
+            {"index.type", "ivf_flat"},
+            {"dimension", "2"},
+            {"nlist", "4"},
+            {"metric", "l2"},
+        },
+        PAIMON_VINDEX_INDEX_TYPE_IVF_FLAT,
+        0,
+        0);
+
+    run_roundtrip(
+        "ivf_pq_roundtrip",
+        {
+            {"index.type", "ivf_pq"},
+            {"dimension", "2"},
+            {"nlist", "4"},
+            {"metric", "l2"},
+            {"pq.m", "1"},
+        },
+        PAIMON_VINDEX_INDEX_TYPE_IVF_PQ,
+        1,
+        0);
+
+    run_roundtrip(
+        "ivf_hnsw_flat_roundtrip",
+        {
+            {"index.type", "ivf_hnsw_flat"},
+            {"dimension", "2"},
+            {"nlist", "4"},
+            {"metric", "l2"},
+            {"hnsw.m", "4"},
+        },
+        PAIMON_VINDEX_INDEX_TYPE_IVF_HNSW_FLAT,
+        0,
+        4);
+
+    run_roundtrip(
+        "ivf_hnsw_sq_roundtrip",
+        {
+            {"index.type", "ivf_hnsw_sq"},
+            {"dimension", "2"},
+            {"nlist", "4"},
+            {"metric", "l2"},
+            {"hnsw.m", "4"},
+        },
+        PAIMON_VINDEX_INDEX_TYPE_IVF_HNSW_SQ,
+        0,
+        4);
 }
 
 int main() {
-    test_basic_roundtrip();
+    test_supported_index_roundtrips();
     return 0;
 }
diff --git 
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
 
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
index 6e40171..320f2ec 100644
--- 
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
+++ 
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
@@ -23,6 +23,11 @@ import java.util.Map;
 
 public class VectorIndexNativeValidationTest {
 
+    private static final int ROUNDTRIP_DIMENSION = 2;
+    private static final int ROUNDTRIP_NLIST = 4;
+    private static final int ROUNDTRIP_PER_LIST = 128;
+    private static final int ROUNDTRIP_VECTOR_COUNT = ROUNDTRIP_NLIST * 
ROUNDTRIP_PER_LIST;
+
     public static void main(String[] args) {
         if (args.length != 1) {
             throw new IllegalArgumentException("native library path is 
required");
@@ -34,6 +39,7 @@ public class VectorIndexNativeValidationTest {
         testWriterRejectsNonFiniteValues();
         testReaderValidationComesFromCore();
         testReaderRejectsNonFiniteQueries();
+        testSupportedIndexRoundtrips();
     }
 
     private static void testWriterValidationComesFromCore() {
@@ -72,7 +78,8 @@ public class VectorIndexNativeValidationTest {
     }
 
     private static void testReaderValidationComesFromCore() {
-        VectorIndexReader reader = new VectorIndexReader(new 
ByteArraySeekableInputStream(buildIndexBytes()));
+        VectorIndexReader reader =
+                new VectorIndexReader(new 
ByteArraySeekableInputStream(buildIndexBytes()));
         try {
             assertThrowsMessage(
                     RuntimeException.class,
@@ -143,7 +150,8 @@ public class VectorIndexNativeValidationTest {
     }
 
     private static void testReaderRejectsNonFiniteQueries() {
-        VectorIndexReader reader = new VectorIndexReader(new 
ByteArraySeekableInputStream(buildIndexBytes()));
+        VectorIndexReader reader =
+                new VectorIndexReader(new 
ByteArraySeekableInputStream(buildIndexBytes()));
         try {
             assertInvalidInput(
                     new ThrowingRunnable() {
@@ -174,12 +182,66 @@ public class VectorIndexNativeValidationTest {
         }
     }
 
+    private static void testSupportedIndexRoundtrips() {
+        runRoundtrip("ivf_flat", ivfFlatOptions(ROUNDTRIP_DIMENSION, 
ROUNDTRIP_NLIST), 0, 0);
+        runRoundtrip("ivf_pq", ivfPqOptions(ROUNDTRIP_DIMENSION, 
ROUNDTRIP_NLIST, 1), 1, 0);
+        runRoundtrip(
+                "ivf_hnsw_flat",
+                ivfHnswOptions("ivf_hnsw_flat", ROUNDTRIP_DIMENSION, 
ROUNDTRIP_NLIST),
+                0,
+                4);
+        runRoundtrip(
+                "ivf_hnsw_sq",
+                ivfHnswOptions("ivf_hnsw_sq", ROUNDTRIP_DIMENSION, 
ROUNDTRIP_NLIST),
+                0,
+                4);
+    }
+
+    private static void runRoundtrip(
+            String indexType, Map<String, String> options, int expectedPqM, 
int expectedHnswM) {
+        byte[] indexBytes =
+                buildIndexBytes(
+                        options, roundtripData(), roundtripIds(), 
ROUNDTRIP_VECTOR_COUNT);
+        VectorIndexReader reader =
+                new VectorIndexReader(new 
ByteArraySeekableInputStream(indexBytes));
+        try {
+            VectorIndexMetadata metadata = reader.metadata();
+            assertEquals(indexType, metadata.indexType());
+            assertEquals(ROUNDTRIP_DIMENSION, metadata.dimension());
+            assertEquals(ROUNDTRIP_NLIST, metadata.nlist());
+            assertEquals("l2", metadata.metric());
+            assertEquals((long) ROUNDTRIP_VECTOR_COUNT, 
metadata.totalVectors());
+            assertEquals(expectedPqM, metadata.pqM());
+            assertEquals(expectedHnswM, metadata.hnswM());
+
+            reader.optimizeForSearch();
+
+            VectorSearchResult single = reader.search(new float[] {0.0f, 
0.0f}, 2, 4, 16);
+            assertIdInCluster(single.ids()[0], 0);
+            assertFinite(single.distances()[0], indexType + " single 
distance");
+
+            VectorSearchBatchResult batch =
+                    reader.searchBatch(new float[] {0.0f, 0.0f, 20.0f, 20.0f}, 
2, 1, 4, 16);
+            assertIdInCluster(batch.ids()[0], 0);
+            assertIdInCluster(batch.ids()[1], 1);
+            assertFinite(batch.distances()[0], indexType + " batch distance 
0");
+            assertFinite(batch.distances()[1], indexType + " batch distance 
1");
+        } finally {
+            reader.close();
+        }
+    }
+
     private static byte[] buildIndexBytes() {
-        VectorIndexWriter writer = new VectorIndexWriter(ivfFlatOptions());
+        return buildIndexBytes(ivfFlatOptions(), new float[] {0.0f, 1.0f}, new 
long[] {1L, 2L}, 2);
+    }
+
+    private static byte[] buildIndexBytes(
+            Map<String, String> options, float[] data, long[] ids, int 
vectorCount) {
+        VectorIndexWriter writer = new VectorIndexWriter(options);
         ByteArrayPositionOutputStream output = new 
ByteArrayPositionOutputStream();
         try {
-            writer.train(new float[] {0.0f, 1.0f}, 2);
-            writer.addVectors(new long[] {1L, 2L}, new float[] {0.0f, 1.0f}, 
2);
+            writer.train(data, vectorCount);
+            writer.addVectors(ids, data, vectorCount);
             writer.writeIndex(output);
             return output.toByteArray();
         } finally {
@@ -188,14 +250,105 @@ public class VectorIndexNativeValidationTest {
     }
 
     private static Map<String, String> ivfFlatOptions() {
+        return ivfFlatOptions(1, 1);
+    }
+
+    private static Map<String, String> ivfFlatOptions(int dimension, int 
nlist) {
         Map<String, String> options = new HashMap<String, String>();
         options.put("index.type", "ivf_flat");
-        options.put("dimension", "1");
-        options.put("nlist", "1");
+        options.put("dimension", Integer.toString(dimension));
+        options.put("nlist", Integer.toString(nlist));
         options.put("metric", "l2");
         return options;
     }
 
+    private static Map<String, String> ivfPqOptions(int dimension, int nlist, 
int m) {
+        Map<String, String> options = ivfFlatOptions(dimension, nlist);
+        options.put("index.type", "ivf_pq");
+        options.put("pq.m", Integer.toString(m));
+        options.put("use-opq", "false");
+        return options;
+    }
+
+    private static Map<String, String> ivfHnswOptions(String indexType, int 
dimension, int nlist) {
+        Map<String, String> options = ivfFlatOptions(dimension, nlist);
+        options.put("index.type", indexType);
+        options.put("hnsw.m", "4");
+        options.put("hnsw.ef-construction", "16");
+        options.put("hnsw.max-level", "4");
+        return options;
+    }
+
+    private static float[] roundtripData() {
+        float[] data = new float[ROUNDTRIP_VECTOR_COUNT * ROUNDTRIP_DIMENSION];
+        for (int i = 0; i < ROUNDTRIP_VECTOR_COUNT; i++) {
+            int cluster = i / ROUNDTRIP_PER_LIST;
+            int local = i % ROUNDTRIP_PER_LIST;
+            float center = cluster * 20.0f;
+            data[i * ROUNDTRIP_DIMENSION] = center + (local % 16) * 0.001f;
+            data[i * ROUNDTRIP_DIMENSION + 1] = center + (local / 16) * 0.001f;
+        }
+        return data;
+    }
+
+    private static long[] roundtripIds() {
+        long[] ids = new long[ROUNDTRIP_VECTOR_COUNT];
+        for (int i = 0; i < ROUNDTRIP_VECTOR_COUNT; i++) {
+            int cluster = i / ROUNDTRIP_PER_LIST;
+            int local = i % ROUNDTRIP_PER_LIST;
+            ids[i] = clusterBaseId(cluster) + local;
+        }
+        return ids;
+    }
+
+    private static long clusterBaseId(int cluster) {
+        return (cluster + 1L) * 100000L;
+    }
+
+    private static void assertEquals(int expected, int actual) {
+        if (expected != actual) {
+            throw new AssertionError("expected " + expected + " but got " + 
actual);
+        }
+    }
+
+    private static void assertEquals(long expected, long actual) {
+        if (expected != actual) {
+            throw new AssertionError("expected " + expected + " but got " + 
actual);
+        }
+    }
+
+    private static void assertEquals(Object expected, Object actual) {
+        if (!expected.equals(actual)) {
+            throw new AssertionError("expected " + expected + " but got " + 
actual);
+        }
+    }
+
+    private static void assertArrayEquals(long[] expected, long[] actual) {
+        if (expected.length != actual.length) {
+            throw new AssertionError(
+                    "expected length " + expected.length + " but got " + 
actual.length);
+        }
+        for (int i = 0; i < expected.length; i++) {
+            if (expected[i] != actual[i]) {
+                throw new AssertionError(
+                        "expected[" + i + "] " + expected[i] + " but got " + 
actual[i]);
+            }
+        }
+    }
+
+    private static void assertIdInCluster(long id, int cluster) {
+        long base = clusterBaseId(cluster);
+        if (id < base || id >= base + ROUNDTRIP_PER_LIST) {
+            throw new AssertionError("id " + id + " should be in cluster " + 
cluster);
+        }
+    }
+
+    private static void assertFinite(float value, String label) {
+        if (!Float.isFinite(value)) {
+            throw new AssertionError(label + " should be finite but was " + 
value);
+        }
+    }
+
     private static void assertThrowsMessage(
             Class<? extends Throwable> expected, String expectedMessage, 
ThrowingRunnable runnable) {
         try {


Reply via email to