* feat(topology): typed event time and session for topology inputs Add `eventTime` (epoch seconds, UTC) and `sessionId` to `TopologyDocumentInput`. The extractor fills them from two documented metadata keys: `event_time` (integer epoch seconds, or ISO-8601 `YYYY-MM-DD` / `YYYY-MM-DD[T ]HH:MM[:SS[.frac]][Z|±HH:MM]`, naive datetimes are UTC) takes precedence over the document createdTime; an unparsable value is skipped with a debug log, never an error, and a zero createdTime leaves the field unset. `session_id` is copied verbatim and empty when absent. The keys are only consulted when the extraction loads metadata; otherwise createdTime is the only source. `parseTopologyEventTime` is public for tests and future callers; the calendar math is Hinnant's days_from_civil with range-checked fields. Extraction stats count the event time source and invalid values. Nothing downstream reads the fields yet. Artifacts, codecs and both routing fingerprints are unchanged, which the new test pins. Tests (tests/unit/topology/topology_input_event_time_catch2_test.cpp): parser accept/reject tables, metadata precedence over createdTime, invalid ignored, session_id, includeMetadata=false fallback, and byte-identical snapshot payload plus equal construction and road fingerprints with and without the typed fields. Signed-off-by: trvon <me@trevon.dev> * bench(longmemeval): event_time and session_id metadata, round docs, haystack sidecar Every exported corpus document now carries `metadata.event_time` (ISO-8601, normalized from the LongMemEval `YYYY/MM/DD (Day) HH:MM` haystack date, first-seen date per session) and `metadata.session_id`. Document ids, titles, texts, queries and qrels are unchanged in the default mode. `--round-docs` adds one document per session round (`<sid>__r<n>`): a user turn opens a round and the replies that follow join it; a leading assistant turn is its own round. Round docs share the session's `event_time`/`session_id` and add `round_index`; qrels stay at the session level. `--full-haystack` also writes `haystacks.jsonl`, one line per question with `haystack_session_ids` and the normalized `haystack_dates`, so per-question membership survives the global corpus deduplication (the 2026-10-10 preflight lost it). The converter is split into `convert` / `write_outputs`; `--self-test` runs both on a three-session synthetic fixture without a download and is the regression check for the default output. Signed-off-by: trvon <me@trevon.dev> * bench(retrieval): carry BEIR corpus metadata onto ingested documents `retrieval_quality_bench` ingests the BEIR corpus as a directory, which cannot carry per-file metadata, so `event_time`, `session_id` and `round_index` from corpus.jsonl never reached the topology extractor. `BEIRDocument::metadata` now holds the corpus line's `metadata` object (strings as is, numbers and booleans stringified; both the generic and the SciFact loader read it), and `applyBenchmarkDocumentMetadata` copies it onto the stored documents matched by exact file path through the metadata repository (`ON CONFLICT DO UPDATE`, so warm caches can repeat it). The bench runs it after the ingest wait, for cold and reused corpora alike, and logs how many documents were updated or not found. Tests: loader parses and coerces metadata; apply helper writes the keys, skips documents without metadata, counts missing ones and is idempotent against a real MetadataRepository. The portability allowlist entries for the two touched bench files are re-pointed at their shifted lines; no raw environment read was added. Signed-off-by: trvon <me@trevon.dev> --------- Signed-off-by: trvon <me@trevon.dev>
300 lines
13 KiB
C++
300 lines
13 KiB
C++
// Copyright (c) 2025 YAMS Contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// Typed event time and session on topology inputs. The extractor reads the documented
|
|
// `event_time` metadata key (ISO-8601 date / datetime or integer epoch seconds) ahead of the
|
|
// document's createdTime and `session_id` for session grouping. Nothing downstream consumes
|
|
// the fields yet: artifacts, codecs and fingerprints must not change.
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <yams/config/config_helpers.h>
|
|
#include <yams/metadata/connection_pool.h>
|
|
#include <yams/metadata/knowledge_graph_store.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
#include <yams/metadata/path_utils.h>
|
|
#include <yams/search/topology_routing_session.h>
|
|
#include <yams/topology/topology_artifacts.h>
|
|
#include <yams/topology/topology_baseline.h>
|
|
#include <yams/topology/topology_codec.h>
|
|
#include <yams/topology/topology_input_extractor.h>
|
|
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <filesystem>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
using namespace yams;
|
|
using namespace yams::metadata;
|
|
using namespace yams::topology;
|
|
|
|
namespace {
|
|
|
|
constexpr std::int64_t kCreated = 1600000000; // 2020-09-13T12:26:40Z
|
|
constexpr std::int64_t kMay20 = 1684540800; // 2023-05-20T00:00:00Z
|
|
constexpr std::int64_t kMay20At0221 = kMay20 + 8460; // 2023-05-20T02:21:00Z
|
|
|
|
std::filesystem::path tempDbPath(const char* prefix) {
|
|
const auto configured = yams::config::getenv_optional("YAMS_TEST_TMPDIR");
|
|
auto base = configured && !configured->empty() ? std::filesystem::path(*configured)
|
|
: 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 ExtractorFixture {
|
|
ExtractorFixture() {
|
|
dbPath = tempDbPath("topology_event_time_");
|
|
ConnectionPoolConfig poolConfig;
|
|
poolConfig.minConnections = 1;
|
|
poolConfig.maxConnections = 2;
|
|
pool = std::make_unique<ConnectionPool>(dbPath.string(), poolConfig);
|
|
REQUIRE(pool->initialize().has_value());
|
|
repository = std::make_shared<MetadataRepository>(*pool);
|
|
auto kgResult = makeSqliteKnowledgeGraphStore(*pool, KnowledgeGraphStoreConfig{});
|
|
REQUIRE(kgResult.has_value());
|
|
kgStore = std::shared_ptr<KnowledgeGraphStore>(kgResult.value().release());
|
|
}
|
|
|
|
~ExtractorFixture() {
|
|
kgStore.reset();
|
|
repository.reset();
|
|
pool->shutdown();
|
|
pool.reset();
|
|
std::error_code ec;
|
|
std::filesystem::remove(dbPath, ec);
|
|
}
|
|
|
|
/// Insert a document with the given createdTime (0 = unset) and metadata rows.
|
|
void addDocument(const std::string& hash, std::int64_t createdUnix,
|
|
const std::map<std::string, std::string>& metadata) {
|
|
DocumentInfo info;
|
|
const std::string path = "/repo/" + hash + ".md";
|
|
info.fileName = hash + ".md";
|
|
info.fileExtension = ".md";
|
|
info.fileSize = 42;
|
|
info.sha256Hash = hash;
|
|
info.mimeType = "text/plain";
|
|
if (createdUnix != 0) {
|
|
info.setCreatedTime(createdUnix);
|
|
info.setModifiedTime(createdUnix);
|
|
info.setIndexedTime(createdUnix);
|
|
}
|
|
auto derived = computePathDerivedValues(path);
|
|
info.filePath = derived.normalizedPath;
|
|
info.pathPrefix = derived.pathPrefix;
|
|
info.reversePath = derived.reversePath;
|
|
info.pathHash = derived.pathHash;
|
|
info.parentHash = derived.parentHash;
|
|
info.pathDepth = derived.pathDepth;
|
|
auto id = repository->insertDocument(info);
|
|
REQUIRE(id.has_value());
|
|
for (const auto& [key, value] : metadata) {
|
|
REQUIRE(repository->setMetadata(id.value(), key, MetadataValue(value)).has_value());
|
|
}
|
|
KGNode node;
|
|
node.nodeKey = "doc:" + hash;
|
|
node.label = hash;
|
|
node.type = std::string{"document"};
|
|
REQUIRE(kgStore->upsertNode(node).has_value());
|
|
}
|
|
|
|
std::map<std::string, TopologyDocumentInput> extract(bool includeMetadata,
|
|
TopologyExtractionStats* stats = nullptr) {
|
|
TopologyInputExtractor extractor(repository, kgStore, nullptr);
|
|
TopologyExtractionConfig config;
|
|
config.limit = 32;
|
|
config.includeEmbeddings = false;
|
|
config.includeMetadata = includeMetadata;
|
|
auto extracted = extractor.extract(config, stats);
|
|
REQUIRE(extracted.has_value());
|
|
std::map<std::string, TopologyDocumentInput> byHash;
|
|
for (auto& doc : extracted.value()) {
|
|
byHash[doc.documentHash] = std::move(doc);
|
|
}
|
|
return byHash;
|
|
}
|
|
|
|
std::filesystem::path dbPath;
|
|
std::unique_ptr<ConnectionPool> pool;
|
|
std::shared_ptr<MetadataRepository> repository;
|
|
std::shared_ptr<KnowledgeGraphStore> kgStore;
|
|
};
|
|
|
|
std::vector<TopologyDocumentInput> twoClusterInputs() {
|
|
// Two reciprocal pairs on orthogonal axes plus a weak cross link so roads exist.
|
|
return {
|
|
TopologyDocumentInput{
|
|
.documentHash = "a1",
|
|
.filePath = "/repo/a1.md",
|
|
.embedding = {1.0F, 0.0F},
|
|
.neighbors = {{.documentHash = "a2", .score = 0.9F, .reciprocal = true},
|
|
{.documentHash = "b1", .score = 0.2F}}},
|
|
TopologyDocumentInput{
|
|
.documentHash = "a2",
|
|
.filePath = "/repo/a2.md",
|
|
.embedding = {0.9F, 0.1F},
|
|
.neighbors = {{.documentHash = "a1", .score = 0.9F, .reciprocal = true}}},
|
|
TopologyDocumentInput{
|
|
.documentHash = "b1",
|
|
.filePath = "/repo/b1.md",
|
|
.embedding = {0.0F, 1.0F},
|
|
.neighbors = {{.documentHash = "b2", .score = 0.8F, .reciprocal = true},
|
|
{.documentHash = "a1", .score = 0.2F}}},
|
|
TopologyDocumentInput{
|
|
.documentHash = "b2",
|
|
.filePath = "/repo/b2.md",
|
|
.embedding = {0.1F, 0.9F},
|
|
.neighbors = {{.documentHash = "b1", .score = 0.8F, .reciprocal = true}}},
|
|
};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("event_time parser accepts epoch seconds and ISO-8601 dates and datetimes",
|
|
"[unit][topology][extractor][event-time]") {
|
|
CHECK(parseTopologyEventTime("1684549260") == std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime(" 1684549260\n") == std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("0") == std::optional<std::int64_t>{0});
|
|
CHECK(parseTopologyEventTime("-86400") == std::optional<std::int64_t>{-86400});
|
|
|
|
CHECK(parseTopologyEventTime("2023-05-20") == std::optional<std::int64_t>{kMay20});
|
|
CHECK(parseTopologyEventTime("2023-05-20T02:21") == std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("2023-05-20T02:21:00") ==
|
|
std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("2023-05-20 02:21:00") ==
|
|
std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("2023-05-20T02:21:00Z") ==
|
|
std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("2023-05-20T02:21:00.250Z") ==
|
|
std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("2023-05-20T04:21:00+02:00") ==
|
|
std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("2023-05-19T21:21:00-05:00") ==
|
|
std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("2023-05-20T04:21:00+0200") ==
|
|
std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(parseTopologyEventTime("1970-01-01") == std::optional<std::int64_t>{0});
|
|
CHECK(parseTopologyEventTime("2024-02-29").has_value());
|
|
}
|
|
|
|
TEST_CASE("event_time parser rejects malformed values", "[unit][topology][extractor][event-time]") {
|
|
for (const char* bad :
|
|
{"", " ", "yesterday", "12abc", "2023-13-01", "2023-00-10", "2023-02-30", "2023-02-29",
|
|
"2023-05-20X", "2023-05-20T25:00", "2023-05-20T02:61", "2023-05-20T02:21:00+25:00",
|
|
"2023-05-20T02:21:00Q", "2023/05/20", "1e9", "99999999999999999999"}) {
|
|
INFO("value='" << bad << "'");
|
|
CHECK_FALSE(parseTopologyEventTime(bad).has_value());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Topology extractor derives event time and session from documented metadata keys",
|
|
"[unit][topology][extractor][event-time]") {
|
|
ExtractorFixture fix;
|
|
fix.addDocument("iso", kCreated,
|
|
{{"event_time", "2023-05-20T02:21:00Z"}, {"session_id", "sess-1"}});
|
|
fix.addDocument("epoch", kCreated, {{"event_time", "1684549260"}, {"session_id", "sess-1"}});
|
|
fix.addDocument("date", kCreated, {{"event_time", "2023-05-20"}});
|
|
fix.addDocument("created", kCreated, {{"other", "x"}});
|
|
fix.addDocument("bad", kCreated, {{"event_time", "not a date"}, {"session_id", ""}});
|
|
fix.addDocument("nocreated", 0, {});
|
|
|
|
TopologyExtractionStats stats;
|
|
const auto docs = fix.extract(/*includeMetadata=*/true, &stats);
|
|
REQUIRE(docs.size() == 6U);
|
|
|
|
SECTION("metadata event_time wins over createdTime, in either spelling") {
|
|
CHECK(docs.at("iso").eventTime == std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(docs.at("epoch").eventTime == std::optional<std::int64_t>{kMay20At0221});
|
|
CHECK(docs.at("date").eventTime == std::optional<std::int64_t>{kMay20});
|
|
}
|
|
SECTION("createdTime is the fallback; a zero createdTime leaves it unset") {
|
|
CHECK(docs.at("created").eventTime == std::optional<std::int64_t>{kCreated});
|
|
CHECK_FALSE(docs.at("nocreated").eventTime.has_value());
|
|
}
|
|
SECTION("an unparsable event_time is ignored, not an error") {
|
|
CHECK(docs.at("bad").eventTime == std::optional<std::int64_t>{kCreated});
|
|
CHECK(stats.eventTimesInvalid == 1U);
|
|
}
|
|
SECTION("session_id is copied verbatim and empty when absent") {
|
|
CHECK(docs.at("iso").sessionId == "sess-1");
|
|
CHECK(docs.at("epoch").sessionId == "sess-1");
|
|
CHECK(docs.at("created").sessionId.empty());
|
|
CHECK(docs.at("bad").sessionId.empty());
|
|
}
|
|
SECTION("the raw metadata map still carries the keys") {
|
|
CHECK(docs.at("iso").metadata.at("event_time") == "2023-05-20T02:21:00Z");
|
|
CHECK(docs.at("iso").metadata.at("session_id") == "sess-1");
|
|
}
|
|
SECTION("stats count the sources") {
|
|
CHECK(stats.eventTimesFromMetadata == 3U);
|
|
CHECK(stats.eventTimesFromCreatedTime == 2U);
|
|
CHECK(stats.sessionIdsLoaded == 2U);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Topology extractor falls back to createdTime when metadata is not loaded",
|
|
"[unit][topology][extractor][event-time]") {
|
|
ExtractorFixture fix;
|
|
fix.addDocument("iso", kCreated,
|
|
{{"event_time", "2023-05-20T02:21:00Z"}, {"session_id", "sess-1"}});
|
|
|
|
TopologyExtractionStats stats;
|
|
const auto docs = fix.extract(/*includeMetadata=*/false, &stats);
|
|
REQUIRE(docs.contains("iso"));
|
|
CHECK(docs.at("iso").eventTime == std::optional<std::int64_t>{kCreated});
|
|
CHECK(docs.at("iso").sessionId.empty());
|
|
CHECK(docs.at("iso").metadata.empty());
|
|
CHECK(stats.eventTimesFromMetadata == 0U);
|
|
CHECK(stats.eventTimesFromCreatedTime == 1U);
|
|
}
|
|
|
|
TEST_CASE("Event time and session do not reach artifacts, codecs or fingerprints",
|
|
"[unit][topology][extractor][event-time][codec][fingerprint]") {
|
|
auto plain = twoClusterInputs();
|
|
auto typed = twoClusterInputs();
|
|
std::int64_t t = kMay20;
|
|
for (auto& doc : typed) {
|
|
doc.eventTime = t;
|
|
t += 3600;
|
|
doc.sessionId = doc.documentHash.front() == 'a' ? "sess-a" : "sess-b";
|
|
}
|
|
|
|
TopologyBuildConfig config;
|
|
config.mode = TopologyBuildMode::Exact;
|
|
config.roadMaxPerCluster = 4;
|
|
config.embeddingSpaceIdentity = "test-space";
|
|
|
|
ConnectedComponentTopologyEngine engine;
|
|
auto plainBatch = engine.buildArtifacts(plain, config);
|
|
auto typedBatch = engine.buildArtifacts(typed, config);
|
|
REQUIRE(plainBatch.has_value());
|
|
REQUIRE(typedBatch.has_value());
|
|
REQUIRE(plainBatch.value().clusters.size() == 2U);
|
|
REQUIRE_FALSE(plainBatch.value().roads.empty());
|
|
|
|
// The two builds differ only by wall-clock stamps; align them before comparing bytes.
|
|
typedBatch.value().snapshotId = plainBatch.value().snapshotId;
|
|
typedBatch.value().generatedAtUnixSeconds = plainBatch.value().generatedAtUnixSeconds;
|
|
typedBatch.value().topologyEpoch = plainBatch.value().topologyEpoch;
|
|
|
|
auto plainBinary = serializeTopologyBatchBinary(plainBatch.value());
|
|
auto typedBinary = serializeTopologyBatchBinary(typedBatch.value());
|
|
REQUIRE(plainBinary.has_value());
|
|
REQUIRE(typedBinary.has_value());
|
|
CHECK(plainBinary.value() == typedBinary.value());
|
|
|
|
CHECK(yams::search::topologyRoutingConstructionFingerprint(plainBatch.value()) ==
|
|
yams::search::topologyRoutingConstructionFingerprint(typedBatch.value()));
|
|
CHECK(yams::search::topologyRoadFingerprint(plainBatch.value()) ==
|
|
yams::search::topologyRoadFingerprint(typedBatch.value()));
|
|
CHECK(plainBatch.value().roads == typedBatch.value().roads);
|
|
}
|