diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f76da7c --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"443297b7-74b4-4a29-9baf-9d9ad5fe34a7","pid":13572,"procStart":"134326775626595361","acquiredAt":1788318795346} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1a4dbcd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to EdgeVector + +Contributions are welcome. The bar here is unusual in one specific way: +**every claim must be measured and every property must be enforced by a +test.** A change that makes the code faster needs a number; a change that +touches the query path needs the allocation gate to still pass; a new file +format field needs hostile-input coverage. + +## Build and test + +```sh +make -C tests run # 6 suites, asserts enabled +make -C tests run-release # same under -DNDEBUG +make -C examples run # the self-checking quickstart +make -C tests bench # benchmarks (pass clustered|random|itq|million) +``` + +Requires GCC or Clang with C++17 (MSVC is unsupported: the code uses +`__builtin_popcountll`/`__builtin_prefetch`). Cross/emulated runs use the +Makefile knobs `ARCH`, `CXX`, `RUNNER`; `tests/arm64.Dockerfile` reproduces +the ARM validation locally. + +## The rules the code holds itself to + +- **Zero allocation, zero syscalls, `noexcept` on the query path.** The test + suites replace the global `operator new` and fail on a single allocation in + any search mode. If your change allocates at query time, it will not merge. +- **No aliasing tricks**: bytes cross into wider types via `std::memcpy` + only; on-disk bytes are never `reinterpret_cast` to structs. +- **Every failure is a status value**, never an exception; a failed load + leaves the object empty. The format loaders are fuzzed + (`tests/test_format_fuzz.cpp`) — new format fields need to survive it. +- **Deterministic ordering**: (distance, id) tie-breaks everywhere, so + results are reproducible and brute-force-comparable. +- **`-Wall -Wextra -Werror` everywhere**, including under sanitizers. +- Kernel optimizations must be **bit-identical** to a naive reference and + gated by an equivalence test. + +## CI + +Every PR runs seven jobs: Linux gcc + clang, native arm64 (with an +on-silicon benchmark), Windows MinGW, the quickstart via make + CMake, +ASan+UBSan, and ThreadSanitizer on the concurrency suite. Green CI is +necessary but not sufficient — a reviewer will also ask what you measured. + +## PRs + +Branch from `main`, keep commits explanatory (this repo's history reads as +an engineering narrative — including negative results; a falsified +hypothesis documented honestly is a welcome commit message), and update the +README's numbers only with fresh measurements, stating the hardware. diff --git a/README.md b/README.md index f81f57b..c903db8 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ to link. include(FetchContent) FetchContent_Declare(edgevector GIT_REPOSITORY https://github.com/JonathanKash/EdgeVector.git - GIT_TAG main) # or pin a commit + GIT_TAG v0.8.0) # or a commit hash, or main for latest FetchContent_MakeAvailable(edgevector) target_link_libraries(your_app PRIVATE edgevector::edgevector) ``` @@ -373,7 +373,7 @@ supported). Linux, WSL, or MinGW-w64 on Windows. ```sh cd tests -make run # 5 test suites, asserts enabled +make run # 6 test suites, asserts enabled make run-release # same suites under -DNDEBUG make bench # the benchmarks reported above (-DNDEBUG; pass a scenario # to the binary: ./benchmark_100k clustered|random|itq) @@ -389,7 +389,10 @@ against float32 ground truth, graph persistence round-trips (bitwise-identical results and surviving tombstones after reload), context isolation plus a 4-thread concurrency test, delete/restore/filter composition, slot reclamation (new-vector serving, no-dangling-edge integrity after churn of -100 slots, full entry-point turnover, single-node bootstrap), growable +100 slots, full entry-point turnover, single-node bootstrap), deterministic +fuzzing of all three format loaders (6,000 mutated/truncated/extended files +per run, under ASan in CI: rejected files must leave objects empty, accepted +files must satisfy full structural invariants), growable capacity (2x growth with a relocated block, parallel fill of the new region, stale-context invalidation, persistence at the new capacity), ITQ invariants (orthogonality, exact cosine preservation, monotone objective, determinism, diff --git a/tests/Makefile b/tests/Makefile index 8a1167f..db35f3a 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -53,15 +53,20 @@ ITQ_HEADERS := ../include/edgevector/itq_rotation.hpp \ ../include/edgevector/hnsw_graph.hpp \ ../include/edgevector/quantize_math.hpp +FUZZ_TARGET := test_format_fuzz$(EXE) +FUZZ_SRC := test_format_fuzz.cpp +FUZZ_HEADERS := $(wildcard ../include/edgevector/*.hpp) + RELEASE_TARGETS := test_quantize_math_release$(EXE) \ test_mmap_storage_release$(EXE) \ test_hnsw_graph_release$(EXE) \ test_integration_release$(EXE) \ - test_itq_rotation_release$(EXE) + test_itq_rotation_release$(EXE) \ + test_format_fuzz_release$(EXE) .PHONY: all run release run-release bench clean -all: $(TARGET) $(STORAGE_TARGET) $(HNSW_TARGET) $(INTEG_TARGET) $(ITQ_TARGET) +all: $(TARGET) $(STORAGE_TARGET) $(HNSW_TARGET) $(INTEG_TARGET) $(ITQ_TARGET) $(FUZZ_TARGET) $(TARGET): $(SRC) $(HEADERS) $(CXX) $(CXXFLAGS) $(INCLUDES) $(SRC) -o $(TARGET) @@ -78,13 +83,17 @@ $(INTEG_TARGET): $(INTEG_SRC) $(INTEG_HEADERS) $(ITQ_TARGET): $(ITQ_SRC) $(ITQ_HEADERS) $(CXX) $(CXXFLAGS) $(INCLUDES) $(ITQ_SRC) -o $(ITQ_TARGET) +$(FUZZ_TARGET): $(FUZZ_SRC) $(FUZZ_HEADERS) + $(CXX) $(CXXFLAGS) $(INCLUDES) $(FUZZ_SRC) -o $(FUZZ_TARGET) + # make stops at the first recipe line that exits non-zero, so this fails fast. -run: $(TARGET) $(STORAGE_TARGET) $(HNSW_TARGET) $(INTEG_TARGET) $(ITQ_TARGET) +run: $(TARGET) $(STORAGE_TARGET) $(HNSW_TARGET) $(INTEG_TARGET) $(ITQ_TARGET) $(FUZZ_TARGET) $(RUNNER) ./$(TARGET) $(RUNNER) ./$(STORAGE_TARGET) $(RUNNER) ./$(HNSW_TARGET) $(RUNNER) ./$(INTEG_TARGET) $(RUNNER) ./$(ITQ_TARGET) + $(RUNNER) ./$(FUZZ_TARGET) # --- release (-DNDEBUG) ----------------------------------------------------- @@ -105,12 +114,16 @@ test_integration_release$(EXE): $(INTEG_SRC) $(INTEG_HEADERS) test_itq_rotation_release$(EXE): $(ITQ_SRC) $(ITQ_HEADERS) $(CXX) $(RELEASE_FLAGS) $(INCLUDES) $(ITQ_SRC) -o $@ +test_format_fuzz_release$(EXE): $(FUZZ_SRC) $(FUZZ_HEADERS) + $(CXX) $(RELEASE_FLAGS) $(INCLUDES) $(FUZZ_SRC) -o $@ + run-release: $(RELEASE_TARGETS) $(RUNNER) ./test_quantize_math_release$(EXE) $(RUNNER) ./test_mmap_storage_release$(EXE) $(RUNNER) ./test_hnsw_graph_release$(EXE) $(RUNNER) ./test_integration_release$(EXE) $(RUNNER) ./test_itq_rotation_release$(EXE) + $(RUNNER) ./test_format_fuzz_release$(EXE) # --- benchmark (always -DNDEBUG: assert-enabled numbers are meaningless) ---- @@ -124,4 +137,4 @@ bench: $(BENCH_TARGET) clean: rm -f $(TARGET) $(STORAGE_TARGET) $(HNSW_TARGET) $(INTEG_TARGET) \ - $(ITQ_TARGET) $(RELEASE_TARGETS) $(BENCH_TARGET) + $(ITQ_TARGET) $(FUZZ_TARGET) $(RELEASE_TARGETS) $(BENCH_TARGET) diff --git a/tests/test_format_fuzz.cpp b/tests/test_format_fuzz.cpp new file mode 100644 index 0000000..7d1cac8 --- /dev/null +++ b/tests/test_format_fuzz.cpp @@ -0,0 +1,327 @@ +// ============================================================================ +// Deterministic fuzz test for the three on-disk format loaders. +// +// The loaders (EVEC vectors, EVHG graphs, EVRT rotations) validate hostile +// input, but until now only against hand-written corruptions. This harness +// generates thousands of seeded random mutations of valid files - byte +// flips, random overwrites, truncations, extensions, biased toward both the +// header and the body - and asserts the loader CONTRACT on every one: +// +// 1. Never crash (run under ASan/UBSan in CI, that check has teeth). +// 2. Always return a status. +// 3. A rejected load leaves the object EMPTY (storage closed, graph empty +// and integrity-valid, rotation reset to identity). +// 4. An ACCEPTED load - mutations can hit only padding or be no-ops - +// must yield an object whose own invariants hold (mapped sizes +// consistent, graph passes the full referential-integrity sweep, +// rotation orthogonal), and reading its data must stay in bounds. +// +// Deterministic seed, so any failure is reproducible by iteration number. +// ============================================================================ + +#include "edgevector/edgevector.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +void check(bool ok, const char* case_name) { + if (ok) { + std::printf(" PASS %s\n", case_name); + } else { + std::printf(" FAIL %s\n", case_name); + ++g_failures; + } +} + +const char* const kFuzzFile = "ev_fuzz_input.bin"; + +bool write_bytes(const std::vector& bytes) { + std::FILE* f = std::fopen(kFuzzFile, "wb"); + if (f == nullptr) { + return false; + } + const bool ok = bytes.empty() || + std::fwrite(bytes.data(), 1u, bytes.size(), f) == + bytes.size(); + return (std::fclose(f) == 0) && ok; +} + +// Applies 1..8 random mutations to `bytes`. Mutation sites are biased half +// the time toward the first 64 bytes (the headers) and half toward the body, +// so both the header validation and the record/payload validation get +// exercised deeply. +void mutate(std::vector& bytes, std::mt19937& rng) { + std::uniform_int_distribution n_muts(1, 8); + std::uniform_int_distribution kind(0, 4); + const int rounds = n_muts(rng); + for (int r = 0; r < rounds; ++r) { + if (bytes.empty()) { + bytes.push_back(static_cast(rng())); + continue; + } + std::uniform_int_distribution header_pos( + 0u, bytes.size() < 64u ? bytes.size() - 1u : 63u); + std::uniform_int_distribution any_pos(0u, + bytes.size() - 1u); + const std::size_t pos = + (rng() & 1u) ? header_pos(rng) : any_pos(rng); + switch (kind(rng)) { + case 0: // flip one bit + bytes[pos] ^= static_cast(1u << (rng() % 8u)); + break; + case 1: // random byte + bytes[pos] = static_cast(rng()); + break; + case 2: // truncate to a random shorter length + bytes.resize(any_pos(rng)); + break; + case 3: { // extend with random bytes + std::uniform_int_distribution extra(1u, 64u); + const std::size_t n = extra(rng); + for (std::size_t i = 0; i < n; ++i) { + bytes.push_back(static_cast(rng())); + } + break; + } + case 4: { // overwrite a 4-byte window with a random word + std::uint32_t w = rng(); + const std::size_t span = + (bytes.size() - pos < 4u) ? bytes.size() - pos : 4u; + std::memcpy(bytes.data() + pos, &w, span); + break; + } + } + } +} + +std::vector read_file(const char* path) { + std::vector bytes; + std::FILE* f = std::fopen(path, "rb"); + if (f == nullptr) { + return bytes; + } + std::uint8_t buf[4096]; + std::size_t got = 0; + while ((got = std::fread(buf, 1u, sizeof(buf), f)) > 0u) { + bytes.insert(bytes.end(), buf, buf + got); + } + std::fclose(f); + return bytes; +} + +// --------------------------------------------------------------------------- +// EVEC: the vector-storage format. +// --------------------------------------------------------------------------- +void fuzz_storage(int iterations) { + std::printf("[1] EVEC storage loader (%d mutated files)\n", iterations); + + const std::size_t dim = 64; + const std::uint64_t count = 20; + const std::size_t rb = edgevector::padded_bytes(dim); + std::vector words((rb / 8u) * count, 0u); + auto* base = reinterpret_cast(words.data()); + std::mt19937 rng(101); + for (std::size_t i = 0; i < count * rb; ++i) { + base[i] = static_cast(rng()); + } + // Zero the padding-free layout is irrelevant here: the loader validates + // structure, not bit patterns. + if (edgevector::write_storage_file(kFuzzFile, dim, count, base) != + edgevector::StorageStatus::ok) { + check(false, "reference EVEC file written"); + return; + } + const std::vector valid = read_file(kFuzzFile); + + int accepted = 0; + bool contract_held = true; + for (int it = 0; it < iterations && contract_held; ++it) { + std::vector bytes = valid; + mutate(bytes, rng); + if (!write_bytes(bytes)) { + continue; + } + + edgevector::MMapStorage s; + const edgevector::StorageStatus st = s.open(kFuzzFile); + if (st == edgevector::StorageStatus::ok) { + ++accepted; + // Accepted: the object's own invariants must hold, and reading + // the extremes of the mapping must stay in bounds (ASan-checked). + if (!s.is_open() || + s.record_bytes() != edgevector::padded_bytes( + static_cast(s.dim()))) { + contract_held = false; + } else if (s.count() > 0u) { + volatile std::uint8_t sink = 0; + sink += s.vector(0)[0]; + sink += s.vector(s.count() - 1u)[s.record_bytes() - 1u]; + (void)sink; + } + } else { + if (s.is_open() || s.count() != 0u) { + contract_held = false; // rejected load must leave it closed + } + } + if (!contract_held) { + std::printf(" contract violated at iteration %d\n", it); + } + } + std::printf(" accepted %d of %d mutants (rest rejected cleanly)\n", + accepted, iterations); + check(contract_held, "EVEC loader contract held for every mutant"); +} + +// --------------------------------------------------------------------------- +// EVHG: the graph format. +// --------------------------------------------------------------------------- +void fuzz_graph(int iterations) { + std::printf("[2] EVHG graph loader (%d mutated files)\n", iterations); + + const std::size_t dim = 64; + const std::uint32_t cap = 50; + const std::size_t rb = edgevector::padded_bytes(dim); + std::vector words((rb / 8u) * cap, 0u); + auto* base = reinterpret_cast(words.data()); + std::mt19937 rng(103); + std::normal_distribution gauss(0.0f, 1.0f); + std::vector raw(dim); + for (std::uint32_t i = 0; i < cap; ++i) { + for (std::size_t d = 0; d < dim; ++d) { + raw[d] = gauss(rng); + } + edgevector::quantize(raw.data(), dim, base + i * rb); + } + + edgevector::HNSWGraph builder(base, rb, dim, cap, 8u, 50u, 64u, 7u); + for (std::uint32_t i = 0; i < cap; ++i) { + builder.insert(i); + } + builder.remove(3u); // a tombstone, so the v2 bitmap is non-trivial + if (builder.save_graph(kFuzzFile) != edgevector::GraphIoStatus::ok) { + check(false, "reference EVHG file written"); + return; + } + const std::vector valid = read_file(kFuzzFile); + + int accepted = 0; + bool contract_held = true; + for (int it = 0; it < iterations && contract_held; ++it) { + std::vector bytes = valid; + mutate(bytes, rng); + if (!write_bytes(bytes)) { + continue; + } + + edgevector::HNSWGraph g(base, rb, dim, cap, 8u, 50u, 64u, 7u); + const edgevector::GraphIoStatus st = g.load_graph(kFuzzFile); + if (st == edgevector::GraphIoStatus::ok) { + ++accepted; + // Anything the loader accepts must be a structurally valid graph. + if (!g.validate_integrity()) { + contract_held = false; + } + } else { + // Anything rejected must leave the graph empty (and empty is + // trivially integrity-valid). + if (g.size() != 0u || g.deleted_count() != 0u || + !g.validate_integrity()) { + contract_held = false; + } + } + if (!contract_held) { + std::printf(" contract violated at iteration %d\n", it); + } + } + std::printf(" accepted %d of %d mutants (rest rejected cleanly)\n", + accepted, iterations); + check(contract_held, "EVHG loader contract held for every mutant"); +} + +// --------------------------------------------------------------------------- +// EVRT: the rotation format. +// --------------------------------------------------------------------------- +void fuzz_rotation(int iterations) { + std::printf("[3] EVRT rotation loader (%d mutated files)\n", iterations); + + const std::size_t dim = 32; + std::mt19937 rng(107); + std::normal_distribution gauss(0.0f, 1.0f); + std::vector data(400 * dim); + for (float& v : data) { + v = gauss(rng); + } + edgevector::ItqRotation trained(dim); + if (trained.train(data.data(), 400, dim, 10u, 7u) != + edgevector::ItqStatus::ok || + trained.save(kFuzzFile) != edgevector::ItqStatus::ok) { + check(false, "reference EVRT file written"); + return; + } + const std::vector valid = read_file(kFuzzFile); + + std::vector probe(dim, 1.0f); + std::vector out(dim); + + int accepted = 0; + bool contract_held = true; + for (int it = 0; it < iterations && contract_held; ++it) { + std::vector bytes = valid; + mutate(bytes, rng); + if (!write_bytes(bytes)) { + continue; + } + + edgevector::ItqRotation r(dim); + const edgevector::ItqStatus st = r.load(kFuzzFile); + if (st == edgevector::ItqStatus::ok) { + ++accepted; + // Accepted: must be (numerically) orthogonal. + if (!(r.orthogonality_error() < 1e-2)) { + contract_held = false; + } + } else { + // Rejected: must have reset to the identity. + r.rotate(probe.data(), out.data()); + for (std::size_t d = 0; d < dim; ++d) { + if (out[d] != probe[d]) { + contract_held = false; + break; + } + } + } + if (!contract_held) { + std::printf(" contract violated at iteration %d\n", it); + } + } + std::printf(" accepted %d of %d mutants (rest rejected cleanly)\n", + accepted, iterations); + check(contract_held, "EVRT loader contract held for every mutant"); +} + +} // namespace + +int main() { + std::printf("=== EdgeVector :: format-loader fuzz tests ===\n\n"); + + fuzz_storage(2000); + fuzz_graph(2000); + fuzz_rotation(2000); + std::remove(kFuzzFile); + + std::printf("\n=== %s ===\n", + (g_failures == 0) ? "ALL CASES PASSED" : "FAILURES DETECTED"); + if (g_failures != 0) { + std::printf("%d check(s) failed\n", g_failures); + } + return (g_failures == 0) ? 0 : 1; +}