yams/tests/unit/search/string_interner_catch2_test.cpp
Trevon c26294a938
feat(topology): opt-in medoid representative and BQ rotation (#277)
* test(topology): characterize degree representatives and raw BQ sign bits

Pin the current behaviour before adding opt-in representative and BQ
rotation options:

- connected, louvain and kmeans engines on a skewed cluster (dense
  clique plus a weak-edge hub): medoid hash, score, extra routing
  representatives and roles;
- equal-degree ties go to the smaller document hash;
- the unrotated BinaryQuantizedIndex matches a raw-coordinate sign-bit
  reference, with and without a prefix dimension.

Signed-off-by: trvon <me@trevon.dev>

* feat(topology): add an opt-in medoid representative rule

topology.representative_rule = degree (default) | medoid selects how a
cluster's representative document is chosen. Degree keeps the max
within-cluster weighted degree. Medoid picks the member minimising
sum_j sqrt(max(0, 2(1 - cos))) over the other members.

- one shared helper, applyRepresentativeRule(), is called by the
  connected, louvain and kmeans engines after their unchanged degree
  choice; medoid falls back to it without usable geometry;
- clusters above 256 usable members are searched on a deterministic
  hash-ordered stride sample (kMedoidRepresentativeMaxMembers);
- ties within 1e-9 go to the smaller document hash;
- with medoid, farthest-first extra routing representatives are seeded
  from the medoid instead of the centroid;
- the rule is stored in the formerly reserved first header byte of the
  binary snapshot (0 = degree, so old snapshots keep their meaning) and
  extends the construction fingerprint only when it is not degree;
- search debug stats report topology_representative_rule.

Signed-off-by: trvon <me@trevon.dev>

* feat(topology): add an opt-in randomized Hadamard rotation for BQ routing

search.topology.bq_rotation = none (default) | fwht selects the rotation
applied before the centroid BQ shortlist takes sign bits. Raw-coordinate
sign bits make cos(pi * h / d) a poor estimate on anisotropic embeddings;
after x -> (1/sqrt(n)) H D [x; 0] every bit is a random hyperplane.

- RandomizedHadamardRotation in yams/vector: fixed splitmix64 sign
  diagonal plus an in-place FWHT, zero-padded to a power of two; Simeon's
  FWHT projection works on int32 sketches with row subsampling, so a
  small local transform is used instead;
- BinaryQuantizedIndex::build() takes the rotation and seed, rotates
  stored vectors before the prefix is taken, and rotates every float
  query itself, so centroids and queries always share one seed
  (kDefaultBinaryRotationSeed);
- the routing snapshot cache records bqRotation and bqRotationSeed and
  rebuilds the route index when either differs;
- the routing policy fingerprint changes only for a non-default rotation;
- search debug stats report topology_route_bq_rotation.

Signed-off-by: trvon <me@trevon.dev>

* test(bench): map representative rule and BQ rotation arm knobs

Harness-only env knobs written into the isolated benchmark config.toml,
so arms go through ConfigResolver like the other topology settings:

- YAMS_BENCH_TOPOLOGY_REPRESENTATIVE_RULE=degree|medoid
  -> [topology] representative_rule
- YAMS_BENCH_TOPOLOGY_BQ_ROTATION=none|fwht
  -> [search.topology] bq_rotation, next to bq_candidate_limit and
     bq_prefix_dim

The debug-file setup entry records the requested values; per-query
search_stats carry the effective topology_representative_rule and
topology_route_bq_rotation. Portability allowlist lines are shifted for
the inserted code.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-08 14:30:27 -06:00

260 lines
11 KiB
C++

// Copyright (c) 2025 YAMS Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <yams/search/string_interner.h>
#include <yams/search/topology_routing_session.h>
#include <yams/topology/topology_artifacts.h>
#include <yams/vector/binary_quantization.h>
#include <string>
#include <string_view>
#include <vector>
using namespace yams;
using namespace yams::search;
using namespace yams::topology;
TEST_CASE("StringInterner basic interning and deduplication", "[unit][search][interner]") {
StringInterner interner;
CHECK(interner.empty());
CHECK(interner.size() == 0);
CHECK(interner.payloadBytes() == 0);
// Empty string
auto emptyView = interner.intern("");
CHECK(emptyView.empty());
CHECK(interner.empty());
// First insertion
std::string str1 = "cluster-01934abc";
auto view1 = interner.intern(str1);
CHECK(view1 == "cluster-01934abc");
CHECK(interner.size() == 1);
CHECK(interner.payloadBytes() == str1.size());
// Re-interning identical string must return pointer to identical storage
std::string str2 = "cluster-01934abc"; // distinct heap string
auto view2 = interner.intern(str2);
CHECK(view2 == "cluster-01934abc");
CHECK(view1.data() == view2.data()); // Zero-allocation hit
CHECK(interner.size() == 1);
// Second distinct string
auto view3 =
interner.intern("doc_hash_64_characters_long_0123456789abcdef0123456789abcdef012345");
CHECK(interner.size() == 2);
CHECK(view3 != view1);
// Move construction preserves interned strings
StringInterner moved(std::move(interner));
CHECK(moved.size() == 2);
auto view1AfterMove = moved.intern("cluster-01934abc");
CHECK(view1AfterMove.data() == view1.data());
moved.clear();
CHECK(moved.empty());
CHECK(moved.size() == 0);
}
TEST_CASE("TopologyRoutingSnapshotCache zero-copy flyweight index maps",
"[unit][search][topology][cache][flyweight]") {
ConnectedComponentTopologyEngine engine;
TopologyBuildConfig config;
config.reciprocalOnly = true;
config.embeddingSpaceIdentity = "test-space";
std::vector<TopologyDocumentInput> docs{
{.documentHash = "hash_doc_1",
.filePath = "/path/1.txt",
.embedding = {1.0F, 0.0F},
.neighbors = {{.documentHash = "hash_doc_2", .score = 0.9F, .reciprocal = true}}},
{.documentHash = "hash_doc_2",
.filePath = "/path/2.txt",
.embedding = {0.9F, 0.1F},
.neighbors = {{.documentHash = "hash_doc_1", .score = 0.9F, .reciprocal = true}}},
{.documentHash = "hash_doc_3",
.filePath = "/path/3.txt",
.embedding = {0.0F, 1.0F},
.neighbors = {}},
};
auto batchRes = engine.buildArtifacts(docs, config);
REQUIRE(batchRes.has_value());
auto batch = std::move(batchRes.value());
batch.topologyEpoch = 42;
TopologyRoutingSnapshotCache cache([batch]() mutable {
return Result<std::optional<TopologyArtifactBatch>>{
std::optional<TopologyArtifactBatch>{std::move(batch)}};
});
auto lookup = cache.get(42, false);
REQUIRE(lookup.has_value());
auto snapshot = lookup.value().snapshot;
REQUIRE(snapshot);
// 1. Verify clustersById size and zero-copy string_view pointing into artifacts
REQUIRE(snapshot->clustersById.size() == snapshot->artifacts->clusters.size());
for (std::size_t i = 0; i < snapshot->artifacts->clusters.size(); ++i) {
const auto& cId = snapshot->artifacts->clusters[i].clusterId;
auto it = snapshot->clustersById.find(cId);
REQUIRE(it != snapshot->clustersById.end());
CHECK(it->second == i);
// Key in map points directly to the string storage in snapshot->artifacts
CHECK(it->first.data() == cId.data());
}
// 2. Verify membershipsByDocumentHash size and zero-copy string_view pointing into artifacts
REQUIRE(snapshot->membershipsByDocumentHash.size() == snapshot->artifacts->memberships.size());
for (std::size_t i = 0; i < snapshot->artifacts->memberships.size(); ++i) {
const auto& dHash = snapshot->artifacts->memberships[i].documentHash;
auto it = snapshot->membershipsByDocumentHash.find(dHash);
REQUIRE(it != snapshot->membershipsByDocumentHash.end());
CHECK(it->second == i);
CHECK(it->first.data() == dHash.data());
}
// 3. Upgrade snapshot (triggering copy-construction for ANN index upgrade)
auto upgradeLookup = cache.get(42, true);
REQUIRE(upgradeLookup.has_value());
auto upgraded = upgradeLookup.value().snapshot;
REQUIRE(upgraded);
CHECK(upgraded->denseAnnBuildAttempted);
// Pointers must still point to identical underlying artifact storage
for (std::size_t i = 0; i < snapshot->artifacts->clusters.size(); ++i) {
const auto& cId = snapshot->artifacts->clusters[i].clusterId;
auto it = upgraded->clustersById.find(cId);
REQUIRE(it != upgraded->clustersById.end());
CHECK(it->first.data() == cId.data());
}
for (std::size_t i = 0; i < snapshot->artifacts->memberships.size(); ++i) {
const auto& dHash = snapshot->artifacts->memberships[i].documentHash;
auto it = upgraded->membershipsByDocumentHash.find(dHash);
REQUIRE(it != upgraded->membershipsByDocumentHash.end());
CHECK(it->first.data() == dHash.data());
}
// 4. A different BQ prefix rebuilds only the route index over the shared artifacts.
REQUIRE(upgraded->sparseRouteIndex.centroidBqIndex);
CHECK(upgraded->sparseRouteIndex.centroidBqIndex->dimension() == 2U);
auto prefixLookup = cache.get(42, true, 1);
REQUIRE(prefixLookup.has_value());
auto prefixed = prefixLookup.value().snapshot;
REQUIRE(prefixed);
CHECK(prefixed->bqPrefixDimension == 1U);
CHECK(prefixed->denseAnnBuildAttempted);
REQUIRE(prefixed->sparseRouteIndex.centroidBqIndex);
CHECK(prefixed->sparseRouteIndex.centroidBqIndex->dimension() == 1U);
CHECK(prefixed->artifacts.get() == upgraded->artifacts.get());
}
TEST_CASE("topologyCommunitySupport scores candidates that share a snapshot cluster",
"[unit][search][topology][community]") {
auto batch = std::make_shared<TopologyArtifactBatch>();
const auto member = [](std::string hash, std::string cluster) {
yams::topology::DocumentClusterMembership m;
m.documentHash = std::move(hash);
m.clusterId = std::move(cluster);
return m;
};
batch->memberships = {member("a", "c1"), member("b", "c1"), member("c", "c1"),
member("d", "c2"), member("e", "c3")};
TopologyRoutingSnapshot snapshot;
snapshot.artifacts = batch;
for (std::size_t i = 0; i < batch->memberships.size(); ++i) {
snapshot.membershipsByDocumentHash.emplace(batch->memberships[i].documentHash, i);
}
// a, b, c share c1; d and e are alone in their clusters; x is not in the snapshot.
const std::vector<std::string> candidates{"a", "d", "b", "x", "c", "e"};
yams::search::TopologyCommunityStats stats;
const auto support = yams::search::topologyCommunitySupport(snapshot, candidates, 8.0F, &stats);
REQUIRE(support.size() == candidates.size());
CHECK(support[0] == Catch::Approx(2.0F / 7.0F));
CHECK(support[2] == Catch::Approx(2.0F / 7.0F));
CHECK(support[4] == Catch::Approx(2.0F / 7.0F));
CHECK(support[1] == 0.0F);
CHECK(support[3] == 0.0F);
CHECK(support[5] == 0.0F);
CHECK(stats.communities == 1U);
CHECK(stats.supportedDocs == 3U);
CHECK(stats.largestCommunity == 3U);
SECTION("Without a reference size the candidate count normalizes") {
const auto relative = yams::search::topologyCommunitySupport(snapshot, candidates, 0.0F);
CHECK(relative[0] == Catch::Approx(2.0F / 5.0F));
}
}
TEST_CASE("TopologyRoutingSnapshotCache keys the BQ index on its rotation",
"[unit][search][topology][cache][bq]") {
TopologyArtifactBatch batch;
batch.topologyEpoch = 7;
for (std::size_t i = 0; i < 4; ++i) {
ClusterArtifact cluster;
cluster.clusterId = "cluster-" + std::to_string(i);
cluster.memberCount = 1;
cluster.memberDocumentHashes = {"doc-" + std::to_string(i)};
cluster.centroidEmbedding = {1.0F, static_cast<float>(i), 0.5F};
batch.clusters.push_back(std::move(cluster));
DocumentClusterMembership membership;
membership.documentHash = "doc-" + std::to_string(i);
membership.clusterId = "cluster-" + std::to_string(i);
batch.memberships.push_back(std::move(membership));
}
TopologyRoutingSnapshotCache cache(
[batch]() { return Result<std::optional<TopologyArtifactBatch>>{std::optional{batch}}; });
auto plain = cache.get(7, false);
REQUIRE(plain.has_value());
REQUIRE(plain.value().snapshot->sparseRouteIndex.centroidBqIndex);
CHECK(plain.value().snapshot->bqRotation == yams::vector::BinaryRotation::None);
CHECK(plain.value().snapshot->sparseRouteIndex.centroidBqIndex->rotation() ==
yams::vector::BinaryRotation::None);
auto rotated = cache.get(7, false, 0, yams::vector::BinaryRotation::Fwht);
REQUIRE(rotated.has_value());
const auto& snapshot = *rotated.value().snapshot;
CHECK(snapshot.bqRotation == yams::vector::BinaryRotation::Fwht);
CHECK(snapshot.bqRotationSeed == yams::vector::kDefaultBinaryRotationSeed);
REQUIRE(snapshot.sparseRouteIndex.centroidBqIndex);
CHECK(snapshot.sparseRouteIndex.centroidBqIndex->rotation() ==
yams::vector::BinaryRotation::Fwht);
CHECK(snapshot.sparseRouteIndex.centroidBqIndex->rotationSeed() == snapshot.bqRotationSeed);
CHECK(snapshot.artifacts.get() == plain.value().snapshot->artifacts.get());
auto repeated = cache.get(7, false, 0, yams::vector::BinaryRotation::Fwht);
REQUIRE(repeated.has_value());
CHECK(repeated.value().snapshot.get() == rotated.value().snapshot.get());
auto back = cache.get(7, false);
REQUIRE(back.has_value());
CHECK(back.value().snapshot->sparseRouteIndex.centroidBqIndex->rotation() ==
yams::vector::BinaryRotation::None);
}
TEST_CASE("Topology routing options carry the BQ rotation and fingerprint it only when set",
"[unit][search][topology][bq]") {
SearchEngineConfig config;
CHECK(config.topologyRoutingBqRotation == SearchEngineConfig::TopologyBqRotation::None);
const auto plainOptions = makeTopologyRoutingOptions(
config, SearchEngineConfig::TopologyRoutingMode::HybridAssist, false);
CHECK(plainOptions.bqRotation == SearchEngineConfig::TopologyBqRotation::None);
auto rotatedConfig = config;
rotatedConfig.topologyRoutingBqRotation = SearchEngineConfig::TopologyBqRotation::Fwht;
const auto rotatedOptions = makeTopologyRoutingOptions(
rotatedConfig, SearchEngineConfig::TopologyRoutingMode::HybridAssist, false);
CHECK(rotatedOptions.bqRotation == SearchEngineConfig::TopologyBqRotation::Fwht);
CHECK(topologyRoutingPolicyFingerprint("repr", plainOptions) !=
topologyRoutingPolicyFingerprint("repr", rotatedOptions));
SearchEngineConfig copy;
copy.applyTopologyPolicyFrom(rotatedConfig);
CHECK(copy.topologyRoutingBqRotation == SearchEngineConfig::TopologyBqRotation::Fwht);
CHECK(std::string_view{SearchEngineConfig::topologyBqRotationToString(
SearchEngineConfig::TopologyBqRotation::Fwht)} == "fwht");
}