// Catch2 unit tests for TurboQuantPackedReranker (IVectorReranker implementation) // // Covers: // - Basic construction and isReady() // - rerank() with mixed candidates (with/without packed codes) // - Score ordering: TurboQuant-scored candidates ranked by rerank_score // - Fallback: candidates without packed codes ranked by initial_score // - Dimension mismatch returns error // - Empty transformed query returns error // - require_packed_codes flag #include #include #include #include #include #include #include #include using namespace yams::search; using namespace yams::vector; namespace { std::vector generateUnitVector(size_t dim, uint32_t seed) { std::mt19937 rng(seed); std::normal_distribution dist(0.0f, 1.0f); std::vector v(dim); float norm_sq = 0.0f; for (size_t i = 0; i < dim; ++i) { v[i] = dist(rng); norm_sq += v[i] * v[i]; } float norm = std::sqrt(norm_sq); for (size_t i = 0; i < dim; ++i) { v[i] /= norm; } return v; } VectorRecord makeRecordWithPackedCodes(const std::string& chunk_id, size_t dim, uint64_t seed, uint8_t bits_per_channel, const std::vector& vec) { TurboQuantConfig cfg; cfg.dimension = dim; cfg.bits_per_channel = bits_per_channel; cfg.seed = seed; TurboQuantMSE quantizer(cfg); VectorRecord rec; rec.chunk_id = chunk_id; rec.embedding_dim = dim; rec.quantized.format = VectorRecord::QuantizedFormat::TURBOquant_1; rec.quantized.bits_per_channel = bits_per_channel; rec.quantized.seed = seed; rec.quantized.packed_codes = quantizer.packedEncode(vec); return rec; } VectorRecord makeRecordWithoutPackedCodes(const std::string& chunk_id) { VectorRecord rec; rec.chunk_id = chunk_id; rec.embedding_dim = 0; rec.quantized.format = VectorRecord::QuantizedFormat::NONE; return rec; } } // namespace TEST_CASE("TurboQuantPackedReranker: construction and isReady", "[vector][turboquant][reranker]") { TurboQuantPackedReranker::Config config; config.dimension = 384; config.bits_per_channel = 4; config.seed = 42; config.rerank_weight = 0.5f; auto reranker = std::make_unique(config); CHECK(reranker->isReady()); CHECK(reranker->name() == "TurboQuantPackedReranker"); CHECK(reranker->dimension() == 384); CHECK(reranker->bitsPerChannel() == 4); } TEST_CASE("TurboQuantPackedReranker: rerank orders by rerank_score descending", "[vector][turboquant][reranker]") { const size_t dim = 128; const uint64_t seed = 123; const uint8_t bits = 4; TurboQuantPackedReranker::Config config; config.dimension = dim; config.bits_per_channel = bits; config.seed = seed; config.rerank_weight = 1.0f; // Only rerank score auto reranker = std::make_unique(config); TurboQuantConfig tq_cfg; tq_cfg.dimension = dim; tq_cfg.bits_per_channel = bits; tq_cfg.seed = seed; TurboQuantMSE quantizer(tq_cfg); // Two queries: one near corpus[0], one near corpus[1] std::vector query_near_0 = generateUnitVector(dim, 999); std::vector query_near_1 = generateUnitVector(dim, 1000); // Transform queries once each std::vector y_q_0 = quantizer.transformQuery(query_near_0); std::vector y_q_1 = quantizer.transformQuery(query_near_1); // Corpus: two vectors std::vector corpus_0 = generateUnitVector(dim, 100); std::vector corpus_1 = generateUnitVector(dim, 101); VectorRerankInput input; input.transformed_query = y_q_0; // candidate-a: should score higher (query_near_0 ≈ corpus_0) input.candidates["a"] = makeRecordWithPackedCodes("a", dim, seed, bits, corpus_0); // candidate-b: should score lower input.candidates["b"] = makeRecordWithPackedCodes("b", dim, seed, bits, corpus_1); auto result = reranker->rerank(input); REQUIRE(result.has_value()); const auto& out = result.value(); REQUIRE(out.candidates.size() == 2); CHECK(out.candidates[0].chunk_id == "a"); // Higher TurboQuant score CHECK(out.candidates[0].has_packed_codes); CHECK(out.candidates[1].chunk_id == "b"); CHECK(out.candidates[1].has_packed_codes); CHECK(out.candidates[0].rerank_score >= out.candidates[1].rerank_score); CHECK(out.packed_candidates_scored == 2); CHECK(out.candidates_skipped == 0); } TEST_CASE("TurboQuantPackedReranker: mixed candidates — with and without packed codes", "[vector][turboquant][reranker]") { const size_t dim = 64; const uint64_t seed = 77; const uint8_t bits = 4; TurboQuantPackedReranker::Config config; config.dimension = dim; config.bits_per_channel = bits; config.seed = seed; config.rerank_weight = 1.0f; auto reranker = std::make_unique(config); TurboQuantConfig tq_cfg; tq_cfg.dimension = dim; tq_cfg.bits_per_channel = bits; tq_cfg.seed = seed; TurboQuantMSE quantizer(tq_cfg); // Use same vector for query and corpus so asymmetric score is positive std::vector v = generateUnitVector(dim, 888); std::vector y_q = quantizer.transformQuery(v); std::vector corpus = v; // Same vector → high positive score VectorRerankInput input; input.transformed_query = y_q; input.candidates["with-packed"] = makeRecordWithPackedCodes("with-packed", dim, seed, bits, corpus); input.candidates["without-packed"] = makeRecordWithoutPackedCodes("without-packed"); input.initial_scores["with-packed"] = 0.1f; input.initial_scores["without-packed"] = 0.9f; auto result = reranker->rerank(input); REQUIRE(result.has_value()); const auto& out = result.value(); // with-packed gets TurboQuant score (non-zero), without-packed gets 0.0f rerank_score CHECK(out.packed_candidates_scored == 1); CHECK(out.candidates_skipped == 1); // Find each candidate in output const VectorRerankOutput::Candidate* with_cand = nullptr; const VectorRerankOutput::Candidate* without_cand = nullptr; for (const auto& c : out.candidates) { if (c.chunk_id == "with-packed") with_cand = &c; if (c.chunk_id == "without-packed") without_cand = &c; } REQUIRE(with_cand != nullptr); REQUIRE(without_cand != nullptr); CHECK(with_cand->has_packed_codes); CHECK(!without_cand->has_packed_codes); CHECK(with_cand->rerank_score > without_cand->rerank_score); } TEST_CASE("TurboQuantPackedReranker: dimension mismatch returns error", "[vector][turboquant][reranker]") { TurboQuantPackedReranker::Config config; config.dimension = 384; config.bits_per_channel = 4; config.seed = 42; auto reranker = std::make_unique(config); // Query transformed for dim=256 (wrong dimension) TurboQuantConfig tq_cfg; tq_cfg.dimension = 256; tq_cfg.bits_per_channel = 4; tq_cfg.seed = 42; TurboQuantMSE quantizer(tq_cfg); std::vector y_q = quantizer.transformQuery(generateUnitVector(256, 1)); VectorRerankInput input; input.transformed_query = y_q; input.candidates["a"] = makeRecordWithoutPackedCodes("a"); auto result = reranker->rerank(input); CHECK(!result.has_value()); } TEST_CASE("TurboQuantPackedReranker: empty transformed query returns error", "[vector][turboquant][reranker]") { TurboQuantPackedReranker::Config config; config.dimension = 384; auto reranker = std::make_unique(config); VectorRerankInput input; input.transformed_query = {}; input.candidates["a"] = makeRecordWithoutPackedCodes("a"); auto result = reranker->rerank(input); CHECK(!result.has_value()); } TEST_CASE("TurboQuantPackedReranker: require_packed_codes skips candidates without codes", "[vector][turboquant][reranker]") { TurboQuantPackedReranker::Config config; config.dimension = 128; config.bits_per_channel = 4; config.seed = 42; config.require_packed_codes = true; auto reranker = std::make_unique(config); TurboQuantConfig tq_cfg; tq_cfg.dimension = 128; tq_cfg.bits_per_channel = 4; tq_cfg.seed = 42; TurboQuantMSE quantizer(tq_cfg); std::vector y_q = quantizer.transformQuery(generateUnitVector(128, 555)); VectorRerankInput input; input.transformed_query = y_q; input.candidates["with-packed"] = makeRecordWithPackedCodes("with-packed", 128, 42, 4, generateUnitVector(128, 300)); input.candidates["without-packed"] = makeRecordWithoutPackedCodes("without-packed"); auto result = reranker->rerank(input); REQUIRE(result.has_value()); const auto& out = result.value(); CHECK(out.candidates.size() == 1); CHECK(out.candidates[0].chunk_id == "with-packed"); CHECK(out.candidates_skipped == 1); } TEST_CASE("TurboQuantPackedReranker: blended score uses rerank_weight", "[vector][turboquant][reranker]") { const size_t dim = 64; const uint64_t seed = 99; const uint8_t bits = 4; TurboQuantPackedReranker::Config config; config.dimension = dim; config.bits_per_channel = bits; config.seed = seed; config.rerank_weight = 0.8f; // 80% TurboQuant, 20% initial auto reranker = std::make_unique(config); TurboQuantConfig tq_cfg; tq_cfg.dimension = dim; tq_cfg.bits_per_channel = bits; tq_cfg.seed = seed; TurboQuantMSE quantizer(tq_cfg); std::vector corpus = generateUnitVector(dim, 400); std::vector query = generateUnitVector(dim, 401); std::vector y_q = quantizer.transformQuery(query); VectorRerankInput input; input.transformed_query = y_q; input.candidates["a"] = makeRecordWithPackedCodes("a", dim, seed, bits, corpus); input.initial_scores["a"] = 0.5f; auto result = reranker->rerank(input); REQUIRE(result.has_value()); const auto& out = result.value(); REQUIRE(out.candidates.size() == 1); const auto& cand = out.candidates[0]; float expected_blended = 0.8f * cand.rerank_score + 0.2f * cand.initial_score; CHECK(std::abs(cand.blended_score - expected_blended) < 1e-6f); } TEST_CASE("TurboQuantPackedReranker: self-score is positive for unit vectors", "[vector][turboquant][reranker]") { const size_t dim = 128; const uint64_t seed = 111; const uint8_t bits = 4; TurboQuantPackedReranker::Config config; config.dimension = dim; config.bits_per_channel = bits; config.seed = seed; config.rerank_weight = 1.0f; auto reranker = std::make_unique(config); TurboQuantConfig tq_cfg; tq_cfg.dimension = dim; tq_cfg.bits_per_channel = bits; tq_cfg.seed = seed; TurboQuantMSE quantizer(tq_cfg); std::vector v = generateUnitVector(dim, 777); std::vector y_q = quantizer.transformQuery(v); VectorRerankInput input; input.transformed_query = y_q; input.candidates["self"] = makeRecordWithPackedCodes("self", dim, seed, bits, v); auto result = reranker->rerank(input); REQUIRE(result.has_value()); const auto& out = result.value(); REQUIRE(out.candidates.size() == 1); // Self-score should be positive (TurboQuant approximates cosine self-similarity) CHECK(out.candidates[0].rerank_score > 0.0f); } TEST_CASE("TurboQuantPackedReranker: initial_scores used as tiebreaker", "[vector][turboquant][reranker]") { // When two candidates have equal TurboQuant scores, initial_score breaks ties const size_t dim = 128; const uint64_t seed = 222; const uint8_t bits = 4; // Both candidates are identical vectors (same packed codes, same score) auto reranker = std::make_unique( TurboQuantPackedReranker::Config{dim, bits, seed, 1.0f, false}); TurboQuantConfig tq_cfg{dim, bits, seed}; TurboQuantMSE quantizer(tq_cfg); std::vector v = generateUnitVector(dim, 500); std::vector y_q = quantizer.transformQuery(v); VectorRerankInput input; input.transformed_query = y_q; input.candidates["low-init"] = makeRecordWithPackedCodes("low-init", dim, seed, bits, v); input.candidates["high-init"] = makeRecordWithPackedCodes("high-init", dim, seed, bits, v); input.initial_scores["low-init"] = 0.1f; input.initial_scores["high-init"] = 0.9f; auto result = reranker->rerank(input); REQUIRE(result.has_value()); const auto& out = result.value(); REQUIRE(out.candidates.size() == 2); // high-init should come first due to tiebreaker CHECK(out.candidates[0].chunk_id == "high-init"); CHECK(out.candidates[1].chunk_id == "low-init"); }