* test(vector): pin vec0 ranking to cosine order on non-unit rows vec0 ranks by L2 distance, which matches cosine order only for unit vectors. Insert rows whose L2 order disagrees with cosine order and assert vec0 returns cosine order, agrees with the exact cosine scan, skips zero rows and rejects a zero query. Pins a vector-enabled env so the cases run under the CI test env. Signed-off-by: trvon <me@trevon.dev> * fix(vector): rank vec0 by cosine on unit-normalized copies vec0 ranks by L2 distance, which matches cosine order only when both sides are unit length. Stored rows reach the vectors table at whatever magnitude the provider, config, import or sync produced, and the vec0 aux table copied them as-is, so vec0 selected and ordered rows differently from the Simeon PQ and exact cosine paths. The vec0 table is a derived index rebuilt from vectors.embedding, so write the unit direction into it and leave the source blob, which export/sync and exact scoring read, untouched. Normalize the query the same way. Zero rows are left out of vec0 and never match; a zero or non-finite query is rejected as the exact scan rejects it. Returned rows are ordered by their cosine score with the PQ tie-break. No schema change. The aux table is rebuilt on first use in each process (ready dims are in-memory), so existing non-unit rows are normalized on the next rebuild without a repair step. Signed-off-by: trvon <me@trevon.dev> * test(topology): pin the k-means stored centroid to the unit direction Spherical k-means assigns against unit centroids but stored the shorter arithmetic mean. Assert the stored centroid is unit length along the member mean direction, with and without boundary spill. Add a SOAR case showing that with unit centroids the spill follows angular distance, and that a short mean centroid reorders the candidates. Reformat the touched k-means test file to the lint formatter. Signed-off-by: trvon <me@trevon.dev> * fix(topology): store the unit k-means centroid The k-means engine clusters by cosine against normalized centroids, then stored the unnormalized member mean as centroidEmbedding. Routing reads the centroid by cosine and does not care, but SOAR boundary spill measures Euclidean residuals against it. A loose cluster's short mean adds a radial term to those residuals, so spill candidates were ranked in a different space from the one the clusters were built and routed in. Store the unit mean direction for k-means, which is the converged Lloyd centroid. Connected-component and Louvain keep the arithmetic mean, as their tests and contract require. Correct the stale field comment. Signed-off-by: trvon <me@trevon.dev> --------- Signed-off-by: trvon <me@trevon.dev>
255 lines
8.8 KiB
C++
255 lines
8.8 KiB
C++
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/generators/catch_generators.hpp>
|
|
|
|
#include <yams/topology/topology_alternate_engines.h>
|
|
#include <yams/topology/topology_artifacts.h>
|
|
|
|
#include <cmath>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
using yams::topology::KMeansTopologyEngine;
|
|
using yams::topology::TopologyBuildConfig;
|
|
using yams::topology::TopologyDocumentInput;
|
|
|
|
namespace {
|
|
|
|
std::vector<float> unit2(float x, float y) {
|
|
const float n = std::sqrt(x * x + y * y);
|
|
return {x / n, y / n};
|
|
}
|
|
|
|
TopologyDocumentInput mk(std::string hash, std::vector<float> emb) {
|
|
TopologyDocumentInput doc;
|
|
doc.documentHash = std::move(hash);
|
|
doc.filePath = "/tmp/" + doc.documentHash + ".txt";
|
|
doc.embedding = std::move(emb);
|
|
return doc;
|
|
}
|
|
|
|
// Two well-separated blobs on the unit circle: cluster A near +x, cluster B near +y.
|
|
std::vector<TopologyDocumentInput> twoBlobs() {
|
|
std::vector<TopologyDocumentInput> docs;
|
|
docs.push_back(mk("a0", unit2(1.0f, 0.02f)));
|
|
docs.push_back(mk("a1", unit2(0.98f, 0.05f)));
|
|
docs.push_back(mk("a2", unit2(0.99f, -0.03f)));
|
|
docs.push_back(mk("b0", unit2(0.02f, 1.0f)));
|
|
docs.push_back(mk("b1", unit2(0.05f, 0.98f)));
|
|
docs.push_back(mk("b2", unit2(-0.03f, 0.99f)));
|
|
return docs;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("KMeansTopologyEngine separates two embedding blobs into two clusters",
|
|
"[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 2;
|
|
|
|
auto result = engine.buildArtifacts(twoBlobs(), cfg);
|
|
REQUIRE(result);
|
|
const auto& batch = result.value();
|
|
CHECK(batch.algorithm == "kmeans_v1");
|
|
REQUIRE(batch.clusters.size() == 2);
|
|
|
|
// Each cluster is singleton-free and carries a centroid.
|
|
for (const auto& cluster : batch.clusters) {
|
|
CHECK(cluster.memberDocumentHashes.size() == 3);
|
|
CHECK_FALSE(cluster.centroidEmbedding.empty());
|
|
}
|
|
|
|
// The a* docs land together and the b* docs land together.
|
|
const auto clusterOf = [&](const std::string& h) -> std::string {
|
|
for (const auto& c : batch.clusters) {
|
|
for (const auto& m : c.memberDocumentHashes) {
|
|
if (m == h) {
|
|
return c.clusterId;
|
|
}
|
|
}
|
|
}
|
|
return {};
|
|
};
|
|
CHECK(clusterOf("a0") == clusterOf("a1"));
|
|
CHECK(clusterOf("a1") == clusterOf("a2"));
|
|
CHECK(clusterOf("b0") == clusterOf("b1"));
|
|
CHECK(clusterOf("b1") == clusterOf("b2"));
|
|
CHECK(clusterOf("a0") != clusterOf("b0"));
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine is deterministic across rebuilds", "[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 2;
|
|
|
|
auto r1 = engine.buildArtifacts(twoBlobs(), cfg);
|
|
auto r2 = engine.buildArtifacts(twoBlobs(), cfg);
|
|
REQUIRE(r1);
|
|
REQUIRE(r2);
|
|
REQUIRE(r1.value().clusters.size() == r2.value().clusters.size());
|
|
for (std::size_t i = 0; i < r1.value().clusters.size(); ++i) {
|
|
CHECK(r1.value().clusters[i].clusterId == r2.value().clusters[i].clusterId);
|
|
CHECK(r1.value().clusters[i].memberDocumentHashes ==
|
|
r2.value().clusters[i].memberDocumentHashes);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine auto-derives k and never exceeds the doc count",
|
|
"[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 0; // auto = round(sqrt(n))
|
|
|
|
auto result = engine.buildArtifacts(twoBlobs(), cfg);
|
|
REQUIRE(result);
|
|
// sqrt(6) ~ 2.45 -> 2 clusters, no empties/singletons collapse.
|
|
CHECK(result.value().clusters.size() >= 2);
|
|
std::size_t members = 0;
|
|
for (const auto& c : result.value().clusters) {
|
|
members += c.memberDocumentHashes.size();
|
|
}
|
|
CHECK(members == 6);
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine handles docs without embeddings as singletons",
|
|
"[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 2;
|
|
|
|
auto docs = twoBlobs();
|
|
docs.push_back(mk("noemb", {})); // no embedding
|
|
|
|
auto result = engine.buildArtifacts(docs, cfg);
|
|
REQUIRE(result);
|
|
std::size_t members = 0;
|
|
for (const auto& c : result.value().clusters) {
|
|
members += c.memberDocumentHashes.size();
|
|
}
|
|
CHECK(members == docs.size());
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine survives identical embeddings with k > distinct points",
|
|
"[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 3;
|
|
|
|
std::vector<TopologyDocumentInput> docs;
|
|
for (int i = 0; i < 4; ++i) {
|
|
docs.push_back(mk("same" + std::to_string(i), unit2(1.0f, 0.0f)));
|
|
}
|
|
|
|
auto result = engine.buildArtifacts(docs, cfg);
|
|
REQUIRE(result);
|
|
std::size_t members = 0;
|
|
for (const auto& c : result.value().clusters) {
|
|
CHECK_FALSE(c.memberDocumentHashes.empty()); // no empty clusters emitted
|
|
members += c.memberDocumentHashes.size();
|
|
}
|
|
CHECK(members == docs.size());
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine keeps every doc when embedding dimensions differ",
|
|
"[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 2;
|
|
|
|
auto docs = twoBlobs(); // dim 2
|
|
docs.push_back(mk("odd", {0.1f, 0.2f, 0.3f, 0.4f})); // dim 4 -> excluded from clustering
|
|
|
|
auto result = engine.buildArtifacts(docs, cfg);
|
|
REQUIRE(result);
|
|
std::size_t members = 0;
|
|
for (const auto& c : result.value().clusters) {
|
|
members += c.memberDocumentHashes.size();
|
|
}
|
|
CHECK(members == docs.size());
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine handles a single usable document", "[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 2;
|
|
|
|
std::vector<TopologyDocumentInput> docs;
|
|
docs.push_back(mk("only", unit2(1.0f, 0.0f)));
|
|
|
|
auto result = engine.buildArtifacts(docs, cfg);
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().clusters.size() == 1);
|
|
CHECK(result.value().clusters.front().memberDocumentHashes.size() == 1);
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine clamps k above the usable document count",
|
|
"[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 50; // >> n
|
|
|
|
auto result = engine.buildArtifacts(twoBlobs(), cfg);
|
|
REQUIRE(result);
|
|
CHECK(result.value().clusters.size() <= 6);
|
|
std::size_t members = 0;
|
|
for (const auto& c : result.value().clusters) {
|
|
members += c.memberDocumentHashes.size();
|
|
}
|
|
CHECK(members == 6);
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine update path falls back to a full rebuild",
|
|
"[topology][kmeans][catch2]") {
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 2;
|
|
|
|
auto base = engine.buildArtifacts(twoBlobs(), cfg);
|
|
REQUIRE(base);
|
|
yams::topology::TopologyUpdateStats stats;
|
|
auto updated = engine.updateArtifacts(base.value(), twoBlobs(), cfg, &stats);
|
|
REQUIRE(updated);
|
|
CHECK(stats.fallbackFullRebuilds == 1);
|
|
CHECK(updated.value().clusters.size() == 2);
|
|
}
|
|
|
|
TEST_CASE("KMeansTopologyEngine stores the unit-length spherical centroid",
|
|
"[topology][kmeans][centroid][catch2]") {
|
|
// Spherical k-means assigns by cosine against unit centroids. The stored centroid
|
|
// must be that same unit centroid, not the shorter arithmetic mean of unit members,
|
|
// so Euclidean consumers (SOAR residuals) measure in the space the clusters were built.
|
|
KMeansTopologyEngine engine;
|
|
TopologyBuildConfig cfg;
|
|
cfg.kmeansK = 2;
|
|
const bool overlap = GENERATE(false, true);
|
|
cfg.allowOverlap = overlap;
|
|
cfg.overlapLimit = 1;
|
|
|
|
const auto docs = twoBlobs();
|
|
auto result = engine.buildArtifacts(docs, cfg);
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().clusters.size() == 2);
|
|
|
|
for (const auto& cluster : result.value().clusters) {
|
|
REQUIRE(cluster.centroidEmbedding.size() == 2);
|
|
// Direction of the arithmetic mean of the primary members.
|
|
std::vector<double> mean(2, 0.0);
|
|
for (const auto& membership : result.value().memberships) {
|
|
if (membership.clusterId != cluster.clusterId) {
|
|
continue;
|
|
}
|
|
for (const auto& doc : docs) {
|
|
if (doc.documentHash == membership.documentHash) {
|
|
mean[0] += doc.embedding[0];
|
|
mean[1] += doc.embedding[1];
|
|
}
|
|
}
|
|
}
|
|
const double meanNorm = std::hypot(mean[0], mean[1]);
|
|
REQUIRE(meanNorm > 0.0);
|
|
const double norm = std::hypot(cluster.centroidEmbedding[0], cluster.centroidEmbedding[1]);
|
|
CHECK(std::abs(norm - 1.0) < 1e-5);
|
|
CHECK(std::abs(cluster.centroidEmbedding[0] - mean[0] / meanNorm) < 1e-5);
|
|
CHECK(std::abs(cluster.centroidEmbedding[1] - mean[1] / meanNorm) < 1e-5);
|
|
}
|
|
}
|