yams/tests/unit/daemon/graph_component_catch2_test.cpp
trvon 1a74d4c73e
fix(graph): make semantic_neighbor reciprocity mean mutual kNN
Every kNN edge was also written in reverse, so each semantic_neighbor
edge looked reciprocal and topology's reciprocalOnly filtering, SGC
smoothing over reciprocal edges, and graph reciprocity metrics filtered
nothing. Write only the edge from a document to each of its k nearest
neighbours; a reverse edge now exists exactly when the pair is mutual.

Semantic maintenance pruned one-way edges on the assumption that edges
were symmetric; with directed kNN edges that would erase every
non-mutual neighbour, so it now removes only self-loops (and no longer
reports a fabricated community size for the scoped pass).

Edge reads truncated by insertion order: getEdgesBidirectional sorted by
id and getEdgesTo had no order, so a LIMIT kept arbitrary or oldest
edges. Both now return strongest first, and the topology extractor reads
a document's own neighbours separately from its incoming edges so a
hub's many incoming edges cannot evict them.

Existing databases keep their mirrored edges until the semantic
neighbour graph is rebuilt.
2026-09-22 15:32:59 -06:00

1135 lines
46 KiB
C++

// Copyright (c) 2025 YAMS Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// GraphComponent test suite (Catch2)
// Covers: Graph lifecycle, document ingestion, maintenance operations
#include <catch2/catch_test_macros.hpp>
#include <yams/daemon/components/EntityGraphService.h>
#include <yams/daemon/components/GraphComponent.h>
#include <yams/metadata/connection_pool.h>
#include <yams/metadata/kg_topology_analysis.h>
#include <yams/metadata/knowledge_graph_store.h>
#include <yams/metadata/metadata_repository.h>
#include <yams/metadata/path_utils.h>
#include "../../common/metadata_test_db.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <memory>
using namespace yams::daemon;
using namespace yams::metadata;
namespace {
// Test fixture helper for GraphComponent tests
struct GraphComponentTestFixture {
GraphComponentTestFixture() {
dbPath = yams::test::migrated_metadata_db_template().clone("yams_graph_component_db_");
testDir = dbPath.parent_path() / dbPath.stem();
std::filesystem::create_directories(testDir);
ConnectionPoolConfig poolConfig;
poolConfig.minConnections = 1;
poolConfig.maxConnections = 2;
pool = std::make_shared<ConnectionPool>(dbPath.string(), poolConfig);
auto poolInitRes = pool->initialize();
REQUIRE(poolInitRes.has_value());
metadataRepo = std::make_shared<MetadataRepository>(
*pool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
// Create KG store using factory function and wire it to metadata repo
KnowledgeGraphStoreConfig kgConfig;
kgConfig.enable_alias_fts = true;
kgConfig.enable_wal = false;
auto kgResult = makeSqliteKnowledgeGraphStore(*pool, kgConfig);
REQUIRE(kgResult.has_value());
kgStore = std::shared_ptr<KnowledgeGraphStore>(std::move(kgResult).value());
metadataRepo->setKnowledgeGraphStore(kgStore);
}
~GraphComponentTestFixture() {
kgStore.reset();
metadataRepo.reset();
pool.reset();
std::error_code ec;
std::filesystem::remove_all(testDir, ec);
yams::test::remove_sqlite_artifacts(dbPath);
}
std::filesystem::path testDir;
std::filesystem::path dbPath;
std::shared_ptr<ConnectionPool> pool;
std::shared_ptr<MetadataRepository> metadataRepo;
std::shared_ptr<KnowledgeGraphStore> kgStore;
};
} // namespace
// =============================================================================
// GraphComponent Lifecycle Tests
// =============================================================================
TEST_CASE("GraphComponent: Initialize and shutdown", "[daemon][graph][lifecycle]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE_FALSE(component.isReady());
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
REQUIRE(component.isReady());
component.shutdown();
REQUIRE_FALSE(component.isReady());
}
TEST_CASE("GraphComponent: Initialize without dependencies", "[daemon][graph][lifecycle]") {
GraphComponentTestFixture fixture;
GraphComponent component(nullptr, nullptr);
auto initResult = component.initialize();
REQUIRE_FALSE(initResult.has_value());
REQUIRE_FALSE(component.isReady());
}
TEST_CASE("GraphComponent: validateGraph reports fragmented semantic topology",
"[daemon][graph][validate]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
std::vector<KGNode> nodes = {
KGNode{
.nodeKey = "blob:hash-a", .label = std::string("hash-a"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-a",
.label = std::string("/tmp/a.txt"),
.type = std::string("document")},
KGNode{
.nodeKey = "blob:hash-b", .label = std::string("hash-b"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-b",
.label = std::string("/tmp/b.txt"),
.type = std::string("document")},
KGNode{
.nodeKey = "blob:hash-c", .label = std::string("hash-c"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-c",
.label = std::string("/tmp/c.txt"),
.type = std::string("document")},
KGNode{
.nodeKey = "blob:hash-d", .label = std::string("hash-d"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-d",
.label = std::string("/tmp/d.txt"),
.type = std::string("document")},
KGNode{
.nodeKey = "blob:hash-e", .label = std::string("hash-e"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-e",
.label = std::string("/tmp/e.txt"),
.type = std::string("document")},
KGNode{
.nodeKey = "blob:hash-f", .label = std::string("hash-f"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-f",
.label = std::string("/tmp/f.txt"),
.type = std::string("document")},
KGNode{
.nodeKey = "blob:hash-g", .label = std::string("hash-g"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-g",
.label = std::string("/tmp/g.txt"),
.type = std::string("document")},
KGNode{
.nodeKey = "blob:hash-h", .label = std::string("hash-h"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:hash-h",
.label = std::string("/tmp/h.txt"),
.type = std::string("document")},
};
auto ids = fixture.kgStore->upsertNodes(nodes);
REQUIRE(ids.has_value());
for (std::size_t i = 0; i < ids.value().size(); i += 2) {
KGEdge hasBlob{.srcNodeId = ids.value()[i + 1],
.dstNodeId = ids.value()[i],
.relation = "has_blob",
.weight = 1.0f};
REQUIRE(fixture.kgStore->addEdge(hasBlob).has_value());
}
auto report = component.validateGraph();
REQUIRE(report.has_value());
CHECK(report.value().topologyDocumentNodes == 8);
CHECK(report.value().topologyDocumentsWithNeighbors == 0);
CHECK(report.value().topologyConnectedComponents == 8);
CHECK(report.value().topologyLargestComponent == 1);
CHECK_FALSE(report.value().issues.empty());
CHECK(std::find(report.value().issues.begin(), report.value().issues.end(),
"semantic topology has zero connected document neighborhoods") !=
report.value().issues.end());
}
TEST_CASE("GraphComponent: validateGraph recognizes semantic neighborhood coverage",
"[daemon][graph][validate]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
std::vector<KGNode> nodes = {
KGNode{.nodeKey = "blob:x", .label = std::string("x"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:x",
.label = std::string("/tmp/x.txt"),
.type = std::string("document")},
KGNode{.nodeKey = "blob:y", .label = std::string("y"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:y",
.label = std::string("/tmp/y.txt"),
.type = std::string("document")},
};
auto ids = fixture.kgStore->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(fixture.kgStore
->addEdgesUnique({KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[0],
.relation = "has_blob",
.weight = 1.0f},
KGEdge{.srcNodeId = ids.value()[3],
.dstNodeId = ids.value()[2],
.relation = "has_blob",
.weight = 1.0f},
KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[3],
.relation = "semantic_neighbor",
.weight = 0.95f},
KGEdge{.srcNodeId = ids.value()[3],
.dstNodeId = ids.value()[1],
.relation = "semantic_neighbor",
.weight = 0.95f}})
.has_value());
auto report = component.validateGraph();
REQUIRE(report.has_value());
CHECK(report.value().topologyDocumentNodes == 2);
CHECK(report.value().topologySemanticEdges == 1);
CHECK(report.value().topologyReciprocalSemanticEdges == 1);
CHECK(report.value().topologyUnreciprocatedSemanticEdges == 0);
CHECK(report.value().topologyDocumentsWithNeighbors == 2);
CHECK(report.value().topologyDocumentsWithReciprocalNeighbors == 2);
CHECK(report.value().topologyReciprocalCommunityCount == 1);
CHECK(report.value().topologyLargestReciprocalCommunity == 2);
CHECK(report.value().topologyConnectedComponents == 1);
CHECK(report.value().topologyLargestComponent == 2);
CHECK(std::find(report.value().issues.begin(), report.value().issues.end(),
"semantic topology has zero connected document neighborhoods") ==
report.value().issues.end());
}
TEST_CASE("GraphComponent: validateGraph flags one-way semantic neighborhoods",
"[daemon][graph][validate]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
std::vector<KGNode> nodes = {
KGNode{.nodeKey = "blob:a", .label = std::string("a"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:a",
.label = std::string("/tmp/a.txt"),
.type = std::string("document")},
KGNode{.nodeKey = "blob:b", .label = std::string("b"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:b",
.label = std::string("/tmp/b.txt"),
.type = std::string("document")},
KGNode{.nodeKey = "blob:c", .label = std::string("c"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:c",
.label = std::string("/tmp/c.txt"),
.type = std::string("document")},
KGNode{.nodeKey = "blob:d", .label = std::string("d"), .type = std::string("blob")},
KGNode{.nodeKey = "doc:d",
.label = std::string("/tmp/d.txt"),
.type = std::string("document")},
};
auto ids = fixture.kgStore->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(fixture.kgStore
->addEdgesUnique({KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[0],
.relation = "has_blob",
.weight = 1.0f},
KGEdge{.srcNodeId = ids.value()[3],
.dstNodeId = ids.value()[2],
.relation = "has_blob",
.weight = 1.0f},
KGEdge{.srcNodeId = ids.value()[5],
.dstNodeId = ids.value()[4],
.relation = "has_blob",
.weight = 1.0f},
KGEdge{.srcNodeId = ids.value()[7],
.dstNodeId = ids.value()[6],
.relation = "has_blob",
.weight = 1.0f},
KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[3],
.relation = "semantic_neighbor",
.weight = 0.95f},
KGEdge{.srcNodeId = ids.value()[3],
.dstNodeId = ids.value()[5],
.relation = "semantic_neighbor",
.weight = 0.94f},
KGEdge{.srcNodeId = ids.value()[5],
.dstNodeId = ids.value()[7],
.relation = "semantic_neighbor",
.weight = 0.93f}})
.has_value());
auto report = component.validateGraph();
REQUIRE(report.has_value());
CHECK(report.value().topologyDocumentNodes == 4);
CHECK(report.value().topologySemanticEdges == 3);
CHECK(report.value().topologyReciprocalSemanticEdges == 0);
CHECK(report.value().topologyUnreciprocatedSemanticEdges == 3);
CHECK(report.value().topologyDocumentsWithNeighbors == 4);
CHECK(report.value().topologyDocumentsWithReciprocalNeighbors == 0);
CHECK(report.value().topologyReciprocalCommunityCount == 0);
CHECK(report.value().topologyLargestReciprocalCommunity == 0);
CHECK(report.value().topologyConnectedComponents == 1);
CHECK(report.value().topologyLargestComponent == 4);
CHECK(std::find(report.value().issues.begin(), report.value().issues.end(),
"semantic topology has no reciprocal document neighborhoods") !=
report.value().issues.end());
}
TEST_CASE("GraphComponent: maintainSemanticTopology keeps one-way kNN edges and drops self-loops",
"[daemon][graph][repair]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
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 = fixture.kgStore->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(fixture.kgStore
->addEdgesUnique({KGEdge{.srcNodeId = ids.value()[0],
.dstNodeId = ids.value()[1],
.relation = "semantic_neighbor",
.weight = 0.95f},
KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[2],
.relation = "semantic_neighbor",
.weight = 0.94f},
KGEdge{.srcNodeId = ids.value()[2],
.dstNodeId = ids.value()[3],
.relation = "semantic_neighbor",
.weight = 0.93f},
// A self-loop is invalid and is removed.
KGEdge{.srcNodeId = ids.value()[3],
.dstNodeId = ids.value()[3],
.relation = "semantic_neighbor",
.weight = 1.0f}})
.has_value());
auto maintenance = component.maintainSemanticTopology(false);
REQUIRE(maintenance.has_value());
// One-way edges are ordinary (non-mutual) kNN edges and are kept.
CHECK(maintenance.value().semanticEdgesPruned == 1);
CHECK(maintenance.value().reciprocalCommunities == 0);
CHECK(maintenance.value().largestReciprocalCommunity == 0);
auto topology = analyzeDocumentTopology(fixture.kgStore.get());
REQUIRE(topology.has_value());
CHECK(topology->semanticEdgeCount == 3);
CHECK(topology->reciprocalCommunityCount == 0);
}
TEST_CASE("GraphComponent: maintainSemanticTopology keeps reciprocal communities intact",
"[daemon][graph][repair]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
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")},
};
auto ids = fixture.kgStore->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(fixture.kgStore
->addEdgesUnique({KGEdge{.srcNodeId = ids.value()[0],
.dstNodeId = ids.value()[1],
.relation = "semantic_neighbor",
.weight = 0.97f},
KGEdge{.srcNodeId = ids.value()[1],
.dstNodeId = ids.value()[0],
.relation = "semantic_neighbor",
.weight = 0.97f}})
.has_value());
auto maintenance = component.maintainSemanticTopology(false);
REQUIRE(maintenance.has_value());
CHECK(maintenance.value().semanticEdgesPruned == 0);
CHECK(maintenance.value().skipped);
CHECK(maintenance.value().reciprocalCommunities == 1);
CHECK(maintenance.value().largestReciprocalCommunity == 2);
auto topology = analyzeDocumentTopology(fixture.kgStore.get());
REQUIRE(topology.has_value());
CHECK(topology->semanticEdgeCount == 1);
CHECK(topology->reciprocalCommunityCount == 1);
}
TEST_CASE("GraphComponent: scoped semantic maintenance keeps one-way kNN edges",
"[daemon][graph][repair]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
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 = fixture.kgStore->upsertNodes(nodes);
REQUIRE(ids.has_value());
REQUIRE(fixture.kgStore
->addEdgesUnique({
// Dirty incident one-way edge: an ordinary kNN edge, kept.
KGEdge{.srcNodeId = ids.value()[0],
.dstNodeId = ids.value()[1],
.relation = "semantic_neighbor",
.weight = 0.95f},
// Non-dirty one-way edge: outside the scoped region, kept.
KGEdge{.srcNodeId = ids.value()[2],
.dstNodeId = ids.value()[3],
.relation = "semantic_neighbor",
.weight = 0.93f},
})
.has_value());
auto maintenance = component.maintainSemanticTopologyForDocuments({"a"}, false);
REQUIRE(maintenance.has_value());
CHECK(maintenance.value().semanticEdgesPruned == 0);
auto aEdges = fixture.kgStore->getEdgesFrom(ids.value()[0], "semantic_neighbor");
REQUIRE(aEdges.has_value());
REQUIRE(aEdges.value().size() == 1);
CHECK(aEdges.value().front().dstNodeId == ids.value()[1]);
auto cEdges = fixture.kgStore->getEdgesFrom(ids.value()[2], "semantic_neighbor");
REQUIRE(cEdges.has_value());
REQUIRE(cEdges.value().size() == 1);
CHECK(cEdges.value().front().dstNodeId == ids.value()[3]);
}
TEST_CASE("GraphComponent: Get query service after init", "[daemon][graph][lifecycle]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.getQueryService() == nullptr);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
auto queryService = component.getQueryService();
REQUIRE(queryService != nullptr);
component.shutdown();
REQUIRE(component.getQueryService() == nullptr);
}
TEST_CASE("GraphComponent: Multiple initializations are idempotent", "[daemon][graph][lifecycle]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto init1 = component.initialize();
REQUIRE(init1.has_value());
REQUIRE(component.isReady());
auto init2 = component.initialize();
REQUIRE(init2.has_value());
REQUIRE(component.isReady());
}
TEST_CASE("GraphComponent: Multiple shutdowns are safe", "[daemon][graph][lifecycle]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
component.shutdown();
REQUIRE_FALSE(component.isReady());
component.shutdown();
REQUIRE_FALSE(component.isReady());
}
// =============================================================================
// GraphComponent Document Ingestion Tests
// =============================================================================
TEST_CASE("GraphComponent: Document ingestion succeeds (stub)", "[daemon][graph][ingestion]") {
// NOTE: onDocumentIngested is currently a stub that accepts context but
// does not create nodes. This test verifies the stub returns success.
// When implementation is added, update this test to verify node creation.
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
GraphComponent::DocumentGraphContext ctx{.documentHash = "abc123",
.filePath = "/test/file.cpp",
.tags = {"code", "cpp"},
.documentDbId = 1};
auto result = component.onDocumentIngested(ctx);
REQUIRE(result.has_value());
// TODO(graph-impl): When onDocumentIngested is fully implemented,
// uncomment these assertions to verify node creation:
// auto docNodeResult = fixture.kgStore->getNodeByKey("doc:abc123");
// REQUIRE(docNodeResult.has_value());
// REQUIRE(docNodeResult.value().has_value());
// auto tagNodeResult = fixture.kgStore->getNodeByKey("tag:code");
// REQUIRE(tagNodeResult.has_value());
// REQUIRE(tagNodeResult.value().has_value());
}
TEST_CASE("GraphComponent: Dedupe predicate detects existing doc entities",
"[daemon][graph][dedupe]") {
GraphComponentTestFixture fixture;
// Insert a document row.
DocumentInfo doc;
doc.fileName = "example.cpp";
doc.filePath = "/tmp/example.cpp";
doc.fileExtension = "cpp";
doc.fileSize = 123;
doc.sha256Hash = "hash-abc";
doc.mimeType = "text/x-c++";
doc.setCreatedTime(1);
doc.setModifiedTime(1);
doc.setIndexedTime(1);
auto docIdRes = fixture.metadataRepo->insertDocument(doc);
REQUIRE(docIdRes.has_value());
// Seed a doc entity.
KGNode sym;
sym.nodeKey = "sym:hash-abc:F";
sym.label = std::string("F");
sym.type = std::string("symbol");
auto symIdRes = fixture.kgStore->upsertNode(sym);
REQUIRE(symIdRes.has_value());
DocEntity de;
de.documentId = docIdRes.value();
de.entityText = "F";
de.nodeId = symIdRes.value();
de.startOffset = 0;
de.endOffset = 1;
de.confidence = 1.0f;
de.extractor = std::string("test");
REQUIRE(fixture.kgStore->addDocEntities({de}).has_value());
CHECK(GraphComponent::shouldSkipEntityExtraction(fixture.kgStore, "hash-abc"));
CHECK_FALSE(GraphComponent::shouldSkipEntityExtraction(fixture.kgStore, "hash-missing"));
CHECK_FALSE(GraphComponent::shouldSkipEntityExtraction(nullptr, "hash-abc"));
}
TEST_CASE("GraphComponent: Document ingestion when not initialized", "[daemon][graph][ingestion]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
GraphComponent::DocumentGraphContext ctx{
.documentHash = "abc123", .filePath = "/test/file.cpp", .documentDbId = 1};
auto result = component.onDocumentIngested(ctx);
REQUIRE_FALSE(result.has_value());
}
// =============================================================================
// GraphComponent Maintenance Operations Tests
// =============================================================================
TEST_CASE("GraphComponent: Repair graph (stub)", "[daemon][graph][maintenance]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
// Insert a document.
const auto filePath = (fixture.testDir / "repo" / "file.cpp").generic_string();
std::filesystem::create_directories(std::filesystem::path(filePath).parent_path());
{
std::ofstream f(filePath);
f << "int main() { return 0; }\n";
}
DocumentInfo doc;
doc.fileName = "file.cpp";
doc.filePath = filePath;
doc.fileExtension = "cpp";
doc.fileSize = 100;
doc.sha256Hash = "repair-hash-abc";
doc.mimeType = "text/x-c++";
doc.setCreatedTime(1);
doc.setModifiedTime(1);
doc.setIndexedTime(1);
auto docIdRes = fixture.metadataRepo->insertDocument(doc);
REQUIRE(docIdRes.has_value());
const auto docId = docIdRes.value();
// Add a tag.
REQUIRE(fixture.metadataRepo
->setMetadata(docId, "tag:alpha", yams::metadata::MetadataValue("alpha"))
.has_value());
// Repair.
auto result = component.repairGraph(false);
REQUIRE(result.has_value());
// Verify nodes.
auto derived = yams::metadata::computePathDerivedValues(filePath);
const std::string normalizedPath = derived.normalizedPath;
const std::string parentPath = derived.pathPrefix;
auto blobNode = fixture.kgStore->getNodeByKey("blob:repair-hash-abc");
REQUIRE(blobNode.has_value());
REQUIRE(blobNode.value().has_value());
auto docNode = fixture.kgStore->getNodeByKey("doc:repair-hash-abc");
REQUIRE(docNode.has_value());
REQUIRE(docNode.value().has_value());
auto fileNode = fixture.kgStore->getNodeByKey("path:file:" + normalizedPath);
REQUIRE(fileNode.has_value());
REQUIRE(fileNode.value().has_value());
auto dirNode = fixture.kgStore->getNodeByKey("path:dir:" + parentPath);
REQUIRE(dirNode.has_value());
REQUIRE(dirNode.value().has_value());
auto tagNode = fixture.kgStore->getNodeByKey("tag:alpha");
REQUIRE(tagNode.has_value());
REQUIRE(tagNode.value().has_value());
// Verify edges.
auto fileToBlob = fixture.kgStore->getEdgesFrom(fileNode.value()->id, "has_version");
REQUIRE(fileToBlob.has_value());
REQUIRE_FALSE(fileToBlob.value().empty());
CHECK(std::any_of(fileToBlob.value().begin(), fileToBlob.value().end(),
[&](const auto& e) { return e.dstNodeId == blobNode.value()->id; }));
auto blobToFile = fixture.kgStore->getEdgesFrom(blobNode.value()->id, "blob_at_path");
REQUIRE(blobToFile.has_value());
REQUIRE_FALSE(blobToFile.value().empty());
CHECK(std::any_of(blobToFile.value().begin(), blobToFile.value().end(),
[&](const auto& e) { return e.dstNodeId == fileNode.value()->id; }));
auto dirToFile = fixture.kgStore->getEdgesFrom(dirNode.value()->id, "contains");
REQUIRE(dirToFile.has_value());
REQUIRE_FALSE(dirToFile.value().empty());
CHECK(std::any_of(dirToFile.value().begin(), dirToFile.value().end(),
[&](const auto& e) { return e.dstNodeId == fileNode.value()->id; }));
auto docToTag = fixture.kgStore->getEdgesFrom(docNode.value()->id, "has_tag");
REQUIRE(docToTag.has_value());
REQUIRE_FALSE(docToTag.value().empty());
CHECK(std::any_of(docToTag.value().begin(), docToTag.value().end(),
[&](const auto& e) { return e.dstNodeId == tagNode.value()->id; }));
auto docToBlob = fixture.kgStore->getEdgesFrom(docNode.value()->id, "has_blob");
REQUIRE(docToBlob.has_value());
REQUIRE_FALSE(docToBlob.value().empty());
CHECK(std::any_of(docToBlob.value().begin(), docToBlob.value().end(),
[&](const auto& e) { return e.dstNodeId == blobNode.value()->id; }));
}
TEST_CASE("GraphComponent: Repair graph dry-run rolls back", "[daemon][graph][maintenance]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
// Insert a document.
const auto filePath = (fixture.testDir / "repo" / "dry" / "file.cpp").generic_string();
std::filesystem::create_directories(std::filesystem::path(filePath).parent_path());
{
std::ofstream f(filePath);
f << "int x = 1;\n";
}
DocumentInfo doc;
doc.fileName = "file.cpp";
doc.filePath = filePath;
doc.fileExtension = "cpp";
doc.fileSize = 10;
doc.sha256Hash = "repair-hash-dry";
doc.mimeType = "text/x-c++";
doc.setCreatedTime(1);
doc.setModifiedTime(1);
doc.setIndexedTime(1);
auto docIdRes = fixture.metadataRepo->insertDocument(doc);
REQUIRE(docIdRes.has_value());
// Repair (dry-run).
auto result = component.repairGraph(true);
REQUIRE(result.has_value());
// Ensure nothing persisted.
auto blobNode = fixture.kgStore->getNodeByKey("blob:repair-hash-dry");
REQUIRE(blobNode.has_value());
CHECK_FALSE(blobNode.value().has_value());
}
TEST_CASE("GraphComponent: Repair graph emits progress during orphan cleanup and batch loop",
"[daemon][graph][maintenance][progress]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
const auto filePath = (fixture.testDir / "repo" / "emit" / "file.cpp").generic_string();
std::filesystem::create_directories(std::filesystem::path(filePath).parent_path());
{
std::ofstream f(filePath);
f << "int y = 2;\n";
}
DocumentInfo doc;
doc.fileName = "file.cpp";
doc.filePath = filePath;
doc.fileExtension = "cpp";
doc.fileSize = 10;
doc.sha256Hash = "repair-hash-emit";
doc.mimeType = "text/x-c++";
doc.setCreatedTime(1);
doc.setModifiedTime(1);
doc.setIndexedTime(1);
auto docIdRes = fixture.metadataRepo->insertDocument(doc);
REQUIRE(docIdRes.has_value());
struct Event {
uint64_t processed;
uint64_t total;
};
std::vector<Event> events;
std::mutex eventsMu;
GraphComponent::RepairProgressFn captureProgress = [&](uint64_t processed, uint64_t total,
const GraphComponent::RepairStats&) {
std::lock_guard<std::mutex> lk(eventsMu);
events.push_back({processed, total});
};
auto result = component.repairGraph(false, captureProgress);
REQUIRE(result.has_value());
REQUIRE_FALSE(events.empty());
CHECK(events.front().total >= 1);
CHECK(events.front().processed == 0);
CHECK(events.back().total >= 1);
std::size_t preLoopEmits = static_cast<std::size_t>(std::count_if(
events.begin(), events.end(), [](const Event& e) { return e.processed == 0; }));
CHECK(preLoopEmits >= 3);
}
TEST_CASE("KnowledgeGraphStore: deleteOrphanedEdges batches multi-thousand row cleanup",
"[daemon][graph][maintenance][batching]") {
GraphComponentTestFixture fixture;
constexpr std::int64_t kOrphanEdges = 2500;
auto seedRes = fixture.pool->withConnection([&](Database& db) -> yams::Result<void> {
auto fkOff = db.execute("PRAGMA foreign_keys = OFF");
if (!fkOff)
return fkOff.error();
auto stmtR = db.prepare("INSERT INTO kg_edges (src_node_id, dst_node_id, relation, weight) "
"VALUES (?, ?, 'orphan_rel', 1.0)");
if (!stmtR)
return stmtR.error();
auto stmt = std::move(stmtR).value();
auto txRes = db.transaction([&]() -> yams::Result<void> {
for (std::int64_t i = 0; i < kOrphanEdges; ++i) {
auto cb = stmt.clearBindings();
if (!cb)
return cb.error();
auto br = stmt.bind(1, 900000 + i);
if (!br)
return br.error();
br = stmt.bind(2, 900000 + i + 1);
if (!br)
return br.error();
auto ex = stmt.execute();
if (!ex)
return ex.error();
auto rr = stmt.reset();
if (!rr)
return rr;
}
return yams::Result<void>();
});
auto fkOn = db.execute("PRAGMA foreign_keys = ON");
if (!fkOn)
return fkOn.error();
return txRes;
});
REQUIRE(seedRes.has_value());
auto deleted = fixture.kgStore->deleteOrphanedEdges();
REQUIRE(deleted.has_value());
CHECK(deleted.value() == kOrphanEdges);
auto secondPass = fixture.kgStore->deleteOrphanedEdges();
REQUIRE(secondPass.has_value());
CHECK(secondPass.value() == 0);
}
TEST_CASE("KnowledgeGraphStore: deleteOrphanedDocEntities batches multi-thousand row cleanup",
"[daemon][graph][maintenance][batching]") {
GraphComponentTestFixture fixture;
constexpr std::int64_t kOrphanRows = 2500;
auto seedRes = fixture.pool->withConnection([&](Database& db) -> yams::Result<void> {
auto fkOff = db.execute("PRAGMA foreign_keys = OFF");
if (!fkOff)
return fkOff.error();
auto stmtR = db.prepare(
"INSERT INTO kg_doc_entities (document_id, entity_text, node_id, confidence, "
"extractor) VALUES (?, 'orphan', NULL, 1.0, 'test')");
if (!stmtR)
return stmtR.error();
auto stmt = std::move(stmtR).value();
auto txRes = db.transaction([&]() -> yams::Result<void> {
for (std::int64_t i = 0; i < kOrphanRows; ++i) {
auto cb = stmt.clearBindings();
if (!cb)
return cb.error();
auto br = stmt.bind(1, 800000 + i);
if (!br)
return br.error();
auto ex = stmt.execute();
if (!ex)
return ex.error();
auto rr = stmt.reset();
if (!rr)
return rr;
}
return yams::Result<void>();
});
auto fkOn = db.execute("PRAGMA foreign_keys = ON");
if (!fkOn)
return fkOn.error();
return txRes;
});
REQUIRE(seedRes.has_value());
auto deleted = fixture.kgStore->deleteOrphanedDocEntities();
REQUIRE(deleted.has_value());
CHECK(deleted.value() == kOrphanRows);
auto secondPass = fixture.kgStore->deleteOrphanedDocEntities();
REQUIRE(secondPass.has_value());
CHECK(secondPass.value() == 0);
}
TEST_CASE("GraphComponent: Validate graph (stub)", "[daemon][graph][maintenance]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
auto result = component.validateGraph();
REQUIRE(result.has_value());
const auto& report = result.value();
REQUIRE(report.totalNodes == 0);
REQUIRE(report.totalEdges == 0);
}
TEST_CASE("GraphComponent: Get entity stats when no entity service", "[daemon][graph][stats]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
auto initResult = component.initialize();
REQUIRE(initResult.has_value());
auto stats = component.getEntityStats();
REQUIRE(stats.jobsAccepted == 0);
REQUIRE(stats.jobsProcessed == 0);
REQUIRE(stats.jobsFailed == 0);
}
// =============================================================================
// KnowledgeGraphStore Query Tests (yams-cqp)
// =============================================================================
TEST_CASE("KnowledgeGraphStore: findNodesByType with pagination", "[daemon][graph][query]") {
GraphComponentTestFixture fixture;
// Insert some test nodes
for (int i = 0; i < 25; i++) {
KGNode node;
node.nodeKey = "fn:test:" + std::to_string(i);
node.label = "TestFunction" + std::to_string(i);
node.type = "function";
auto r = fixture.kgStore->upsertNode(node);
REQUIRE(r.has_value());
}
SECTION("List all nodes of type") {
auto result = fixture.kgStore->findNodesByType("function", 100, 0);
REQUIRE(result.has_value());
REQUIRE(result.value().size() == 25);
}
SECTION("Pagination with limit") {
auto result = fixture.kgStore->findNodesByType("function", 10, 0);
REQUIRE(result.has_value());
REQUIRE(result.value().size() == 10);
}
SECTION("Pagination with offset") {
auto result = fixture.kgStore->findNodesByType("function", 10, 20);
REQUIRE(result.has_value());
REQUIRE(result.value().size() == 5); // Only 5 remaining after offset 20
}
SECTION("Pagination with offset and limit") {
auto first = fixture.kgStore->findNodesByType("function", 5, 0);
auto second = fixture.kgStore->findNodesByType("function", 5, 5);
REQUIRE(first.has_value());
REQUIRE(second.has_value());
REQUIRE(first.value().size() == 5);
REQUIRE(second.value().size() == 5);
// Verify no overlap
for (const auto& n1 : first.value()) {
for (const auto& n2 : second.value()) {
REQUIRE(n1.nodeKey != n2.nodeKey);
}
}
}
SECTION("countNodesByType returns full count") {
auto count = fixture.kgStore->countNodesByType("function");
REQUIRE(count.has_value());
REQUIRE(count.value() == 25);
}
SECTION("Empty result for unknown type") {
auto result = fixture.kgStore->findNodesByType("nonexistent_type", 100, 0);
REQUIRE(result.has_value());
REQUIRE(result.value().empty());
}
}
TEST_CASE("KnowledgeGraphStore: findIsolatedNodes", "[daemon][graph][query]") {
GraphComponentTestFixture fixture;
// Create test nodes: 3 functions, 1 gets called, 2 are isolated
KGNode fn1, fn2, fn3;
fn1.nodeKey = "fn:called";
fn1.label = "CalledFunction";
fn1.type = "function";
fn2.nodeKey = "fn:isolated1";
fn2.label = "IsolatedFunction1";
fn2.type = "function";
fn3.nodeKey = "fn:isolated2";
fn3.label = "IsolatedFunction2";
fn3.type = "function";
auto r1 = fixture.kgStore->upsertNode(fn1);
auto r2 = fixture.kgStore->upsertNode(fn2);
auto r3 = fixture.kgStore->upsertNode(fn3);
REQUIRE(r1.has_value());
REQUIRE(r2.has_value());
REQUIRE(r3.has_value());
// Create a CALLS edge to fn1 (making it not isolated)
KGNode caller;
caller.nodeKey = "fn:caller";
caller.label = "CallerFunction";
caller.type = "function";
auto callerResult = fixture.kgStore->upsertNode(caller);
REQUIRE(callerResult.has_value());
KGEdge callEdge;
callEdge.srcNodeId = callerResult.value();
callEdge.dstNodeId = r1.value();
callEdge.relation = "CALLS";
auto edgeResult = fixture.kgStore->addEdge(callEdge);
REQUIRE(edgeResult.has_value());
SECTION("Find isolated functions (no incoming CALLS)") {
auto result = fixture.kgStore->findIsolatedNodes("function", "CALLS", 100);
REQUIRE(result.has_value());
// Should find fn:isolated1, fn:isolated2, and fn:caller (which has no incoming CALLS)
REQUIRE(result.value().size() == 3);
// fn:called should NOT be in the list (it has an incoming CALLS edge)
for (const auto& node : result.value()) {
REQUIRE(node.nodeKey != "fn:called");
}
}
SECTION("Find isolated with different relation") {
auto result = fixture.kgStore->findIsolatedNodes("function", "IMPORTS", 100);
REQUIRE(result.has_value());
// All 4 functions should be isolated for IMPORTS (no IMPORTS edges exist)
REQUIRE(result.value().size() == 4);
}
}
TEST_CASE("KnowledgeGraphStore: getNodeTypeCounts", "[daemon][graph][query]") {
GraphComponentTestFixture fixture;
// Insert nodes of various types
for (int i = 0; i < 10; i++) {
KGNode node;
node.nodeKey = "fn:func:" + std::to_string(i);
node.label = "Function" + std::to_string(i);
node.type = "function";
REQUIRE(fixture.kgStore->upsertNode(node).has_value());
}
for (int i = 0; i < 5; i++) {
KGNode node;
node.nodeKey = "cls:class:" + std::to_string(i);
node.label = "Class" + std::to_string(i);
node.type = "class";
REQUIRE(fixture.kgStore->upsertNode(node).has_value());
}
for (int i = 0; i < 3; i++) {
KGNode node;
node.nodeKey = "path:file:file:" + std::to_string(i);
node.label = "File" + std::to_string(i);
node.type = "file";
REQUIRE(fixture.kgStore->upsertNode(node).has_value());
}
SECTION("Returns all types with counts") {
auto result = fixture.kgStore->getNodeTypeCounts();
REQUIRE(result.has_value());
REQUIRE(result.value().size() == 3); // function, class, file
}
SECTION("Counts are correct and ordered by count desc") {
auto result = fixture.kgStore->getNodeTypeCounts();
REQUIRE(result.has_value());
const auto& counts = result.value();
// Should be ordered by count descending
REQUIRE(counts[0].first == "function");
REQUIRE(counts[0].second == 10);
REQUIRE(counts[1].first == "class");
REQUIRE(counts[1].second == 5);
REQUIRE(counts[2].first == "file");
REQUIRE(counts[2].second == 3);
}
}
TEST_CASE("KnowledgeGraphStore: getNodeTypeCounts empty", "[daemon][graph][query]") {
// Test empty result with fresh fixture (no nodes inserted)
GraphComponentTestFixture fixture;
auto result = fixture.kgStore->getNodeTypeCounts();
REQUIRE(result.has_value());
REQUIRE(result.value().empty());
}
TEST_CASE("GraphComponent: repairGraph removes orphaned KG nodes after document deletion",
"[daemon][graph][repair][orphan]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
const std::string filePath = (fixture.testDir / "gone.cpp").string();
DocumentInfo doc;
doc.filePath = filePath;
const auto derived = computePathDerivedValues(filePath);
doc.fileName = "gone.cpp";
doc.fileExtension = ".cpp";
doc.fileSize = 1;
doc.sha256Hash = "orphan-hash-gone";
doc.mimeType = "text/plain";
doc.pathPrefix = derived.pathPrefix;
doc.reversePath = derived.reversePath;
doc.pathHash = derived.pathHash;
doc.parentHash = derived.parentHash;
doc.pathDepth = derived.pathDepth;
const auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.createdTime = now;
doc.modifiedTime = now;
doc.indexedTime = now;
auto docId = fixture.metadataRepo->insertDocument(doc);
REQUIRE(docId.has_value());
// KG nodes as the ingest pipeline creates them: doc node + canonical symbol node.
KGNode docNode;
docNode.nodeKey = "doc:orphan-hash-gone";
docNode.type = "document";
docNode.properties = R"({"document_hash":"orphan-hash-gone"})";
REQUIRE(fixture.kgStore->upsertNode(docNode).has_value());
KGNode canonical;
canonical.nodeKey = "function:gone@" + filePath;
canonical.label = "gone";
canonical.type = "function";
canonical.properties =
nlohmann::json{{"qualified_name", "gone"}, {"file_path", filePath}}.dump();
REQUIRE(fixture.kgStore->upsertNode(canonical).has_value());
// Delete the backing document row -> both nodes are now orphaned.
REQUIRE(fixture.metadataRepo->deleteDocument(docId.value()).has_value());
REQUIRE(component.repairGraph(false).has_value());
// FIXED: repairGraph removes orphaned nodes (no WriteCoordinator required).
auto docAfter = fixture.kgStore->getNodeByKey("doc:orphan-hash-gone");
REQUIRE(docAfter.has_value());
CHECK_FALSE(docAfter.value().has_value());
auto canonAfter = fixture.kgStore->getNodeByKey("function:gone@" + filePath);
REQUIRE(canonAfter.has_value());
CHECK_FALSE(canonAfter.value().has_value());
}
TEST_CASE("GraphComponent: repairGraph aborts promptly on cancellation",
"[daemon][graph][repair][cancel]") {
GraphComponentTestFixture fixture;
GraphComponent component(fixture.metadataRepo, fixture.kgStore);
REQUIRE(component.initialize().has_value());
const std::string filePath = (fixture.testDir / "cancel.cpp").string();
DocumentInfo doc;
doc.filePath = filePath;
const auto derived = computePathDerivedValues(filePath);
doc.fileName = "cancel.cpp";
doc.fileExtension = ".cpp";
doc.fileSize = 1;
doc.sha256Hash = "hash-cancel";
doc.mimeType = "text/plain";
doc.pathPrefix = derived.pathPrefix;
doc.reversePath = derived.reversePath;
doc.pathHash = derived.pathHash;
doc.parentHash = derived.parentHash;
doc.pathDepth = derived.pathDepth;
const auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.createdTime = now;
doc.modifiedTime = now;
doc.indexedTime = now;
REQUIRE(fixture.metadataRepo->insertDocument(doc).has_value());
// Pre-cancel: repairGraph must observe the flag and return early rather than running the
// full scan/maintenance to completion (which is what wedged the in-progress guard).
std::atomic<bool> cancel{true};
auto res = component.repairGraph(false, {}, &cancel);
REQUIRE(res.has_value());
bool sawCancel = false;
for (const auto& issue : res.value().issues) {
if (issue.find("canceled") != std::string::npos) {
sawCancel = true;
}
}
CHECK(sawCancel);
}