yams/tests/benchmarks/topology_ablation_bench.cpp
Trevon 4e97b31fef
Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
chore(warnings): clean up compiler warnings on GCC, Clang and Apple Clang (#279)
* chore(warnings): default-initialize aggregate members left out of brace-init

GCC -Wmissing-field-initializers fired about 1300 times where tests and
src build structs with a short positional brace list. Give the omitted
members an empty default member initializer. Value-initialization from
{} is what aggregate init already did for them, so behavior is
unchanged.

Signed-off-by: trvon <me@trevon.dev>

* chore(warnings): remove or annotate unused parameters, functions and variables

- init_command, s3_signer test, request_handler_lifetime test: comment out
  the unused parameter name.
- video_content_handler: path is only read under YAMS_HAVE_FFPROBE.
- vector_memory_scaling_bench: main() takes no arguments.
- daemon_status_render: delete unused neutralText helper.
- DaemonMetrics: mallocStackLoggingNoCompactEnabled is only called in the
  __APPLE__ block, so define it there.
- SocketServer: einval_streak only matters in the __APPLE__ EINVAL path.
- onnx_plugin_harness test, vector_backend_engine_compare bench,
  work_coordinator test: mark platform-conditional or busy-loop
  variables [[maybe_unused]].

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): fix sign-compare, range-loop, nodiscard and dangling-ref in tests

- search_admission_controller: count started workers in std::uint32_t so
  the comparison with the uint32 cap is same-signed.
- p2p_manager: iterate the invalid-host list by const reference.
- integrity_verifier: discard the nodiscard results the tests do not
  inspect with static_cast<void>.
- post_ingest_queue: findNode/findEdge take std::string_view so no
  temporary std::string is created for the call (GCC
  -Wdangling-reference false positive on the returned reference).
- topology_ablation_bench: the doc comment ended a line with a backslash,
  which continues the comment (-Wcomment); show the env export on its own
  line.

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): drop redundant std::move of a const captured json

The coroutine lambda is not mutable, so the captured optional<json> is
const and std::move(*progressToken) already selected the copy assignment.
Drop the std::move so the code states what actually happens.

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): mark __int128 use in db_integrity_stamp as an extension

-Wpedantic flags __int128. Use a __extension__ typedef, the GCC/Clang
idiom for an intentional extension, instead of the bare keyword.

Signed-off-by: trvon <git@trevon.dev>

* chore(warnings): remove always-true unsigned >= 0 comparisons

GCC -Wtype-limits flagged 51 test assertions of the form
CHECK(unsignedValue >= 0) that can never fail, plus a dead
`totalCount.value() < 0` clause in the graph listByType handler (the
count is std::size_t). In the tests keep the evaluation as a
static_cast<void> so the accessors are still exercised; in the handler
drop the dead clause and keep the range check.

Signed-off-by: trvon <me@trevon.dev>

---------

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

