yams/tests/unit/metadata/kg_store_catch2_test.cpp

376 lines
15 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2025 YAMS Contributors
#include <chrono>
#include <filesystem>
#include <unordered_map>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <yams/metadata/kg_topology_analysis.h>
#include <yams/metadata/knowledge_graph_store.h>
using namespace yams;
using namespace yams::metadata;
using Catch::Approx;
namespace {
std::filesystem::path tempDbPath(const char* prefix) {
const char* t = std::getenv("YAMS_TEST_TMPDIR");
auto base = (t && *t) ? std::filesystem::path(t) : std::filesystem::temp_directory_path();
std::error_code ec;
std::filesystem::create_directories(base, ec);
auto ts = std::chrono::steady_clock::now().time_since_epoch().count();
auto p = base / (std::string(prefix) + std::to_string(ts) + ".db");
std::filesystem::remove(p, ec);
return p;
}
struct KGStoreFixture {
KGStoreFixture() {
dbPath_ = tempDbPath("kg_store_catch2_test_");
KnowledgeGraphStoreConfig cfg{};
auto storeRes = makeSqliteKnowledgeGraphStore(dbPath_.string(), cfg);
REQUIRE(storeRes.has_value());
store_ = std::move(storeRes.value());
REQUIRE(store_ != nullptr);
}
~KGStoreFixture() {
store_.reset();
std::error_code ec;
std::filesystem::remove(dbPath_, ec);
}
std::unique_ptr<KnowledgeGraphStore> store_;
std::filesystem::path dbPath_;
};
} // namespace
TEST_CASE("KG Store: batch upsert nodes is idempotent", "[unit][metadata][kg]") {
KGStoreFixture fix;
std::vector<KGNode> nodes;
nodes.push_back(KGNode{.id = 0,
.nodeKey = "tag:alpha",
.label = std::string("alpha"),
.type = std::string("tag")});
nodes.push_back(KGNode{
.id = 0, .nodeKey = "tag:beta", .label = std::string("beta"), .type = std::string("tag")});
SECTION("First upsert creates nodes") {
auto ids1 = fix.store_->upsertNodes(nodes);
REQUIRE(ids1.has_value());
REQUIRE(ids1.value().size() == 2);
CHECK(ids1.value()[0] > 0);
CHECK(ids1.value()[1] > 0);
SECTION("Second upsert returns same IDs") {
auto ids2 = fix.store_->upsertNodes(nodes);
REQUIRE(ids2.has_value());
REQUIRE(ids2.value().size() == 2);
CHECK(ids1.value()[0] == ids2.value()[0]);
CHECK(ids1.value()[1] == ids2.value()[1]);
}
SECTION("getNodeByKey returns correct node") {
auto n1 = fix.store_->getNodeByKey("tag:alpha");
REQUIRE(n1.has_value());
REQUIRE(n1.value().has_value());
CHECK(n1.value()->id == ids1.value()[0]);
}
}
}
TEST_CASE("KG Store: getNodesByKeys batches stable node-key lookup", "[unit][metadata][kg]") {
KGStoreFixture fix;
std::vector<KGNode> nodes{
KGNode{.id = 0,
.nodeKey = "doc:a",
.label = std::string("a"),
.type = std::string("document")},
KGNode{.id = 0,
.nodeKey = "doc:b",
.label = std::string("b"),
.type = std::string("document")},
};
auto ids = fix.store_->upsertNodes(nodes);
REQUIRE(ids.has_value());
auto out = fix.store_->getNodesByKeys({"doc:b", "doc:missing", "doc:a"});
REQUIRE(out.has_value());
REQUIRE(out.value().size() == 2);
std::unordered_map<std::string, std::int64_t> byKey;
for (const auto& node : out.value()) {
byKey.emplace(node.nodeKey, node.id);
}
CHECK(byKey.at("doc:a") == ids.value()[0]);
CHECK(byKey.at("doc:b") == ids.value()[1]);
}
TEST_CASE("KG Store: addEdgesUnique deduplicates edges", "[unit][metadata][kg]") {
KGStoreFixture fix;
// Prepare two nodes
std::vector<KGNode> nodes;
nodes.push_back(KGNode{
.id = 0, .nodeKey = "doc:a", .label = std::string("a"), .type = std::string("document")});
nodes.push_back(
KGNode{.id = 0, .nodeKey = "tag:x", .label = std::string("x"), .type = std::string("tag")});
auto ids = fix.store_->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(ids.value().size() == 2);
auto src = ids.value()[0];
auto dst = ids.value()[1];
// Prepare duplicate edges
KGEdge e;
e.srcNodeId = src;
e.dstNodeId = dst;
e.relation = "HAS_TAG";
std::vector<KGEdge> edges{e, e, e};
SECTION("Insert twice via unique batch") {
auto r1 = fix.store_->addEdgesUnique(edges);
REQUIRE(r1.has_value());
auto r2 = fix.store_->addEdgesUnique(edges);
REQUIRE(r2.has_value());
// Verify only one edge exists for (src,dst,relation)
auto out = fix.store_->getEdgesFrom(src, std::string_view("HAS_TAG"), 100, 0);
REQUIRE(out.has_value());
size_t count = 0;
for (const auto& ed : out.value()) {
if (ed.dstNodeId == dst && ed.relation == "HAS_TAG")
++count;
}
CHECK(count == 1);
}
}
TEST_CASE("KG Store: semantic edge upsert keeps strongest score and reads strongest first",
"[unit][metadata][kg]") {
KGStoreFixture fix;
std::vector<KGNode> nodes{
KGNode{.id = 0,
.nodeKey = "doc:src",
.label = std::string("src"),
.type = std::string("document")},
KGNode{.id = 0,
.nodeKey = "doc:weak",
.label = std::string("weak"),
.type = std::string("document")},
KGNode{.id = 0,
.nodeKey = "doc:strong",
.label = std::string("strong"),
.type = std::string("document")},
};
auto ids = fix.store_->upsertNodes(nodes);
REQUIRE(ids.has_value());
KGEdge weak;
weak.srcNodeId = ids.value()[0];
weak.dstNodeId = ids.value()[1];
weak.relation = "semantic_neighbor";
weak.weight = 0.2F;
weak.createdTime = 10;
weak.properties = R"({"rank":2})";
KGEdge strong;
strong.srcNodeId = ids.value()[0];
strong.dstNodeId = ids.value()[2];
strong.relation = "semantic_neighbor";
strong.weight = 0.8F;
strong.createdTime = 11;
strong.properties = R"({"rank":1})";
REQUIRE(fix.store_->addEdgesUnique({weak, strong}).has_value());
weak.weight = 0.9F;
weak.createdTime = 12;
weak.properties = R"({"rank":1,"updated":true})";
REQUIRE(fix.store_->addEdgesUnique({weak}).has_value());
auto out = fix.store_->getEdgesFrom(ids.value()[0], "semantic_neighbor", 10, 0);
REQUIRE(out.has_value());
REQUIRE(out.value().size() == 2);
CHECK(out.value()[0].dstNodeId == ids.value()[1]);
CHECK(out.value()[0].weight == Approx(0.9F));
REQUIRE(out.value()[0].properties.has_value());
CHECK(out.value()[0].properties->find("updated") != std::string::npos);
CHECK(out.value()[1].dstNodeId == ids.value()[2]);
}
TEST_CASE("KG Store: ensurePathNode links logical path to snapshot path", "[unit][metadata][kg]") {
KGStoreFixture fix;
PathNodeDescriptor descriptor;
descriptor.snapshotId = "snap1";
descriptor.path = "/repo/file.cpp";
descriptor.rootTreeHash = "";
descriptor.isDirectory = false;
auto snapshotId = fix.store_->ensurePathNode(descriptor);
REQUIRE(snapshotId.has_value());
auto logicalNode = fix.store_->getNodeByKey("path:logical:/repo/file.cpp");
REQUIRE(logicalNode.has_value());
REQUIRE(logicalNode.value().has_value());
auto edges = fix.store_->getEdgesFrom(logicalNode.value()->id, "path_version", 10, 0);
REQUIRE(edges.has_value());
REQUIRE_FALSE(edges.value().empty());
CHECK(edges.value()[0].dstNodeId == snapshotId.value());
}
TEST_CASE("KG Store: ensureDocumentNode is idempotent and refreshes label",
"[unit][metadata][kg]") {
KGStoreFixture fix;
auto first = fix.store_->ensureDocumentNode(
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "/tmp/a.txt");
REQUIRE(first.has_value());
auto second = fix.store_->ensureDocumentNode(
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "/tmp/renamed-a.txt");
REQUIRE(second.has_value());
CHECK(first.value() == second.value());
auto node = fix.store_->getNodeByKey(
"doc:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef");
REQUIRE(node.has_value());
REQUIRE(node.value().has_value());
CHECK(node.value()->type == std::optional<std::string>{"document"});
CHECK(node.value()->label == std::optional<std::string>{"/tmp/renamed-a.txt"});
}
TEST_CASE("KG topology analysis summarizes semantic neighborhoods", "[unit][metadata][kg]") {
KGStoreFixture fix;
std::vector<KGNode> nodes = {
KGNode{.nodeKey = "doc:a", .label = std::string("a"), .type = std::string("document")},
KGNode{.nodeKey = "doc:b", .label = std::string("b"), .type = std::string("document")},
KGNode{.nodeKey = "doc:c", .label = std::string("c"), .type = std::string("document")},
};
auto ids = fix.store_->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(ids.value().size() == 3);
KGEdge ab{.srcNodeId = ids.value()[0],
.dstNodeId = ids.value()[1],
.relation = "semantic_neighbor",
.weight = 0.92f};
KGEdge ba{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[0],
.relation = "semantic_neighbor",
.weight = 0.92f};
REQUIRE(fix.store_->addEdgesUnique({ab, ba}).has_value());
auto topology = analyzeDocumentTopology(fix.store_.get());
REQUIRE(topology.has_value());
CHECK(topology->documentNodeCount == 3);
CHECK(topology->semanticEdgeCount == 1);
CHECK(topology->reciprocalSemanticEdgeCount == 1);
CHECK(topology->unreciprocatedSemanticEdgeCount == 0);
CHECK(topology->documentsWithSemanticNeighbors == 2);
CHECK(topology->documentsWithReciprocalNeighbors == 2);
CHECK(topology->reciprocalCommunityCount == 1);
CHECK(topology->reciprocalSingletonDocumentCount == 1);
CHECK(topology->largestReciprocalCommunitySize == 2);
CHECK(topology->isolatedDocumentCount == 1);
CHECK(topology->connectedComponentCount == 2);
CHECK(topology->largestComponentSize == 2);
CHECK(topology->semanticReciprocity == Approx(1.0));
}
TEST_CASE("KG topology analysis distinguishes one-way semantic neighborhoods",
"[unit][metadata][kg]") {
KGStoreFixture fix;
std::vector<KGNode> nodes = {
KGNode{.nodeKey = "doc:a", .label = std::string("a"), .type = std::string("document")},
KGNode{.nodeKey = "doc:b", .label = std::string("b"), .type = std::string("document")},
KGNode{.nodeKey = "doc:c", .label = std::string("c"), .type = std::string("document")},
KGNode{.nodeKey = "doc:d", .label = std::string("d"), .type = std::string("document")},
};
auto ids = fix.store_->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(ids.value().size() == 4);
REQUIRE(fix.store_
->addEdgesUnique({KGEdge{.srcNodeId = ids.value()[0],
.dstNodeId = ids.value()[1],
.relation = "semantic_neighbor",
.weight = 0.91f},
KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[2],
.relation = "semantic_neighbor",
.weight = 0.90f},
KGEdge{.srcNodeId = ids.value()[2],
.dstNodeId = ids.value()[3],
.relation = "semantic_neighbor",
.weight = 0.89f}})
.has_value());
auto topology = analyzeDocumentTopology(fix.store_.get());
REQUIRE(topology.has_value());
CHECK(topology->documentNodeCount == 4);
CHECK(topology->semanticEdgeCount == 3);
CHECK(topology->reciprocalSemanticEdgeCount == 0);
CHECK(topology->unreciprocatedSemanticEdgeCount == 3);
CHECK(topology->documentsWithSemanticNeighbors == 4);
CHECK(topology->documentsWithReciprocalNeighbors == 0);
CHECK(topology->reciprocalCommunityCount == 0);
CHECK(topology->reciprocalSingletonDocumentCount == 4);
CHECK(topology->largestReciprocalCommunitySize == 0);
CHECK(topology->connectedComponentCount == 1);
CHECK(topology->largestComponentSize == 4);
CHECK(topology->semanticReciprocity == Approx(0.0));
}
TEST_CASE("KG topology analysis summarizes reciprocal communities", "[unit][metadata][kg]") {
KGStoreFixture fix;
std::vector<KGNode> nodes = {
KGNode{.nodeKey = "doc:a", .label = std::string("a"), .type = std::string("document")},
KGNode{.nodeKey = "doc:b", .label = std::string("b"), .type = std::string("document")},
KGNode{.nodeKey = "doc:c", .label = std::string("c"), .type = std::string("document")},
KGNode{.nodeKey = "doc:d", .label = std::string("d"), .type = std::string("document")},
KGNode{.nodeKey = "doc:e", .label = std::string("e"), .type = std::string("document")},
};
auto ids = fix.store_->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(fix.store_
->addEdgesUnique({KGEdge{.srcNodeId = ids.value()[0],
.dstNodeId = ids.value()[1],
.relation = "semantic_neighbor",
.weight = 0.9f},
KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[0],
.relation = "semantic_neighbor",
.weight = 0.9f},
KGEdge{.srcNodeId = ids.value()[2],
.dstNodeId = ids.value()[3],
.relation = "semantic_neighbor",
.weight = 0.88f},
KGEdge{.srcNodeId = ids.value()[3],
.dstNodeId = ids.value()[2],
.relation = "semantic_neighbor",
.weight = 0.88f}})
.has_value());
auto topology = analyzeDocumentTopology(fix.store_.get());
REQUIRE(topology.has_value());
CHECK(topology->reciprocalCommunityCount == 2);
CHECK(topology->largestReciprocalCommunitySize == 2);
CHECK(topology->reciprocalSingletonDocumentCount == 1);
REQUIRE(topology->reciprocalCommunitySizes.size() == 2);
CHECK(topology->reciprocalCommunitySizes[0] == 2);
CHECK(topology->reciprocalCommunitySizes[1] == 2);
}