// SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2025 YAMS Contributors #include #include #include #include #include #include #include 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 store_; std::filesystem::path dbPath_; }; } // namespace TEST_CASE("KG Store: batch upsert nodes is idempotent", "[unit][metadata][kg]") { KGStoreFixture fix; std::vector 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 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 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 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 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 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{"document"}); CHECK(node.value()->label == std::optional{"/tmp/renamed-a.txt"}); } TEST_CASE("KG topology analysis summarizes semantic neighborhoods", "[unit][metadata][kg]") { KGStoreFixture fix; std::vector 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 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 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); }