363 lines
14 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Topology ablation bench — isolated parameter sweep over the topology
// subsystem. Exercises the prepared SparseGuidedClusterRouter path and the
// role-score boost application independent of the full search engine,
// daemon, or corpus.
//
// Topology ablation sweeps:
// - Axis 1: router limit (topologyWeakQueryMaxClusters)
// × max docs (topologyWeakQueryMaxDocs)
// - Axis 2: medoid boost × bridge boost
//
// Axis 5 (KG rerank) is skipped in v1; it requires a populated KG
// store. See matrix doc appendix for the follow-up hooks.
//
// Environment:
// YAMS_BENCH_OUTPUT - JSONL output path (default:
// bench_results/topology_ablation.jsonl)
// YAMS_BENCH_TOPO_CLUSTERS - synthetic cluster count (default 32)
// YAMS_BENCH_TOPO_DOCS_PER - members per cluster (default 16)
// YAMS_BENCH_TOPO_BRIDGE_RATIO - bridge fraction per cluster (0..1, default 0.1)
// YAMS_BENCH_RUN_ID - stamped into every record
//
// Run:
// export YAMS_BENCH_OUTPUT=/tmp/topology_ablation.jsonl
// ./build/debug/tests/benchmarks/topology_ablation_bench "[!benchmark][topology-ablation]"
// (plus --allow-running-no-tests)
#define CATCH_CONFIG_MAIN
#include <catch2/catch_session.hpp>
#include <catch2/catch_test_macros.hpp>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <numeric>
#include <optional>
#include <random>
#include <sstream>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include <yams/topology/topology_artifacts.h>
#include <yams/topology/topology_baseline.h>
namespace fs = std::filesystem;
using json = nlohmann::json;
using yams::topology::ClusterArtifact;
using yams::topology::ClusterRepresentative;
using yams::topology::DocumentClusterMembership;
using yams::topology::DocumentTopologyRole;
using yams::topology::SparseGuidedClusterRouter;
using yams::topology::TopologyArtifactBatch;
using yams::topology::TopologyRouteRequest;
namespace {
fs::path resolveOutputPath() {
if (const char* override = std::getenv("YAMS_BENCH_OUTPUT"); override && *override) {
return fs::path(override);
}
return fs::path("bench_results/topology_ablation.jsonl");
}
std::size_t envSize(const char* name, std::size_t fallback) {
if (const char* v = std::getenv(name); v && *v) {
try {
auto parsed = std::stoull(v);
return static_cast<std::size_t>(parsed);
} catch (...) {
return fallback;
}
}
return fallback;
}
double envDouble(const char* name, double fallback) {
if (const char* v = std::getenv(name); v && *v) {
try {
return std::stod(v);
} catch (...) {
return fallback;
}
}
return fallback;
}
std::string makeHash(std::size_t clusterIdx, std::size_t docIdx) {
std::ostringstream oss;
oss << "sha256_" << std::setfill('0') << std::setw(4) << clusterIdx << "_" << std::setw(6)
<< docIdx;
return oss.str();
}
TopologyArtifactBatch synthesizeBatch(std::size_t numClusters, std::size_t docsPerCluster,
double bridgeRatio) {
TopologyArtifactBatch batch;
batch.snapshotId = "ablation_synth";
batch.algorithm = "synthetic_ablation_v1";
batch.generatedAtUnixSeconds =
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count());
batch.topologyEpoch = 1;
batch.clusters.reserve(numClusters);
batch.memberships.reserve(numClusters * docsPerCluster);
std::mt19937 rng(0xA11BA7);
std::uniform_real_distribution<double> persistenceDist(0.3, 1.0);
std::uniform_real_distribution<double> cohesionDist(0.2, 1.0);
for (std::size_t c = 0; c < numClusters; ++c) {
ClusterArtifact cluster;
cluster.clusterId = "cluster_" + std::to_string(c);
cluster.level = 0;
cluster.memberCount = docsPerCluster;
cluster.persistenceScore = persistenceDist(rng);
cluster.cohesionScore = cohesionDist(rng);
cluster.bridgeMass = bridgeRatio;
const auto medoidHash = makeHash(c, 0);
cluster.medoid = ClusterRepresentative{.clusterId = cluster.clusterId,
.documentHash = medoidHash,
.filePath = "/synth/" + medoidHash,
.representativeScore = 1.0};
const auto bridgeCount =
static_cast<std::size_t>(static_cast<double>(docsPerCluster) * bridgeRatio);
for (std::size_t d = 0; d < docsPerCluster; ++d) {
const auto hash = makeHash(c, d);
cluster.memberDocumentHashes.push_back(hash);
DocumentClusterMembership m;
m.documentHash = hash;
m.clusterId = cluster.clusterId;
m.clusterLevel = 0;
m.persistenceScore = cluster.persistenceScore;
m.cohesionScore = cluster.cohesionScore;
m.bridgeScore = (d > 0 && d <= bridgeCount) ? 0.8 : 0.1;
if (d == 0) {
m.role = DocumentTopologyRole::Medoid;
} else if (d <= bridgeCount) {
m.role = DocumentTopologyRole::Bridge;
} else {
m.role = DocumentTopologyRole::Core;
}
batch.memberships.push_back(std::move(m));
}
batch.clusters.push_back(std::move(cluster));
}
return batch;
}
void emitRecord(const fs::path& outputPath, const json& record) {
json enriched = record;
enriched["schema_version"] = "topology_ablation_v1";
if (const char* v = std::getenv("YAMS_BENCH_RUN_ID"); v && *v) {
enriched["run_id"] = v;
}
if (const char* v = std::getenv("YAMS_BENCH_PHASE"); v && *v) {
enriched["run_phase"] = v;
}
if (const char* v = std::getenv("YAMS_BENCH_CASE"); v && *v) {
enriched["run_case"] = v;
}
fs::create_directories(outputPath.parent_path());
std::ofstream ofs(outputPath, std::ios::app);
ofs << enriched.dump() << '\n';
}
struct LatencySummary {
double p50Us{0.0};
double p95Us{0.0};
double p99Us{0.0};
double meanUs{0.0};
std::size_t samples{0};
};
LatencySummary summarizeUs(std::vector<double>& samples) {
LatencySummary s;
s.samples = samples.size();
if (samples.empty()) {
return s;
}
std::sort(samples.begin(), samples.end());
auto pick = [&](double q) {
const auto idx = static_cast<std::size_t>(q * (samples.size() - 1));
return samples[std::min(idx, samples.size() - 1)];
};
s.p50Us = pick(0.50);
s.p95Us = pick(0.95);
s.p99Us = pick(0.99);
s.meanUs =
std::accumulate(samples.begin(), samples.end(), 0.0) / static_cast<double>(samples.size());
return s;
}
} // namespace
// ─────────────────────────────────────────────────────────────────────────────
// Axis 1 — routing depth grid
// ─────────────────────────────────────────────────────────────────────────────
TEST_CASE("[!benchmark][topology-ablation] axis-1 routing depth sweep", "[topology-ablation]") {
const auto numClusters = envSize("YAMS_BENCH_TOPO_CLUSTERS", 32);
const auto docsPerCluster = envSize("YAMS_BENCH_TOPO_DOCS_PER", 16);
const auto bridgeRatio = envDouble("YAMS_BENCH_TOPO_BRIDGE_RATIO", 0.1);
const auto batch = synthesizeBatch(numClusters, docsPerCluster, bridgeRatio);
const auto outputPath = resolveOutputPath();
// Seed with first-cluster medoid hashes so the router has an anchor.
std::vector<std::string> seeds;
for (std::size_t c = 0; c < std::min<std::size_t>(4, numClusters); ++c) {
seeds.push_back(makeHash(c, 0));
}
struct Point {
std::size_t limit;
std::size_t maxDocs;
};
const std::vector<Point> grid = {
{1, 32}, {1, 64}, {1, 128}, {2, 32}, {2, 64}, {2, 128}, {4, 32}, {4, 64}, {4, 128},
};
SparseGuidedClusterRouter router;
const auto routeIndex = SparseGuidedClusterRouter::buildRouteIndex(batch);
constexpr std::size_t kIterations = 200;
for (const auto& point : grid) {
std::vector<double> latencyUs;
latencyUs.reserve(kIterations);
std::size_t totalRoutes = 0;
std::size_t totalRoutedDocs = 0;
for (std::size_t iter = 0; iter < kIterations; ++iter) {
TopologyRouteRequest request;
request.queryText = "synthetic";
request.seedDocumentHashes = seeds;
request.limit = point.limit;
const auto start = std::chrono::steady_clock::now();
auto result = router.route(request, batch, routeIndex);
const auto end = std::chrono::steady_clock::now();
REQUIRE(result.has_value());
latencyUs.push_back(std::chrono::duration<double, std::micro>(end - start).count());
totalRoutes += result.value().size();
for (const auto& r : result.value()) {
totalRoutedDocs += std::min(r.memberCount, point.maxDocs);
}
}
const auto summary = summarizeUs(latencyUs);
json record = {
{"test", "topology_ablation"},
{"axis", "routing_depth"},
{"axis_id", 1},
{"max_clusters", point.limit},
{"max_docs_cap", point.maxDocs},
{"synthetic_clusters", numClusters},
{"synthetic_docs_per_cluster", docsPerCluster},
{"synthetic_bridge_ratio", bridgeRatio},
{"iterations", kIterations},
{"route_latency_us",
{{"p50", summary.p50Us},
{"p95", summary.p95Us},
{"p99", summary.p99Us},
{"mean", summary.meanUs},
{"samples", summary.samples}}},
{"avg_routes_per_call",
static_cast<double>(totalRoutes) / static_cast<double>(kIterations)},
{"avg_routed_docs_per_call",
static_cast<double>(totalRoutedDocs) / static_cast<double>(kIterations)},
};
emitRecord(outputPath, record);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Axis 2 — role-score weight sweep
// ─────────────────────────────────────────────────────────────────────────────
TEST_CASE("[!benchmark][topology-ablation] axis-2 role-score weights", "[topology-ablation]") {
const auto numClusters = envSize("YAMS_BENCH_TOPO_CLUSTERS", 32);
const auto docsPerCluster = envSize("YAMS_BENCH_TOPO_DOCS_PER", 16);
const auto bridgeRatio = envDouble("YAMS_BENCH_TOPO_BRIDGE_RATIO", 0.1);
const auto batch = synthesizeBatch(numClusters, docsPerCluster, bridgeRatio);
const auto outputPath = resolveOutputPath();
// Base score that would come from graphExpansionFtsPenalty * topologyRoutedBaseMultiplier.
constexpr float kBaseScore = 0.50f;
const std::vector<float> medoidGrid = {0.0f, 0.025f, 0.05f, 0.075f, 0.10f};
const std::vector<float> bridgeGrid = {0.0f, 0.015f, 0.03f, 0.045f, 0.06f};
for (const auto medoidBoost : medoidGrid) {
for (const auto bridgeBoost : bridgeGrid) {
double medoidScoreSum = 0.0;
double bridgeScoreSum = 0.0;
double coreScoreSum = 0.0;
std::size_t medoidCount = 0;
std::size_t bridgeCount = 0;
std::size_t coreCount = 0;
float maxScore = 0.0f;
float minScore = 1.0f;
for (const auto& m : batch.memberships) {
float score = kBaseScore;
switch (m.role) {
case DocumentTopologyRole::Medoid:
score = std::clamp(score + medoidBoost, 0.0f, 1.0f);
medoidScoreSum += score;
++medoidCount;
break;
case DocumentTopologyRole::Bridge:
score = std::clamp(score + bridgeBoost, 0.0f, 1.0f);
bridgeScoreSum += score;
++bridgeCount;
break;
case DocumentTopologyRole::Core:
coreScoreSum += score;
++coreCount;
break;
case DocumentTopologyRole::Outlier:
break;
}
maxScore = std::max(maxScore, score);
minScore = std::min(minScore, score);
}
auto safeAvg = [](double sum, std::size_t n) {
return n == 0 ? 0.0 : sum / static_cast<double>(n);
};
const double medoidAvg = safeAvg(medoidScoreSum, medoidCount);
const double bridgeAvg = safeAvg(bridgeScoreSum, bridgeCount);
const double coreAvg = safeAvg(coreScoreSum, coreCount);
json record = {
{"test", "topology_ablation"},
{"axis", "role_score_weights"},
{"axis_id", 2},
{"medoid_boost", medoidBoost},
{"bridge_boost", bridgeBoost},
{"base_score", kBaseScore},
{"counts", {{"medoid", medoidCount}, {"bridge", bridgeCount}, {"core", coreCount}}},
{"avg_score", {{"medoid", medoidAvg}, {"bridge", bridgeAvg}, {"core", coreAvg}}},
{"score_spread",
{{"min", minScore},
{"max", maxScore},
{"medoid_minus_core", medoidAvg - coreAvg},
{"bridge_minus_core", bridgeAvg - coreAvg}}},
};
emitRecord(outputPath, record);
}
}
}