yams/tests/unit/topology/topology_roads_catch2_test.cpp
Trevon beef87e66c
Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
bench(retrieval): seed one-way kNN semantic edges on request (#308)
Benchmark seeding wrote only mutual neighbor pairs. The connected
topology engine keeps every reciprocal edge at or above min_edge_score
inside its component, so with mutual-only seeding no raw neighbor
crosses a cluster boundary and the engine builds no roads. The Eval 2
smoke showed roads_absent on every connected-engine query.

The daemon stores one-way kNN edges (GraphComponent treats them as
ordinary non-mutual neighbors). YAMS_BENCH_SEED_SEMANTIC_DIRECTED=1
seeds them too, as single directed edges. Default off keeps existing
plans unchanged.

The pair loop moves into buildSemanticNeighborSeedEdges in the bench
support library, sorted by (source, target), with a unit test for both
modes. A topology test pins the mutual-only case: no roads on the
connected engine until a one-way edge exists.

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

418 lines
17 KiB
C++

// Cluster roads: undirected cluster-to-cluster links accumulated from the raw neighbor lists at
// build time. Roads are an in-memory batch field here; persistence and routing consumers land in
// later PRs. These tests drive the public engines through buildArtifacts only.
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <yams/search/topology_routing_session.h>
#include <yams/topology/topology_alternate_engines.h>
#include <yams/topology/topology_artifacts.h>
#include <yams/topology/topology_baseline.h>
#include <yams/topology/topology_codec.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include <string>
#include <vector>
using yams::topology::ClusterRoad;
using yams::topology::ConnectedComponentTopologyEngine;
using yams::topology::KMeansTopologyEngine;
using yams::topology::LouvainTopologyEngine;
using yams::topology::TopologyArtifactBatch;
using yams::topology::TopologyBuildConfig;
using yams::topology::TopologyDocumentInput;
using yams::topology::TopologyNeighbor;
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;
}
void link(TopologyDocumentInput& doc, std::string target, float score, bool reciprocal) {
doc.neighbors.push_back(TopologyNeighbor{
.documentHash = std::move(target), .score = score, .reciprocal = reciprocal});
}
TopologyDocumentInput* find(std::vector<TopologyDocumentInput>& docs, const std::string& hash) {
for (auto& doc : docs) {
if (doc.documentHash == hash) {
return &doc;
}
}
return nullptr;
}
// Three graph clusters on the connected engine: A = {a0, a1}, B = {b0, b1}, C = {c0, c1}.
// Intra-cluster edges are reciprocal and above minEdgeScore. Cross-cluster edges are raw
// neighbor observations that the construction filter drops (non-reciprocal or below the score
// floor), so the components stay apart while the roads still see them:
// A-B: a1->b0 0.30 (non-reciprocal) weight 0.30
// A-C: a0->c0 0.20 (below floor) + a1->c1 0.25 (below) weight 0.45
// B-C: none
std::vector<TopologyDocumentInput> threeGraphClusters() {
std::vector<TopologyDocumentInput> docs;
docs.push_back(mk("a0", unit2(1.0F, 0.0F)));
docs.push_back(mk("a1", unit2(0.99F, 0.05F)));
docs.push_back(mk("b0", unit2(0.0F, 1.0F)));
docs.push_back(mk("b1", unit2(0.05F, 0.99F)));
docs.push_back(mk("c0", unit2(-1.0F, 0.0F)));
docs.push_back(mk("c1", unit2(-0.99F, 0.05F)));
link(*find(docs, "a0"), "a1", 0.9F, true);
link(*find(docs, "a1"), "a0", 0.9F, true);
link(*find(docs, "b0"), "b1", 0.9F, true);
link(*find(docs, "b1"), "b0", 0.9F, true);
link(*find(docs, "c0"), "c1", 0.9F, true);
link(*find(docs, "c1"), "c0", 0.9F, true);
link(*find(docs, "a1"), "b0", 0.30F, false);
link(*find(docs, "a0"), "c0", 0.20F, true);
link(*find(docs, "a1"), "c1", 0.25F, true);
return docs;
}
TopologyBuildConfig graphConfig(std::size_t roadMaxPerCluster) {
TopologyBuildConfig cfg;
cfg.reciprocalOnly = true;
cfg.minEdgeScore = 0.5;
cfg.roadMaxPerCluster = roadMaxPerCluster;
return cfg;
}
const std::string kA = "topology.cluster.a0";
const std::string kB = "topology.cluster.b0";
const std::string kC = "topology.cluster.c0";
std::vector<ClusterRoad> roadsFrom(const TopologyArtifactBatch& batch, const std::string& from) {
std::vector<ClusterRoad> out;
for (const auto& road : batch.roads) {
if (road.fromClusterId == from) {
out.push_back(road);
}
}
return out;
}
std::string clusterOf(const TopologyArtifactBatch& batch, const std::string& hash) {
for (const auto& membership : batch.memberships) {
if (membership.documentHash == hash) {
return membership.clusterId;
}
}
return {};
}
} // namespace
TEST_CASE("Roads are off by default and leave the batch untouched",
"[unit][topology][roads][catch2]") {
CHECK(TopologyBuildConfig{}.roadMaxPerCluster == 0);
ConnectedComponentTopologyEngine engine;
auto off = engine.buildArtifacts(threeGraphClusters(), graphConfig(0));
REQUIRE(off.has_value());
CHECK(off.value().roads.empty());
REQUIRE(off.value().clusters.size() == 3);
auto on = engine.buildArtifacts(threeGraphClusters(), graphConfig(4));
REQUIRE(on.has_value());
CHECK_FALSE(on.value().roads.empty());
// Publication stamps differ between two builds; everything else must be identical, and the
// persisted encoding must not know about roads in this PR.
auto normalized = on.value();
normalized.snapshotId = off.value().snapshotId;
normalized.generatedAtUnixSeconds = off.value().generatedAtUnixSeconds;
auto offBytes = yams::topology::serializeTopologyBatchBinary(off.value());
auto onBytes = yams::topology::serializeTopologyBatchBinary(normalized);
REQUIRE(offBytes.has_value());
REQUIRE(onBytes.has_value());
CHECK(offBytes.value() == onBytes.value());
}
TEST_CASE("Connected engine accumulates roads from raw neighbors the construction filter dropped",
"[unit][topology][roads][connected][catch2]") {
ConnectedComponentTopologyEngine engine;
auto result = engine.buildArtifacts(threeGraphClusters(), graphConfig(8));
REQUIRE(result.has_value());
const auto& batch = result.value();
REQUIRE(batch.clusters.size() == 3);
CHECK(clusterOf(batch, "a1") == kA);
CHECK(clusterOf(batch, "b1") == kB);
CHECK(clusterOf(batch, "c1") == kC);
// Every cross-cluster observation was filtered out of the construction graph, so the
// construction relation digest has no cross-cluster pairs, yet the roads do.
for (const auto& cluster : batch.clusters) {
CHECK(cluster.protectedPairCount == cluster.preservedProtectedPairCount);
}
const auto fromA = roadsFrom(batch, kA);
REQUIRE(fromA.size() == 2);
CHECK(fromA[0].toClusterId == kC);
CHECK(fromA[0].weight == Catch::Approx(0.45).margin(1e-6));
CHECK(fromA[1].toClusterId == kB);
CHECK(fromA[1].weight == Catch::Approx(0.30).margin(1e-6));
const auto fromB = roadsFrom(batch, kB);
REQUIRE(fromB.size() == 1);
CHECK(fromB[0].toClusterId == kA);
CHECK(fromB[0].weight == Catch::Approx(0.30).margin(1e-6));
const auto fromC = roadsFrom(batch, kC);
REQUIRE(fromC.size() == 1);
CHECK(fromC[0].toClusterId == kA);
CHECK(fromC[0].weight == Catch::Approx(0.45).margin(1e-6));
// Roads are listed by source cluster, heaviest first; no self roads.
CHECK(std::ranges::is_sorted(batch.roads, [](const ClusterRoad& lhs, const ClusterRoad& rhs) {
if (lhs.fromClusterId != rhs.fromClusterId) {
return lhs.fromClusterId < rhs.fromClusterId;
}
if (lhs.weight != rhs.weight) {
return lhs.weight > rhs.weight;
}
return lhs.toClusterId < rhs.toClusterId;
}));
for (const auto& road : batch.roads) {
CHECK(road.fromClusterId != road.toClusterId);
}
}
TEST_CASE("Connected engine has no road source when every raw neighbor joins its component",
"[unit][topology][roads][connected][catch2]") {
// Mutual-only seeding (every edge reciprocal and above minEdgeScore) puts each raw neighbor
// inside its own component, so nothing crosses a cluster boundary and no road can form. A
// one-way kNN edge, as the daemon writes, is the road source.
auto docs = threeGraphClusters();
for (auto& doc : docs) {
std::erase_if(doc.neighbors, [&](const TopologyNeighbor& n) {
return n.documentHash.front() != doc.documentHash.front();
});
}
ConnectedComponentTopologyEngine engine;
auto mutualOnly = engine.buildArtifacts(docs, graphConfig(8));
REQUIRE(mutualOnly.has_value());
REQUIRE(mutualOnly.value().clusters.size() == 3);
CHECK(mutualOnly.value().roads.empty());
CHECK(mutualOnly.value().roadMaxPerCluster == 0);
link(*find(docs, "a1"), "b0", 0.9F, false);
auto withOneWay = engine.buildArtifacts(docs, graphConfig(8));
REQUIRE(withOneWay.has_value());
REQUIRE(withOneWay.value().clusters.size() == 3);
const auto fromA = roadsFrom(withOneWay.value(), kA);
REQUIRE(fromA.size() == 1);
CHECK(fromA[0].toClusterId == kB);
}
TEST_CASE("Roads keep the top-K per cluster with a deterministic tie-break",
"[unit][topology][roads][catch2]") {
ConnectedComponentTopologyEngine engine;
SECTION("K=1 keeps the heaviest road per cluster") {
auto result = engine.buildArtifacts(threeGraphClusters(), graphConfig(1));
REQUIRE(result.has_value());
const auto& batch = result.value();
const auto fromA = roadsFrom(batch, kA);
REQUIRE(fromA.size() == 1);
CHECK(fromA[0].toClusterId == kC);
// B's only road is to A; truncation on A's side does not remove B's view of it.
const auto fromB = roadsFrom(batch, kB);
REQUIRE(fromB.size() == 1);
CHECK(fromB[0].toClusterId == kA);
CHECK(batch.roads.size() == 3);
}
SECTION("equal weights break ties on the target cluster id") {
auto docs = threeGraphClusters();
// Exactly representable scores so the tie is exact: A-C = 0.25 + 0.25, A-B = 0.5.
for (auto& neighbor : find(docs, "a0")->neighbors) {
if (neighbor.documentHash == "c0") {
neighbor.score = 0.25F;
}
}
for (auto& neighbor : find(docs, "a1")->neighbors) {
if (neighbor.documentHash == "b0") {
neighbor.score = 0.5F;
}
}
auto docsB = docs;
auto result = engine.buildArtifacts(docs, graphConfig(1));
REQUIRE(result.has_value());
const auto fromA = roadsFrom(result.value(), kA);
REQUIRE(fromA.size() == 1);
CHECK(fromA[0].toClusterId == kB);
CHECK(fromA[0].weight == 0.5);
CHECK(roadsFrom(result.value(), kC).front().weight == 0.5);
// Reordering the input documents does not change the kept road.
std::ranges::reverse(docsB);
auto reordered = engine.buildArtifacts(docsB, graphConfig(1));
REQUIRE(reordered.has_value());
CHECK(reordered.value().roads == result.value().roads);
}
}
TEST_CASE("Road weights sum every raw observation between two clusters, in both directions",
"[unit][topology][roads][catch2]") {
auto docs = threeGraphClusters();
// A reciprocal cross pair below the score floor is observed from both endpoints.
link(*find(docs, "b1"), "a0", 0.10F, true);
link(*find(docs, "a0"), "b1", 0.10F, true);
ConnectedComponentTopologyEngine engine;
auto result = engine.buildArtifacts(docs, graphConfig(8));
REQUIRE(result.has_value());
const auto fromA = roadsFrom(result.value(), kA);
REQUIRE(fromA.size() == 2);
// A-B = 0.30 + 0.10 + 0.10 = 0.50 now outranks A-C = 0.45.
CHECK(fromA[0].toClusterId == kB);
CHECK(fromA[0].weight == Catch::Approx(0.50).margin(1e-6));
CHECK(fromA[1].toClusterId == kC);
const auto fromB = roadsFrom(result.value(), kB);
REQUIRE(fromB.size() == 1);
CHECK(fromB[0].weight == Catch::Approx(0.50).margin(1e-6));
}
TEST_CASE("Roads ignore neighbors outside the batch and non-finite scores",
"[unit][topology][roads][catch2]") {
auto docs = threeGraphClusters();
link(*find(docs, "a0"), "zz-not-in-batch", 0.9F, true);
link(*find(docs, "b0"), "c0", std::numeric_limits<float>::quiet_NaN(), true);
link(*find(docs, "b1"), "c1", -0.5F, true);
ConnectedComponentTopologyEngine engine;
auto result = engine.buildArtifacts(docs, graphConfig(8));
REQUIRE(result.has_value());
CHECK(roadsFrom(result.value(), kB).size() == 1);
CHECK(roadsFrom(result.value(), kC).size() == 1);
CHECK(result.value().roads.size() == 4);
}
TEST_CASE("KMeans engine accumulates roads between embedding clusters",
"[unit][topology][roads][kmeans][catch2]") {
// Two blobs; k-means ignores the neighbor graph for assignment, so a raw edge between the
// blobs is purely road evidence.
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("b0", unit2(0.02F, 1.0F)));
docs.push_back(mk("b1", unit2(0.05F, 0.98F)));
link(*find(docs, "a1"), "b0", 0.40F, false);
link(*find(docs, "b1"), "a0", 0.20F, false);
KMeansTopologyEngine engine;
TopologyBuildConfig cfg;
cfg.kmeansK = 2;
auto off = engine.buildArtifacts(docs, cfg);
REQUIRE(off.has_value());
REQUIRE(off.value().clusters.size() == 2);
CHECK(off.value().roads.empty());
cfg.roadMaxPerCluster = 2;
auto on = engine.buildArtifacts(docs, cfg);
REQUIRE(on.has_value());
const auto& batch = on.value();
const auto a = clusterOf(batch, "a0");
const auto b = clusterOf(batch, "b0");
REQUIRE(a != b);
REQUIRE(batch.roads.size() == 2);
const auto fromA = roadsFrom(batch, a);
REQUIRE(fromA.size() == 1);
CHECK(fromA[0].toClusterId == b);
CHECK(fromA[0].weight == Catch::Approx(0.60).margin(1e-6));
const auto fromB = roadsFrom(batch, b);
REQUIRE(fromB.size() == 1);
CHECK(fromB[0].toClusterId == a);
CHECK(fromB[0].weight == Catch::Approx(0.60).margin(1e-6));
}
TEST_CASE("Louvain engine accumulates roads from filtered cross-community edges",
"[unit][topology][roads][louvain][catch2]") {
LouvainTopologyEngine engine;
auto result = engine.buildArtifacts(threeGraphClusters(), graphConfig(8));
REQUIRE(result.has_value());
const auto& batch = result.value();
const auto a = clusterOf(batch, "a0");
const auto b = clusterOf(batch, "b0");
const auto c = clusterOf(batch, "c0");
REQUIRE(a != b);
REQUIRE(a != c);
REQUIRE(b != c);
CHECK(clusterOf(batch, "a1") == a);
const auto fromA = roadsFrom(batch, a);
REQUIRE(fromA.size() == 2);
CHECK(fromA[0].toClusterId == c);
CHECK(fromA[0].weight == Catch::Approx(0.45).margin(1e-6));
CHECK(fromA[1].toClusterId == b);
CHECK(fromA[1].weight == Catch::Approx(0.30).margin(1e-6));
}
TEST_CASE("Routing construction fingerprint is road-free",
"[unit][topology][roads][fingerprint][catch2]") {
ConnectedComponentTopologyEngine engine;
auto off = engine.buildArtifacts(threeGraphClusters(), graphConfig(0));
auto on = engine.buildArtifacts(threeGraphClusters(), graphConfig(8));
REQUIRE(off.has_value());
REQUIRE(on.has_value());
REQUIRE(off.value().roads.empty());
REQUIRE_FALSE(on.value().roads.empty());
const auto offIdentity = yams::search::topologyRoutingConstructionFingerprint(off.value());
CHECK(yams::search::topologyRoutingConstructionFingerprint(on.value()) == offIdentity);
// Editing roads in place must not move the fingerprint either.
auto edited = on.value();
edited.roads.front().weight += 10.0;
edited.roads.push_back(ClusterRoad{.fromClusterId = kB, .toClusterId = kC, .weight = 1.0});
CHECK(yams::search::topologyRoutingConstructionFingerprint(edited) == offIdentity);
// The road fingerprint is the identity that does follow roads.
const auto roadsOff = yams::search::topologyRoadFingerprint(off.value());
const auto roadsOn = yams::search::topologyRoadFingerprint(on.value());
CHECK(roadsOff != roadsOn);
CHECK(yams::search::topologyRoadFingerprint(edited) != roadsOn);
SECTION("the persisted path preserves both identities") {
// Main payload (road-free v1 codec) plus the road sidecar, as the store writes them.
auto mainPayload = yams::topology::serializeTopologyBatchCompressed(on.value());
REQUIRE(mainPayload.has_value());
auto reloaded = yams::topology::deserializeTopologyBatchCompressed(mainPayload.value());
REQUIRE(reloaded.has_value());
REQUIRE(reloaded.value().roads.empty());
CHECK(yams::search::topologyRoutingConstructionFingerprint(reloaded.value()) ==
offIdentity);
CHECK(yams::search::topologyRoadFingerprint(reloaded.value()) == roadsOff);
yams::topology::TopologyRoadSidecar sidecar;
sidecar.snapshotId = on.value().snapshotId;
sidecar.maxPerCluster = on.value().roadMaxPerCluster;
sidecar.roads = on.value().roads;
CHECK(sidecar.maxPerCluster == 8);
auto sidecarPayload = yams::topology::serializeTopologyRoadSidecarCompressed(sidecar);
REQUIRE(sidecarPayload.has_value());
auto decodedSidecar =
yams::topology::deserializeTopologyRoadSidecarCompressed(sidecarPayload.value());
REQUIRE(decodedSidecar.has_value());
CHECK_FALSE(
yams::topology::validateTopologyRoadSidecar(reloaded.value(), decodedSidecar.value())
.has_value());
reloaded.value().roads = std::move(decodedSidecar.value().roads);
reloaded.value().roadMaxPerCluster = decodedSidecar.value().maxPerCluster;
CHECK(reloaded.value().roads == on.value().roads);
CHECK(yams::search::topologyRoutingConstructionFingerprint(reloaded.value()) ==
offIdentity);
CHECK(yams::search::topologyRoadFingerprint(reloaded.value()) == roadsOn);
}
}