From 58cfe1d287ca0bd0d5d08d12fa59671978c08e85 Mon Sep 17 00:00:00 2001 From: Torin Etheridge Date: Mon, 5 Oct 2026 11:25:18 -0400 Subject: [PATCH] initial v2 --- .github/workflows/quality.yml | 11 +- .gitignore | 72 +-- CHANGELOG.md | 16 +- MANIFEST.in | 7 - README.md | 28 +- __init__.py | 1 - benchmarks/README.md | 16 +- benchmarks/ann_benchmark.py | 224 ++++++-- benchmarks/graphs/accuracy_vs_speed.png | Bin 60365 -> 0 bytes benchmarks/graphs/insertion_performance.png | Bin 30252 -> 0 bytes benchmarks/graphs/latency_distribution.png | Bin 31948 -> 0 bytes benchmarks/graphs/memory_usage.png | Bin 28003 -> 0 bytes benchmarks/graphs/query_performance.png | Bin 38368 -> 0 bytes benchmarks/results/deletion-1000x64.json | 83 +++ benchmarks/results/frontier-1000x64.json | 82 +++ .../results/insertion-order-1000x64.json | 86 +++ benchmarks/results/smoke-1000x64-seed42.json | 71 --- benchmarks/test_cli_stress.py | 195 ------- benchmarks/test_vector_operations.py | 66 --- docs/advanced_usage.md | 287 ---------- docs/api_reference.md | 182 ------ docs/audit.md | 17 +- docs/design.md | 2 +- docs/limitations.md | 4 +- docs/performance.md | 8 +- examples/example_advanced_features.py | 227 -------- examples/example_binary_store.py | 147 ----- examples/example_inmemory.py | 64 --- mypy.ini | 4 + pyproject.toml | 37 -- pytest.ini | 3 + ruff.toml | 2 + run_tests.py | 74 --- sample_texts.txt | 10 - scripts/run_server.py | 98 ---- .../benchmarks/benchmark_vector_operations.py | 226 -------- .../benchmark_results_20250316_162423.json | 53 -- .../benchmark_results_20250317_215701.json | 78 --- .../benchmark_results_20250317_215756.json | 78 --- .../benchmark_results_20250317_221204.json | 78 --- tests/conftest.py | 30 +- .../integration/test_features_integration.py | 108 ++-- tests/property/test_graph_invariants.py | 57 +- tests/unit/test_collections.py | 78 +-- tests/unit/test_hnsw_index.py | 55 +- tests/unit/test_hnsw_internals.py | 122 ++++ tests/unit/test_hnsw_rigour.py | 8 +- tests/unit/test_metadata_filtering.py | 258 --------- tests/unit/test_numerics.py | 21 + tests/unit/test_vector_store.py | 15 +- vecstream/__init__.py | 22 +- vecstream/binary_store.py | 54 +- vecstream/cli.py | 541 ------------------ vecstream/client.py | 86 --- vecstream/collections.py | 27 +- vecstream/hnsw_index.py | 81 ++- vecstream/index_manager.py | 163 ------ vecstream/persistent_store.py | 79 --- vecstream/query_engine.py | 129 ----- vecstream/server.py | 127 ---- vecstream/vector_store.py | 19 +- 61 files changed, 1041 insertions(+), 3676 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 __init__.py delete mode 100644 benchmarks/graphs/accuracy_vs_speed.png delete mode 100644 benchmarks/graphs/insertion_performance.png delete mode 100644 benchmarks/graphs/latency_distribution.png delete mode 100644 benchmarks/graphs/memory_usage.png delete mode 100644 benchmarks/graphs/query_performance.png create mode 100644 benchmarks/results/deletion-1000x64.json create mode 100644 benchmarks/results/frontier-1000x64.json create mode 100644 benchmarks/results/insertion-order-1000x64.json delete mode 100644 benchmarks/results/smoke-1000x64-seed42.json delete mode 100644 benchmarks/test_cli_stress.py delete mode 100644 benchmarks/test_vector_operations.py delete mode 100644 docs/advanced_usage.md delete mode 100644 docs/api_reference.md delete mode 100644 examples/example_advanced_features.py delete mode 100644 examples/example_binary_store.py delete mode 100644 examples/example_inmemory.py create mode 100644 mypy.ini delete mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 ruff.toml delete mode 100644 run_tests.py delete mode 100644 sample_texts.txt delete mode 100644 scripts/run_server.py delete mode 100644 tests/benchmarks/benchmark_vector_operations.py delete mode 100644 tests/benchmarks/results/benchmark_results_20250316_162423.json delete mode 100644 tests/benchmarks/results/benchmark_results_20250317_215701.json delete mode 100644 tests/benchmarks/results/benchmark_results_20250317_215756.json delete mode 100644 tests/benchmarks/results/benchmark_results_20250317_221204.json create mode 100644 tests/unit/test_hnsw_internals.py delete mode 100644 tests/unit/test_metadata_filtering.py delete mode 100644 vecstream/cli.py delete mode 100644 vecstream/client.py delete mode 100644 vecstream/index_manager.py delete mode 100644 vecstream/persistent_store.py delete mode 100644 vecstream/query_engine.py delete mode 100644 vecstream/server.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 68a7d5d..f3b9e55 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -7,8 +7,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: {python-version: "3.12"} - - run: pip install -e '.[dev,cli]' - - run: ruff check --select E9,F63,F7,F82 vecstream tests benchmarks/ann_benchmark.py - - run: mypy --follow-imports=skip vecstream/hnsw_index.py vecstream/vector_store.py vecstream/binary_store.py + - run: pip install numpy hypothesis mypy pytest ruff + - run: ruff check --select E,F,I,B vecstream tests benchmarks/ann_benchmark.py + - run: ruff format --check vecstream tests benchmarks/ann_benchmark.py + - run: mypy vecstream - run: pytest -q - - run: python benchmarks/ann_benchmark.py --vectors 500 --queries 10 --dim 32 --ef-search 10 20 + - run: python -m benchmarks.ann_benchmark --experiment frontier --vectors 300 --queries 10 --dim 32 --ef-search 10 20 + - run: python -m benchmarks.ann_benchmark --experiment insertion-order --vectors 300 --queries 10 --dim 32 --ef-search 20 --order-seeds 1 2 + - run: python -m benchmarks.ann_benchmark --experiment deletion --vectors 300 --queries 10 --dim 32 --ef-search 20 diff --git a/.gitignore b/.gitignore index 9876d6e..623c70d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,63 +1,49 @@ -# Python +# Python bytecode and native extensions __pycache__/ *.py[cod] -*$py.class *.so + +# Build output .Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -# Virtual Environment -venv/ +# Virtual environments and local configuration .venv/ +venv/ env/ ENV/ .env -# IDE -.idea/ -.vscode/ -*.swp -*.swo -.DS_Store - -# Testing +# Test, type-check, and lint caches .coverage htmlcov/ +.hypothesis/ +.mypy_cache/ .pytest_cache/ +.ruff_cache/ .tox/ -# Project specific -*.vec -*.idx +# Editors and operating systems +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo + +# VecStream local stores *.db -benchmarks/results/ -!benchmarks/results/ -benchmarks/results/* -!benchmarks/results/smoke-1000x64-seed42.json +*.idx +*.vec +.vecstream/ +vector_db/ +test_env/ +test_vectors/ tests/data/ -.vecstream/ -.vscode -vector_db -test_vectors -test_env -.vscode -vecstream/__pycache__/ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Benchmark runs are generated by default. These three measured Stage Two +# experiments are intentional, reviewable repository artifacts. +benchmarks/results/* +!benchmarks/results/frontier-1000x64.json +!benchmarks/results/insertion-order-1000x64.json +!benchmarks/results/deletion-1000x64.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 29538c8..6b569b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,21 @@ All notable changes to VecStream will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added -- Nightly maintenance (#27) +- Exact-search recall oracle and three reproducible ANN experiments. +- Comprehensive HNSW invariant validation and property-based mutation tests. +- Versioned, crash-safe vector checkpoints with explicit corruption errors. -### Fixed -- fix: create PR for changelog updates (#26) +### Changed +- Correct HNSW level generation, diversity selection, reciprocal pruning, and stable float32 normalization. +- Reduced the supported architecture to `Collection`, `BinaryVectorStore`, and `HNSWIndex`. -### Fixed -- Fixed metadata filtering in HNSW search by increasing the number of candidates from 100 to 1000 to ensure enough matches are found after filtering +### Removed +- Legacy manager/query layers, JSON store, client/server, CLI, embedding dependencies, examples, and obsolete benchmark artifacts. ## [0.3.4] - 2024-03-30 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d4129d6..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,7 +0,0 @@ -include LICENSE -include README.md -include requirements.txt -recursive-include docs * -recursive-include vecstream *.py -recursive-exclude * __pycache__ -recursive-exclude * *.py[cod] \ No newline at end of file diff --git a/README.md b/README.md index dc92c06..4a44fd8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The project is deliberately single-machine and small enough that the important a - generation-based local checkpoints without pickle - recovery to the previous complete checkpoint and explicit corruption errors - property tests for randomized graph mutations -- a reproducible `ef_search` recall/latency benchmark +- reproducible frontier, insertion-order, and deletion experiments ## Architecture @@ -50,17 +50,21 @@ This is a committed smoke measurement, not a large-scale performance claim: 1,00 | ef_search | recall@10 | p50 ms | p99 ms | QPS | |---:|---:|---:|---:|---:| -| 10 | 0.758 | 0.538 | 1.092 | 1,731 | -| 20 | 0.910 | 0.826 | 1.552 | 1,136 | -| 40 | 0.988 | 1.241 | 2.298 | 753 | -| 80 | 0.996 | 1.652 | 2.808 | 567 | -| 160 | 1.000 | 1.907 | 2.694 | 505 | +| 10 | 0.768 | 0.522 | 0.666 | 1,902 | +| 20 | 0.908 | 0.746 | 0.857 | 1,327 | +| 40 | 0.990 | 1.106 | 1.189 | 903 | +| 80 | 0.996 | 1.475 | 1.652 | 674 | +| 160 | 1.000 | 1.777 | 1.861 | 562 | -Build time was 7.77 s (129 vectors/s). The result demonstrates the expected frontier: greater search effort recovers more exact neighbors while reducing throughput. Full raw output is in [`benchmarks/results/smoke-1000x64-seed42.json`](benchmarks/results/smoke-1000x64-seed42.json). Results are hardware- and dataset-specific. +Build time was 7.37 s (136 vectors/s). The result demonstrates the expected frontier: greater search effort recovers more exact neighbors while reducing throughput. Full raw output is in [`benchmarks/results/frontier-1000x64.json`](benchmarks/results/frontier-1000x64.json). Results are hardware- and dataset-specific. + +At `ef_search=20`, five deterministic insertion orders produced recall@10 from 0.904 to 0.922 (mean 0.9116, population standard deviation 0.0061). This small experiment demonstrates topology sensitivity; it does not establish statistical significance. After cumulative physical deletion of 20%, recall was 0.916 versus a baseline of 0.908; rebuilding the remaining vectors raised it to 0.954. This dataset did not show monotonic deletion degradation, so none is claimed. The rebuild result still shows that an unrepaired graph and a fresh graph are measurably different. These files record the base Git revision and `working_tree_dirty=true`, because Stage Two measurements were produced before its changes were committed. ```bash -python benchmarks/ann_benchmark.py --vectors 10000 --dim 128 --queries 100 \ - --ef-search 10 20 40 80 160 --seed 42 +python -m benchmarks.ann_benchmark --experiment frontier --vectors 10000 \ + --dim 128 --queries 100 --ef-search 10 20 40 80 160 +python -m benchmarks.ann_benchmark --experiment insertion-order --ef-search 20 +python -m benchmarks.ann_benchmark --experiment deletion --ef-search 20 ``` Use 100,000 vectors and dimensions 128/384/768 for full experiments; those runs are deliberately excluded from CI. @@ -74,12 +78,14 @@ Each mutation writes an immutable `gen-*/` directory containing `manifest.json`, ```bash python -m venv .venv . .venv/bin/activate -pip install -e '.[dev,cli]' -ruff check vecstream tests benchmarks/ann_benchmark.py +python -m pip install numpy hypothesis mypy pytest ruff +ruff check --select E,F,I,B vecstream tests benchmarks/ann_benchmark.py mypy vecstream pytest -q ``` +VecStream is run directly from the repository and is not published as a Python package. + See [HNSW design](docs/design.md), [limitations](docs/limitations.md), [performance notes](docs/performance.md), and the [repository audit](docs/audit.md). ## Scope and limitations diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 0519ecb..0000000 --- a/__init__.py +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/benchmarks/README.md b/benchmarks/README.md index ddbef40..6778fbe 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,12 +1,16 @@ # ANN benchmarks -`ann_benchmark.py` measures VecStream HNSW results against vectorized exact cosine top-k. It reports environment and configuration, build throughput, recall@k, latency percentiles, and QPS while sweeping `ef_search`. +`ann_benchmark.py` measures HNSW against vectorized exact cosine top-k. It provides three deliberately narrow experiments: + +- `frontier`: sweep `ef_search` and report recall/latency. +- `insertion-order`: index one fixed dataset under independently seeded shuffles. +- `deletion`: measure baseline, cumulative 5%/10%/20% physical deletion, and rebuild. ```bash -python benchmarks/ann_benchmark.py --vectors 10000 --dim 128 --queries 100 \ - --ef-search 10 20 40 80 160 --seed 42 --output run.json +python -m benchmarks.ann_benchmark --experiment frontier --vectors 10000 \ + --dim 128 --queries 100 --ef-search 10 20 40 80 160 --output run.json +python -m benchmarks.ann_benchmark --experiment insertion-order --ef-search 20 +python -m benchmarks.ann_benchmark --experiment deletion --ef-search 20 ``` -The committed `results/smoke-1000x64-seed42.json` is a small measured regression artifact. It must not be generalized to other hardware, dimensions, distributions, or dataset sizes. Full 10k/100k experiments are manual because this pure-Python implementation builds too slowly for routine CI at those sizes. - -Historical graph images in this directory predate the exact-recall harness and are retained only as project history; they are not evidence for current performance claims. +Dataset, graph-level, insertion-order, and deletion RNG seeds are separate. Each JSON result records the Git revision, Python/NumPy versions, platform, architecture, configuration, and seeds. The three committed 1,000 × 64 results are small measured examples, not claims about other workloads. Full 10k/100k experiments remain manual. diff --git a/benchmarks/ann_benchmark.py b/benchmarks/ann_benchmark.py index ec98654..1cecdda 100644 --- a/benchmarks/ann_benchmark.py +++ b/benchmarks/ann_benchmark.py @@ -1,8 +1,4 @@ -"""Reproducible exact-vs-HNSW recall/latency benchmark. - -CI example: python benchmarks/ann_benchmark.py --vectors 1000 --queries 25 --dim 64 -Full runs are intentionally opt-in; use --vectors 100000 and desired dimensions. -""" +"""Three reproducible ANN experiments: frontier, insertion order, deletion.""" from __future__ import annotations @@ -10,6 +6,7 @@ import json import platform import statistics +import subprocess import sys import time from pathlib import Path @@ -19,57 +16,180 @@ from vecstream import HNSWIndex, VectorStore -def percentile(values: list[float], p: float) -> float: - return float(np.percentile(np.asarray(values), p)) +def environment() -> dict: + try: + revision = subprocess.run( + ["git", "rev-parse", "HEAD"], capture_output=True, check=True, text=True + ).stdout.strip() + working_tree_dirty = bool( + subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + check=True, + text=True, + ).stdout.strip() + ) + except (OSError, subprocess.CalledProcessError): + revision = "unknown" + working_tree_dirty = None + return { + "vecstream_revision": revision, + "working_tree_dirty": working_tree_dirty, + "platform": platform.platform(), + "architecture": platform.machine(), + "processor": platform.processor(), + "python": sys.version.split()[0], + "numpy": np.__version__, + } + + +def dataset(count: int, dimension: int, queries: int, seed: int) -> tuple[np.ndarray, np.ndarray]: + rng = np.random.default_rng(seed) + return ( + rng.normal(size=(count, dimension)).astype(np.float32), + rng.normal(size=(queries, dimension)).astype(np.float32), + ) + +def exact_truth(vectors: dict[str, np.ndarray], queries: np.ndarray, k: int) -> list[set[str]]: + store = VectorStore() + for item_id, vector in vectors.items(): + store.add_vector(item_id, vector) + return [ + {item_id for item_id, _ in store.exact_search(query, k=k, threshold=-1.0)} + for query in queries + ] -def run(args: argparse.Namespace) -> dict: - rng = np.random.default_rng(args.seed) - vectors = rng.normal(size=(args.vectors, args.dim)).astype(np.float32) - queries = rng.normal(size=(args.queries, args.dim)).astype(np.float32) - exact = VectorStore() - index = HNSWIndex(args.dim, M=args.M, ef_construction=args.ef_construction, seed=args.seed) + +def evaluate( + index: HNSWIndex, + queries: np.ndarray, + truth: list[set[str]], + k: int, + ef_search: int, + warmups: int, +) -> dict: + for query in queries[:warmups]: + index.search(query, k=k, ef_search=ef_search) + latencies: list[float] = [] + recalls: list[float] = [] + for query, expected in zip(queries, truth, strict=True): + started = time.perf_counter_ns() + actual = {item_id for item_id, _ in index.search(query, k=k, ef_search=ef_search)} + latencies.append((time.perf_counter_ns() - started) / 1e6) + recalls.append(len(expected & actual) / min(k, len(expected))) + return { + f"recall@{k}": statistics.mean(recalls), + "p50_ms": float(np.percentile(latencies, 50)), + "p99_ms": float(np.percentile(latencies, 99)), + "qps": 1000.0 / statistics.mean(latencies), + } + + +def build( + vectors: np.ndarray, order: np.ndarray, args: argparse.Namespace, graph_seed: int +) -> tuple[HNSWIndex, float]: + index = HNSWIndex(args.dim, M=args.M, ef_construction=args.ef_construction, seed=graph_seed) started = time.perf_counter() - for i, vector in enumerate(vectors): - item_id = str(i) - exact.add_vector(item_id, vector) - index.add_item(item_id, vector) - build_seconds = time.perf_counter() - started + for position in order: + index.add_item(str(int(position)), vectors[position]) + elapsed = time.perf_counter() - started index.validate() - truth = [{item_id for item_id, _ in exact.exact_search(q, k=args.k, threshold=-1.0)} for q in queries] + return index, elapsed + + +def frontier(args: argparse.Namespace, vectors: np.ndarray, queries: np.ndarray) -> dict: + live = {str(i): vector for i, vector in enumerate(vectors)} + truth = exact_truth(live, queries, args.k) + index, build_seconds = build(vectors, np.arange(len(vectors)), args, args.graph_seed) + return { + "build": {"seconds": build_seconds, "vectors_per_second": len(vectors) / build_seconds}, + "search": [ + {"ef_search": ef, **evaluate(index, queries, truth, args.k, ef, args.warmups)} + for ef in args.ef_search + ], + } + + +def insertion_order(args: argparse.Namespace, vectors: np.ndarray, queries: np.ndarray) -> dict: + truth = exact_truth({str(i): vector for i, vector in enumerate(vectors)}, queries, args.k) rows = [] - for ef in args.ef_search: - for query in queries[: args.warmups]: - index.search(query, k=args.k, ef_search=ef) - latencies, recalls = [], [] - for query, expected in zip(queries, truth): - started = time.perf_counter_ns() - actual = {item_id for item_id, _ in index.search(query, k=args.k, ef_search=ef)} - latencies.append((time.perf_counter_ns() - started) / 1e6) - recalls.append(len(expected & actual) / args.k) - rows.append({ - "ef_search": ef, - f"recall@{args.k}": statistics.mean(recalls), - "p50_ms": percentile(latencies, 50), - "p95_ms": percentile(latencies, 95), - "p99_ms": percentile(latencies, 99), - "qps": 1000.0 / statistics.mean(latencies), - }) + ef = args.ef_search[0] + for order_seed in args.order_seeds: + order = np.random.default_rng(order_seed).permutation(len(vectors)) + index, build_seconds = build(vectors, order, args, args.graph_seed) + rows.append( + { + "insertion_order_seed": order_seed, + "build_seconds": build_seconds, + **evaluate(index, queries, truth, args.k, ef, args.warmups), + } + ) + recalls = [row[f"recall@{args.k}"] for row in rows] return { - "environment": {"platform": platform.platform(), "processor": platform.processor(), - "python": sys.version.split()[0], "numpy": np.__version__}, - "configuration": { - key: (str(value) if isinstance(value, Path) else value) - for key, value in vars(args).items() - if key != "output" + "ef_search": ef, + "runs": rows, + "summary": { + "mean_recall": statistics.mean(recalls), + "recall_stddev": statistics.pstdev(recalls), + "min_recall": min(recalls), + "max_recall": max(recalls), }, - "build": {"seconds": build_seconds, "vectors_per_second": args.vectors / build_seconds}, - "search": rows, + } + + +def deletion(args: argparse.Namespace, vectors: np.ndarray, queries: np.ndarray) -> dict: + index, build_seconds = build(vectors, np.arange(len(vectors)), args, args.graph_seed) + live = {str(i): vector for i, vector in enumerate(vectors)} + deletion_order = np.random.default_rng(args.deletion_seed).permutation(len(vectors)) + rows = [] + prior_target = 0 + for fraction in [0.0, 0.05, 0.10, 0.20]: + target = int(len(vectors) * fraction) + for position in deletion_order[prior_target:target]: + item_id = str(int(position)) + index.remove_item(item_id) + del live[item_id] + prior_target = target + index.validate() + truth = exact_truth(live, queries, args.k) + rows.append( + { + "deleted_fraction": fraction, + "remaining_vectors": len(live), + **evaluate(index, queries, truth, args.k, args.ef_search[0], args.warmups), + } + ) + rebuilt = HNSWIndex( + args.dim, M=args.M, ef_construction=args.ef_construction, seed=args.graph_seed + ) + started = time.perf_counter() + for item_id, vector in live.items(): + rebuilt.add_item(item_id, vector) + rebuild_seconds = time.perf_counter() - started + rebuilt.validate() + truth = exact_truth(live, queries, args.k) + rows.append( + { + "deleted_fraction": 0.20, + "remaining_vectors": len(live), + "state": "rebuilt", + **evaluate(rebuilt, queries, truth, args.k, args.ef_search[0], args.warmups), + } + ) + return { + "initial_build_seconds": build_seconds, + "rebuild_seconds": rebuild_seconds, + "ef_search": args.ef_search[0], + "stages": rows, } def main() -> None: parser = argparse.ArgumentParser() + parser.add_argument( + "--experiment", choices=["frontier", "insertion-order", "deletion"], default="frontier" + ) parser.add_argument("--vectors", type=int, default=10_000) parser.add_argument("--queries", type=int, default=100) parser.add_argument("--dim", type=int, default=128) @@ -78,10 +198,22 @@ def main() -> None: parser.add_argument("--ef-construction", type=int, default=100) parser.add_argument("--ef-search", type=int, nargs="+", default=[10, 20, 40, 80, 160]) parser.add_argument("--warmups", type=int, default=5) - parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--dataset-seed", type=int, default=42) + parser.add_argument("--graph-seed", type=int, default=43) + parser.add_argument("--order-seeds", type=int, nargs="+", default=[1, 2, 3, 4, 5]) + parser.add_argument("--deletion-seed", type=int, default=44) parser.add_argument("--output", type=Path) args = parser.parse_args() - result = run(args) + vectors, queries = dataset(args.vectors, args.dim, args.queries, args.dataset_seed) + runner = {"frontier": frontier, "insertion-order": insertion_order, "deletion": deletion}[ + args.experiment + ] + result = { + "environment": environment(), + "experiment": args.experiment, + "configuration": {key: value for key, value in vars(args).items() if key != "output"}, + "results": runner(args, vectors, queries), + } rendered = json.dumps(result, indent=2) print(rendered) if args.output: diff --git a/benchmarks/graphs/accuracy_vs_speed.png b/benchmarks/graphs/accuracy_vs_speed.png deleted file mode 100644 index 979192601b145c9dbf334c4c1d28d44c738d6265..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60365 zcmbrm1z48b{w<12ktMcb3+NJ*MnpuUEL2K5C6>~lfTT3bzy>6x4LYS8R8$(25NVW> zR#N(o`GI@y^WW#(`#ksXD15-j`_6aH`KvL;Z{C-^cz!+YZdxiTs`cU*&dO6!QMXW0 z{h|8jYW(CzDiagFoUjs8wNfzCx3ax#p+_Zs*~EWo~(boBP_o-oa&NVZiNUp=yDvtTDf!W=Tc0@iO_pB3d-ch>B`JK>X|}#T&tc zt+rNl6AK@H8t~`Wh`Y+3Hhj*1>IBP{z?=L2&ZWy$S@quH6e}&;j%}+>o$`q_XTAAd zjGD*w!e-A%uT3YOQ}?Yn-Cs50F_+Ttbk(s}rB}VC27>pt28>h)&6b*14IWBuPu}Tv z`A-aj^3k$Au5rchZ&=nDX02TQreoF7mfycH|6}*?@An*EvEKIg>tI(m!QZbho-CsO z{rb?euN#(MyKenxPP6>_^_u_7%XfHA3^bHqNj99Hnb1F9s$!P+(~V>M!>SNL0~)OP^&r|xMxT<+# zzQa`ir}K`qRtv>2_oKb`B2hqXj#-G@?Zpex2dY z`*E|VIqh1razm1~e^gX2Cu765+js8{j8%(Pr(Jz_Z}aHXRKq}0p{YC@6_x8-v+2=p z`|r>1DyHa)3orcYq+P$h9v9{_`OM)o-R-ZQcO%(;{QG5_3XRgCbUi7lNAaKEx{MrX zXu(jiyrb`)95|nbQE+M(Xvm1pr;&m4=zMre&wi;PQPVptEUb|y<$F_lT)}kDo)AIX zixHCk8Qj{Kpqn>uD(2f;Yv$eHskUS~c(AOtRzW##A|r4v@h=t@mU2d(h4~SeIIF%I zHNSSQpvuUuuCCK^{1|le_3t0|&v!B|%GSp!rC%;u?aQk0p}_R}$LGeik&hXi#^vTG zo6Ybfl?_+dtXVTUF_9l}&C=moUw5~)WHGB@R?li#pKS}Gaeo|sf0KHx;)}CutOq_z zZxs6Bis5?>Wl8#T^}XMrt?0|9L{`evbJN5~yJ)S3-TnLb?K>ayE51Cp9@8GDk!{gm zALqY;{sa?iufVW7gD~5}_b;+7dz<{%?>#QNu&{8#>bqx-%}}hXE0yqQ$pNqMFqZ{2 zrU|(%NH^OPiM5WwA6E~jQpI&hniJPO-&n3r*OGwaqokpKYz-VdNEIQ+}hax z>u2ZW#87i)LuR{koKCTOW8nui3_vPfBhB#1IrqIWwA2!-LO&Kv z!iQzQsHkXtlD4)&q*QW}$HRxU&raQ<+p^^^ZfJ-6->T+$(sA*`*v2*wD-*5lp4UIK++#X;41}@EmTUZ@29y2U+oWzMPkoxeI>1IMWQbrQ4f7)uwRz^iBP$;DDG$E^%zu&xgVs zU&N^-_2XBm+>FP~pUt0`NIKHfu#g+1Nh8_U@D_Wg9#3AEaqZId%uIiJgI2BUij|63 zmWc3h-A1#hiy?}s`jwX)djsa3HcVlAl))KcY`c%>-YfHAefjPE!-2l2Fl7si6f!9b z^Rsbh?{8s~dvROKX+mk2+Y_U(V?om1hh&sPPFQyh4JBX>gy+X=`SS(tnwTbLujpDo zY)<2Oh}fQ1zI`bSX`}GCXQWJ6A{KqXU2yo7N4EV$Y*U)i%kSR}g(tu07`J34!vRK_ z6-=KybTL@AuO`CR&o6SgHJAO!5&4ZGzn-~mppRu&Ni?hsZ0ZPn=_L_Z+u%5BJ7~75Oq-r9y8sf zfomJLmPS(HLJpU9K|DtD{nkbiPp0YFS%aqPtm~cFpE3U0&T8V~;sO^BU~zU^kF;ra zm-*Iye12xz-o5ddrUZ>_`7?Jn_%cllw_b9X8m@;2O@dGUUM;#<7szKqw`Wh#5Dkks z830))PPLS?NqM6Zk=Bxa9CBpQ;a8Xs9b*6MuhY1X{lauFxiXf~7&pqX>hpr}+ti*A zj5TX7AZ`~{nr_*9kvt;XrHuH4QcpdNTAI>L(sfI>tY;8X#b#87N3!av*wt#+8#1|( zMz7506J{={n9)T8wmTW)Br-p)uzuH(v&5H?6@2&ZEb)a+Io5Fu&cD8D^xA#9f9R-L zn~?C2*DLM&BK*^=2b3Q(ifVoO^r>;-6iX2tk@ZxoE&H)!%4gix$-!19rhCIohMLkZ z#vP)fVo@in7mJm|!b)chV7BAPoD?q3Cy5|@XpFQ!8R%HoTOFq2&!ss|oDYd{va+6Y zb8|;s7910_^3_{&Z8JR{!=2#a#^#!Hb~U76A4WP)ckieS7EpNqa7RN=Wsp*gJahh7 zmEg>9?#OtX!$^#2YfgWAVWCrVRG726Wp7nIywOqJl1=mi7H2V-+Cb?2xg)^yyI}d3T-936~5m9KmOQqj^NRb>bg_1uO^4SFhXo_k|;F8yQ3O zTo7H<{4jlOuT(icBA+$ z?Cf1;h4VI%^YgGhT*o1@yEVs}mrW%xxHCCUIUz8D!6nJk*f@6c?%l?Ui5iVrH-28p zNXO?F`%7<(VmMzz{IxpOaztJ@8%aD#;xk76I zcB?xR(snTv%e=?y(V@^Af7NEe*c!10nz^3~@X02iZLzD=d%gGvFJ{TXax9#_Vw^H( zvx^B5C4P&(8s;-z2rM3Z+;c|?X4i?Ib9BtRytA)ixKT4zUt^igxzk}J59>VeA)M`T)pm9VJNSG`~K2< z_wJR9+r5CNA@d$;*RyLjrLIo#0232aZGp?ew80s#6QD%oVvAIZTOGsF9&s` z;8iE)#;SXd_bBVO@GPi^h=@!s7TOF264&d;d8zF1IvAFum(}E;+O;pQPI(AVK7}>; z=!7B$u(Ps?YdcRzI5i?Z_yz_B#tEzywC)eblo@||A$DZlnTOjsYtC#G_8I!pUG875 zpBe*4a>8~vc>d7wDh)Z-1A9e=5sZ%19}vWZ)nFTszg^3%o1=4KMw`>o(UCYp zn0&8&!8B&isMwu;GUitJ_-=bUyGF)H-|PI9NN`de(`y?sO#xC#%?Kf5z18e^lG;(5 zp{5x8pKNfuY}lBhmowGoptti}WV`g)v$u)gMY<>9^51fBgsC|EAHg?0pFc!}%+||`}k9)al7*CsA zcK(J*Bn2@3Bb=$~3HDu2&W~5}x9gr*KrUC8qE{{yrn2y}n6Vm5Y&X_}dGZil$Z`q@ z3JMB5b}hE(?#5<+PR$&}_Ybz!3Qx6o_vP~&)tp7H#y`LAHQ&@w_t(HZCv8x3n8#vG zIn~oH(4VmSh?%qc(Q%4i=!R@%5Pw4euL0ezT}F%!$VBTCE={@33L>%@e~xFhwe78R z?H(RZ^z`)PDA&=^;WO_z>C2%ek2|m_L_UJgJ0w1ty@r>|)XXneIYB+A(_QdLrDoyZ zZ~h=|Jr;&sq$VsRR2L<~$f=PfyXV-|k3Tz$QynXLNJ6E%(=ApdDG@oM?#z96_s>Wu z`+i)fpFa8Ix}5V%^&56}MsIJgnSqPgMn@N(ajm(()wW$pL!(7{svTEUOHk(2%2Sn} z5gV_GkgSi8+(Es1b(7_WJ;$WNoF)S#{J9eD-TTvN{L}f1VUqIlexk2@SmaK7rOfUB zI+8BvFd2^wMK>&IJV|sH7Pw!mg2#B>l?382gES`{C?fIN``kWtM|qc+?U; zbM#&^H-Cjq5IUq0vI}Waq;Y+$-tF-Jc$#^{5H~HvrBpy;Zt92p<$qUCC!HKsHPA! zulh|nPxRLZIj`;r3^FmLsw&<)OciVlBRk19=jICYYu)%t z-RdxsubzD6j&hP#g51k9m18s{|dpmWIerveubye{GU(=q-G> zzx9xD$LoJ)W(JZbiZ+TGmU!;pE$W?UmP_?hG@?-dIi zr>};c8doWtpA3R`9jr7zpEPFaKp$M2Zi)%54;SA`N?$S|1;4(nwX?S;i41?m&^iIl zX1&b0qy`6Mt?-DIRxhiy=jT_XyA<>Bqi_uNK>Z)34mVon#~J4#GQ(1yg9jdnODd>= zKkw@7eEI6t;dz)ae~UKK_YBk4SRWssy^NFI*Ztoh3p&DC_^EZ3%hc>hp~>RHd?3G> zRwT`)1hv$IwOObYBxm z<+#Yk!6D^#ndRtFMLy%Ytgj*=*H1nD`MQY|)AWKid3|4qqvFmV_u8=c_%pZldyK!m zyFc^oA-8FkW})-^c(u!d0uK)lDU=?WxX$iys${aZFUWZHubIo4)l;@0D z{>0~m!c&J>;RgQr;}0J{Kcs)+UZ|0=I#KYytO5dR`Suev&1QuOn-82TX>M-*+NqqV zp@xa1*|1mk#*HkeX}BI(3bRt8#~qhw4_H37W{njdpxr(L_Y z7EUu7`@eNmnpeZpvU2_F?5FapU28Q8oi)q-xmBiyTPI$x+^9RfOX$X{M2+khq>}I= z)!tBJ>Y?UCnFcL9AMqVx09UhM5$^{iBx)#7n_gW$6FI@?GM8M`$UCg@YaoOc(F0Xb zzz0qQ5!)Cu0x{hSLB#lH#Yxw3Sb$?5?Wnv)%djx;6=-iJpk9YC*xM>>< zp#pDdX&LVcGSe@;veT_9Ow`O}sJGX)S$oAblsTu*oZ+(@?c!j(eZ)P?)AJdeiPXT` zw{Ph-ZF&|S9v;N-(srcH!qT$VWnqkw1P0_S{#X+yCuAM+3JSc}6tJA|n|CL}7;z)= zjGUJ;IGRQ4f`uJEFD$yu(~EmWMMW7mBytuV_VM!ait9vJRPs8C+e(!mk`81N$ILdb z&lI-qSBx~+ggnErry?M`47TR^6}Ih%;x*KHrjMgR2sqA1-fE$juV0@;&i|!A2)omt zTf552V6Lw=D%H^$xzCSEq4AGrQdBv1pRkJFz$g+N*UKI79L6UhDOro^CM8}{TDo#_ z8e@qnJ?%lCS z`uef^>rsgEJ|4pEoZo-EQFE1G?0FA{$-CRTnJ^tNBfoQxc0R{@V-2c9Y5)9_MCb8( z745#5sL+~n?m`Z6seUnl3|y0Z)DQon#a})nQ3@8SF10IM$+?H!XAzp34c*KE?YHdURlYb3ZA z{*+b`{92aHkh;6h>J57hZ``=C6Z65luIMl^-F4XY(X!8Peg%XCJZLA$+mra$6op9IgKA%)GO+)sVs-lu#Uku2hDjM*EUw645 zCodw*HQa5Nv$ONL1oiYsy`c_O_wL_Mrl2rCe-v%pgBq8gX&2V2pf8yhHjv3xI|_fj zd#c6KWAmm>WURXouJ{3_7z20@7PPIpfz;}edb%;?sxY=k*aUk3HC&J0jzTlcc_wCm z>7Rf8St4JA6n76PB{py0u7AV9VGpyU-%$WBf6rk?HjJR$`=4dr^~u3Tsq)^60G$g* z-1iDx(LPv8?v~P8jbK9=eY9%wi+Q1f+2P#B_)$}_Bc|;fivP#*5fZzQJDo$E{#nMJ zv=hFnAVP?hmGwiAS%J}FrUC#S9iYzVu3Whid94{`X7@a!piM;{2AMnLn-Vm+!Oths`Wu(jmK&vOlanBrq(@m)f$ug`>0OLbc0^py9FRa6A< zK0YWNxD%DkxsxYd<>2Ex;X?RO0e8#lQ{aORq3uP#nx_(|s4jX32F4P=p2Sul2^H>y z@W}F+9_uwhHfE1>nGfJbYUc&7f`u8AiNQt%r0udu;mA8#{zmAe8vTfb6_sA2)W$2w ze5pJWT^1KeS_&uu#lj|nb?Be3!Oo2(aR02i3Kf;98$9>EjaOT9lt>!(;qjg*WbFol zI$Ll7*8w%ax6ZRe`~<*(t$p&`f2P8H6%~sss+W@tJAqY_%#82q7g@k1O7JO__$?Jx z$7*1#bMy0nc11~EGCV8=_NqkqKj@l&>Id=0r4PE*|H{FRpw28e)Mmp{_=Z8jp9exJWh`I9qfqpUGdve9~xnI}s z+`j#ke&wRafE|9+^&vs4y4KuQ7Ap}xewt?W>YnE73i9&fNO6f-jsEyilG(ZaXkF>` zx-y=+QvCb(3;jAa5fQCz?8<4{qHp=kg-4pAy>+6!JEMK^`QI-pVrz$%Zt|hw;>wjP z$L9ZN1?K(FKmW|F%v;glb=e%8QDFJ?}D+}*Z~ zOnSg4LVq2W10oE2{41!k=f^SRs3_};Js5l61oJ;pNk_Q?)X*1(P&>H>fAg&4CE?Hh z-h!2w88^z#&L%Uyp`GViYNORezY@StOaBS!F0q8G$AYlSBarmP!F=aFk-h9nPa){M z!y_Zv_U}K1eB2*NNl~+&)Tz#)A?c#|gQKy^1aHK``veBEA)SsI+JQej`R2o+qxkBr zTfthD#zZaiVdRm(IGK^TZA+KrVHST=G_sDomz34`NTOlj%+7a489LLq@tpYR&sgQ*T`+7Z=4}1-_m_n zc=r88+`}rAzPY?y67P@I$W9@Ewe{c^xpv2~#|UuI2-Fm~8(69mT$XqZf@OWCnKpr_ z;hGga7=*3HzkiaNMLL;*2zp?dUb#iHF(YYq;3!XNY3Xic*~nB5AtoX5SL-bHsJ4k` z8fwi|L5504>>tnEzH@8 z%j@cf|FLpqIzV%Ac?pRJfBp4W9YDo-6d4w_whfoe@*@Cm4S;I$#@-|(1WCf$7Z)-S zDPN#=FhRD`$S?rYuH$cae2FQD#x^|RJeTFpC>lES#iSvz?;jclS-@LqPO~-yHMJ+G z^^G5YSB3~Sh`{|@6VMZ3GYWGQt6k`9{N>g2P{gE5iNq3IU)*)eWzVtFkhrk;I<`)6 zo8)n}khTNai;GF+nLdhnFN}W5QeD6JCg6Bj$JJm3n+Am%MTc?YsKIhix*cKB_GWV( zAAdzBZyuefn2&gVdFO*ikLmz}5h!l}#R-7~48olB!z6kTeTXw8-H z7==S9_AU{G{ixM<+1Y}TtWNi<9=rDJiNlrRP?jNm$7Zqke2P_E-s0wVXAv2Yi-A*( z>!^RtO7g`TH~DO|GON}KJbW2o<4q=UW(l8QG^`tV)a%q2Vyu zL~x#UB~K0j<#;WnBQo>-0LU(f2M-@A+*-ZJJ{3qR_TLS)uotaq-rRzq~xmN@h0orB>>`E)r zu6b&(@g>%dZQs6qwIZlptqG%xg+5{3?=xI5YkJpR7>G+Nz?fLI)JTAu@u&#m5DEI& z6})Q~9lFDfF=zz1J z%;}l9R2Fi3Y%=nUyS#Tz@uwTE2aZQ|NCnH7AFGOyaujNz<2&{(vJ)QJxyj*lHRt1k zzAxFghGkPfdTpQ=Vb7vR1*yrcwjy%T5VgrvFy#Pz9+b9!oL{B%p zXV|?R3Bmx@k)VGQ$bJbR(~o4gU~W{h)UW?FL(0W>onOB`=}UiqP}xf0jDGvlL% zoOXYU`zdfP=;rwey*leuI4D^QQN}Y*i)=Rsl;3p#3GWtHBT;z%{CVK<>o1sCg@iQr z@SE1rFgpHimL(H-RGbtyU~qNYdr0*LBjI!WW!_tOf6mihQ=&lMhPL5}*I$`w;p2fj z8Tc8seZsYD9ryOuEt+4{^BC*A5*RqskeFaX8W&qb})&Kr2~zW0M6m_^hxNr@ zSLq~=2a4EoLf<28mDuyLPcwp_U*mIM+@i@kn%PTyk=J;v6MHOgC5(9_d3YS-Sd?0{ zpFJ~S|4mOb+wU3e8$D~=0YSQ*o+9%Vt_4$UJgnl3jyba29iz=Cdq(?h> zP8YeYPc3x!@K6Dlfshlmw~%&-E^12GSyp3%{7jP1&Hipp_dl@Bln_(vHEmm&H*UN* zAXPQTNsDFK>rZRsqcR4(@ zZgSZbYwvnv-+F0dwbu6xtIYh47Begur>ll*h{W^NPAbQ7Y?sVvaGTr1-Ncq26aL!s z8{AjlFl#*0ZtMurb8X6;Uh zo=QqC%T`cCJ+eO1H&KEAps2XEk!8X6s)Y$ zND2$6NgCn5tYNBWQ@gyE!)L((-s@8mj5Nn@TGCd%n_gwm8GJKUt<^WZeA?D;k&&kB z@ei%9{j+-7)XF77+w}gvb8=!nCF!lhogg2Q{M%J_ix|y;x9I=))n&{<2V z(Wykqgk|uzGqVDMZ+D(e&~p4KCLM4@40@p%=t>Y|FEiGnR3!=zV1CLP8o|IJqX}SO z=NT)>q9wOG?~KH8ZgB~5>3Wbcb%5LLbfx$^FR?l8?fA2b_4wA_t-+5m=$>I#-vrLa z8b)1X-gTO}_DaqR3w^aE7y2q~Y-P5d3G4-5QUhKlDl6+aIGmHnH;}PcTI?+ZPycu~ zp9D}yB|vC=m*1|k9%`yDXgj@xEBC{2V4iF9akGEn*8_BNShn z^Sr$iU;l+_`-SIRWi6t4kJ8i(9YmTVeBLxO!q zfIAcu6Dx(<3lMo7QWxGKTZnX!swyJuA1aQ~E-WC_8Y>IS1-KSPfR;)?)S$j;{mPM8 z(yy=NJE^3Y;v^n2KceUrv5Ua@QG(XWmuwjnH#mBcAv>c?#Wf0i6ZQmaG{LI~0pegDFj@d^ z{lhU=vn07>;o{<|eTrH=2I-@~@ECxzPXLz8W;h~HEyd}UdTlVcZta{t*R^9XD!g3$ zM9Sp7MiL1;?`oJz>>_hg1zc7jA|gW1&P@(l8*CQ;p<~ytUvG4WYA-dZTGg-iX<-l$ zUy_+_+2W=18C4gdQCBvh{$Vldke5;vHBkVx z`+yiLLCR)2a07*G1D-cPZyFnEpSE^;bJJ8p))osfukl&`!t(F>CExT*r`A!wf#9mb zU3Q=Bp<+4qVtoGBi!R#`5+Y3;DCiZ7IpBnXnSOQx!4Yf4z62K&0sj_AU&-vtrKt`Q z>V##FRAe_ohn%^&c}DXp8k%-<#}6*m!2&NWy36!-d$*2Ut_l_)#WRI5@tDGh`v~@j zjF)gBy=;;;gn~e2OPRActRBI>k!)JFR5fA((@-8yI6jXOS@@x&De9tW{YQVa#4Ovi z-}8MX(L{G}U?W3d$btVvo0jmW)PA3zl_i(Nh%Wlsvm8f_9A}>1iGqmmZ`(qgzd!(B z+?HqO#bj%13j{4%CQM|vpiO*Uetw9vP>aY?!Z)F`G>{Zv-S`A}$8*#s@c z8F?ZQc51|QlA>K)9`geveH*k4X0NdLCXn{oLx9FRRDO6}B??-Q@YRz=HKeA63y^+# z_>_$BgkoZ0Y&UPBIz6Xmh|P5P2!CBSoGN4tcE0iH;c&rxu)e5~Ndc*qKfF4w;BZdM-PS zu3nA57$$O!u{}jklDKhRgQ~Q}4Jz@f&mj?ND$bkfk0biT38*v)UG|YA69Cva+O3|# zX+S6t1I$6M6f~9)GssJ56$k(`ABAE*yG(x5?KXP0q?6p5!cIzuoCPjZ5aF&BOE}Xu zxBU^vf4DLPNlQpuT@8zAi0CrmXYBpTu4X!9+DusXAbvAtFlo#YqVb~uR6arjK!o8G zL6pc$G*QUHg!{l9l60^s(<}*W3)|tt1YVbi+>NOZ$QaooAWPVa4j;Y#?JR<`5+Te_ zo_x)zcQ)tEEt=lrcPg-nQ#l4|qdX9!y*n4MqdvIP$3eE&_9F|dT*Y+~Tls8wM4+C43nCkFgTX2dxf>(q^93EYV%!gvqoxE)Egrgj z{{sUvc|j0hSB-&>&u zq@Tkz?#B=5!rHlm$dD817sOUbGGtAQFkWJ&Zm=EvlHJkU8-*Pu?xkNHngFE0@N>M{ zq(C}YjZ3iD{k6y7OT3sE8N>DrJG^#Xal&OGFH$Pt1y;5kjt0gnYhhuLc;t`75)7d} zS6QXbU+#g{c4>;6^n6a9yMv8mdvT<7awC)NNRWp&lWmxW^fpU&gkCL&AwG!Cq-Li^ z{P6*$Wj!>9SBudip-qw()Sel(*=9{?;zXDLy*`^bfg@p8{!t?jX`Sa|?ATHfM)RL`${IMS2BDcum1w}z*)z0%1 zD)h(APZEk2lo*pZ(dmIMP6Y9L4K$C3&}fPD$O9T$um0B9^2&1Q-l?#L#(aZH3gdK$ zvcR%bjd_w~=2JO8_t|onW-1nRT?g=rTyO%>$`}YXab_Pp7?XbdFeij9NvffOU>1jl zvj()iHP`@{&_(IMqn|)G85cG?VKqn%y_?@O-esqk{-)klea*jn=6x{Dw4c`l6|0$KcJ^T3 z$rz0p0>dw%GyuVFoiTvXg+rHapg#F}Wk7EnxZQ=d|&jID(0ub zq=_t(VqjDjdY~Es8>=~QU=WJNe*TV(L%&Iq#nT3?DJo`?VpUcjMtoQ z3@ni-*%o5Zc-lKwc0S17c_8R=YEq_pe5~4w$eyzm*KY*wWQ0S~n;08YKxnl{U+=nS zZ7gH>mdngmKR=4m|5|YEACJ+oA>D#qUQ6uj1p`oB178O|dU)h|*n~AFd|&%1AaKY_ zm-p&}f=vmn2DXBEqCI9b6?h+Ao1lglT|P$>r3lH|wnU@tD>^*>Y1l(oZt}6kLyo{4 zlh}1dB@G$~+eBeQ@B(q72~NX{_a8p=m~43VEVeZ_gQ@RzCXG+vQ)7y`cWtNG-ybaR z{a$35TtDLDbf(qwIbKO5BbFyZUay4OSpMa?$4uftq!jStD0;>mD_77dc8{4`(*fKI0tiN{k^!mk4J{vireJ~EIxmT;;QnxCeoa-`9%9? zhm%z~Xh*gsCMaaE!IF*JRB8t=;36f|OWC91EA3ZhG(P--tEmpH9SLbO_n1C-&ZyML zV$6zHkg()?-}w+5rRq4%mJ!cVxKET>OQPqwQ;xutDY|XL}Tv zLQct*^r!TM2jhQph|vAssvO&iD(JlX-cLw&_O?|WUPmBC;A<(WhOb*MXw{b)r^kYW zt7Ei_H7<@Fr5&&FRZy|IGO$MJs@x~ad}pnp%=f|3@o`1ebC!GwQsDlMUH$S5da$yh zq5~Uiw>jD=v7-Vh&~z2*9qvxt-D>iE>P{hi`O&2fMCtoPG@_Evg;G*97o|s^dkhb>hK|+8Z@b*o$A!{kF7pLBE%- zjAR5kir!3OoDYu(tj8aix{>vPD&ZAo#w+~0-lrq$ik1vxGvzU;I^G&v#qH;p;Scc; zNP<_Pgjd%+zb{$A{!#LB$Xlj}_HMFc8z|Kn6_rMdb`)`qt^*W@K}E%t*yF^fXPwz! zVP;_UwSzjf6Yt(fvm9vNPy39^`EF2R5|=A~QL!G**mdtxIB@;Lm6AzlkhW5Muuzcz zq!zBwEYoK(sJC>PJxiB)M)hOks7m)SUuN-7<=N#Z3tw;heU+O_SJ|_Cm3?m=BKTW$ z_}}-J^|2p}!bo4Y|9Y|7%Cwzap0$&RQIr7Kab+pYo?PMk$z1fu(29tIk%1Ho zUw)@+toeCrYPVHv)5a+q=FLke<}1dqJg3h%lph*T#4_Jsde-GWW|r5e{W!%REIAv> zRI@bwzK?RUlGwEGgu=Iov-E1V@0PB&d--~=<4u(*@zs3u@B5cORn~8R;FJ>4D0PuU zY^&cBnE1c(D`FIHjM?sJ5o%FPJufXTUzfr{nHM@uu151k0O+LKk@TkmPXgR{$z2Ch znxi&@s(0?(A<*3V-A6w{VszxOIP{`Ww?TNx#>mKs-x{@KU5A`FshyY~Ro6p``8btN zmA?9^2GPLlbs$K!m-$X~`4Sx?P`H19DvcwD)faT`DtSVZVZs>$2kZ+l2>k^0dlInZ z#$qXe2VkHZz+%(U(SQATgVstw@WkU z1zrsz+HfLt1RyPsa*0pp!#cP>W2mVb-HE(;qRAvF2IWpH$|{Qf0tFzTr!B!N0vpLAQs2+@&o#D6+Aoe|;2PPbt}jX>b`LLecEIcmwe|na z*)?ASE`|=w5TgKa4XED?U0P96gAz6dlIax=c$74D_Cut_khhlH;PCkmX-7VlzX&+J z74x@hSfA!s93mVmC#UqOQ>Oy_XsD@;+6$Z-O}B2|{2YB*F;GB5eysqJ1^UtwNmtQR zB-3cP(L&nbg(ep8T;piz8+dY_p_B(L8W@q+U{YsiCv!ygt}rhz3qdnpl_T!HVZ|Kx z{u_D0Pb#9f74CQ%quy&g`eWi_#7lp=O!kZ2yZtHg^5m0m^6RHA@#vM&2~T%sNu(FQVJhA(B-&xJIBYjch)L_V<#*FK~eynBmt7q&25LcWdQ9s6)AI&_j)EJ znT6(u1w%4-u-!v#liYv7cfj$w4H8ghjt;42PoP(@kF?+th#wq077FygzJR+n+Dr+& zOeDYPG9jV}RB6WAW;@-`0``(H7s&7Ap`j(BRRZaF5xkwK3YwkFnlq$rTdv=Q%wVE| zx0(P-58U0AQTL{!;+E2}Y^nxOjowVhwZ1HQLXN_U36gxIw1!<#eA09Ub_BHOewcm0 zg5E|%gKv@N$psBHq)X4yWEmprq6LP&;m6k^B{YrAK1Ca!7}%Bul;14k5IuyF))&l8 z?-z4Mo-4NjVd=ZjGcqOtdX7bz2=*$kHM9Q}gkk7oI7eil(9w<#Wl^uUw%D6)vCf+O z``3%$UFl`N^uy!S{@=6*`nw9zm@f>eIOz}}Skj%fJBVVsDcdp%f^>FqdvtnEe7(y^ zAVt!FN_+vfVRSSL@g8X1fIQJEa^4-x2Ove`rnDF)vT>0(kOC1fwoTF0rmb7O`_zSU zTP}ntA4GQBN%Yh$P02HaX7jQ zUPs|8oMwkI(Jr1r>Drw0VO6NhwlpC0i&r?r=SJvp2E!dlhy>tAcB8tF_X@~E)9%Sc z7y{TSY1CnG{Ba5zJ^h^PAXLSW^`?iqEZ8}0{Zd^m1GnbSbPf0cc4661VWJU-63F(P z0B!p2R^yV&TIpV$s)4I@YQ70Yj0fQsMjuM@wJ;lfd3BipSEMfuN@F7sFxb|C^S4eB zUgY^{Y9j3PA}zWhLU!_~`0CJ#H1T1NS&4pXA9%dfRr!)^Mm- zDtxBMc^zF*V3dcxB%eVm>ML$E%n?Bd4iNAfI);9!((%VKtn<_2yK3|IqKy`m0r|C) z6A|<0*;zuT@P1*arf>$$6-4|so9<*vXGl5?gVy*mQuDGGp9nLAU-6 zTbhYtFVQ-E-5^K7$%Jo>bneeDy1!H=pa1oOCk_Cx5adZy0}V3RlJ(y|z1+5AM-=RW zv?fi`#C-VhK|kk`FnC~4Bz16s-RLp{YST{+vasqchZje`4$J&l*kT>Plo+DUMHBsJ zTxe`|h=h~rVZeE4iAdE}F!+;}wg#kUO6x(8EzLjioRbr~$}M(}*@EAbN53LEoGg3` zA;=;f4`e+6+>uUf91N0A8jzr@G02scb2+FWuR6oHC!@MOk^ON}*FdrvO?9OCf7<4f zkxDr?^x1@!c3G^q6^D4w++Cq0Ub|4Fb^uT&D;rzC+w5BOEdbL*Z;I|AW|F6S?m;L1 zN65p_@a?5xb_rTRXpL(Za)==U`xW`svC;`BZKJ6|U0A8reAPhU5vK=RJP!6(?Y528 zP4}9%8NBo+mG$2O)qg8h2`3XEh4cVXoT@|Ue>$@xmB|Y#kT3G@4g1*+z%d#V5%A$C z)M4cd=u~-$+Mth!ze6&wG{md%qOFFfctUJGK#JiMLu}3S2OE+E;#aosl^t+NugcVE z-w-PO^?wo4QmBomj)m?yl{#u>BIIdLp6Y>$6MGfpcTGXX5>o-3uY|%>=KGW0ABg*%=N~+Tm1jATMJe4_{`kLU3dE5` z5*5$*=Sya!zj4`1qB+hE3=?l{GI-cHY|!segq0k-gtJ_{yuC?VhbGW~T-y;prrD5D za&iNn0|e1agbK2&NT(m#@o9Q@#BPeIPxUKsn!FU!NK4f2>EgdK?R*Q$Zr=|)#B z0Q3QzHK2HR1AVYU@aZzq5^djR#m~v>i|O)()NKpQqbR5h?UIuG;x#bFBdd)!Y=0t<}dZ}F!j z!zCLlmJSEHqX>-6j#A?ow>|_7*N_eKxr&J13M|T1)cHl zJL#x{cXgdxh6rf0AGysw=ez53L*Nso%)GN>Iot0lyX`O6_oF5y;!7HUtEH=`FJI-) zzbRMAJHIrV2d2L((azRJWwdl5$KMx{`|3_h<_X0Vy}IR6v2$m`@^Jt9Uy7a4KbC)d z`rpM4tI^W+G=5)C{&%tS{w%$wZ5<_LzeD@`4^iyY=ZHF~2Y^8NAAh)&f>@>gbuQy* z5Cx#6-MIWX?-Y%+#oCzjtcR*i9<=T>At%)&8B8%j38i+oqrtx*{~kV)Vg9Q$eGpEB>>D640F*)*6oel<&sGYOLn7 zG!&ZUp`2}u@>Sc;S+ulL&C58zgJ|QBxYE{QO@DU{`Ih11OUt^ zLoSc9N@pUa#D3PXT*Z4bZHrTt+7_rp@P`LZOE)lIzQG^`H|Y4hRQ6JR(L37W5ij`S zUnZ&g`+X8_zKSUMcNw`|DY(zG^pvNUCf9Qf<#}3mFHMQ76lKev$m@CK_04;!tTtah zQ%qMr(q@-$qLMmb!lput!X;Z;xBQ13l)>zqTDm6}8xBGdU7SlLZQAH%Wn;Y`Bkbvd zqpxTRM4$c3Om{B7y9?O|8sZlsz+16H&V*7`fhcIxj1&l2OVWBjE={(eN_`teI*T{! zZz&0-WyvQ+^#fpDnv+e-SGt16ZcwsDM+rKX zYwZQCgC#8QIJU2)%tDvf5}HUAJ_fBiawpI$F&qP=-8y$TN7A;{+Mkq7P*C+j+e6ez zQWqY)cwsZr)HJ%VeQ3#VSWNXC02=-Jv3WX)*ID;ay$A|rpm?==y6S?;b2Vye#Xmp# zdaGC&R_?FQ%Z#M>evRD%Boj;+>Kt#TAI%{oe1XxFtE9BDUV z>O-9>o1~Smo33sRewIYQgHdPXu2`qW>Q|3vH^1}~3m|aO>-44lsUeG$m+&q0qRtvP z7)lumA|`Ph8xjTehxEm}FCsCIUg1x-@%#pKI&zj5)evnS_F(swfX|SYP^3GaKF*mA zK+L7cA4=S18|C_%VB(^sF!(`rX}C)&3D>m*xd0U#N8onUxhdR*$j2tow)ynwI9wKS!DHHT94)}w=%B1Y))6l_lBWP2w>*v-A$>GBl1&?jynKfCa{@iT z(cl;L&!5|@6Q-=MmbUH|1+H6gU%u8BwAMCgyJ%95ghJQrmoKUW0w)dT=v)NC(YRUe zw$gyS^svZqj#Zb}H=E)Zjh7abM9iFZdAlj$Kqp@oxwnHmT|=V6q5w)qI%y9nV9F_5 z%B5(DX`s40Z%qO_mF}{;?s!G~eE;S8Y^PqUWBu{O;hfwLDfz$pHfH;tUNt(FdO+Y2 zDr?s5bmQ-A@5Ao-GtmcGN&U0`pMIf; zR3fts9`0F6e5pLyDbVNW=_Pgl{rtwz{mb(HPvx46O-r$~;|1E=NuNN!w~WXqPy|fk zL=6W+mA>fqB2nKXFeOXPO22xS=5ebxWW}FtdKJ-_hP#)R=yxx2Uz&K!<{zfD1h#kF zBnSsm$bcW0-Lwlhv3!og%U?{rU|ptz1ID=@54?z~IP z>uYM#eY9J^B3wvVn3pq2KH|Z}P$46Ewhov7j%(B!C$p*k<4@85M3i%C&fngxpzvi2148;^!hJq<#mb?$9zY*>y{Wzs;pP)h8_AT}aWv9lQCbrht43VajlbiV< zSRvQ}D0$^8S6-0L*IBD0n7G4&4Ew}sM|?qQweF0IyN+eS#L?!IC->FXskLwbJ#`W20n>1HMxw8uuT zkqw^tzO_{oXMaSuIZbIg&HXGUEncQ^jiEsUSH*FpA?fd9#^taXWze6e899MtBH%uW z?uh85pq+w@;&lP6egZXGzVOymat!Ic>+EcbLfOM6u{52dtpe&UN(YQN}hx*_oU4RE5Nmlifl?Ly7Q` z!*8)t$0%79Blpx8;?8Vn{10jG1uxPH4Z%E_J2Dqg6%Z`NqU!?5CA`QC*uiaM%`}KYRB3ogG>w*Mt63|0`iO6BQob)&DE=K&R@oofL1~pqhfgWUw40SFy8( ztr}+9iv3o(|C3u#&?m|;_g#Wa;@65Zl!!2I3~wv8jB%UAnU^^224{CZlPbS;^JW^l zbgpLJzju$EqCw8h1ocFOs*pRYV81$5AZ6tP^o&Q7O3I%M%rI+D+<(Tc1Z{9Q4x+@S zP~+JSj{j@{|5sDEeqEVbF*3W4xjcZbrCpZ#Z65TxHS6-V;oA!r0vds<=s6g+wai#{)bR`jQQ68L)u%% zRhh1B!>FT<7;I;-1q&5K!2l@>b<`0BaS=)z^dgjyl28UI!Jwo> zN+nf5kp7Nyf%EP6d7tmEZ+>it?I>%l`@Zh$I?tm{--)@xXl=Y8Hek zj2-}1%`}(Otm@F+tX;n}LcDR&|CA{YNPkAl8fxdvBbyyHq;=I~Nq5()SoN2lHEAXP z3s7}5bZp7yYt&)Vgd$I(!oIn=8ER71ucE7Z4DOpQpQbc$4P&s|>-52eSlsN=SYl^yZT)Rwq3PHV3PYS0dXYP@j9w+> z!t5Q5h|)~-POHmZlDES6U;Cj^Ko;i$LS{>n8uIh=eceP%xJ0C@T<<@1bsaVBnRLos zIe7Mh{3tKGo$Z|ec@=mpS7)hSD^r1QLo-6t?@S@I=vG5V1*)uvpprm}4kF@WuzkzP z9GHIz9Rvj!sEt`Vj)-H{^Sbw8f~4Kef;771)BpuFMX{m)+I{1c_788gGcs1 znwWS~*^utn)&0v#w5P+OI!+b-X&zZfo2df;otXWc8!H`MV`r^tSXqi7m<}kcQAJPs zcTy^(3&@}V|8e2@dizOUIyU&4IQ5N&^8ToGskd{VFOk~n`!MGgW9vKpvyH~~tD|gU zu4Pf2p8_6B6|7s})w>kou>jtfEPXTv0T)E=K#C|ROA(|f%3=V?c2Z@47-dj91D(vo zx}jmjX~kkoB7JNnoblj56@fxm;tnc0-p1~iSEhjTBiZzIH8KD~l5!giOga8{dH49r zx|2_6Q+q>3bX+28!_oh!K_}OvIKBWJaTTQn{qv`?5rx6Xf$62ZH;S+Vm@Lo8zB5TA zAzhe~eS*R!hAjnLY| zhuy?u_cJ+)JFPTFMe2jN&`TvW3&@fW;X5;)u;77ANCG@OJTg0^fRZObj3%*x8=uJg zGllDc_wB?Ee7;WV+_{i#vXqw{CglPeu;3(Bn!@n1ui|zD3O2nbz_zoPl}|xnwIhs%Ms#<(IKu?qIimk~YrZJKFc}>P|9KYi;`e`tfN$ zy?Hikh5v>*#;0J8Wq14;k#>)&Wf{6T@6@C=x+p05icEEf){BueL^}fIWrI~OKKZ0g z)E-if;d=|LgR9D710^lYKfelrB7#~_N|a&K%j?a^vV+2D*#CP~h+qRsoIO*pruAYX zO2NN$VmFdVK<-Tb zIkBwf#V0DgjCCQ_LCjqN^?I4tjE1G8u>&mnFR1Gt03>jrUqV`*QJ4GHpx5B)D7W=A z7PMnQe)sS0>d=MW<>@(=qkPS?eeZwsffRPz0a_Z9lbs?NCE@qvj*$h2u}Ya6p1Ue^6#|%J-&Keit|D zK%=3KYg^{!BRGxKd_rrj0TU;E;HTig2Cq36&f|g}n03?oWXEn?unc;t$T(k9?@)Yl z+sc|5(@;hQJd%h$ukK!+XUVLN2yAd^u>1b)AXTm2Th}YXC zG3TQYDX*bod#I=ca;C3zWJi~A_=?yM@tIpvV~y;CFHS+$*6XNAiBXHdBP6Qm*(+@H z<8i0nfLMs96pIV+E^Hb6r{nhI*gCvce3pWe&*ao=vGv_@*m3r1qmR^CegYX(e#WoL zP-18dJPK4wjgSqMmnNWE$eqDpPG@jb!@`AczsBNliK&qEi55pFfSZQ zN?-SEgJ3z8`$Ju~Xr!%Ez9bBZNUB6)j@@7a-k$sVM{o%R?lktGMidEZBnUTe%uIzg zgb6892Q;WAhkvszkbZCIr>n{Eop=BuhI7$Vji72Th1{&{0{Lxi+Am+X9r$Fcn=c8? z234TQog-loT{l9ngIoHBrgIo!p6x* z4zG#f*KSlqAs>Tb;5uxK>wEcC)x(1gFHf`P+Rsk8GhGbg)xSWmVvCU-p^W zB>4IKW#?Twme0C&i;A^V4cy6|E23)!I(2f_<;C3eaI! zqLeH1nklMZY`$@chV&6rSBXdW&YrBaKE9ts_UW*AUk*gn5ciSBEoP5>XM9a;*Ph(<OCQ@3+d*AWHbTfRq1+e>l0PnkrQzt08PXYXG5`w=HBM!2e^}F zFhQ5Uhjy1urHv#jzC1AB@Rf>VTu4ev%9z)0qKW}^#u~9Ed?ZL7Z|c>KpKH!Sd__;^ zA6w+dhr#V|IA!tD_nZy#YOfMesr=ix$3zP*9nFD@U0<=lP{Kud+50cLDC4 z=#X7V;O^yiOWV4%*J@HCN>V5l2rU(pNbvOS(KfigU`0#ZeryJ82jzf00|B&m ze)7rGj%B+JO0loc`n||qj$-~awKh;^l8g`d&KJZFh)ArrK6ZK4Y~}^Omdx`A*o~Ls zaSRB7YN??p%xx^DDd#60T@nrf!7uH^Ead_qv}pTz?&r4?rd;HU;r-)->!>9q2;kl% z0Q&7Ig?*I?$m!KTM(*dv>~sU}C7xul8&)Qb(RPeG6W{v#6iZN$8n+ie`nWLP`^0j` zCloq=|HwUQw;W2@Jk0HQ0G6)7c2dbgGNhm|M^yPx{y1b!#_~^COi)|fKh=NMrJW1| zTP}X9>o_jOr^;)iAfBK+lmymE(_64Zk*}-w6{|=)_yQ6NO~8R$OD%T?sj1!jy7KD& zBmMuG?aQH}c9D_Lox#e{^S+XrnCQnN{H008_(kcx;Fqal@y(QIFFD$sOhZ}xx9U8k zz%1-4lt(lGWJk0M%Maa$N|wDP!QeXnZlIXR+c)n9+vnt?+hVJj{yZIh{-rFkyjE!;3{kv>v@2^QHhdKtKgs=O#-u|jP300skMybc?k%T154Dhx1ouZFGXD&k)!RAN8%IQh7o(%5>hi17#`VWy4CD7MV>;qycSd&8l zR_vhH@~hKeoLylUp)af6eT_wyx##{pEPiNnT8QuU#Orm>KD_J}oqZX5I0R)C$c0SW zO44O$F?*-?h(i>y4WBjzPZ4GWOp$`SaHc95gZ+EyBFrN@n$vPA59}xVu|P$O!;$z1 zV^=@*arB9ZsXqUh!e{*9V!tA8wKCivNrQ!P|z_iX_*sh87{^d!xTS-cwNzUVD`<5NZ%EJizwu0tH^@=kk&d(7Zp9g$~4f;)b3!5ntyuvTGTuRYi`%X z^hA-%0bRLWr{A+6qD!Sc-&gb!M%qU z$g(L{WKr$PG)?uEsM8h=TFH;@dw&eRyvs)Fd97&F$1}5sgK5Bzoc~s{Nj`joJa8f4 z=ajo-#)w-;Fk`Z!Vl|KOf7c&$l zG=au{Y?K47TqJQKB)_e7UgyCHw_I}q$F6OwGF_u(&Dg!3$iEEadLYvroGDh$S7;+8cI{k= zO|ZI1;c%NMvmiDmD-xp&{AYg>7@|2#lr)d{XhDdbfrF_dIYAqN?X_>+_bh?b3H~u+ ziW`M{1&G*J;n5Sfmz6N-_U36EFK?XvDX?})V0;URf#}q-?1*aP@TZDLh}C?7QoyF6Egx(r(#xaomQ_2eE3lg(00zt4nhbBf;JixUnF@I8G z^1iz?eks%3KRQNi@wuP;6;0uhk>+?S2%3Q{y&@-F^)&wUUgliBPbU|A_Prt+W&3jY zyaoMB%+EPkS;YR)x!AQ0?NW#yplES=G!d0XK(X#B%}@7*Y;%gD4zxhRgL8I z?!;x75dkSSMU?pQAeu%M$i#!@(D}IF)$pb!Z%tdV`=H_9)xF7C^DD<54X-=M*Ozjk zq_DAY;sNJmaz|U_ncN-E#V{5pCfG)*aV7mrsU3Mzo{e9EOVUdl{a(j__B=wB5kkX* z%`(6bUR$u@A)Ig|)n_GHBCyj(ZKfkX%MbU#-sl)>#YD%iN>!SIo0r&{A_9i}S$(M5 zCrpQ7XJ=2!oq+n8Hr>%KiQe@6c=B=^^8r9Rq#g4e*VkUEBV<>jfmvd&i&a$q-|S24 zr!TCPC;<{6PLYiZ%4*}~*Vb>?0I9=`iwbkv!gM9gi(5=oA#&5-bMY;S062A{?1R<7 z1u+h0c5Umma=2D+l@1$A2Z9F~;lz2t`R?$+G0J5e3pu@i%QCeyd>&a#H}9`?`(4P0 zw^uh9zcC!VVD^&FoWVEx=as)iP7yzDKI|3HH=WaiiDkW;n;Y^`_e}1aG5jhmB@q?p zdX@p*YB>s#C#))|$}K#nq1u z-V+`Rl;Jq-%sH~`55XgHVUO%8UMiYbW%{{zRWm+pZ14DFW}?8k_TLyw&fxg%!Ro-u zsxeq*r(*q2Z8Gh{A|L!Z#(iuVp6SEY+yr{2Iu+POU7Crw7V(h^RW4nZBU%p3y6m^* z-J(}tT8cCjf~y2aLE-H*d=ZHNZ5_k89NU3slX%E#X!}7q*jO)*UxsAx`T2jw|9&BV ztzU6Q!_NQ8uF`t*nl-Wv79$Oqehi#CHjWN>g6pG1M$%Woei9V?N0D=CZ(_vPIc`xt z$CS4q>2l`&nFQy^AjkAtb&kKQgLCWVRZOF!vOa$<;ZAF`7Szv7AsZ*Sx6-C2LzKP& z)%ZOPSeetDaj{Vvf59_P;`8jWzEXNM)Fqo_NsUHh7hpi3WE^3!jow2uOJ+x(EcFo2 z=}3Qg_b_wV`x7B{_uq(qYn;K=bWsoDnmzX$imkGmL)W!l$!qf4T5Nb2FX7&B0lIcO zh&(JXGD+O}qdO04m<%LO8psd#shBam`I*B7r+gQdemA(_@ruEW`XI?Bg&FO$6qGd5 zarO`{mFI|4X8>eEAlIzzngCwMw|CFw8Xdlu5Sp=Na&3;}Lbkk~x-6r_@@}yW2rNl; z^7|Yv8uTmc+9>Kmd&`!llxIdquv_mUec@~aUAe$&PueK^Cy!&1Ir!>h^%`P<%_dnM5JzuIX?WP}L?yq)b zVj&8u=xJr#o5v^eg>WcSzy)kQgY8)HRUmxOL$&4Zv@40$@KlqNSE`seI~0VT(tYN} zX1&W#6&lvdc5Y9&64#sZSN|VX2>W8wdjmZG$3TU$sg40`Fa-q#0p7oFzm3tWKWCA$ zDKGy0&##_xIgGfEhn^3f^<4LL8YUwk8tH1uTbpv-azV46+P6MWZ^gpPSDMyn$nEnW zaWC(3AhZ@3k;(|qYRWjalv&Vr;OdkQH*Yo!i7wP0tEs(PbrW(PBT6uhX& z?YZY=&1Z5ps8A^rfRMraGC$24l;z~*)S(LET)NoAK6w3U!h|`D?(#RD? zk*oNNk@O>BTOy}U?OV1AN;B}kag6lk0I^CMZ{ALU^4)0s-Vc~`HNWcW`{`=$lu)|! zyoyZWA72pn0}LgIYnJuAb!GcGRitFJmGs{>ui;=GN?S=!Y~IF#o*NZwCWrU4kmG>h z$kQiSL1THdT>wDUPSDUz3cbURi2{D1*VF%h3xh;hsd?zVY0e2`$D zi?j~iY;)O@q_nd#YAmv2go}@N@}{47Tx{mCl$AY%Wkh*h*k&pA1_8?6JeW4CiWw2K z4U=gIg(?6~JLpkb6o=9vcO-ZfbUQ`bkuis40-H7+A}DUBD}IkAH)*C=6N;EdmIE-zki8A)5sUY!xKIvT7GEKBFQJ ze49CmXz&0M;TVIFs5B6zyB@Q-wrtw;aw1Ud$jt;ii582p(6}^5+P#h+&2ptW@NcbJF3HZ ze!9oU#;ITEDsDoMsh-D)<+`JFy0U8ddlzcP9^%6}COOS&2toft8H+H`CJUk)5B%UZ z=|USLjh+R)4Lmk7sFxo&IAK)-d78|sSi1JOIki^FxXW8PPJ90S_v72rwrzj6w6yH> zBE4_BkQD0 z9Zja`NXkyt(uagZxm$=0kvgXtTTA$Ndm^m zQhqjNE|GT!9yuo*vQ$X^Rq>}AE|ZGo;L zg0hu>C>KwrI($B%S~WgdXqdlnlU)8{k}=^xEcY_PexyEAXa$KiB=gqByP<&I_btv{Q~ z0$$%?GMV9B+G!47-t48am4`MJQW;L#8)BjRB_}7tH*R}ISx$}tlXk-E@0gO^^K;dt zB7P$*%+PBGp5yz)FHU;aAg}(KQ)7bK+S)WE2-~(y!fp9?^hwXWf871m$H#|s z?X*$AAe?qFG~3w7h~`4V_ohA=w!z9?#BL||?i7o10YAB|5lWNYzThfnefH^z_Op!k z!54kg_fV-?foU>f5y=xX%wJYD^1a%=HZkCpc$n42oqbkfM)@u&7LLWQxQa);@j|aX=QHWoeCC8|=Js_Vk z+HmphjK16X`pqD|5}=a$bgx@NOe_Wk3XQBwc#W(KA86#fEb-qV6|wZP#C&9$2^NhL zq%%J4L#E{-t0axbDx2$7`RnoPe}5C8%J0nG1y>7kmH6|K0|NuAZ$lIN-D|LlrsRp8 zJ46^aD45Qg#KbROzI3nh<(9BlP*R%A=8nRFO%7~2-m)h~x@d$Nrq^oVDs}H5$0TqN zn!w!&D1h9Y4<9``8yEyf%4Zn+GjNyAmXLLUCUVhCXDnzL!y!I5zK?xFi zLALaSZ?1^WmU~k>z`t9;)HiVS4>|cntip3gn-EQvpeziFnrg_nfHXmZY^^x4@iPP7=-<03dZR}F?w^~kN z5I`+32}K=sG9*!?RHN}Q;86<%3L*E;n>(|;rTO9fxq`fAri_bS?XHjX0fI0+eMGjj zpZAcP?fY(AxoL7pStj&c`ZZ_lxb5BDaTsm5y50wAC0#+IZ84z3$$kUB2li(%!nH_O zi;T*RvKVbqg~H>U-aKDFzX<5hmlYLE^v z%6>c<>FG2PaErmSbX@HyT;#^{r_}EA+Vb~Cj%GGQ{XF;`*On#}@v0?+*YZ=xB5)}ale7kn3qnw63A&4gi zquFVvoB-q1od{Bqh98e08{n+@(^=TrO+efq9n9>(2sUxJkOxmeoFvS@^rJOy^btnO z!xjZdP-G@_hdzi$=*y`Oi??fzKB;q?{H<;N^{f~A404V`lfXn3PZTg^Dxf-AxO}?| zqY_!ng)v?q`I>Ef_r}S@a;fbTf27w?{|r>a?>)tQW{9O9=8#26N7FMK?}#?Aa1R;P&sgIWVu&CblfyIE|N3b4JW2GJZ@Q%F zvPIvA=CG-x$FI&(LoG2=d~UQ;eWyv`^T{dSs_1N&6lKXjNZB(fP zxmx3Kho7f)B-eS#DlH+2MYhe4Em-re4(i~V^H4sKSarM5g8kSsumzeNoV8l*TmJJA z;Xdt<_ZRjRZ)UWs*A~*1eP2~vws}b|uffvIH|splLMzfR<;BjML8O28?=qKKdOpW* zDPw;5nc3=bYcfs~@@D)s?>H z*f|BhD0Nm(;{pi3JyGy3kFZnD9XTz2Gpn@?Q!!O}GvIrnaWObGDfu4!9S?=1yn~tV zl&yH)4ibHj0&mT#~c<*55zQ`{&U^?Oncig)3sSZw>_#zLn+g7aFnTk@gXW z?ilmx9yzCb!M{KKwFoH)o$_KK%E3sp^Q0bvl7TXVz($sN%@q~$N7fwYoO-FI$eY&% zW7E&t2(PF^n%LtzH(6PMYz^J1H3mLa=9^b^SBle^ISArA@oieK%GwS*UZTxftBZ=DpY{~_@CFg| z6cCB54^^6eigu^WYmVqM+tiX6g|eL`a;5Jk3jMVck3nzdMWv9-jPL(w!gI+E_1}UO?r@G=|lI<>|#X3tbDSDc=9=bG|0W7#vSg z4loN~t-(P7?(Ar3Dd2EZJK)w$n_o0eUs^&I%@J`!BO4IxD?}<0%pk8zHoXnbapnWt zijuyBFYzgDLR?;AX9zm6vZzYeERlPy<~b;g?Yya5G<|AV0o&XGf}L=o$(>9=I{+Oz zff`Y8)a5I~7j_(i48 z(_oMMH9Bnc3AAcK_ZILTWu3l*6Y1L6m%^Z*+9wY`ght?U81hPhn@O+j6Y-a4 zbPmQd`%+7h0y@OvlG#2Ur|2-idlWiOo<$hy$j*deEgb-iHQ^E~^ZK2W_kkobmcP$$ zH9oqKIQwsghqc#KUHUT17OxZZ#1e7@iRpXiO$I*#a*)JDr3KVCWO-oBvV8TSTR^X<`>;1^MBA(NmuD3_O(#gmoxiwE|dMz4ZTPHGyDlh7% z0qs()B(YTG95L8wZeTTTCva3E`e zV&@`p(+3*Pz3u>Fs}2$l(Z1AO;}+I-|1EQg^NpBHLV=!L$NP;TBdxxqRXNyDV7JW9 z>9bkJXgafixs5K;RJ^0;eUSAX6zPOb7WKXWYg`IYJWYh}py#aWz94g{wX-8OkfYj{ zr{G`#X|q&(jg5`?nuC6mRqJ=#eOG&hpwFMrL#N-Q01^U-s#XdXeE@_Rntho|cBga# z(xXLbtG#FPSzE#{)LS6Y)_~o42ed7ZcTE)=Gn!Fs006LnbcKqI@VvieGA^Wl8N1Xk zp52wg`Wz}I=8@GosalN6vD8m2l4WtX=aiuM{t;_b!S3JKWYwl`$HBM$x6{7#6_?s> z>x!LoWhHhVM^-v2=(eJtT}cqYwngE|jdwKlN(oBh z$)9hP_ezo~T4r%l^P`2C_Pv2}HCyA~^~hmscKmDl4HSLj?N~RtXo8Vm!o}w8mJpg9 zyKs$_H&5z{`a?@iZx-M1)}FdXY#y9+DcaIfzb0sow4WdT@Rj+I`?*QsUhe78Oty&q(r)F(t+Jipl;-LLr)OqwXAL6-+oI;-#~*2KiRAZ| zrPh8DY-`WPugsOxG1G~yi;bQ6Viu0~J<~pUl{Q*tYo&vwtqtm>n*=&06Rgs#?VL|c z2jr-<{k7*J0%A|*&)XQLup3dY_aE!-aJqVH>;6lNFU8g+#n#$*bNu=stG{}Dh<*3W zgNM)c#4~yZuBx_p9q7TK{%gk?o6ZhV;dw%@D;U2#V;@6<>9B3MdPrDoVc&GDx6B>R zcdbtO<6k--$%%!W#O5b+!uI00P0y%f*w*r;XktM079E}@`wprVcW!L2d82%-N@Z+4 zUCj5GEbZYad5EFmCgb84qw})uK#}sVKfRRB!0w(mN&Ce!JEMaeL#j;9uS#xR8#Wz= z!l}dBRj%J%Q=K}$hE6-)FB497&aKNn@nv|5PRX6 z=#16K6hZj$rCy&*zzQ6#ZNH>xUzU~x6Jp1KdH4jzMl2a zK`t(sVj9h3Hkml;XCq93KJJ%!#^o}rpQRpRR63ov$Z*OG#Jl~qF8jt-j>H^D{i7s3 z@WHHHE(*c9&sIASrih#NP;ExU`N;}m9T7@P$R9*dLoLXJgrt?z$zm7<273RwXhH!qYd-;sm4^J~|Z9d9OFNSY!!Fs`* z4dWw8J8e{LsPqthSmCT>QCK6vqi>kS^^5F09aEWh@;|K3U0L zoIdgOV8Nj!^meDoarfMBUq1>C{n4ArxNxwr23p@mYKyIwKXRbGS7E2l*VmJsJZZg67^oqDIQtR(6iwvR|8l5 zzGynWh;1^zszOtEO&p&rkcwT{UGmO2FZ)_mthl+ovprVpYzpGbS1+GVPqMZ7_uz0} z|0>Y9idsL}6M|P`E?jNdwE6Oe>18G(FmnS7zUiM^-ZhX=L2Kfk(NJ2Jozsw+qwj!9 ze{&gKw&^X-T`zr!gSyNGcOJgoTdGMcu*)dI)U5rUA0vF}r1`qnYN4OzT+W~R;hc*> z!Kp(`)ed|FN4MpNXTwiha}hRxSpL%d{QaciMdC?ds}0Pazz2qhhl}B%#>n7GU>nJ6 z3^At!qv{e2DG8E0jnZ_?tK_)3xe*5L0Z~V`MET4%zgz(@?z~Py`a+Q z^q2*Yb2+nqy?uMl+?nc zlKq-HnL{&N;M;FdUAi{$H%U&O&DxvE#$-B#8fFpO+BH<%cYbGXl#M@}>VIs9h|ix7 z*8Y0qWJ9>W_lnT*JhrRXu9<_Ng%$W6H0`!r(x89I$}l_C`5fd$2ptxNu z@hUX*6lFUi_8KW>50Q1+U~+xoz~zxev*QC!Wl$T$35|O(hh`cX6^wB3Lt7*z^(k@^ zQgEudJ0{0-C!+wf)CJEjl4rpz&kg_3&X^^mn*2YTs=YIsPukPg#pcM5pF+4D$d{xv zvHymtYagGu0_@8F)UgW}F4*rHM<6YN<27JPNnRreohg!hQ%>a1ksN$~N~Gr;>KV+| z4W6@Lxp%85KyUw`dbh;o^Eo(bawo^n>Ze=pMQ1TmzcmBsx4N#b?%)vYvW)Osw-$ig zY3Oqtw5NoCSC@<4c4;I#D{}oUb^raElU3Ms6-%4H*xB+_mLRhfU-nmHv|{e6r4Ba2 zA*=OBw;0;Mz!RyZIrG&+`WQNPnKi9b`z?D0(e?cN@=La86g|n_ca`1a_U+d{#2{MC z@KRP#c;!0w^64?pVM8LdKt^j~4Daf~7}ZBF6-U#*9|g}mv0L5>lcMee%Ic5oLE@p` z-Mcm0!tQDlNAne-$g5M80zv~AK7DFSgbIM9Upt1UjxyGWvoDx-O~F(B?H3TUT4bYn5#$dt zw;lqaDlIJBY`F7CO`4$V)Hk&Eb7^=i60#^i#CA8Csf|FFl;#^XP%r z(+5iQ#s4L>?&riI<8Y(sldT@HKaRwAJHLK^Y;U5vYw=fckWJ}kTd-PHiZU>Z9#%85 zxPL&JHqmKdL)%A!>DT3g`jetxJ-;K(mExkkXxus6*AGLuDrE8@xXb))leZpjb z@AJ>)4w0(r*zhMg;|S9jr>xG{w+~)6NzeGXW-Du$4d;yi4~Ta~$27#-WkaHE%J;yQ zRN0l7OH;!TE_MWMQ#p4(JoZ}ECm={?eNDqRHfE+&U8-Hvh z0&U@nod-bXX@E%(#;HNx69?bfMI3-S&!g@|s}&<_gaZJDYj^jD~_|?El>kcE;SCOub;hkqoo-ob3WBd@zels&2Jn8%hE z>Yw{WkD(Ez%%vHv{=7|yw`g@zvlLRID9@Ub7J#Gy1r@;j9pJS5HQy53xY}wVqAt$UQB3>Zhfi&7EHM)< z6S%)^+j=5b?Lua@IUt6#J>@KIA%4kem#Tn|BUf2T$^X2|a3C&7y}>Q6hAGX-L#3yW ze(f;-ezWV5)>sDH)nfsH#u_>Hjv*ghbVpt5qMXJ%fd{6avYFq)Ies#Qp{)1SZgt27 z4JkjDN@Lq)R|hb8h08m4LB7hXt{sf<(YYTI-_6JvVxMtLOfP>N;--?45`^uDNoT5( z%&_ot&LIO-E~MK7KFp701P)5~@$pNbHEp>ty!*#HdBGUqKCw_XCY<`ejv20;Sm!jT z{VqzIU^3>J!|`YKhtSu*-%wTyVp(#y=DUjl3;QzATfw5myk>5;+8g{ydZ*z|TPtO$ zfVkn?ue1}_IK4f8_>S%I;u&0q4tfII0_MA7J!@@Leiy~Nw^BgR2F*jTwtkFOh72Uf z^W2XK?A-+|6{4L+pK9mbo*EKeIw1wk6F4gxjY@%PLp9d?D^{%d9|zKZztMl+vFV|B zC@=nyX(n=p`9UuNy_HH`yHJL6RuI>`$2YK4zASqL)=fS$r)>SfT+aCWR(ox2?Ta-3 zo{_aPZZbPFyZFr}Ad!}~>pACuCKf#QG%h$K#Lw5)_mne~%uryQb5X)EPKjmYLq%#g zsiqb?F z3zEv!1vqo{ul(`McV%UYv@I$qC=Bj~atvfPgg@O+U;p^_jSyJhfPff8=Ob@p+vtw` z?G*k(aU7s?NedX7O_oJAnP$iwIp6hnaQyDOAjSOv6w@$H; z=7aYToOBvf3rIjfv2AdhgC^{(6Ykx@nt+kMi}m!6x-}-=vS`@4Vs?JJ`cvU|+2cpG zW?gOa5_I@7Hqqa=<#R-&pfCz*_QltasU2d_I6*RX!l*~(CD_j;TwEb88>*m}C@${E z&E-D=|1TT?G=c#_e!azu7mq+!qM2k=+`WTzTs$=CLd=Uo!zh%*ahySyhwbX$hWn-c zqP6zj^)h((ylD{&IKSh5jG8#tRf9ogVMU}q@XJX|xwz$XlVrF1m~TK}jON({IJcd$ zmkhPI>q8`@pP5tb1RGw$?RdS6orObSzpyWNTMn5n&uCms-ZwUP2@>DF+i+l&bX_zc zz?Hq$M}WKQPCj>vcXamLnP^-BluPC=8`n|Wk0b86Pvy2wj=K&yWG$II^^Eu&-Ld4W zdfDmH^Vsih3p=CQcf)8{Z#Tjf{O78~gdGbW~36fF>UJ;wZIpW{u01(P(WA!G$BIyknon#lEr#&=t1# zi=3lem2lR__ zO=EL^)$xfRK=Jqe%}-&&z|`l1!o0jDC&%|zyv3M6a1Ivg%%<1R(`WBlu|?LykW|g+ z4;%&!FGLmRheTGjY7t%~EMEDhUm{F9YDm1XqHL|cq<$3;@`a7k8UP=BczS-CUyrN| zepq!jQR`*u0_W@nYYpSZhjb$hmF|8W&tZ4rsl-tjK8>VU-;GU8*z|g5C?T<42~y_v zt2uX|7p-a#I47K2c#%d}Noigj_`-3!v)EWUR_};d7FS``!?aAdTK!of3kmrl&IqgH zv~H15&#)^;SJoq$BRCSasiz%5bV(yf;nJo(v(hD|v9xq~B>^S&URM{LfcMcyeJbup zAIW7TG#QTShgA+An0uGoH#sO-H~X#J{+4UJDW+c(OAFu2M{M#N7|4vOI%UN%tTFf& zQ(y-6C$1SVt{3!sEV)kEYu@`(G|heiPUCZ44B2J8#z*|;*GJ@-(oigWS^C&ELa@*4 z#PT}HvuU(CGu;w>05Aa_&wl)QcB&gQyTK!)-Ej`sH*uHF(?`bsle(hfkU*#am3SsX z!4C>%E$fnd=eOrITSE*G5{uE#^(-{Y+cejACeJkJ)bJ;uYg@tQ1pay2=P%~l0DABi z&mK+5F`x8%$bIu&yU$(Cq(icjPk-)^oak@e;$qlw@pY@#D@EkcmF4g;7ntG43U9Rb zsGe|+-&wJhzH5(Gs1y*O?AC~;o#av{_2VGF@ca^`k zt;x{a8LpdcQT)|a8dvG}X(TsKlKpoMZ;Kt;wEE^9%W&lBRcr~n5!h({bmQ$hFN zyeGX9r4wAFdwdFNAKJI3XL1csr?j&T6#+~GD$fX?T9fxP?I!|VSM=SayWZM$yEOG4 zQ~X)|-YX6Zt6iFh0=QnASQRu@PQ(v-+`6q1Z;%R7&~p4dGx9g3x7Tj0^7RWfwm-l* zEo{{8!!opIO9 zeJ;685K<)M`=L5>|u5uuOeeL-a2375Br= z|LE5AV`?gm`wvxYXp&qXRw89mn0(VbKGy8xr%Rn9j@nC@Dhf2$)j>bEBKVt_c=q*N z{>Qnw28|znUVXVmHR7-08!En2juy7OMTi7SzB~Rm3zJ-}UlPE%jXULNnDDql)YD)_ z*6pkp((EciAM?S)@?nK~$yD`if0j0)3@`>8!2AGizbLZHFfOjpk_P3_nE!V>G* z@1)PRk$WGwHm$|VT4YRZ3py`IuUX50erRThz&K~a{~m4WK1Lo*$25c`LSxKRm=8?TB$;j(M>rh)`rp;2CkKw^>6LljVBvR#%BKyR^vMU4f3m`>h8zvRZs2j3Sk^14tM>~!PxKfJ=@z9 zjvdO($#Kp%U1k1d;(_^~+%B;U@s2#ktda2Y&2(DV2;6f4P4p zmfS(kuT$aM?^Ugbj)O?R?Yw{UumFT7o5Y^AJeG=|jPYM|EOwx@Bldmz!#P%~qpzFn z`6uvQ+DFwF`|0doHu&s*AV-xi7$8L-5i_}uoR-5EZpH8b}!906P$i_9z9{s&WY@E6wJeaqO` zBb4O2E)MaL*darQosB>teD{9o(Xc9J&TAp>f@18DGdJH?cMqNHcRZ+Y)7x}~GA%4a zhY{T-yHTajgXh#wj@5ueOX?u{ zu_y2BT%qjV^98s5R()BuK+{b1;A<-vt;Oma+H~qFx-ZNQL@T^9_s!A$LmZm#Z=OIg z^A5z@51aVgspo&HMHtB8QCpptIaJNpqZU|F!^onfn1C%$eA@Ll8 zGtHaXFrSJ0XZ|6-Uns3qFQnc--o`O&l4kI}Gt*XJ-I&WH=*C_)+r$ zoPYmxU)cUUFs+7a*DWHLXYR>Z%?|rOGnHk zVyF5p&1`);$=p`Qt8U9FH!pd^(iN9De(M|#dCh-OEu9`eJcT)Dh9W)<1t-qGE^v>F zW4P@_^L+YjJTdtaTtAJMt)3oA!qz7fvofNsCJ#jGn-6P9a5eO$VdC7#r{`w}^wYkc zN1U0Xc3<^|es0y=d#+myna^s!d+?ZQn+m%;iMiIhCKk^Tef);YC1K^C?@lQ- zRH-cr~#r|UU{&zo^ z^)n9t;`sL1!YRu8$y-g3MpQbyfb3)9KTNZA6)A%usifQiGi307{Ia;x7hY`LCWrr- zl$OmXwA($H!* z_oGFWE&U`;VXw`m-dj1n1F1P~1I`=QC~Ms?-&pB<;_MIociJfJk-B1)fAR@0Si0=T zqqFTs?)xA%&hPQIy@n|h`)C7LcH2NmVtx0=wog7DhRX#Z8R~hNhJD?8dJYR)-+xL= zI9Hq9twY&QwLM33aL6xcivuF$hj63nE8a?Sppk;%x_}27$Qm;Uu2){I|3#Lc^k zjbnFFpqZvh&Dw=GxE?+|RbNt7QPur-x_m*&vWop-QsK1GclN~c?-EwG_@MiUHtbd= z7oI3KE(6!2*IlKFT1w@r2lADtIK63jU#G|V-4OCy%p(UUGoh*axkgsPXW(FvV9K`} zS2G#b2P3}n$4li7ygel?6{$9XP`kMj-{U_sU3ZLETI>}>l6(aA8w=IRgCoysKa1xy z-&t2-8ndGX}2hss|kHGp3D+>E+O z-N6+H4z1${?$>_zo=Xfj_oBD+p9n_hXK|4MVzWqCW!$Ial>1+2>WjM;cmMr!)qioE z)n`+%J$e5kd)uBs66uoLt&XP@QPRr0F9q1drY*{&-owMnhV9 z@eTKe>4RnMZOtlYIdfYcW+T#7#geJ)XO;WI45gE?RbwB9S308 zD#yZzac%fxn#t|E2V1fA?c=gr`A=PAeop9&`t17d)uvr`wiU7&AY-FA#5;z$UYF)R z%EgV@nLEeT$^3`9lhMS6EvoTY_R*c&!Gu-VF4SRDy;%8nFu2`3KAu;J)a$EPOGxp*8UV}eg6E^vv{p{A7)czVCS-X*H zRkWkU=nIQGJ|4k+G?&zshs0Wa)h4SqkQLg2?w}61%JHFBTu;2o;jQofi=X8joJ4Hr ztcImil1?^nj##0@hb=h;eyDS=pIj8c74=5`hVt8GWw~zzueiO%Yii^uIZCE>^8L71 zam7T!s<^6uq)haB@2Xfptg;O98>KI6NwIn%-uwLc2mu ztUx9NHTIk2HQxJB88cZM&Z@JRA&hV*<*JG|F8i147E(|WNRhGryhS)B+?6>JQ&(Em zreAxE049!X*Eh>7b5vdo{+8Uj#OYs#a`&nF$DGU9f15~U-L|!18fVq@Lyh;?^)v`` z>ARrezxoow!vV7^%1eMdzY~Zk9&$CDJt3jOQGP)M8_F&TZgx?0y=sUnPu;H z+kQX)_K7gc+vWx1KVRL3E&@-?)#?$BYJOC)CD(j48#j026ni{9PMNLm2FeSFtz;Rj za#j7CzLcGgs>?%a((RmT%pCm(E2X8zuZQYR`g+WMd-}QyDW-ewJ*A=u?e>vt1E1rh z>2Zw*Twxq(9LV)VMHXV1DRnEFbEsQSJv{zVfrXT;T?4VOALM?w?w&B_`jT}A+GV@1 zi4~RiW9snq6t5S)nDc-N*XHDfX0O&M`$enmll(OJrlKoMtKj7lKUXpR9f<{BTHoAW zlD6IJM`MpsnS_NeOa8jWq-k;{@9%oASjrnj!KyvlzYypu3!rMU)?-!MYL{%SYxf?U z%sn0ADa|h%+mMp9KJs8u;-#?SVwFmQv}TDcs)REC$EZba7r?B*rc*hwY0LxxpUmie zjqG>^9uqz_C6}ED8N@cZY4gl8g^3qgH?in2HYWr#U|=#!@5$~ZUnpQUv7ui$s(&?J zl6kWIW=wszm6KCrg;4#8l{j^NjOgczoh6Ah{5Ko;y}nSrs{DF9+;S+>!~pw z)mhr|k6+^UuwynNscV`H_P1P*)=Jbx%lfqJeI(zI;@c698h!wW-G9%1S(|N|+Io`< zIkI2Rf9$m96#Z4#nGRiT+N>=&-QEBB%B%J9;CPbqnwp8D{D%@$$e>}NulU;1SxJAB z-JQd-9LcRSMEG>#!?^>7{-4^;11jokUHcegjM$D*gHkj`kYWLpBGOcBh|&i{nu?%+ z0@C{s3nqx5BSq;-@6rUR#zq(E9aNeOO?qeUvjNY^NpkM@t#$8pt*jXd4$Ob{fA6jU1#bM$IWW zV-`g!9$hoP4^NFfHXZGg&aJ+6gQZ=&TvIZ)`|;e)>6ASp%r{Bu``+i!-=@A@sr7^G zYJcjtnSGTbp1ax%%y%$mIpP;*)T3v0SUh47L><_KASNye#0$+2QZ+}_)T~U3r z21(1in~zSrspAAr7m%+!9BDW@Q4P5}je4BAx<&X*U`r3DjVXfHZ){4N8?`yOEvxQj zY-8?T_w)AR_dbT2LLIrh4g2jvllPu!)rPmARLs)ymHjz3J4JI-Gi>E^j*_M?(y(#Q($)q9Mg8qR=r9)58 z4(6+h4MBc9)fbMkRKI1PZgOj{ZN%PHGJ=WuCusd!ZSxjX!e--Ne;5ra9+j#vUD?`! z#MuM|y5o}@a%-Ws*N8v%O!=Z?7|x|+$5qVntbInG>*LGlTZ89hbULyvMdspif15aF zvAvTHy?096qfZEV7lg!GRR*e%Dl(0~ezTB?GHsw7(!N37Dykwqpe*sJq`24L9|}Ma z2Huj-8r_&;Zhko$6j9aa&Uxp0#-3FY+Ak&^jV*-&-DSM9ahk~KmawIAYd)i9LG*wP z?uOfOh#KUVu(*!qsi%u>Za9Kd0|LD!wuE^%Q>GW3^Lf3~>P79?P)m1nc=WX@!xTkc3+=o;^Vp1zp*_L32i8NbHm%@ zmptar8$!Amjymfqe zDmPt!Y2odF6S34pSo6*lzw$|CH=-=fEqAJRIArP_r&B>D9Z2OKB2;6PEtnm6am30u zj8{rIY4zys=EzlzW>G2ixXkh>`qA0ZPCks8AVh3#Y;Hf|;`Fu7bn%v<#v|jd#BN^} z=kk(c`$$p$`HvQqe5y^=2kjeHx)ZPoT*|x(pFVv^iG|#Hs3#^HoJsrmYPFxTh1=Z3 z$XbmQ=CP@MO{w}P?duDNqCbTmD-(rG@0{q@_oabhrPbpoIQ9%h1AzHl96@Z&Bh5t7 zrP7l!*xmZ_jdmh)+pTkT8#ME{sh26&e@Lbe%Wa*{dKS~)j!9ji(BD^a=v=GowYA~u zGxw{>O@Y+gU{$f)W3xq%LzhBFzR4(zUC_cZZY>+&8-L)hKu?JX?(Vuotx2<}RZUP6 z8`Uk>mCWnoov#TlWxhp5BfD&*68AjFDhLg`jY4sE?3k{hj;CFw+2`x^gZ%}l2Gh~q zAh!~IJQ2d$7YU2^^arNHTX=1;76W9?dX|W^as)@|&fQ|Ay}wd>{?tNo_~F*dOVA(U&zcW! zllLB1=b)hW_@J^W2$5E4rHHXnX>`Nfq_wPa&fMsR=c3A|b&dEgsrz^?8KPl1A3b#! zU6mm9gI!FJwHP+%)f%2GeQE4%QtJD={PV>K@tH`WbfCdza|8bpdsTA z+^>u-4|qbwY$2Clv!&Ag*uGRw^WTv36s6}Teiz3P(ywc&ey#Nr)`*+eP+C!}sim&u zTxA;{$C5iej4lw` z)DXJ3@PZRDZEV&DP!Tvi-_D9_d^2mxS z>9|>ZcB1_-x#MKTY3)iyGV8V;zx;md4b`-x6l^C)(WFx6pQxLsL&)7hcAcByB&iK~jQV(gGlswVdj-k7#2kRdm+`GoWj%n}DdNi) zGxX^VJb!a@t5~D}UWUMsKn3w#4`wHDZHSxE9_`G_(_?+mCI$V-uM-Dn;=YJekf&y0 zN+dZC^Cp+`nU{Ec1YB3KWLvn-sagN>$?c?EiX4YTonn_FFWzTeWfbyw+BIhI!&`In zh%~1C-{3w)W%m>*y1nXRB~9Ai@gzT12}<$tJ?c~5mMX)6(q>W)D`F5Q-Fx%0x{oJd zLVX?9G0!(d*Z~ozAq1Jk$r;4ij*gCSD9btyIK#n>UJ-tW(8KvSIgfxaq7HQh8DWyG zO*9DxOq|#&W3ZY0Hw8@<8QCKuD;jD1gQBBE)Tl)HL+Fnnuwd*>ee4=YjFFjEJx1u1 z6YCj_`>_$oCoIPO_UPsYY*&Fu6wCyHg=^rQ1?GEuqwn2sV+N|Y*Otj#DNajpR&Po=p*1^0+e$xN->|gNSV;JC zjJ0DLHaP*VS|p_UrKx-ZKPyr+hoU!q)x#a$vmhj;5Pj%hgrqVagEYu-gCyq`RHP+> z5tx?*T&H+T3ItGOFjQV1&4#(@j;^lA!FJrjQU=kBbSUj5ApSc-_UMD2R1tGq069QJYUK!=z*w8;vu2|bXeKyiAzP@%Kr(>hR)lb6 z)c7nO1hPng>UUyz6{BYrPqlOm59M^fwleRy@1{V8KRpCMHPwFocoOSjY6-tiL1#DKpV| zUfJCj9ullF`36eTp23A9rC>c&BeapXY^G5d0i%TxKogioQ^2>5Hd&NK142|U_f7)F zZ*bSEjZz9FViPejZN@UaG15ri|}HY9Rm-CzhHdJci!asyZiw4-t04PcDt)g-Y$+k?8$6yp5A(R6E&W4 z($Q^OdE(I$*;P3oLoz=HXSJxlUIQ#7v1i;|T!k?I zs_mMXnrgA0xy#srFO-$>=RbtrQ%-qo40w#*d_rfzq$F03zA5O>4~f+P76tK`IskkW zfG69iREB15#M7rw8|S_V!-I?c^c47*lxXFnuoCw1^qfs-pBDd@4(qkwaQdM@lg{c| zbd2hLO5CpFX3Kq8xuu%8P)s>Z3>+A#990P4FuXfr&H4jTYQblhjYYTys1PkKucZ}z zCzpvrRkFHI_e4Y6Nk8AP*q($KpaM2do%t*|o5N^5i{Xdo>$^_U=+YG3bvJ(Q9pz(A zJ!~W2u`Y#f+4kVR&B7$aAN9IQI0(43^QI&~LA!td{&T5FY?jdRmdo+yc;#Hhrw&5H zBhF|57zl6^(kSa2#AgCx{tApSbIC$5lnwc2If+b94m$I7`IMzWlKAdF%setFSUczra2m)y{{_MVXhSO_Qkpxw_mX2 zJ-ANbN}T|4p#}9v8CwLIzy&_*G7cA77%&XbqM!7G@|2T|d8^`zjKG$d4q|&129hHe zjot29<{X-XsIVIZ8Rc{5LI{fyYO!d{KGvUh0-sotF*D4%&%m9i$pH`S1|!awQ*!Ea zXnlPP=w?Lfg-w`f6p1$^fw5!$mKFwI4P~%A$>z*8FAc*`!w8JOI^_D8_NgwES>K22 z`I5=-Wi~6HHRb}$mm56BI~9Hzq*o=HD(Te-bS&{sq_mjiFZobMipg77P)HPlhJ0#g zpzwjDk2j@OPM;!0cr3_*$cknvUx|Qwk8!%3(ad2zRES>clrOoxFy6=W&3_P$RaTOF zDDlyrdFyA$_Cv8k=%B@^<9cAAg7D}r%{y3m!>{2|u#*}Kzm6)75=hQ=uU+ejAO#1n zmX_8?`vV0)@{MsqRe}daiK(L2hUvh$i`WDJt3{0BrZ9^#`eNzY_5x{e{5T}-Rrl=K z1NCza1ll77+dbPU&zUMgB40NCeNBX{Khc1s!@qF03;NQL(VbmfP|GMG0^2!K?x1<> zn}fd-G>ckdx-x1xJF&s`O_<-dcFs|OxQ{f}{cHr>%h1PF#dAQgFqe8UO=I%gw5&S9{UruLF!IZY>Vp*a41=Jf99HfKfTcG*QY8zQyGGWU8M$=Ia`< z94@60rjg;z)y<-+sNdgT)m^4RH9}XNbgj|+Ovyi+5=Z3J9#lsy6rYtbC`c+CzjZIM zc)FPPuZgkurw{FFi;lgNNUv+qOXo1&#Q>LTYGQUMV;cRU$~Y- z@%Z|6`@y-zTAs@dlE_2)H!Ieo(5IcD&RpiCg*n;Xn=_B(*Jzm*`9f)#d8+@(i(g+j zo?035G!}K)p!zTF*6*65AJzhX_~mHnfS7b`5{l=-jQf-HQ5k-}eja6ZYuQJC&8&)( zaNH4uGUUqbNUo4~@g6uZ6*SiUeR7>MnetL-?ntyF8`dWws~bWT+#%22D_zuQMXV^Rj674H+a9Y5#S z$24^FP2m%Jg&GxT454JIj$c04TD5a3{B!U4t`Huxf}KrW2+=W~Vu<90Y#s02^y6+PD90&31bHC%RW9!<`d)I>b;CY(0*y-dG(OoK5y`g%3)8$%ZS1C;p z-Uyu~VHL|VI|->0jRt9P1#CK~^Z^^k9LH>0XP%1HmWtAkSd7|sW05?{+jvr1g@e`3S^ z&}$yh-3vhX7y#W4Z2{jXK776VSCy(ZrFwO@mV3DCsE#wY@$1GUZ_^)bA5fr|PkL5d zS$;2Z54YB{p8X*LW*Ix1Dl03~ta{Wy;r!XbA2IR)>)t2?1?^J-si{JsxVP7JBAdpb zO3SEJDNSFPTt|kuFU``)S^vI}^|iROP3mgz6xMtfElPExL@MfH(X2sxjWTklTvWJ`QIr#w71TTx zrzNR9%)g0gW~-^RX_m)Q=B2k6Oqc$eF08;X95RDy7(gxi_FF=Oh0t12O|3?IU>_WA zknYsCa&znE6`LPB=DK(9-u|i4*}&7c%UUgkYjii$et4ilxzt-BQLJe8{Bw0_ap@YR z5AZWs(^VDt$#F_NK*h$*f^NM(xZiaf??>er5%&G@Pg%o<+c(*Xseh}O$_~m7xIm`j zJNhp8$(tM~R9?+=N(ps3vS5Y^+ncCjm+w15EsUIhGHA8Homaw518)hD_450l$P_60 z^_XJ7&`3sP$A!PlE9AV+iN_5`3mCq_z@C23C!s#f;+mbsdZez2orTUzHc0NOW_nRB zh8k^g`yM|=!A#Fqu9=<@Iz#GIoxU;){zrRE&A}kEzXOl;`1e>qupfze30`4g3T6)o z6JoT%II*y`m60$mcH#B+SPHwQr~fS~F3ZV&-NrOnZwDpKvUoki$NPajp>!-9|dU33$e`;z2AzM11)q-M%!0KJKCS6PBt z-eBPgrVMqwn~ZHOK;vZ}oex|5C+wqLW0$?H zZ4?5#qJjcNRaGzSUsko#mNquNSy|Jk8!P8heRPp4si* zmTk=RAH!sJt}*PRr5Q~gl$%T+?kck0AiKt{X6@es7zyT)Gg>##SgobT7>Plw6jOV9c$Xnby6CB} zFdncGpk(YrXAu2YAacWafUN&`_leA^u}W?Qp?jt)JlPwaJ;8$v2YUg=xbMQ6Hna2g$d?0mm^rH?wl} ze3*ne1CGUtH~#)&&0_5q&(AGitWo3n)1`}wFg&mON(aF6-mkhUJh%Qz8Nl;Jjtrjt z@4fto3)U{zp*WINHa9oV-6Fbuy9`of3Iz1vUXN&$5GP>|2aC2Iwpl-Gx z^`_oAse!tMxtl)%UGbHYn97`YU4|KrpAnVQ!r=b(Fo29;Ec+cbAws51(fhy<2wsls z6PVFBnf>NN1)Kkwc(uR-RwuzkM8R~@x@mEX0ASJA(^DZAEL3E@|C|r|=MxNyA?U%+ zA(>@Bz?t96l>~xqt}F~8|8p97;fXfix&_{Te3`Mv~cDhQ7tlqZLVhaW^nMv?;>l<=<6;sTbjglZK9Nvq0- zDCM@}3l=uY+_C_sJ%MA%&mbxxMUTY{?-Y`?0mI;BzvT1?!JF6rUF3%kAF`b{Pkxd& zss<|CpMEas%m_9F15e(#0hO_Y^#e+^gg6ca(h#h&arcja^tDr1cr<8CL$NMP?bfIh zU7qgscjKJ+K}^GTjRL;u9ONN5eYJ&MqaYp@zNmfwVHsP2C~>6v*x6}N;wM~O2PBpt z&co1)g|uT}r6#nYx^;D8Vq(*Z6)Wm>FV@bZ`M!F(VgOdX-__ zch)&OOyU7c8of0yuN?D>?0O)n2XE!)E^9IjM19X4)sM~V)~$Ps9qcXuZfwL3jese$ zS}q;B$6`g&cO~G+*}EzcLk{(DbvzP}uLcajyy_h4E3RNM3t~pNSMb)L6CXeQwS(9B zl0flVGaDPx&Ub;v>{5<(6MgBet8l^FFaws`Ye{V_K>)ykdz~o!0 z2XCoOq}3EM@l~*J*)A#BY%;y;dr+O>qa%ol_@#lUh^$&^1~6I$U&h8n;JbZc)3J|E z!O1p(t8R?*yb^H7z}hB|BFd6Lqdk8Nguj0K;AKa3s$v9k6`BnL(SLD=U0r8g4a_`4 zLH)GJQP^;`r0eP{trlzY;MgKk(s4DPJS=?isJ>JgZSN%K-`Bcd?GCo-DB4IIS;>SX zfDWlx9=-Xnb&Y%d`ZfFXt_5s@NtVKKUzW7Y^T^csbaS(>U%xJ7`{f)ed3zLeWSlen z4-j%OK|9~uCZmdRQrL@W5}O~)j^Z77?BS;j#G5ajTsP%Js=M@RVi{ z+iy?Fv4_N5CE6@kq2p+v2@ich34G&P%%Y}9&jIDVZ5A_hh}|==1ir-*U}LVFu4+ab z0+Y7-yy!rsfkl_q!tJ)FbNJc$J*ML1LCTb^bLg2Kd6@rV))S%N$d5q^#FjiRZWkp= zaa;Zv^F}@%G_7j8zFoG=5F^>7P9h9{c!UR_oNFPwxEI#j)m$jG@_KfZHzz4O={k*i zql`W>fGQ_ecLn_2%qqiTLvSSsYE*jW%MHBs5hNbuMk4I-QH;)GNpfiGRLRmnq8LG8 zj0dCWWFC-QZWxevW9zn?vLC9*nVU3i$<`poOaRfPq;GsX9-STa05=CGAwZ)BPfSLI>VS*K|8bBvg+5;&+72G*1duzanZbBzn9ARCfO_zK&o(#MGxa&;F&6_v>f3A>? zU|+$6CO%FT;5w4?@Vh)G<}%=s8O=-#?$}`=@vMrTl)t6eIU61Zgk*}8Hr2f2B$;?@ z-RJ+iQ5s1f{RH(~0|Eo9!H_2Xl6hc>`({0BiQDJp0=L+H_@#Fcw+#jTLulV3z+CKk zbSX%1XJ)h%W5xn*gvYT>3W~eQWKjZbJu{t;z6Urn6y3Tr=g*%f&KWw6gGcXfmDYiU z>0a?5nWS*FYz0~Huxn6D-N4Si3mPh}E~R|d7~VR56!%m{E;k6M?8JU@MbOLIBS-PB)ciL_|S>d=BOIp!g_|_Ns>~?4r^yoFAx1$q; z$P%@5F$c{PCw>Jv&pRl{#J54*FFQ)b1b%nX_}@R)4a;Vx=Q&^oehOSYld0QzxVf?9 z%~dwE;;pcTa|b&r1Eipe7+?e|rzg;+r5wG4i)m+9b~t!>iKZ2uK12=9^gjSnp*;2o zWlY3+Y>|wan6M?|reK$oZ9)6L-4+a`6kbUNXZ!_D@u1Dfqp`54r~+VKydQ2^f}bH{ z%Dwv*O54eH!5%#PuwVo~<}=2X(>dA-`Xl7g_j!+tW1juXKx4WB$io1^q{#JgoYtx0 z6(v+%px{^bPI5Lkw&q|V=pc6_kX7?5{O$uPc1n>=8{Ui)s$)?hZ&y|O+qFfm=Qk9M z^YEJDCtaVDvf`)C5vqY|G;Qy`eH&q{ODN03Qc5F#IRyW>!$(hM8>SoYzD{`Cuh6uZ zfIpIZUEP^BRD*?y{~+g%9jvbBHz`O>h7BS&)AytTlzkU_d+C1LFCj@t-rKOw_mLw7 z=2I6v?ZDu>3xft$l(0jS01XNSiUyyVusZ;^9n(E2+zz#L+{SOwHN1=bZqx0xTSc51 zvo@hQIT=#ZSnX@04!F4%CLR!-io>`o5Vo?oY@c*iW1na|G36Kuq1Ts zIyUNI&wUF5{9W8Dn}IJ9!h$mfHTXcQZm?vtVY~@^2Exm+S7?oEY-)|{)#h#qO<=>Z z`?0U@s^fHD`df7Q+;McX0@ZyCjh`zxnY~%k_;BHE&PKxHixpt$xA=VSBJ*X#=_86g z>%3Nm+3%fB-%#KD^va@i?uEyd@ zQG!*@tLp0NDEOHmiV68<*46n{U=@&%dKkMthMj}_n@0lc`ZKmpw4#QC##9hJV;k;%rdML?M1D)@ z&f({JQfry0#NDK6-rX2AHR5jEgr*<_A5cgDu(8MC)ZxR|$tLe`;2ml*1TNF0*Wgbm z(l~4DTNwjuzp)8LoQ4!9eFjbEzMNZx63}~da;EfQ*;1F;jEC@yX7&q`bRgg;UFXSg zFx!Dn2m^e8L>>jy z$^o1Lg}=7<8g-WE(_PPl2!wUf%cCK0Kvg#m29bt1m2vOfC#`Wv0uF*5T-cJ|svSLq zB!S@6;fCasn3#xSYiMPp!X@ckoSA62?AAy=ve+yH9^JofJMo69CSDR-sWXDpbZ>0* zF?fA9clVNO+50ZND=019onI{^>IHjTV(EnTBOnr-vie0{g)M5ZMe9vM99IoTrdpIPy-}Lc%bP36mQEBqAj;0-e5o(XGW&RaYOIg+y9p!hmtTH?VTZMcJ4z+W zFj{DbP6WjsL$Dacws?Zlk9XbP-=Bbxwg*2VTfdLX5OD_Fq|&fa68|S+0bnhGPqQT7 zF=%pTbxGHf_KisM&6b;JolYjKi}3K~a*KTT&7|Lj5C5w9wQEYi28}>lVgxk`F)3Zc zzCQ%6%Z=H2fTWOf9;wNDct2RS7px=L1kt+=eQCACdWw)CNB}-qhiw;|7(WToklz4+ zJnv=&yA~(x>tZ@>0b&;DQFEo4w$6Fzj5F-xrwQXBX);B5npfwzw zzm=->=+Pru814tCj&$sMlk!smb0wgB9Q2tT$6eypV&RyyO;l7Alm=O>iQ2rm(L{%s zmAH08TZZjAhkwArv?~Fq{yywKf9IjZcmcd~UdXhiiP$IL4Dn#s83z{>CxQrSsGBONGN1zNys-f4dhRs7S7JjCLp}7I)cH|lbD-m!-4x&X07DKRhtAp zJhPiS%fQjPiRKR9lve_)LKT9gjzL6WE-?_yn2B7+%IY`k1T>#J$(jUKy_(MkSy))w zu&LC0@@ph><_8l?7WVbSlZ|>(d0*7RmoWL8-6ES4whm&zLySjoPY`d}7#zb?2br)u zox93NQ)HD9W0_x>?m2#f@eY~B=zE#9(daZ2*D_onnD}~d6bmNc0~Z+>{tmPyN$5Wi z!})L;V$C){?~e$v%JL z+0HbKJxdg~)%@_u3+*ou@Zq8f8CCI+-i(6es28d&7L8p9Tn!j`Z!Q#C`BxjIbx$Db zWL~=#Wh76e*Ri`bpI!d=@)pg@DJ7H**_T136r&a?2LTroJ1T-8gNluqw`Vof>duY| z%RSHQ&ojWj<;K~K4XPX{x<|7c+1UJZ`rD8Mg}}*$jy$~vS!&f|ndP`0W_i)JV?T+?~*w5iJhx0ZWuTIwnAFJUNzuO)MH=2k}B8cxm7f>m1$TI6)4j z{AbuEqEWRmx3+#~H3P08!7S!nMMh;aNXKRU@cqNGE{@J&CO1}=6gX%fGKXadQ5@J$ zbw^SFJG66f=s^nd9)L(<^wFW|fxxE-bR5vH$`QdOLP2PZB*`W%k!+8IjR)KoCF0a( zZyu$er|I{xIQRhdK{KEdd*_uh-*fvA1d?q7vV~-n8j!0+gWP8bZ$7LjC2Xt&LFBdoJWl-oC`j7aLE ze({g!480&IR3@f=)X|s?R^c|3PB1{iq`_o!jCAf4ZiN zrULygK6`l*s@3E Lk0cyEcm2Nr?1=oE diff --git a/benchmarks/graphs/insertion_performance.png b/benchmarks/graphs/insertion_performance.png deleted file mode 100644 index e224f2ae753e12ff4d832d166ab4a729245eee0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30252 zcmeFZXH-;K*DhLWD<+_A1r!Obf&!A13=$LpOG%P5ijpNrl#B^X1dF7^B4?1CBq$;R zk|~g!bCOWxaONudeBbw;_dS2^k9+@I#^^y0*|qoDYt8V?XFjuDE6U4|AEiBt!C=U- zw{9w7Fb5_unEe`u55d1IF#KeOU&3}$8g|N7Ms|+(Z45DT_wB47S=l`@HTc`X(8ku( z%94lk3O6S=``;#ZcGkASTwE6abp@xDjWJiCjfM@}<%snyO@+Uy^vDDF$Qz z0ekbh%9HTfUME-C)t%~v)rb8L?%X_V5OP@Sc5>c|R+mjlbFj9qlDhbw2VurjI;Wy~gSbe%|3iLQ9crv`*tc z!~Lqbk4wTDFqjGzc_T8?|6xwqk$%(dKS)pdA2NySq`y?`Q*7FY{>A#gF2Vf&m%!Hj zf3z=bg`GLZbp}}zKi*$3l3`U}SXh|qF38;4-ZsqASnhS~|44rX<9SsIPEV)B|D5E= z=xF@%@^alAkHNRYMMXuETkA`P&i$$KJ2> z%GO|)Vto4a=~P!_?-4<&u89Fd_c6N4scFK|VV@S_>P(8J?Ij>&oOst(* z!(asWlb+~x{LFcMef|26Jma_mE_mX{j~{8T7`%uG(2e@}Q#R*<4oxL@U_``r;-;X8 zNL^*6B=y@W`Q$gL+65VN<-6{^9=qGl3sp3EkB6n5TwJDM=QxO2wl+2ob2{x-W;C%% zd1i#$Za?3$O@(4GDth1_70gb~&Sn!$(HoPAQGtPhDnlzmwqrj!I@Be-j=yJ$kBgi5 zXdHR>)~#1Py}g7u?8*8MS0wqv(tmYyKH@Lk-PwHe>XpB@xA*@2``gMrJhtKWaZGQk zVqm#$JL}_8Twx4+W-84cyBpJ)wA}YRx3;!MhKFPIN}WyXf;p<@hKF3TJ2G@rFGW7N z0f*D!nok_M!f)A=WE|zDwr}6Q#g&!z2**B66>6>x*w(g8eQ`pH>Yd2Q$fM-s1W8I! zm7&Mwt9>p8DD3G_vNW7JEcyDCUgUfxO@DJ}=gZD^7lb4xCW>t@w~B9< z>CA~vFnVldeXXnZZg2locn}xxA%-B02 zoT}(H)6^>=o578e(l;O}4^-&5DL_hSUl)8rh$VCXtt zow%?J_B!pcyBYP}mtH$pR7gk@j;GaawfB;Of`Xx;VY18WoUqek9D~cu6+))Iei*++ z%Zno?13!F7g5A%YFqZ+d1}`?R@Z9ITHt4OYsE8*{cbLOn%=*hp%eEHph;IF^oN9`G z&fvCu7ppY<-G>6N))_ zjQW&F!vjCj1X$X1rpI<_r1KOjGZWK9C8c;z0#DwVpdwh#M>XTW9k;X=yY`4*QSdw`WG?(S^!fsgmXR&A_w zTM=9CaIv00f8)TR|dXO-<1E z*j8lUkV&~s-!nX$Ce`*hWq0_Ooid8 zQ>RYbPqkeUwx3dYvdOk6I6UuzA#*UwxOZnJH*E0 zmE7-CQ`OGk-X%slr+jnke3Ty}?&9Kd_JWKrdsZX8W6%A+4<1x^4L@?^h!@i^tc>mPyS`EvnmPzAf|U@7Z(*@l;4zR0 zOm1Dxx9E209M04)@AH4?x=rk;%9oLm3HjBBzkWaxcY>Z?cStZ~vrOk_sK5U`ST_?h z^BGG^OX>bHH}X`1R&KvHqdVMXXDMp;l*M#kY00^owV}6MZ}LaS#%Ql!zyAHx{YuWw z5AIO~R{dq)!4iFPj2mf79A>Z1m9FYZ;ub~%6llHfp9Zt%pK42f5H8}F@vEcHWxmv_ zyuAE

>-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 diff --git a/benchmarks/graphs/latency_distribution.png b/benchmarks/graphs/latency_distribution.png deleted file mode 100644 index 262e4709ffc7ab0b76182bc294e0753fe27df8ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31948 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 HcjG?*1rfXk diff --git a/benchmarks/graphs/memory_usage.png b/benchmarks/graphs/memory_usage.png deleted file mode 100644 index 05d1cce6042c24286940c0ec9446205337488d33..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28003 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 EKa{9#w*UYD diff --git a/benchmarks/results/deletion-1000x64.json b/benchmarks/results/deletion-1000x64.json new file mode 100644 index 0000000..918c1b4 --- /dev/null +++ b/benchmarks/results/deletion-1000x64.json @@ -0,0 +1,83 @@ +{ + "environment": { + "vecstream_revision": "ea285da89d72a3e9d05e96ac7d760ccb03cdcf4a", + "working_tree_dirty": true, + "platform": "macOS-27.0-arm64-arm-64bit-Mach-O", + "architecture": "arm64", + "processor": "arm", + "python": "3.14.3", + "numpy": "2.5.3" + }, + "experiment": "deletion", + "configuration": { + "experiment": "deletion", + "vectors": 1000, + "queries": 50, + "dim": 64, + "k": 10, + "M": 16, + "ef_construction": 100, + "ef_search": [ + 20 + ], + "warmups": 5, + "dataset_seed": 42, + "graph_seed": 43, + "order_seeds": [ + 1, + 2, + 3, + 4, + 5 + ], + "deletion_seed": 44 + }, + "results": { + "initial_build_seconds": 7.59150420900005, + "rebuild_seconds": 5.429771624998466, + "ef_search": 20, + "stages": [ + { + "deleted_fraction": 0.0, + "remaining_vectors": 1000, + "recall@10": 0.908, + "p50_ms": 0.7918335, + "p99_ms": 0.8779598299999999, + "qps": 1266.2045695296988 + }, + { + "deleted_fraction": 0.05, + "remaining_vectors": 950, + "recall@10": 0.916, + "p50_ms": 0.718459, + "p99_ms": 0.81786067, + "qps": 1381.205892743672 + }, + { + "deleted_fraction": 0.1, + "remaining_vectors": 900, + "recall@10": 0.916, + "p50_ms": 0.7032499999999999, + "p99_ms": 0.770887, + "qps": 1410.7041351942103 + }, + { + "deleted_fraction": 0.2, + "remaining_vectors": 800, + "recall@10": 0.916, + "p50_ms": 0.6229374999999999, + "p99_ms": 0.71083683, + "qps": 1591.5921660942295 + }, + { + "deleted_fraction": 0.2, + "remaining_vectors": 800, + "state": "rebuilt", + "recall@10": 0.954, + "p50_ms": 0.7115, + "p99_ms": 0.77522308, + "qps": 1408.3086832370143 + } + ] + } +} diff --git a/benchmarks/results/frontier-1000x64.json b/benchmarks/results/frontier-1000x64.json new file mode 100644 index 0000000..cc0b7b2 --- /dev/null +++ b/benchmarks/results/frontier-1000x64.json @@ -0,0 +1,82 @@ +{ + "environment": { + "vecstream_revision": "ea285da89d72a3e9d05e96ac7d760ccb03cdcf4a", + "working_tree_dirty": true, + "platform": "macOS-27.0-arm64-arm-64bit-Mach-O", + "architecture": "arm64", + "processor": "arm", + "python": "3.14.3", + "numpy": "2.5.3" + }, + "experiment": "frontier", + "configuration": { + "experiment": "frontier", + "vectors": 1000, + "queries": 50, + "dim": 64, + "k": 10, + "M": 16, + "ef_construction": 100, + "ef_search": [ + 10, + 20, + 40, + 80, + 160 + ], + "warmups": 5, + "dataset_seed": 42, + "graph_seed": 43, + "order_seeds": [ + 1, + 2, + 3, + 4, + 5 + ], + "deletion_seed": 44 + }, + "results": { + "build": { + "seconds": 7.368972875001418, + "vectors_per_second": 135.7041228082696 + }, + "search": [ + { + "ef_search": 10, + "recall@10": 0.768, + "p50_ms": 0.5220625, + "p99_ms": 0.6656896599999998, + "qps": 1901.921267610745 + }, + { + "ef_search": 20, + "recall@10": 0.908, + "p50_ms": 0.7463960000000001, + "p99_ms": 0.8570514899999998, + "qps": 1327.4483829660974 + }, + { + "ef_search": 40, + "recall@10": 0.99, + "p50_ms": 1.106354, + "p99_ms": 1.18859933, + "qps": 903.4688523963364 + }, + { + "ef_search": 80, + "recall@10": 0.996, + "p50_ms": 1.4753545, + "p99_ms": 1.6519151699999997, + "qps": 673.8146814758736 + }, + { + "ef_search": 160, + "recall@10": 1.0, + "p50_ms": 1.7770415, + "p99_ms": 1.86097267, + "qps": 561.7590671761492 + } + ] + } +} diff --git a/benchmarks/results/insertion-order-1000x64.json b/benchmarks/results/insertion-order-1000x64.json new file mode 100644 index 0000000..efd3ce6 --- /dev/null +++ b/benchmarks/results/insertion-order-1000x64.json @@ -0,0 +1,86 @@ +{ + "environment": { + "vecstream_revision": "ea285da89d72a3e9d05e96ac7d760ccb03cdcf4a", + "working_tree_dirty": true, + "platform": "macOS-27.0-arm64-arm-64bit-Mach-O", + "architecture": "arm64", + "processor": "arm", + "python": "3.14.3", + "numpy": "2.5.3" + }, + "experiment": "insertion-order", + "configuration": { + "experiment": "insertion-order", + "vectors": 1000, + "queries": 50, + "dim": 64, + "k": 10, + "M": 16, + "ef_construction": 100, + "ef_search": [ + 20 + ], + "warmups": 5, + "dataset_seed": 42, + "graph_seed": 43, + "order_seeds": [ + 1, + 2, + 3, + 4, + 5 + ], + "deletion_seed": 44 + }, + "results": { + "ef_search": 20, + "runs": [ + { + "insertion_order_seed": 1, + "build_seconds": 7.245805540998845, + "recall@10": 0.914, + "p50_ms": 0.738146, + "p99_ms": 0.85547358, + "qps": 1350.7580764999675 + }, + { + "insertion_order_seed": 2, + "build_seconds": 7.160134833000484, + "recall@10": 0.91, + "p50_ms": 0.763146, + "p99_ms": 0.81895866, + "qps": 1316.2195657459965 + }, + { + "insertion_order_seed": 3, + "build_seconds": 7.097335499998735, + "recall@10": 0.922, + "p50_ms": 0.7316245, + "p99_ms": 1.101236249999999, + "qps": 1337.7733853623724 + }, + { + "insertion_order_seed": 4, + "build_seconds": 7.23738725000112, + "recall@10": 0.904, + "p50_ms": 0.7155210000000001, + "p99_ms": 0.78817041, + "qps": 1393.906409889777 + }, + { + "insertion_order_seed": 5, + "build_seconds": 7.005199167000683, + "recall@10": 0.908, + "p50_ms": 0.708187, + "p99_ms": 0.80132342, + "qps": 1398.718499705444 + } + ], + "summary": { + "mean_recall": 0.9116000000000001, + "recall_stddev": 0.006118823416311347, + "min_recall": 0.904, + "max_recall": 0.922 + } + } +} diff --git a/benchmarks/results/smoke-1000x64-seed42.json b/benchmarks/results/smoke-1000x64-seed42.json deleted file mode 100644 index dc5efe5..0000000 --- a/benchmarks/results/smoke-1000x64-seed42.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "environment": { - "platform": "macOS-27.0-arm64-arm-64bit-Mach-O", - "processor": "arm", - "python": "3.14.3", - "numpy": "2.5.3" - }, - "configuration": { - "vectors": 1000, - "queries": 50, - "dim": 64, - "k": 10, - "M": 16, - "ef_construction": 100, - "ef_search": [ - 10, - 20, - 40, - 80, - 160 - ], - "warmups": 5, - "seed": 42 - }, - "build": { - "seconds": 7.771016916000008, - "vectors_per_second": 128.68328698925703 - }, - "search": [ - { - "ef_search": 10, - "recall@10": 0.758, - "p50_ms": 0.538146, - "p95_ms": 0.9373293499999998, - "p99_ms": 1.0919939199999997, - "qps": 1731.4193254514971 - }, - { - "ef_search": 20, - "recall@10": 0.91, - "p50_ms": 0.8258125000000001, - "p95_ms": 1.3074291999999998, - "p99_ms": 1.5520239899999992, - "qps": 1135.9571186184144 - }, - { - "ef_search": 40, - "recall@10": 0.988, - "p50_ms": 1.241104, - "p95_ms": 1.8408544999999998, - "p99_ms": 2.2981681699999985, - "qps": 752.7536292322537 - }, - { - "ef_search": 80, - "recall@10": 0.996, - "p50_ms": 1.651875, - "p95_ms": 2.24563475, - "p99_ms": 2.8083543299999985, - "qps": 566.532924106207 - }, - { - "ef_search": 160, - "recall@10": 1.0, - "p50_ms": 1.9066670000000001, - "p95_ms": 2.51787285, - "p99_ms": 2.6939151699999995, - "qps": 505.18766605329137 - } - ] -} diff --git a/benchmarks/test_cli_stress.py b/benchmarks/test_cli_stress.py deleted file mode 100644 index 5b6ad0d..0000000 --- a/benchmarks/test_cli_stress.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Stress tests for CLI operations under high load.""" - -import tempfile -import os -import numpy as np -from click.testing import CliRunner -from vecstream.cli import main as cli_main -from rich.console import Console -from rich.table import Table -from rich import box - -console = Console() - -def print_benchmark_results(benchmark): - """Print benchmark results in a nice table format.""" - if not benchmark or not hasattr(benchmark, 'stats'): - console.print("[red]No benchmark results available[/red]") - return - - stats = benchmark.stats - - # Create the table - table = Table( - title="🚀 VecStream CLI Performance Results", - box=box.ROUNDED, - header_style="bold magenta", - title_style="bold cyan", - border_style="blue" - ) - - # Add columns - table.add_column("Metric", style="cyan") - table.add_column("Value", justify="right", style="green") - - # Add rows - table.add_row("Min Time", f"{stats.min * 1000:.2f} ms") - table.add_row("Max Time", f"{stats.max * 1000:.2f} ms") - table.add_row("Mean Time", f"{stats.mean * 1000:.2f} ms") - table.add_row("Std Dev", f"{stats.stddev * 1000:.2f} ms") - table.add_row("Operations/sec", f"{1 / stats.mean:.2f}") - table.add_row("Rounds", str(stats.rounds)) - - # Print the table - console.print() - console.print(table) - console.print() - -def generate_large_batch_data(num_vectors=1000, dimension=384): - """Generate a large batch of test vectors.""" - return { - f"stress_vec_{i}": np.random.random(dimension).astype(np.float32).tolist() - for i in range(num_vectors) - } - -def test_cli_bulk_add_stress(benchmark): - """Stress test adding many vectors through CLI.""" - runner = CliRunner() - vectors = generate_large_batch_data(num_vectors=1000) - - with tempfile.TemporaryDirectory() as tmpdir: - db_path = os.path.join(tmpdir, "stress_test.vec") - - def bulk_add(): - for vec_id, vector in list(vectors.items())[:100]: # Test with 100 vectors per batch - result = runner.invoke(cli_main, [ - 'add', - '--id', vec_id, - '--text', f"Test vector {vec_id}", # Using text instead of raw vector - '--db-path', db_path - ]) - assert result.exit_code == 0 - - try: - result = benchmark(bulk_add) - console.rule("[bold red]Bulk Add Stress Test") - print_benchmark_results(result) - except Exception as e: - console.print(f"[red]Error in bulk add test: {str(e)}[/red]") - -def test_cli_concurrent_search_stress(benchmark): - """Stress test concurrent search operations.""" - runner = CliRunner() - - with tempfile.TemporaryDirectory() as tmpdir: - db_path = os.path.join(tmpdir, "stress_test.vec") - - # First populate the database - for i in range(500): # Add 500 vectors for search - runner.invoke(cli_main, [ - 'add', - '--id', f'stress_vec_{i}', - '--text', f'Test vector {i}', - '--db-path', db_path - ]) - - def concurrent_search(): - # Simulate concurrent searches - for _ in range(10): # 10 concurrent searches - result = runner.invoke(cli_main, [ - 'search', - '--text', 'Test vector', # Search by text - '--k', '10', - '--db-path', db_path - ]) - assert result.exit_code == 0 - - try: - result = benchmark(concurrent_search) - console.rule("[bold red]Concurrent Search Stress Test") - print_benchmark_results(result) - except Exception as e: - console.print(f"[red]Error in concurrent search test: {str(e)}[/red]") - -def test_cli_mixed_operations_stress(benchmark): - """Stress test mixed operations (add, search, delete).""" - runner = CliRunner() - - with tempfile.TemporaryDirectory() as tmpdir: - db_path = os.path.join(tmpdir, "stress_test.vec") - - # Initial population - for i in range(50): - runner.invoke(cli_main, [ - 'add', - '--id', f'stress_vec_{i}', - '--text', f'Test vector {i}', - '--db-path', db_path - ]) - - def mixed_operations(): - # Add operation - vec_id = f"stress_vec_{np.random.randint(1000)}" - runner.invoke(cli_main, [ - 'add', - '--id', vec_id, - '--text', f'Test vector {vec_id}', - '--db-path', db_path - ]) - - # Search operation - runner.invoke(cli_main, [ - 'search', - '--text', 'Test vector', - '--k', '5', - '--db-path', db_path - ]) - - # Delete operation - runner.invoke(cli_main, [ - 'remove', # Changed from delete to remove - '--id', vec_id, - '--db-path', db_path - ]) - - try: - result = benchmark(mixed_operations) - console.rule("[bold red]Mixed Operations Stress Test") - print_benchmark_results(result) - except Exception as e: - console.print(f"[red]Error in mixed operations test: {str(e)}[/red]") - -def test_cli_large_batch_processing(benchmark): - """Stress test processing large batches of vectors.""" - runner = CliRunner() - - with tempfile.TemporaryDirectory() as tmpdir: - db_path = os.path.join(tmpdir, "stress_test.vec") - - def batch_process(): - # Generate and process a large batch of vectors - batch_size = 100 - - # Add vectors - for i in range(batch_size): - runner.invoke(cli_main, [ - 'add', - '--id', f'stress_vec_{i}', - '--text', f'Test vector {i}', - '--db-path', db_path - ]) - - # Perform batch search - runner.invoke(cli_main, [ - 'search', - '--text', 'Test vector', - '--k', str(batch_size // 2), - '--db-path', db_path - ]) - - try: - result = benchmark(batch_process) - console.rule("[bold red]Large Batch Processing Test") - print_benchmark_results(result) - except Exception as e: - console.print(f"[red]Error in batch processing test: {str(e)}[/red]") \ No newline at end of file diff --git a/benchmarks/test_vector_operations.py b/benchmarks/test_vector_operations.py deleted file mode 100644 index 029b38d..0000000 --- a/benchmarks/test_vector_operations.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Benchmark tests for vector operations.""" - -import numpy as np -from vecstream import VectorStore - -def generate_random_vectors(num_vectors, dimension): - """Generate random vectors for testing.""" - return { - f"vec_{i}": np.random.random(dimension).astype(np.float32) - for i in range(num_vectors) - } - -def test_add_vector_benchmark(benchmark): - """Benchmark vector addition performance.""" - store = VectorStore() - vector = np.random.random(384).astype(np.float32) - - def add_vector(): - store.add_vector("test_vec", vector) - - benchmark(add_vector) - -def test_search_similar_benchmark(benchmark): - """Benchmark similarity search performance.""" - store = VectorStore() - dimension = 384 - num_vectors = 10000 - - # Add test vectors - vectors = generate_random_vectors(num_vectors, dimension) - for vec_id, vector in vectors.items(): - store.add_vector(vec_id, vector) - - # Create query vector - query = np.random.random(dimension).astype(np.float32) - - def search_similar(): - store.search_similar(query, k=10) - - benchmark(search_similar) - -def test_bulk_add_benchmark(benchmark): - """Benchmark bulk vector addition performance.""" - store = VectorStore() - dimension = 384 - num_vectors = 1000 - vectors = generate_random_vectors(num_vectors, dimension) - - def bulk_add(): - for vec_id, vector in vectors.items(): - store.add_vector(vec_id, vector) - - benchmark(bulk_add) - -def test_vector_normalization_benchmark(benchmark): - """Benchmark vector normalization performance.""" - dimension = 384 - num_vectors = 1000 - vectors = list(generate_random_vectors(num_vectors, dimension).values()) - - def normalize_vectors(): - norms = np.linalg.norm(vectors, axis=1, keepdims=True) - normalized = vectors / norms - return normalized - - benchmark(normalize_vectors) \ No newline at end of file diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md deleted file mode 100644 index 4455493..0000000 --- a/docs/advanced_usage.md +++ /dev/null @@ -1,287 +0,0 @@ -# Advanced Usage Guide - -## Custom Embedding Models - -VecStream supports any embedding model that produces numerical vectors. Here's how to use a custom model: - -```python -from transformers import AutoModel, AutoTokenizer -from vecstream import VectorStore - -class CustomEmbedding: - def __init__(self): - self.model = AutoModel.from_pretrained("your-model-name") - self.tokenizer = AutoTokenizer.from_pretrained("your-model-name") - - def encode(self, text): - tokens = self.tokenizer(text, return_tensors="pt", padding=True, truncation=True) - outputs = self.model(**tokens) - return outputs.last_hidden_state.mean(dim=1).squeeze().tolist() - -# Use custom embeddings -embedder = CustomEmbedding() -store = VectorStore() - -# Add vectors -text = "Example text" -vector = embedder.encode(text) -store.add_vector("doc1", vector) -``` - -## Batch Operations - -For better performance when adding many vectors: - -```python -from vecstream import VectorStore, IndexManager -import numpy as np - -store = VectorStore() -index = IndexManager(store) - -# Generate random vectors -vectors = [(f"vec_{i}", np.random.randn(384)) for i in range(10000)] - -# Batch add vectors -for vec_id, vector in vectors: - store.add_vector(vec_id, vector.tolist()) - -# Create index once after adding all vectors -index.create_index() -``` - -## Persistent Storage with Automatic Saving - -```python -from vecstream import PersistentVectorStore -import time - -class AutoSaveStore(PersistentVectorStore): - def __init__(self, filepath, save_interval=300): # 5 minutes - super().__init__(filepath) - self.last_save = time.time() - self.save_interval = save_interval - - def add_vector(self, id, vector): - super().add_vector(id, vector) - if time.time() - self.last_save > self.save_interval: - self.save() - self.last_save = time.time() - -# Use auto-saving store -store = AutoSaveStore("vectors.vec", save_interval=60) # Save every minute -``` - -## Client-Server with Load Balancing - -```python -from vecstream import VectorDBServer, VectorDBClient -import random - -class LoadBalancedClient: - def __init__(self, servers): - self.servers = servers - self.clients = [VectorDBClient(host, port) for host, port in servers] - - def add_vector(self, id, vector): - # Round-robin distribution - client_idx = hash(id) % len(self.clients) - return self.clients[client_idx].add_vector(id, vector) - - def search_similar(self, query, k=5): - # Query all servers and merge results - all_results = [] - for client in self.clients: - results = client.search_similar(query, k=k) - all_results.extend(results) - - # Sort by similarity and return top k - return sorted(all_results, key=lambda x: x[1], reverse=True)[:k] - -# Usage -servers = [ - ("localhost", 5000), - ("localhost", 5001), - ("localhost", 5002) -] -client = LoadBalancedClient(servers) -``` - -## Custom Index Strategies - -```python -from vecstream import IndexManager -import faiss - -class FaissIndexManager(IndexManager): - def create_index(self): - dimension = next(iter(self.store.vectors.values())).shape[0] - self.index = faiss.IndexFlatL2(dimension) - - # Add existing vectors - vectors = list(self.store.vectors.values()) - if vectors: - self.index.add(np.array(vectors)) - - def update_index(self): - self.create_index() # Recreate index for simplicity - - def search(self, query, k): - distances, indices = self.index.search(np.array([query]), k) - vector_ids = list(self.store.vectors.keys()) - return [(vector_ids[i], 1/(1 + d)) for i, d in zip(indices[0], distances[0])] - -# Usage -store = VectorStore() -index = FaissIndexManager(store) -``` - -## Thread-Safe Vector Store - -```python -from vecstream import VectorStore -from threading import Lock - -class ThreadSafeVectorStore(VectorStore): - def __init__(self): - super().__init__() - self.lock = Lock() - - def add_vector(self, id, vector): - with self.lock: - super().add_vector(id, vector) - - def get_vector(self, id): - with self.lock: - return super().get_vector(id) - - def remove_vector(self, id): - with self.lock: - super().remove_vector(id) - - def search_similar(self, query, k=5): - with self.lock: - return super().search_similar(query, k) - -# Usage -store = ThreadSafeVectorStore() -``` - -## Performance Monitoring - -```python -from vecstream import VectorStore -import time -import logging - -class MonitoredVectorStore(VectorStore): - def __init__(self): - super().__init__() - self.logger = logging.getLogger("vecstream") - self.metrics = { - "add_count": 0, - "search_count": 0, - "total_add_time": 0, - "total_search_time": 0 - } - - def add_vector(self, id, vector): - start = time.time() - super().add_vector(id, vector) - duration = time.time() - start - - self.metrics["add_count"] += 1 - self.metrics["total_add_time"] += duration - self.logger.info(f"Added vector {id} in {duration:.4f}s") - - def search_similar(self, query, k=5): - start = time.time() - results = super().search_similar(query, k) - duration = time.time() - start - - self.metrics["search_count"] += 1 - self.metrics["total_search_time"] += duration - self.logger.info(f"Search completed in {duration:.4f}s") - - return results - - def get_metrics(self): - metrics = self.metrics.copy() - metrics["avg_add_time"] = ( - metrics["total_add_time"] / metrics["add_count"] - if metrics["add_count"] > 0 else 0 - ) - metrics["avg_search_time"] = ( - metrics["total_search_time"] / metrics["search_count"] - if metrics["search_count"] > 0 else 0 - ) - return metrics - -# Usage -store = MonitoredVectorStore() -``` - -## Error Recovery - -```python -from vecstream import PersistentVectorStore -import json -import os - -class RecoverableStore(PersistentVectorStore): - def __init__(self, filepath): - super().__init__(filepath) - self.backup_path = filepath + ".backup" - self.transaction_log = filepath + ".log" - - def add_vector(self, id, vector): - # Log operation before executing - with open(self.transaction_log, "a") as f: - json.dump({"op": "add", "id": id, "vector": vector}, f) - f.write("\n") - - super().add_vector(id, vector) - - def save(self): - # Create backup of current state - if os.path.exists(self.filepath): - os.rename(self.filepath, self.backup_path) - - try: - super().save() - # Clear transaction log after successful save - os.remove(self.transaction_log) - except Exception as e: - # Restore from backup if save fails - if os.path.exists(self.backup_path): - os.rename(self.backup_path, self.filepath) - raise e - -# Usage -store = RecoverableStore("vectors.vec") -``` - -## Best Practices Summary - -1. **Memory Management** - - Use batch operations for large datasets - - Implement periodic saving for persistent storage - - Monitor memory usage and implement cleanup strategies - -2. **Performance** - - Create indices after batch additions - - Use appropriate batch sizes - - Implement caching for frequent queries - - Consider distributed storage for large datasets - -3. **Reliability** - - Implement proper error handling - - Use transaction logs for critical operations - - Create backup strategies - - Monitor system performance - -4. **Scalability** - - Use load balancing for distributed setups - - Implement proper thread safety - - Consider sharding for large datasets - - Monitor and optimize resource usage diff --git a/docs/api_reference.md b/docs/api_reference.md deleted file mode 100644 index 24c61a3..0000000 --- a/docs/api_reference.md +++ /dev/null @@ -1,182 +0,0 @@ -# VecStream API Reference - -## Core Classes - -### VectorStore - -The base class for vector storage and retrieval. - -```python -from vecstream import VectorStore - -store = VectorStore() -``` - -#### Methods - -- `add_vector(id: str, vector: List[float]) -> None` - - Add a vector to the store with the given ID - - Raises `ValueError` if vector dimensions don't match - -- `get_vector(id: str) -> List[float]` - - Retrieve a vector by ID - - Raises `KeyError` if ID not found - -- `remove_vector(id: str) -> None` - - Remove a vector from the store - - Raises `KeyError` if ID not found - -- `search_similar(query: List[float], k: int = 5, threshold: float = 0.0) -> List[Tuple[str, float]]` - - Find k most similar vectors to the query - - Returns list of (id, similarity) tuples - -### PersistentVectorStore - -Extends VectorStore with persistent storage capabilities. - -```python -from vecstream import PersistentVectorStore - -store = PersistentVectorStore("vectors.vec") -``` - -#### Additional Methods - -- `save() -> None` - - Save the current state to disk - -- `load() -> None` - - Load the state from disk - -### IndexManager - -Manages vector indexing for efficient similarity search. - -```python -from vecstream import IndexManager - -index = IndexManager(vector_store) -``` - -#### Methods - -- `create_index() -> None` - - Create or recreate the search index - -- `update_index() -> None` - - Update the index with new vectors - -- `search(query: List[float], k: int) -> List[Tuple[str, float]]` - - Search the index for similar vectors - -### QueryEngine - -High-level interface for vector queries. - -```python -from vecstream import QueryEngine - -engine = QueryEngine(index_manager) -``` - -#### Methods - -- `search(query: List[float], k: int = 5, threshold: float = 0.0) -> List[Tuple[str, float]]` - - Search for similar vectors with optional filtering - -### VectorDBServer - -Server component for distributed vector storage. - -```python -from vecstream import VectorDBServer - -server = VectorDBServer(host="localhost", port=5000) -server.start() -``` - -#### Methods - -- `start() -> None` - - Start the server - -- `stop() -> None` - - Stop the server - -### VectorDBClient - -Client component for connecting to a VectorDBServer. - -```python -from vecstream import VectorDBClient - -client = VectorDBClient(host="localhost", port=5000) -``` - -#### Methods - -- `add_vector(id: str, vector: List[float]) -> None` - - Add a vector through the server - -- `search_similar(query: List[float], k: int = 5) -> List[Tuple[str, float]]` - - Search for similar vectors through the server - -## CLI Commands - -### Add Vector -```bash -vecstream add TEXT ID [--model MODEL_NAME] -``` - -### Search Vectors -```bash -vecstream search TEXT [--k K] [--threshold THRESHOLD] [--model MODEL_NAME] -``` - -### Get Vector -```bash -vecstream get ID -``` - -### Remove Vector -```bash -vecstream remove ID -``` - -### Database Info -```bash -vecstream info -``` - -### Clear Database -```bash -vecstream clear -``` - -## Error Handling - -The library uses the following exception types: - -- `VectorDimensionError`: When vector dimensions don't match -- `VectorNotFoundError`: When a vector ID is not found -- `IndexNotInitializedError`: When trying to search without an index -- `ConnectionError`: When client-server communication fails - -## Best Practices - -1. **Memory Management** - - Use PersistentVectorStore for large datasets - - Call save() periodically when adding many vectors - -2. **Performance Optimization** - - Create indices before searching - - Batch vector additions when possible - - Use appropriate k values for search - -3. **Error Handling** - - Always catch specific exceptions - - Implement proper cleanup in client-server mode - -4. **Thread Safety** - - VectorStore operations are not thread-safe by default - - Use appropriate synchronization in multi-threaded environments \ No newline at end of file diff --git a/docs/audit.md b/docs/audit.md index 71b7c9b..d01318b 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -12,12 +12,19 @@ The initial repository had a useful narrow core but its claims exceeded its evid - Exact search looped in Python and repeatedly normalized vectors; it is now a vectorized oracle. - Persistence pickled a dictionary, overwrote files, and suppressed every exception. It now uses versioned immutable generations and explicit errors. - There was no invariant checker, deterministic seed, statistical level test, property test, or ANN recall benchmark. -- Packaging made large ML libraries core dependencies. Core now requires only NumPy; CLI and text embedding are extras. +- Packaging made large ML libraries core dependencies. Core now requires only NumPy. - Unsupported speed, compression, optimization, and scalability claims were removed. +## Stage Two cleanup + +- Removed `IndexManager`, `QueryEngine`, `PersistentVectorStore`, client/server, CLI, text-embedding dependencies, and their compatibility exports. +- Removed obsolete examples, stress scripts, benchmark code, generated plots, and historical results. +- The only supported path is now `Collection` composed from `BinaryVectorStore` and `HNSWIndex`; `VectorStore` supplies exact-search ground truth. + +## Stage Two evidence + +- Stage Two added measured insertion-order variance and physical-deletion/rebuild experiments using exact ground truth. + ## Remaining findings -- `CollectionManager`, `IndexManager`, `QueryEngine`, CLI, and legacy client/server overlap. The primary path is `Collection` + store + HNSW; compatibility deletion requires a breaking release. -- The legacy client/server API mismatch was repaired, but the protocol remains a compatibility layer rather than the project's focus. -- HNSW graph persistence, tombstone compaction, generation cleanup, adversarial datasets, insertion-order variance, and memory accounting remain open. -- Old benchmark images/results predate the recall-aware harness and are not current evidence. +- HNSW graph persistence, tombstone compaction, generation cleanup, adversarial datasets, and memory accounting remain open. diff --git a/docs/design.md b/docs/design.md index 6f3f40b..5edeba0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,6 +20,6 @@ Querying greedily descends upper layers, then performs a best-first level-0 sear Graph vectors are normalized at insertion, making cosine distance `1 - dot(a, b)`. Zero vectors normalize to zero and therefore have similarity zero. -`validate()` checks entry-point, node/level membership, edge target, reciprocal-edge, degree, and finite-value invariants. Level-0 degree is at most `2M`; upper-layer degree is at most `M`. +`validate()` checks that the entry point occupies the maximum level; IDs agree across raw vectors, normalized vectors, and levels; nodes occupy exactly their allowed levels; edges have existing reciprocal targets; self-edges are absent; degree limits hold; and raw/normalized vectors have valid shapes, finite values, and normalized norms of zero or one. Level-0 degree is at most `2M`; upper-layer degree is at most `M`. The graph requires vector memory `O(Nd)` and adjacency memory approximately `O(NM)`. Expected search behavior is logarithmic on well-formed data, but HNSW has no worst-case logarithmic guarantee and quality depends on parameters, distribution, and insertion order. diff --git a/docs/limitations.md b/docs/limitations.md index b88da80..a7f4564 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -3,9 +3,9 @@ - The HNSW graph is in memory and is rebuilt from collection vectors at startup. Graph serialization remains future work; startup includes construction cost. - Search is approximate. Quality is recall against exact top-k, not generic “accuracy.” - Metadata predicates are post-filtered. A matching vector outside the ANN candidate set can be missed; results are not exact filtered top-k. -- Deletion removes a node and incident edges. Returned IDs are correct, but navigability may degrade. Tombstones plus compaction are not yet implemented. +- Deletion removes a node and incident edges. Returned IDs remain correct, but links are not repaired. In the committed Gaussian experiment, recall did not decline monotonically through 20% deletion; rebuilding improved recall from 0.916 to 0.954. - Objects provide no internal locking. Concurrent read-only searches on an immutable index do not mutate state, but mutation requires exclusive ownership. There is no process-safe writer protocol. - Checkpoints protect vector-store mutation visibility and can recover one prior generation. They are not multi-collection transactions; old generations are not yet garbage-collected. - Python heap and adjacency traversal constrain throughput. Threads are not claimed to scale CPU-bound query work. - The engine is single-machine, with no replication, authentication, distributed query, or transactional multi-client semantics. -- HNSW quality varies with distribution and insertion order. Clustered, duplicate, concentrated, and shuffled-order studies remain to be published. +- HNSW quality varies with distribution and insertion order. The committed five-order experiment measured recall@10 from 0.904 to 0.922. Clustered and concentrated distribution studies remain unmeasured. diff --git a/docs/performance.md b/docs/performance.md index 4c90adb..898372d 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,8 +1,12 @@ # Performance methodology and current evidence -The harness creates one seeded dataset, builds exact and HNSW stores, computes exact top-k once, then sweeps `ef_search`. Each configuration performs warmups followed by measured single-query searches. It records the environment, dataset, seed, HNSW parameters, build throughput, recall@k, latency percentiles, and QPS. +The harness creates one seeded dataset, computes exact top-k, and runs three experiments: an `ef_search` frontier, independently seeded insertion-order shuffles, and cumulative physical deletion followed by rebuild. Dataset and topology/order/deletion RNG streams are separate. Each configuration performs warmups followed by measured single-query searches and records the Git revision and environment. -The committed 1,000 × 64 smoke run shows recall@10 rising from 0.758 to 1.000 as `ef_search` rises from 10 to 160, while QPS falls from 1,731 to 505. This is a regression signal, not a 100k-scale claim. +The 1,000 × 64 frontier shows recall@10 rising from 0.768 to 1.000 as `ef_search` rises from 10 to 160, while QPS falls from 1,902 to 562. The committed files identify the base revision and explicitly record that the working tree contained the Stage Two changes. + +Five insertion orders at `ef_search=20` ranged from 0.904 to 0.922 recall@10 (mean 0.9116, population standard deviation 0.0061). This demonstrates insertion-order sensitivity on one small Gaussian dataset, not statistical significance. + +Physical deletion produced recall@10 of 0.908, 0.916, 0.916, and 0.916 at 0%, 5%, 10%, and 20% deleted. A rebuild at 20% yielded 0.954. Deletion did not monotonically reduce recall here. The honest conclusion is that physical deletion changes graph quality unpredictably and rebuilding produced a better graph for this run. ## Measurement-driven optimization diff --git a/examples/example_advanced_features.py b/examples/example_advanced_features.py deleted file mode 100644 index 3ea4c40..0000000 --- a/examples/example_advanced_features.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env python -""" -Advanced features example for VecStream, demonstrating: -1. HNSW indexing for fast similarity search -2. Collections/namespaces for organizing vectors -3. Metadata filtering for fine-grained search -""" - -import time -import numpy as np -import os -from pathlib import Path -from rich.console import Console -from rich.table import Table - -from vecstream.collections import CollectionManager -from vecstream.hnsw_index import HNSWIndex - -# Create a console for pretty output -console = Console() - -# Create a temporary directory for this example -temp_dir = os.path.join(Path.home(), ".vecstream_example") -os.makedirs(temp_dir, exist_ok=True) - -console.print("\n[bold cyan]VecStream Advanced Features Example[/bold cyan]") -console.print("[dim]This example demonstrates HNSW indexing, collections, and metadata filtering[/dim]\n") - -# Initialize the collection manager -manager = CollectionManager(temp_dir, use_hnsw=True) - -# Create collections for different vector types -science_collection = manager.create_collection("science") -tech_collection = manager.create_collection("technology") -art_collection = manager.create_collection("art") - -console.print("[green]Created three collections: science, technology, art[/green]") - -# Generate some random document vectors and metadata -def create_random_vectors(n=1000, dim=128): - """Create random normalized vectors.""" - vectors = np.random.randn(n, dim) - # Normalize - norms = np.linalg.norm(vectors, axis=1, keepdims=True) - vectors = vectors / norms - return vectors - -# Generate vectors -console.print("[yellow]Generating random vectors...[/yellow]") -n_vectors = 10000 -dim = 128 -all_vectors = create_random_vectors(n_vectors, dim) - -# Split vectors between collections -science_vectors = all_vectors[:3000] -tech_vectors = all_vectors[3000:7000] -art_vectors = all_vectors[7000:] - -# Add vectors to collections with appropriate metadata -console.print("[yellow]Adding vectors to collections with metadata...[/yellow]") - -# Science collection -for i in range(len(science_vectors)): - category = np.random.choice(["physics", "biology", "chemistry"]) - year = np.random.randint(2000, 2024) - metadata = { - "category": category, - "year": year, - "is_peer_reviewed": bool(np.random.choice([True, False], p=[0.8, 0.2])), - "details": { - "citations": np.random.randint(0, 1000), - "journal": np.random.choice(["Nature", "Science", "PNAS", "PLoS ONE"]) - } - } - science_collection.add_vector(f"sci_{i}", science_vectors[i].tolist(), metadata) - -# Technology collection -for i in range(len(tech_vectors)): - category = np.random.choice(["ai", "web", "mobile", "cloud"]) - year = np.random.randint(2010, 2024) - metadata = { - "category": category, - "year": year, - "is_open_source": bool(np.random.choice([True, False])), - "details": { - "stars": np.random.randint(0, 50000), - "language": np.random.choice(["Python", "JavaScript", "Go", "Rust"]) - } - } - tech_collection.add_vector(f"tech_{i}", tech_vectors[i].tolist(), metadata) - -# Art collection -for i in range(len(art_vectors)): - category = np.random.choice(["painting", "sculpture", "digital", "photography"]) - year = np.random.randint(1800, 2024) - metadata = { - "category": category, - "year": year, - "is_exhibited": bool(np.random.choice([True, False])), - "details": { - "artist": np.random.choice(["Anonymous", "Smith", "Johnson", "Garcia", "Wong"]), - "medium": np.random.choice(["Oil", "Acrylic", "Digital", "Mixed"]) - } - } - art_collection.add_vector(f"art_{i}", art_vectors[i].tolist(), metadata) - -console.print("[green]Added vectors to collections with rich metadata![/green]") - -# Show collection stats -console.print("\n[bold cyan]Collection Statistics:[/bold cyan]") -table = Table() -table.add_column("Collection", style="cyan") -table.add_column("Vectors", style="green") -table.add_column("Using HNSW", style="yellow") - -for collection_name in ["science", "technology", "art"]: - stats = manager.get_collection_stats(collection_name) - table.add_row( - collection_name, - str(stats["vector_count"]), - "Yes" if stats["using_hnsw"] else "No" - ) - -console.print(table) - -# Benchmark search performance -console.print("\n[bold cyan]Search Performance Benchmark:[/bold cyan]") - -# Create a query vector (normalized random vector) -query = np.random.randn(dim) -query = query / np.linalg.norm(query) - -# Get the technology collection -tech_coll = manager.get_collection("technology") - -# Benchmark HNSW search -console.print("\n[yellow]Benchmarking HNSW search...[/yellow]") -start_time = time.time() -hnsw_results = tech_coll.search_similar(query.tolist(), k=10) -hnsw_time = time.time() - start_time -console.print(f"[green]HNSW search time: {hnsw_time:.6f} seconds[/green]") - -# Benchmark standard search (by temporarily disabling HNSW) -console.print("\n[yellow]Benchmarking standard search...[/yellow]") -# Store the HNSW index and temporarily disable it -saved_index = tech_coll.hnsw_index -tech_coll.hnsw_index = None -tech_coll.use_hnsw = False - -start_time = time.time() -standard_results = tech_coll.search_similar(query.tolist(), k=10) -standard_time = time.time() - start_time -console.print(f"[green]Standard search time: {standard_time:.6f} seconds[/green]") - -# Restore HNSW index -tech_coll.hnsw_index = saved_index -tech_coll.use_hnsw = True - -# Speed comparison -speedup = standard_time / hnsw_time if hnsw_time > 0 else float('inf') -console.print(f"[bold green]HNSW speedup: {speedup:.2f}x faster![/bold green]") - -# Demonstrate metadata filtering -console.print("\n[bold cyan]Metadata Filtering Examples:[/bold cyan]") - -# Example 1: Filter science papers by category and year -filter1 = {"category": "physics", "year": 2022} -console.print(f"\n[yellow]Searching science collection with filter: {filter1}[/yellow]") -start_time = time.time() -results1 = science_collection.search_similar( - query.tolist(), - k=5, - filter_metadata=filter1 -) -filter_time = time.time() - start_time - -# Print results -table = Table(title="Physics papers from 2022") -table.add_column("ID", style="cyan") -table.add_column("Similarity", style="green") -table.add_column("Metadata", style="white") - -for id, score in results1: - _, metadata = science_collection.get_vector_with_metadata(id) - table.add_row(id, f"{score:.4f}", str(metadata)) - -console.print(table) -console.print(f"[green]Filter search time: {filter_time:.6f} seconds[/green]") - -# Example 2: Filter technology repositories by nested property -filter2 = {"details.language": "Python", "is_open_source": True} -console.print(f"\n[yellow]Searching technology collection with nested filter: {filter2}[/yellow]") -results2 = tech_collection.search_similar( - query.tolist(), - k=5, - filter_metadata=filter2 -) - -# Print results -table = Table(title="Open source Python projects") -table.add_column("ID", style="cyan") -table.add_column("Similarity", style="green") -table.add_column("Stars", style="yellow") -table.add_column("Year", style="blue") - -for id, score in results2: - _, metadata = tech_collection.get_vector_with_metadata(id) - table.add_row( - id, - f"{score:.4f}", - str(metadata["details"]["stars"]), - str(metadata["year"]) - ) - -console.print(table) - -# Clean up -console.print("\n[yellow]Do you want to keep the example data? (y/n)[/yellow]") -response = input().lower() -if response != 'y': - import shutil - shutil.rmtree(temp_dir) - console.print("[green]Example data cleaned up.[/green]") -else: - console.print(f"[green]Example data kept at: {temp_dir}[/green]") - -console.print("\n[bold cyan]Example completed![/bold cyan]") \ No newline at end of file diff --git a/examples/example_binary_store.py b/examples/example_binary_store.py deleted file mode 100644 index 643bd4a..0000000 --- a/examples/example_binary_store.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Example script demonstrating binary vector storage with VecStream. -""" - -from vecstream.binary_store import BinaryVectorStore -import numpy as np -import os -from rich.console import Console -from rich.table import Table - -console = Console() - -def format_size(size_bytes): - """Format size in bytes to human readable format.""" - for unit in ['B', 'KB', 'MB', 'GB']: - if size_bytes < 1024: - return f"{size_bytes:.2f} {unit}" - size_bytes /= 1024 - return f"{size_bytes:.2f} TB" - -def main(): - # Create a binary store in the 'vector_db' directory - store_dir = "vector_db" - store = BinaryVectorStore(store_dir) - - # Sample documents with metadata - documents = [ - { - "text": "Machine learning is a subset of artificial intelligence", - "tags": ["AI", "ML"], - "category": "technology" - }, - { - "text": "Python is a popular programming language", - "tags": ["programming", "Python"], - "category": "software" - }, - { - "text": "Neural networks are inspired by biological brains", - "tags": ["AI", "neuroscience"], - "category": "science" - }, - { - "text": "Data science involves statistical analysis", - "tags": ["data", "statistics"], - "category": "analytics" - }, - { - "text": "Deep learning is revolutionizing AI", - "tags": ["AI", "deep learning"], - "category": "technology" - } - ] - - console.print("\n🔄 [bold cyan]Adding documents to binary store...[/bold cyan]") - # Add documents with vectors and metadata - for i, doc in enumerate(documents): - # Simulate document embedding (normally you'd use a proper embedding model) - vector = np.random.random(384).astype(np.float32).tolist() - doc_id = f"doc_{i}" - - # Add vector with metadata - store.add_vector( - id=doc_id, - vector=vector, - metadata={ - "text": doc["text"], - "tags": doc["tags"], - "category": doc["category"] - } - ) - - # Display store contents - console.print("\n📚 [bold cyan]Current documents in store:[/bold cyan]") - table = Table(show_header=True, header_style="bold magenta") - table.add_column("ID", style="dim") - table.add_column("Text") - table.add_column("Tags", style="cyan") - table.add_column("Category", style="green") - - for i in range(len(documents)): - doc_id = f"doc_{i}" - vector, metadata = store.get_vector_with_metadata(doc_id) - table.add_row( - doc_id, - metadata["text"], - ", ".join(metadata["tags"]), - metadata["category"] - ) - - console.print(table) - - # Perform similarity search - console.print("\n🔍 [bold cyan]Performing similarity search...[/bold cyan]") - query_vector = np.random.random(384).astype(np.float32).tolist() - results = store.search_similar(query_vector, k=3) - - # Display search results - console.print("\n[bold cyan]Top 3 similar documents:[/bold cyan]") - table = Table(show_header=True, header_style="bold magenta") - table.add_column("Score", style="cyan") - table.add_column("Text") - table.add_column("Category", style="green") - - for doc_id, score in results: - _, metadata = store.get_vector_with_metadata(doc_id) - table.add_row( - f"{score:.4f}", - metadata["text"], - metadata["category"] - ) - - console.print(table) - - # Display storage information - vectors_size, metadata_size = store.get_store_size() - total_size = vectors_size + metadata_size - - console.print("\n💾 [bold cyan]Storage Information:[/bold cyan]") - table = Table(show_header=True, header_style="bold magenta") - table.add_column("Metric", style="dim") - table.add_column("Size", style="cyan") - table.add_column("Percentage", style="green") - - table.add_row( - "Vectors File", - format_size(vectors_size), - f"{(vectors_size/total_size)*100:.1f}%" - ) - table.add_row( - "Metadata File", - format_size(metadata_size), - f"{(metadata_size/total_size)*100:.1f}%" - ) - table.add_row( - "Total Size", - format_size(total_size), - "100%" - ) - - console.print(table) - - # Show storage location - console.print(f"\n📁 [bold cyan]Storage Location:[/bold cyan] {os.path.abspath(store_dir)}") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/examples/example_inmemory.py b/examples/example_inmemory.py deleted file mode 100644 index 14ddc6a..0000000 --- a/examples/example_inmemory.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Example script demonstrating in-memory vector storage and search with VecStream. -""" - -from vecstream.vector_store import VectorStore -import numpy as np - -def main(): - # Initialize the in-memory vector store - vector_store = VectorStore() - - # Sample data to store (simulated document embeddings) - documents = [ - "Machine learning is a subset of artificial intelligence", - "Python is a popular programming language", - "Neural networks are inspired by biological brains", - "Data science involves statistical analysis", - "Deep learning is revolutionizing AI" - ] - - # Dictionary to store document texts - doc_texts = {} - - print("🔄 Adding documents to vector store...") - # Add documents with unique IDs - for i, doc in enumerate(documents): - # Simulate document embedding (normally you'd use a proper embedding model) - vector = np.random.random(384).astype(np.float32).tolist() - doc_id = f"doc_{i}" - vector_store.add_vector(id=doc_id, vector=vector) - doc_texts[doc_id] = doc - - print("\n📚 Current documents in store:") - for i in range(len(documents)): - doc_id = f"doc_{i}" - vector = vector_store.get_vector(doc_id) - print(f"ID: {doc_id} | Text: {doc_texts[doc_id]}") - - # Perform a similarity search - print("\n🔍 Performing similarity search...") - query_vector = np.random.random(384).astype(np.float32).tolist() - results = vector_store.search_similar(query_vector, k=3) - - print("\nTop 3 similar documents:") - for doc_id, score in results: - print(f"Score: {score:.4f} | Text: {doc_texts[doc_id]}") - - # Demonstrate removing a document - print("\n🗑️ Removing document 'doc_0'...") - vector_store.remove_vector("doc_0") - doc_texts.pop("doc_0") - - # Verify removal - print("\n📚 Remaining documents:") - for i in range(1, len(documents)): - doc_id = f"doc_{i}" - try: - vector = vector_store.get_vector(doc_id) - print(f"ID: {doc_id} | Text: {doc_texts[doc_id]}") - except KeyError: - continue - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..77490d5 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +python_version = 3.10 +packages = vecstream +ignore_missing_imports = true diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7e91765..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,37 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "vecstream" -version = "0.4.0" -description = "A compact vector-search engine with a from-scratch HNSW implementation" -readme = "README.md" -requires-python = ">=3.10" -license = {text = "MIT"} -authors = [{name = "Torin Etheridge"}] -dependencies = ["numpy>=1.24"] - -[project.optional-dependencies] -cli = ["click>=8", "rich>=13"] -dev = ["hypothesis>=6", "mypy>=1.10", "pytest>=8", "ruff>=0.6"] -text = ["sentence-transformers>=2.2"] - -[project.scripts] -vecstream = "vecstream.cli:main" - -[tool.setuptools.packages.find] -include = ["vecstream*"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "--strict-markers" - -[tool.ruff] -line-length = 100 -target-version = "py310" - -[tool.mypy] -python_version = "3.10" -packages = ["vecstream"] -ignore_missing_imports = true diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6d9ad96 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = --strict-markers diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..6c4c0a3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,2 @@ +line-length = 100 +target-version = "py310" diff --git a/run_tests.py b/run_tests.py deleted file mode 100644 index cf5bb2f..0000000 --- a/run_tests.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python -""" -Run all VecStream tests. -""" - -import os -import sys -import subprocess -import argparse - -def run_tests(run_benchmarks=False): - """Run all VecStream tests using pytest. - - Args: - run_benchmarks: Whether to run benchmarks in addition to tests - - Returns: - Exit code (0 if all tests pass, non-zero otherwise) - """ - # Get the current directory - current_dir = os.path.dirname(os.path.abspath(__file__)) - exit_code = 0 - - # Print header - print("\n===== Running VecStream unit tests =====\n") - - # Run unit tests - result = subprocess.run( - ["pytest", "-xvs", "tests/unit/"], - cwd=current_dir, - capture_output=False - ) - - if result.returncode != 0: - exit_code = result.returncode - - # Run integration tests - print("\n===== Running VecStream integration tests =====\n") - - result_integration = subprocess.run( - ["pytest", "-xvs", "tests/integration/"], - cwd=current_dir, - capture_output=False - ) - - if result_integration.returncode != 0: - exit_code = result_integration.returncode - - # Run benchmarks if requested - if run_benchmarks: - print("\n===== Running VecStream benchmarks =====\n") - - result_benchmarks = subprocess.run( - ["python3", "tests/benchmarks/benchmark_vector_operations.py"], - cwd=current_dir, - capture_output=False - ) - - if result_benchmarks.returncode != 0: - exit_code = result_benchmarks.returncode - - # Return exit code (0 if all tests pass, non-zero otherwise) - return exit_code - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Run VecStream tests") - parser.add_argument( - "--benchmarks", - action="store_true", - help="Run benchmarks in addition to tests" - ) - args = parser.parse_args() - - sys.exit(run_tests(run_benchmarks=args.benchmarks)) \ No newline at end of file diff --git a/sample_texts.txt b/sample_texts.txt deleted file mode 100644 index b019cf9..0000000 --- a/sample_texts.txt +++ /dev/null @@ -1,10 +0,0 @@ -The quick brown fox jumps over the lazy dog. -A fast orange fox leaps across a sleepy canine. -The weather is beautiful today. -The sun is shining brightly in the sky. -Machine learning is fascinating. -Artificial intelligence is transforming industries. -Data science helps us understand patterns. -Neural networks can learn complex relationships. -Deep learning models achieve remarkable results. -Natural language processing is advancing rapidly. \ No newline at end of file diff --git a/scripts/run_server.py b/scripts/run_server.py deleted file mode 100644 index 8c2d9c7..0000000 --- a/scripts/run_server.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -import socket -import threading -import argparse -import sys -import os -import signal - -# Add parent directory to path to import vector_db -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -from vector_db.command_interface import CommandInterface - -class VectorDBServer: - def __init__(self, host='127.0.0.1', port=6379): - self.host = host - self.port = port - self.command_interface = CommandInterface() - self.server_socket = None - self.running = False - - def start(self): - self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - try: - self.server_socket.bind((self.host, self.port)) - self.server_socket.listen(5) - self.running = True - print(f"Server running on {self.host}:{self.port}") - - while self.running: - try: - client_socket, address = self.server_socket.accept() - print(f"Connection from {address}") - client_thread = threading.Thread( - target=self.handle_client, - args=(client_socket,) - ) - client_thread.daemon = True - client_thread.start() - except KeyboardInterrupt: - break - except Exception as e: - print(f"Error accepting connection: {e}") - - finally: - if self.server_socket: - self.server_socket.close() - - def stop(self): - self.running = False - if self.server_socket: - self.server_socket.close() - print("Server stopped") - - def handle_client(self, client_socket): - try: - while True: - data = client_socket.recv(1024) - if not data: - break - - command = data.decode('utf-8').strip() - if command.lower() == "quit" or command.lower() == "exit": - break - - response = self.command_interface.execute_command(command) - client_socket.send(f"{response}\n".encode('utf-8')) - - except Exception as e: - print(f"Error handling client: {e}") - finally: - client_socket.close() - -def signal_handler(sig, frame): - print("\nShutting down server...") - if 'server' in globals(): - server.stop() - sys.exit(0) - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Vector DB Server") - parser.add_argument('--host', default='127.0.0.1', help='Host address') - parser.add_argument('--port', type=int, default=6379, help='Port number') - - args = parser.parse_args() - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - server = VectorDBServer(args.host, args.port) - - try: - server.start() - except KeyboardInterrupt: - print("\nShutting down server...") - server.stop() diff --git a/tests/benchmarks/benchmark_vector_operations.py b/tests/benchmarks/benchmark_vector_operations.py deleted file mode 100644 index 52316d2..0000000 --- a/tests/benchmarks/benchmark_vector_operations.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Benchmark tests for VecStream vector operations. -""" -import time -import os -import numpy as np -import tempfile -import shutil -from vecstream import VectorStore -from vecstream.hnsw_index import HNSWIndex -from vecstream.collections import Collection, CollectionManager -from vecstream.persistent_store import PersistentVectorStore - -def generate_random_vectors(num_vectors, dimension): - """Generate random vectors for benchmarking.""" - vectors = [] - for i in range(num_vectors): - vec = np.random.randn(dimension) - vec = vec / np.linalg.norm(vec) # Normalize - vectors.append((f"vec_{i}", vec.tolist())) - return vectors - -def benchmark_add_vectors(num_vectors=10000, dimension=384): - """Benchmark vector addition performance.""" - store = VectorStore() - vectors = generate_random_vectors(num_vectors, dimension) - - start_time = time.time() - for vec_id, vector in vectors: - store.add_vector(vec_id, vector) - end_time = time.time() - - add_time = end_time - start_time - vectors_per_second = num_vectors / add_time - - return { - "operation": "add_vectors", - "num_vectors": num_vectors, - "dimension": dimension, - "total_time": add_time, - "vectors_per_second": vectors_per_second - } - -def benchmark_search(num_vectors=10000, dimension=384, k=10, num_queries=100): - """Benchmark similarity search performance.""" - # Setup - store = VectorStore() - vectors = generate_random_vectors(num_vectors, dimension) - for vec_id, vector in vectors: - store.add_vector(vec_id, vector) - - # Generate random queries - queries = [vec for _, vec in generate_random_vectors(num_queries, dimension)] - - # Benchmark search - start_time = time.time() - for query in queries: - store.search_similar(query, k=k) - end_time = time.time() - - search_time = end_time - start_time - queries_per_second = num_queries / search_time - - return { - "operation": "search", - "num_vectors": num_vectors, - "dimension": dimension, - "k": k, - "num_queries": num_queries, - "total_time": search_time, - "queries_per_second": queries_per_second - } - -def benchmark_hnsw_index(num_vectors=10000, dimension=128, k=10, num_queries=100, M=16, ef_construction=200): - """Benchmark HNSW index performance.""" - # Setup - hnsw_index = HNSWIndex(dim=dimension, M=M, ef_construction=ef_construction) - vectors = generate_random_vectors(num_vectors, dimension) - - # Benchmark add items - start_time = time.time() - for vec_id, vector in vectors: - hnsw_index.add_item(vec_id, np.array(vector)) - add_time = time.time() - start_time - - # Generate random queries - queries = [np.array(vec) for _, vec in generate_random_vectors(num_queries, dimension)] - - # Benchmark search - start_time = time.time() - for query in queries: - hnsw_index.search(query, k=k) - search_time = time.time() - start_time - - return { - "operation": "hnsw_index", - "num_vectors": num_vectors, - "dimension": dimension, - "M": M, - "ef_construction": ef_construction, - "k": k, - "num_queries": num_queries, - "add_time": add_time, - "search_time": search_time, - "vectors_per_second_add": num_vectors / add_time, - "queries_per_second": num_queries / search_time - } - -def benchmark_collections(num_vectors=5000, dimension=128, k=10, num_queries=50): - """Benchmark collections performance.""" - temp_dir = tempfile.mkdtemp() - try: - # Setup - collection_dir = os.path.join(temp_dir, "collections") - manager = CollectionManager(collection_dir, use_hnsw=True) - collection = manager.create_collection("benchmark_collection") - - vectors = generate_random_vectors(num_vectors, dimension) - - # Create metadata for vectors - metadata_list = [] - for i in range(num_vectors): - metadata = { - "category": f"category_{i % 5}", - "price": i % 100 + 10, - "in_stock": i % 2 == 0, - "details": { - "color": f"color_{i % 10}", - "brand": f"brand_{i % 8}" - } - } - metadata_list.append(metadata) - - # Benchmark add vectors with metadata - start_time = time.time() - for i, (vec_id, vector) in enumerate(vectors): - collection.add_vector(vec_id, vector, metadata_list[i]) - add_time = time.time() - start_time - - # Generate random queries - queries = [vec for _, vec in generate_random_vectors(num_queries, dimension)] - - # Benchmark simple search - start_time = time.time() - for query in queries: - collection.search_similar(query, k=k) - search_time = time.time() - start_time - - # Benchmark search with filter - start_time = time.time() - for query in queries: - collection.search_similar(query, k=k, filter_metadata={"category": "category_1"}) - filter_search_time = time.time() - start_time - - # Benchmark search with nested filter - start_time = time.time() - for query in queries: - collection.search_similar(query, k=k, filter_metadata={"details.brand": "brand_2"}) - nested_filter_time = time.time() - start_time - - return { - "operation": "collections", - "num_vectors": num_vectors, - "dimension": dimension, - "k": k, - "num_queries": num_queries, - "add_time": add_time, - "search_time": search_time, - "filter_search_time": filter_search_time, - "nested_filter_time": nested_filter_time, - "vectors_per_second_add": num_vectors / add_time, - "queries_per_second": num_queries / search_time, - "filter_queries_per_second": num_queries / filter_search_time, - "nested_filter_queries_per_second": num_queries / nested_filter_time - } - finally: - shutil.rmtree(temp_dir) - -def run_benchmarks(): - """Run all benchmarks and save results.""" - import json - import os - from datetime import datetime - - results = { - "timestamp": datetime.now().isoformat(), - "benchmarks": [] - } - - # Run add vectors benchmark with different sizes - for num_vectors in [1000, 10000]: - result = benchmark_add_vectors(num_vectors=num_vectors) - results["benchmarks"].append(result) - - # Run search benchmark with different sizes - for num_vectors in [1000, 10000]: - result = benchmark_search(num_vectors=num_vectors) - results["benchmarks"].append(result) - - # Run HNSW index benchmark - for num_vectors in [1000, 10000]: - result = benchmark_hnsw_index(num_vectors=num_vectors, dimension=128) - results["benchmarks"].append(result) - - # Run collections benchmark - result = benchmark_collections(num_vectors=5000, dimension=128) - results["benchmarks"].append(result) - - # Save results - os.makedirs("tests/benchmarks/results", exist_ok=True) - filename = f"benchmark_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" - filepath = os.path.join("tests/benchmarks/results", filename) - - with open(filepath, "w") as f: - json.dump(results, f, indent=2) - - return results - -if __name__ == "__main__": - results = run_benchmarks() - print("Benchmark results:") - for benchmark in results["benchmarks"]: - print(f"\n{benchmark['operation']}:") - for key, value in benchmark.items(): - if key != "operation": - print(f" {key}: {value}") \ No newline at end of file diff --git a/tests/benchmarks/results/benchmark_results_20250316_162423.json b/tests/benchmarks/results/benchmark_results_20250316_162423.json deleted file mode 100644 index 70e0aa8..0000000 --- a/tests/benchmarks/results/benchmark_results_20250316_162423.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "timestamp": "2025-03-16T16:24:04.601378", - "benchmarks": [ - { - "operation": "add_vectors", - "num_vectors": 1000, - "dimension": 384, - "total_time": 0.006134986877441406, - "vectors_per_second": 162999.5336545935 - }, - { - "operation": "add_vectors", - "num_vectors": 10000, - "dimension": 384, - "total_time": 0.057238101959228516, - "vectors_per_second": 174708.79812057948 - }, - { - "operation": "add_vectors", - "num_vectors": 100000, - "dimension": 384, - "total_time": 0.5889060497283936, - "vectors_per_second": 169806.37241903102 - }, - { - "operation": "search", - "num_vectors": 1000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 0.13065099716186523, - "queries_per_second": 765.397908720962 - }, - { - "operation": "search", - "num_vectors": 10000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 1.300077199935913, - "queries_per_second": 76.91850915078695 - }, - { - "operation": "search", - "num_vectors": 100000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 13.37088680267334, - "queries_per_second": 7.478935501870097 - } - ] -} \ No newline at end of file diff --git a/tests/benchmarks/results/benchmark_results_20250317_215701.json b/tests/benchmarks/results/benchmark_results_20250317_215701.json deleted file mode 100644 index 5e60454..0000000 --- a/tests/benchmarks/results/benchmark_results_20250317_215701.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "timestamp": "2025-03-17T21:55:28.177175", - "benchmarks": [ - { - "operation": "add_vectors", - "num_vectors": 1000, - "dimension": 384, - "total_time": 0.007476091384887695, - "vectors_per_second": 133759.73466849508 - }, - { - "operation": "add_vectors", - "num_vectors": 10000, - "dimension": 384, - "total_time": 0.0725250244140625, - "vectors_per_second": 137883.44203660847 - }, - { - "operation": "search", - "num_vectors": 1000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 0.15668606758117676, - "queries_per_second": 638.2188381120195 - }, - { - "operation": "search", - "num_vectors": 10000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 1.6238160133361816, - "queries_per_second": 61.5833315958911 - }, - { - "operation": "hnsw_index", - "num_vectors": 1000, - "dimension": 128, - "M": 16, - "ef_construction": 200, - "k": 10, - "num_queries": 100, - "add_time": 0.31320905685424805, - "search_time": 0.0154571533203125, - "vectors_per_second_add": 3192.7556950096446, - "queries_per_second": 6469.496544916091 - }, - { - "operation": "hnsw_index", - "num_vectors": 10000, - "dimension": 128, - "M": 16, - "ef_construction": 200, - "k": 10, - "num_queries": 100, - "add_time": 8.220263957977295, - "search_time": 0.0254666805267334, - "vectors_per_second_add": 1216.505948120507, - "queries_per_second": 3926.69943360015 - }, - { - "operation": "collections", - "num_vectors": 5000, - "dimension": 128, - "k": 10, - "num_queries": 50, - "add_time": 81.87265396118164, - "search_time": 0.07735610008239746, - "filter_search_time": 0.08352398872375488, - "nested_filter_time": 0.0872490406036377, - "vectors_per_second_add": 61.07045219727035, - "queries_per_second": 646.3614368710607, - "filter_queries_per_second": 598.6304146150004, - "nested_filter_queries_per_second": 573.0722040502911 - } - ] -} \ No newline at end of file diff --git a/tests/benchmarks/results/benchmark_results_20250317_215756.json b/tests/benchmarks/results/benchmark_results_20250317_215756.json deleted file mode 100644 index 06ccb19..0000000 --- a/tests/benchmarks/results/benchmark_results_20250317_215756.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "timestamp": "2025-03-17T21:56:24.623781", - "benchmarks": [ - { - "operation": "add_vectors", - "num_vectors": 1000, - "dimension": 384, - "total_time": 0.006494760513305664, - "vectors_per_second": 153970.26540875886 - }, - { - "operation": "add_vectors", - "num_vectors": 10000, - "dimension": 384, - "total_time": 0.07675909996032715, - "vectors_per_second": 130277.7130681377 - }, - { - "operation": "search", - "num_vectors": 1000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 0.16481781005859375, - "queries_per_second": 606.730546683331 - }, - { - "operation": "search", - "num_vectors": 10000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 1.7749769687652588, - "queries_per_second": 56.33875918376777 - }, - { - "operation": "hnsw_index", - "num_vectors": 1000, - "dimension": 128, - "M": 16, - "ef_construction": 200, - "k": 10, - "num_queries": 100, - "add_time": 0.6160528659820557, - "search_time": 0.01662898063659668, - "vectors_per_second_add": 1623.2373148786355, - "queries_per_second": 6013.59771746455 - }, - { - "operation": "hnsw_index", - "num_vectors": 10000, - "dimension": 128, - "M": 16, - "ef_construction": 200, - "k": 10, - "num_queries": 100, - "add_time": 7.680387735366821, - "search_time": 0.033773183822631836, - "vectors_per_second_add": 1302.0175991834078, - "queries_per_second": 2960.9290176838094 - }, - { - "operation": "collections", - "num_vectors": 5000, - "dimension": 128, - "k": 10, - "num_queries": 50, - "add_time": 81.1283688545227, - "search_time": 0.07472586631774902, - "filter_search_time": 0.07747101783752441, - "nested_filter_time": 0.07507610321044922, - "vectors_per_second_add": 61.63072265098625, - "queries_per_second": 669.1123497637378, - "filter_queries_per_second": 645.4026472823963, - "nested_filter_queries_per_second": 665.990879412624 - } - ] -} \ No newline at end of file diff --git a/tests/benchmarks/results/benchmark_results_20250317_221204.json b/tests/benchmarks/results/benchmark_results_20250317_221204.json deleted file mode 100644 index af8a71e..0000000 --- a/tests/benchmarks/results/benchmark_results_20250317_221204.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "timestamp": "2025-03-17T22:10:37.931558", - "benchmarks": [ - { - "operation": "add_vectors", - "num_vectors": 1000, - "dimension": 384, - "total_time": 0.0068950653076171875, - "vectors_per_second": 145031.25864453666 - }, - { - "operation": "add_vectors", - "num_vectors": 10000, - "dimension": 384, - "total_time": 0.07133793830871582, - "vectors_per_second": 140177.86660338956 - }, - { - "operation": "search", - "num_vectors": 1000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 0.15673303604125977, - "queries_per_second": 638.0275819685846 - }, - { - "operation": "search", - "num_vectors": 10000, - "dimension": 384, - "k": 10, - "num_queries": 100, - "total_time": 1.6680800914764404, - "queries_per_second": 59.9491598221094 - }, - { - "operation": "hnsw_index", - "num_vectors": 1000, - "dimension": 128, - "M": 16, - "ef_construction": 200, - "k": 10, - "num_queries": 100, - "add_time": 0.5421199798583984, - "search_time": 0.017783641815185547, - "vectors_per_second_add": 1844.6101179690882, - "queries_per_second": 5623.145193725701 - }, - { - "operation": "hnsw_index", - "num_vectors": 10000, - "dimension": 128, - "M": 16, - "ef_construction": 200, - "k": 10, - "num_queries": 100, - "add_time": 7.078456878662109, - "search_time": 0.023868799209594727, - "vectors_per_second_add": 1412.7372916750871, - "queries_per_second": 4189.56978614166 - }, - { - "operation": "collections", - "num_vectors": 5000, - "dimension": 128, - "k": 10, - "num_queries": 50, - "add_time": 76.09976983070374, - "search_time": 0.06311297416687012, - "filter_search_time": 0.06307291984558105, - "nested_filter_time": 0.06317496299743652, - "vectors_per_second_add": 65.70322106260387, - "queries_per_second": 792.2301342953742, - "filter_queries_per_second": 792.733238328161, - "nested_filter_queries_per_second": 791.4527785640155 - } - ] -} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 9c269ac..0a5e768 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,30 +1,20 @@ """ Common test fixtures and configuration for VecStream tests. """ -import os -import pytest + import tempfile + import numpy as np -from vecstream import ( - VectorStore, - PersistentVectorStore, - BinaryVectorStore, - HNSWIndex, - Collection, - CollectionManager -) +import pytest + +from vecstream import BinaryVectorStore, CollectionManager, HNSWIndex, VectorStore + @pytest.fixture def vector_store(): """Fixture that provides a clean VectorStore instance.""" return VectorStore() -@pytest.fixture -def persistent_store(): - """Fixture that provides a temporary PersistentVectorStore instance.""" - with tempfile.TemporaryDirectory() as temp_dir: - store = PersistentVectorStore(os.path.join(temp_dir, "test.vec")) - yield store @pytest.fixture def binary_store(): @@ -33,6 +23,7 @@ def binary_store(): store = BinaryVectorStore(temp_dir) yield store + @pytest.fixture def sample_vectors(): """Fixture that provides sample vectors for testing.""" @@ -42,11 +33,13 @@ def sample_vectors(): ("vec3", [0.0, 0.0, 1.0]), ] + @pytest.fixture def hnsw_index(): """Fixture that provides a clean HNSW index.""" return HNSWIndex(dim=3, M=16, ef_construction=100) + @pytest.fixture def populated_hnsw_index(hnsw_index, sample_vectors): """Fixture that provides an HNSW index populated with sample vectors.""" @@ -54,19 +47,22 @@ def populated_hnsw_index(hnsw_index, sample_vectors): hnsw_index.add_item(id, np.array(vector)) return hnsw_index + @pytest.fixture def temp_directory(): """Fixture that provides a temporary directory for testing.""" with tempfile.TemporaryDirectory() as temp_dir: yield temp_dir + @pytest.fixture def collection_manager(temp_directory): """Fixture that provides a CollectionManager instance.""" return CollectionManager(temp_directory) + @pytest.fixture def test_collection(collection_manager): """Fixture that provides a test collection.""" collection = collection_manager.create_collection("test") - return collection \ No newline at end of file + return collection diff --git a/tests/integration/test_features_integration.py b/tests/integration/test_features_integration.py index 12cbccc..96210e0 100644 --- a/tests/integration/test_features_integration.py +++ b/tests/integration/test_features_integration.py @@ -7,15 +7,15 @@ """ import os -import time import shutil -import numpy as np import tempfile + +import numpy as np import pytest -from pathlib import Path +from vecstream.collections import CollectionManager from vecstream.hnsw_index import HNSWIndex -from vecstream.collections import Collection, CollectionManager + @pytest.fixture def temp_dir(): @@ -24,12 +24,13 @@ def temp_dir(): yield temp_dir shutil.rmtree(temp_dir) + def test_hnsw_indexing(temp_dir): """Test the HNSW indexing functionality.""" # Create an HNSW index directly dim = 3 hnsw_index = HNSWIndex(dim=dim, M=16, ef_construction=100) - + # Add vectors to the index vectors = [ ("vec1", np.array([1.0, 0.0, 0.0])), @@ -38,84 +39,117 @@ def test_hnsw_indexing(temp_dir): ("vec4", np.array([0.0, 1.0, 0.0])), ("vec5", np.array([0.0, 0.0, 1.0])), ] - + for id, vector in vectors: hnsw_index.add_item(id, vector) - + # Search for nearest neighbors query = np.array([0.8, 0.2, 0.0]) results = hnsw_index.search(query, k=3) - + # Verify we got reasonable results assert len(results) > 0, "Expected at least one result" for _, sim in results: assert sim >= 0.0, "Expected positive similarity" - + # Test removing a vector hnsw_index.remove_item("vec1") new_results = hnsw_index.search(query, k=3) assert "vec1" not in [r[0] for r in new_results], "vec1 should be removed from results" + def test_collections_and_filtering(temp_dir): """Test collections with metadata filtering.""" # Create a collection manager collection_dir = os.path.join(temp_dir, "collections") manager = CollectionManager(collection_dir, use_hnsw=True) - + # Create two collections images = manager.create_collection("images") texts = manager.create_collection("texts") - + # Add vectors to collections with metadata images.add_vector("img1", [1.0, 0.0, 0.0], {"format": "jpg", "size": "1024x768"}) images.add_vector("img2", [0.0, 1.0, 0.0], {"format": "png", "size": "800x600"}) - + texts.add_vector("text1", [0.0, 0.0, 1.0], {"language": "en", "word_count": 500}) texts.add_vector("text2", [0.7, 0.7, 0.0], {"language": "fr", "word_count": 300}) - + # Verify collections collections = manager.list_collections() assert "images" in collections, "Images collection not found" assert "texts" in collections, "Texts collection not found" - + # Get collection stats img_stats = manager.get_collection_stats("images") - assert img_stats["vector_count"] == 2, f"Expected 2 vectors in images collection, got {img_stats['vector_count']}" - + assert img_stats["vector_count"] == 2, ( + f"Expected 2 vectors in images collection, got {img_stats['vector_count']}" + ) + # Create a collection for testing filtering products = manager.create_collection("products") - + # Add products with various metadata - products.add_vector("prod1", [1.0, 0.0, 0.0], - {"category": "electronics", "price": 100, "in_stock": True, - "details": {"color": "black", "brand": "Sony"}}) - - products.add_vector("prod2", [0.9, 0.1, 0.0], - {"category": "electronics", "price": 200, "in_stock": False, - "details": {"color": "silver", "brand": "Apple"}}) - - products.add_vector("prod3", [0.0, 1.0, 0.0], - {"category": "clothing", "price": 50, "in_stock": True, - "details": {"color": "blue", "size": "M"}}) - - products.add_vector("prod4", [0.0, 0.0, 1.0], - {"category": "books", "price": 15, "in_stock": True, - "details": {"author": "Jane Doe", "pages": 300}}) - + products.add_vector( + "prod1", + [1.0, 0.0, 0.0], + { + "category": "electronics", + "price": 100, + "in_stock": True, + "details": {"color": "black", "brand": "Sony"}, + }, + ) + + products.add_vector( + "prod2", + [0.9, 0.1, 0.0], + { + "category": "electronics", + "price": 200, + "in_stock": False, + "details": {"color": "silver", "brand": "Apple"}, + }, + ) + + products.add_vector( + "prod3", + [0.0, 1.0, 0.0], + { + "category": "clothing", + "price": 50, + "in_stock": True, + "details": {"color": "blue", "size": "M"}, + }, + ) + + products.add_vector( + "prod4", + [0.0, 0.0, 1.0], + { + "category": "books", + "price": 15, + "in_stock": True, + "details": {"author": "Jane Doe", "pages": 300}, + }, + ) + # Test basic metadata filtering query = [0.5, 0.5, 0.0] filter1 = {"category": "electronics", "in_stock": True} - + results1 = products.search_similar(query, k=2, filter_metadata=filter1) assert len(results1) > 0, "No results found with filter" - + # Test nested metadata filtering with dot notation filter2 = {"details.color": "black"} results2 = products.search_similar(query, k=2, filter_metadata=filter2) assert len(results2) > 0, "No results found with nested filter" - + # Test combined filters filter3 = {"category": "electronics", "details.brand": "Apple"} results3 = products.search_similar(query, k=2, filter_metadata=filter3) assert len(results3) > 0, "No results found with combined filter" - assert results3[0][0] == "prod2", f"Expected prod2 as result for combined filter, got {results3[0][0]}" \ No newline at end of file + assert results3[0][0] == "prod2", ( + f"Expected prod2 as result for combined filter, got {results3[0][0]}" + ) diff --git a/tests/property/test_graph_invariants.py b/tests/property/test_graph_invariants.py index 0501256..890c771 100644 --- a/tests/property/test_graph_invariants.py +++ b/tests/property/test_graph_invariants.py @@ -1,5 +1,6 @@ import numpy as np -from hypothesis import given, settings, strategies as st +from hypothesis import given, settings +from hypothesis import strategies as st from vecstream.hnsw_index import HNSWIndex @@ -19,3 +20,57 @@ def test_random_insert_delete_sequences_preserve_invariants(dimension, count, se for i in rng.permutation(count)[: count // 2]: index.remove_item(str(i)) index.validate() + + +operation = st.tuples( + st.sampled_from(["insert", "update", "delete", "search", "validate"]), + st.integers(min_value=0, max_value=24), +) + + +@given( + dimension=st.integers(min_value=1, max_value=8), + seed=st.integers(min_value=0, max_value=2**32 - 1), + operations=st.lists(operation, min_size=1, max_size=80), +) +@settings(max_examples=35, deadline=None) +def test_random_operation_sequences(dimension, seed, operations): + rng = np.random.default_rng(seed) + index = HNSWIndex(dimension, M=4, ef_construction=16, seed=seed) + for action, numeric_id in operations: + item_id = str(numeric_id) + if action in {"insert", "update"}: + index.add_item(item_id, rng.normal(size=dimension).astype(np.float32)) + index.validate() + elif action == "delete" and item_id in index.nodes: + index.remove_item(item_id) + index.validate() + elif action == "search": + results = index.search(rng.normal(size=dimension), k=5, ef_search=10) + assert len(results) <= 5 + assert all(result_id in index.nodes for result_id, _ in results) + assert all(np.isfinite(score) for _, score in results) + assert [score for _, score in results] == sorted( + [score for _, score in results], reverse=True + ) + else: + index.validate() + + +def test_larger_ef_has_nonworse_aggregate_recall(): + rng = np.random.default_rng(123) + vectors = rng.normal(size=(300, 16)).astype(np.float32) + queries = rng.normal(size=(40, 16)).astype(np.float32) + index = HNSWIndex(16, M=8, ef_construction=40, seed=9) + normalized = vectors / np.linalg.norm(vectors, axis=1, keepdims=True) + for i, vector in enumerate(vectors): + index.add_item(str(i), vector) + small_recall = large_recall = 0.0 + for query in queries: + q = query / np.linalg.norm(query) + truth = {str(i) for i in np.argsort(-(normalized @ q))[:10]} + small = {item_id for item_id, _ in index.search(query, k=10, ef_search=10)} + large = {item_id for item_id, _ in index.search(query, k=10, ef_search=80)} + small_recall += len(truth & small) / 10 + large_recall += len(truth & large) / 10 + assert large_recall >= small_recall diff --git a/tests/unit/test_collections.py b/tests/unit/test_collections.py index 8808b7f..12f761a 100644 --- a/tests/unit/test_collections.py +++ b/tests/unit/test_collections.py @@ -1,13 +1,16 @@ """ Unit tests for the Collections and CollectionManager classes. """ + import os -import pytest -import tempfile import shutil -import numpy as np +import tempfile + +import pytest + from vecstream import Collection, CollectionManager + @pytest.fixture def temp_dir(): """Create a temporary directory for testing.""" @@ -15,75 +18,81 @@ def temp_dir(): yield temp_dir shutil.rmtree(temp_dir) + @pytest.fixture def collection_manager(temp_dir): """Fixture that provides a CollectionManager with a temporary directory.""" return CollectionManager(temp_dir, use_hnsw=True) + @pytest.fixture def sample_collection(collection_manager): """Fixture that provides a sample collection with vectors.""" collection = collection_manager.create_collection("test_collection") - + # Add sample vectors with metadata collection.add_vector("vec1", [1.0, 0.0, 0.0], {"tag": "red", "score": 10}) collection.add_vector("vec2", [0.0, 1.0, 0.0], {"tag": "green", "score": 20}) collection.add_vector("vec3", [0.0, 0.0, 1.0], {"tag": "blue", "score": 30}) collection.add_vector("vec4", [0.7, 0.7, 0.0], {"tag": "yellow", "score": 25}) - + return collection + def test_collection_initialization(temp_dir): """Test that Collection initializes correctly.""" collection = Collection("test", temp_dir) - + assert collection.name == "test" assert collection.collection_dir == os.path.join(temp_dir, "collections", "test") assert collection.use_hnsw is True assert collection.hnsw_index is None # Should be None until vectors are added assert os.path.exists(collection.collection_dir) + def test_collection_add_vector(sample_collection): """Test adding vectors to a collection.""" # Test existing vectors assert len(sample_collection.store.vectors) == 4 - + # Add a new vector sample_collection.add_vector("vec5", [0.5, 0.5, 0.5], {"tag": "gray", "score": 15}) - + # Verify it was added to the store assert "vec5" in sample_collection.store.vectors assert sample_collection.store.dimension == 3 - + # Get metadata and verify _, metadata = sample_collection.get_vector_with_metadata("vec5") assert metadata["tag"] == "gray" assert metadata["score"] == 15 - + # Verify it was added to the HNSW index if enabled if sample_collection.use_hnsw and sample_collection.hnsw_index: assert "vec5" in sample_collection.hnsw_index.nodes + def test_collection_remove_vector(sample_collection): """Test removing vectors from a collection.""" # Verify vec1 exists assert "vec1" in sample_collection.store.vectors - + # Remove it sample_collection.remove_vector("vec1") - + # Verify it's gone from the store assert "vec1" not in sample_collection.store.vectors - + # Verify it's gone from the HNSW index if enabled if sample_collection.use_hnsw and sample_collection.hnsw_index: assert "vec1" not in sample_collection.hnsw_index.nodes + def test_collection_search_similar(sample_collection): """Test searching for similar vectors in a collection.""" # Search for vectors similar to [1.0, 0.1, 0.0] results = sample_collection.search_similar([1.0, 0.1, 0.0], k=2) - + assert len(results) == 2 # Check that we get valid results with similarity scores result_ids = [id for id, _ in results] @@ -91,27 +100,29 @@ def test_collection_search_similar(sample_collection): # Check that similarity scores are positive for _, score in results: assert score >= 0.0 - + # Test with k parameter limited_results = sample_collection.search_similar([1.0, 0.1, 0.0], k=1) assert len(limited_results) == 1 + def test_collection_get_vector_count(sample_collection): """Test getting the vector count from a collection.""" assert sample_collection.get_vector_count() == 4 - + # Add another vector sample_collection.add_vector("vec5", [0.5, 0.5, 0.5], {"tag": "gray"}) assert sample_collection.get_vector_count() == 5 - + # Remove a vector sample_collection.remove_vector("vec1") assert sample_collection.get_vector_count() == 4 + def test_collection_manager_initialization(temp_dir): """Test that CollectionManager initializes correctly.""" manager = CollectionManager(temp_dir) - + assert manager.base_storage_dir == temp_dir assert manager.collections_dir == os.path.join(temp_dir, "collections") assert os.path.exists(manager.collections_dir) @@ -119,78 +130,81 @@ def test_collection_manager_initialization(temp_dir): assert manager.collections == {} assert manager.collections_metadata == {} + def test_collection_manager_create_collection(collection_manager): """Test creating collections with the CollectionManager.""" # Create a collection collection = collection_manager.create_collection("test1") - + # Verify it was created assert "test1" in collection_manager.collections assert "test1" in collection_manager.collections_metadata assert collection.name == "test1" assert collection.use_hnsw is True - + # Create another collection with custom HNSW settings collection2 = collection_manager.create_collection( - "test2", - use_hnsw=False, - hnsw_params={"M": 20, "ef_construction": 300} + "test2", use_hnsw=False, hnsw_params={"M": 20, "ef_construction": 300} ) - + assert "test2" in collection_manager.collections assert collection2.use_hnsw is False assert collection2.hnsw_params["M"] == 20 - + # Test that creating an existing collection raises ValueError with pytest.raises(ValueError): collection_manager.create_collection("test1") + def test_collection_manager_get_collection(collection_manager, sample_collection): """Test getting collections from the CollectionManager.""" # Check the existing collection is accessible retrieved = collection_manager.get_collection("test_collection") assert retrieved is sample_collection - + # Test getting a non-existent collection raises KeyError with pytest.raises(KeyError): collection_manager.get_collection("non_existent") + def test_collection_manager_list_collections(collection_manager): """Test listing collections from the CollectionManager.""" # Create a new collection for testing collection_manager.create_collection("test_list_collection") - + # Should have the new collection collections = collection_manager.list_collections() assert len(collections) >= 1 assert "test_list_collection" in collections + def test_collection_manager_delete_collection(collection_manager): """Test deleting collections with the CollectionManager.""" # Create a collection to delete collection_manager.create_collection("to_delete") assert "to_delete" in collection_manager.list_collections() - + # Delete it collection_manager.delete_collection("to_delete") - + # Verify it's gone assert "to_delete" not in collection_manager.list_collections() assert "to_delete" not in collection_manager.collections assert "to_delete" not in collection_manager.collections_metadata - + # Test that deleting a non-existent collection raises KeyError with pytest.raises(KeyError): collection_manager.delete_collection("non_existent") + def test_collection_manager_get_collection_stats(collection_manager, sample_collection): """Test getting collection statistics from the CollectionManager.""" stats = collection_manager.get_collection_stats("test_collection") - + assert stats["name"] == "test_collection" assert stats["vector_count"] == 4 assert stats["dimension"] == 3 assert stats["using_hnsw"] is True assert "vectors_size_bytes" in stats assert "metadata_size_bytes" in stats - assert "total_size_bytes" in stats \ No newline at end of file + assert "total_size_bytes" in stats diff --git a/tests/unit/test_hnsw_index.py b/tests/unit/test_hnsw_index.py index ee243e1..07ec668 100644 --- a/tests/unit/test_hnsw_index.py +++ b/tests/unit/test_hnsw_index.py @@ -1,15 +1,18 @@ """ Unit tests for the HNSW index implementation. """ -import pytest + import numpy as np +import pytest + from vecstream import HNSWIndex + @pytest.fixture def sample_hnsw_index(): """Fixture that provides a small HNSW index with sample vectors.""" index = HNSWIndex(dim=3, M=10, ef_construction=100) - + # Add some sample vectors vectors = [ ("vec1", np.array([1.0, 0.0, 0.0])), @@ -18,16 +21,17 @@ def sample_hnsw_index(): ("vec4", np.array([0.7, 0.7, 0.0])), ("vec5", np.array([0.3, 0.3, 0.3])), ] - + for id, vector in vectors: index.add_item(id, vector) - + return index + def test_hnsw_index_initialization(): """Test that HNSW index initializes correctly.""" index = HNSWIndex(dim=5, M=16, ef_construction=200, ml=4) - + assert index.dim == 5 assert index.M == 16 assert index.M_max0 == 32 # Should be 2*M @@ -37,95 +41,100 @@ def test_hnsw_index_initialization(): assert index.node_levels == {} assert len(index.graphs) == 5 # ml + 1 levels + def test_add_item(sample_hnsw_index): """Test adding items to the HNSW index.""" # Test that existing vectors are in the index assert "vec1" in sample_hnsw_index.nodes assert "vec2" in sample_hnsw_index.nodes assert "vec3" in sample_hnsw_index.nodes - + # Add a new vector new_vec = np.array([0.5, 0.5, 0.5]) sample_hnsw_index.add_item("new_vec", new_vec) - + # Verify it was added assert "new_vec" in sample_hnsw_index.nodes np.testing.assert_array_equal(sample_hnsw_index.nodes["new_vec"], new_vec) - + # Check that nodes dictionary has the item assert "new_vec" in sample_hnsw_index.nodes + def test_remove_item(sample_hnsw_index): """Test removing items from the HNSW index.""" # Verify vec1 exists before removal assert "vec1" in sample_hnsw_index.nodes - + # Remove vec1 sample_hnsw_index.remove_item("vec1") - + # Verify it was removed from nodes, levels, and graphs assert "vec1" not in sample_hnsw_index.nodes assert "vec1" not in sample_hnsw_index.node_levels - + # Check it's removed from all graph levels for level in sample_hnsw_index.graphs: assert "vec1" not in sample_hnsw_index.graphs[level] - + # Also verify no other nodes have connections to vec1 - for node_id, neighbors in sample_hnsw_index.graphs[level].items(): + for neighbors in sample_hnsw_index.graphs[level].values(): assert "vec1" not in neighbors - + # Test removing non-existent item raises KeyError with pytest.raises(KeyError): sample_hnsw_index.remove_item("non_existent_vec") + def test_search(sample_hnsw_index): """Test searching for nearest neighbors in the HNSW index.""" # Search for the nearest neighbor to [1.0, 0.1, 0.0] query = np.array([1.0, 0.1, 0.0]) results = sample_hnsw_index.search(query, k=1) - + # Just check that we get a result with valid ID assert len(results) == 1 assert isinstance(results[0][0], str) # ID should be a string - + # Search for nearest neighbors to [0.5, 0.5, 0.0] query2 = np.array([0.5, 0.5, 0.0]) results2 = sample_hnsw_index.search(query2, k=5) - + # Check that we get multiple results assert len(results2) > 0 - + # Test searching with custom ef_search parameter results3 = sample_hnsw_index.search(query2, k=3, ef_search=50) assert len(results3) <= 3 # May return fewer if not enough vectors + def test_search_empty_index(): """Test searching an empty index returns empty results.""" empty_index = HNSWIndex(dim=3) results = empty_index.search(np.array([1.0, 0.0, 0.0]), k=5) assert len(results) == 0 + def test_distance_calculation(sample_hnsw_index): """Test the distance calculation used by HNSW.""" vec1 = np.array([1.0, 0.0, 0.0]) vec2 = np.array([0.0, 1.0, 0.0]) - + # Cosine distance between orthogonal vectors should be 1.0 distance = sample_hnsw_index._distance(vec1, vec2) assert distance == pytest.approx(1.0) - + # Distance to self should be 0.0 self_distance = sample_hnsw_index._distance(vec1, vec1) assert self_distance == pytest.approx(0.0) - + # Test with non-normalized vectors vec3 = np.array([2.0, 0.0, 0.0]) vec4 = np.array([0.0, 2.0, 0.0]) distance2 = sample_hnsw_index._distance(vec3, vec4) assert distance2 == pytest.approx(1.0) - + # Test with zero vector zero_vec = np.array([0.0, 0.0, 0.0]) zero_distance = sample_hnsw_index._distance(vec1, zero_vec) - assert zero_distance == pytest.approx(1.0) # Should clamp to reasonable distance \ No newline at end of file + assert zero_distance == pytest.approx(1.0) # Should clamp to reasonable distance diff --git a/tests/unit/test_hnsw_internals.py b/tests/unit/test_hnsw_internals.py new file mode 100644 index 0000000..29ec5ba --- /dev/null +++ b/tests/unit/test_hnsw_internals.py @@ -0,0 +1,122 @@ +import numpy as np +import pytest + +from vecstream.errors import IndexInvariantError +from vecstream.hnsw_index import HNSWIndex +from vecstream.vector_store import normalize + + +def make_manual_index() -> HNSWIndex: + index = HNSWIndex(2, M=2, ef_construction=4, seed=1) + vectors = {"a": [1, 0], "b": [0.9, 0.1], "c": [0, 1], "d": [-1, 0]} + for item_id, vector in vectors.items(): + raw = np.asarray(vector, dtype=np.float32) + index.nodes[item_id] = raw + index._normalized_nodes[item_id] = normalize(raw) + index.node_levels[item_id] = 0 + index.graphs = {0: {"a": {"b"}, "b": {"a", "c"}, "c": {"b", "d"}, "d": {"c"}}} + index.ep = "a" + return index + + +def test_search_layer_orders_and_bounds_results(): + index = make_manual_index() + query = normalize(np.asarray([-1, 0], dtype=np.float32)) + one = index._search_layer(query, "a", ef=1, level=0) + many = index._search_layer(query, "a", ef=3, level=0) + assert one == [(0.0, "d")] + assert len(many) == 3 + assert many == sorted(many) + assert many[0] == (0.0, "d") + + +def test_search_layer_handles_sparse_or_missing_level(): + index = make_manual_index() + query = normalize(np.asarray([1, 0], dtype=np.float32)) + assert index._search_layer(query, "a", ef=4, level=7) == [(0.0, "a")] + assert index._search_layer(query, "missing", ef=4, level=0) == [] + + +@pytest.mark.parametrize("candidate_count", [1, 2, 5]) +def test_neighbor_selection_respects_capacity(candidate_count): + index = HNSWIndex(2, M=2, ef_construction=4, seed=1) + for i in range(candidate_count): + angle = i * 0.4 + raw = np.asarray([np.cos(angle), np.sin(angle)], dtype=np.float32) + index.nodes[str(i)] = raw + index._normalized_nodes[str(i)] = normalize(raw) + query = np.asarray([1, 0], dtype=np.float32) + candidates = [ + (index._normalized_distance(query, index._normalized_nodes[str(i)]), str(i)) + for i in range(candidate_count) + ] + selected = index._select_neighbors(query, candidates, M=2) + assert len(selected) == min(candidate_count, 2) + assert len(selected) == len(set(selected)) + + +def test_neighbor_selection_handles_duplicate_and_equidistant_vectors(): + index = HNSWIndex(2, M=2, ef_construction=4, seed=1) + for item_id, raw in {"a": [1, 1], "b": [1, 1], "c": [1, -1]}.items(): + index.nodes[item_id] = np.asarray(raw, dtype=np.float32) + index._normalized_nodes[item_id] = normalize(index.nodes[item_id]) + query = np.asarray([1, 0], dtype=np.float32) + candidates = [ + (index._normalized_distance(query, index._normalized_nodes[i]), i) for i in index.nodes + ] + assert index._select_neighbors(query, candidates, 2) == ["a", "c"] + + +def test_pruning_removes_reciprocal_edges(): + index = HNSWIndex(2, M=2, ef_construction=4, seed=1) + center = np.asarray([1, 0], dtype=np.float32) + for item_id, raw in {"x": center, "a": [1, 0.1], "b": [0, 1], "c": [-1, 0]}.items(): + index.nodes[item_id] = np.asarray(raw, dtype=np.float32) + index._normalized_nodes[item_id] = normalize(index.nodes[item_id]) + index.node_levels[item_id] = 1 + index.graphs = { + 0: {item_id: set() for item_id in index.nodes}, + 1: {item_id: set() for item_id in index.nodes}, + } + index.ep = "x" + index.graphs[1]["x"] = {"a", "b", "c"} + for neighbor in ("a", "b", "c"): + index.graphs[1][neighbor].add("x") + index._prune("x", 1) + removed = {"a", "b", "c"} - index.graphs[1]["x"] + assert len(index.graphs[1]["x"]) <= index.M + assert all("x" not in index.graphs[1][node] for node in removed) + index.validate() + + +def test_entry_point_lifecycle_and_update(): + index = HNSWIndex(2, M=2, ef_construction=4, seed=4) + index._get_random_level = iter([0, 2, 0, 1]).__next__ + index.add_item("first", [1, 0]) + assert index.ep == "first" + index.add_item("high", [0, 1]) + assert index.ep == "high" + index.add_item("ordinary", [-1, 0]) + assert index.ep == "high" + index.remove_item("ordinary") + assert index.ep == "high" + index.remove_item("high") + assert index.ep == "first" + index.add_item("first", [0, -1]) + assert index.nodes["first"].tolist() == [0.0, -1.0] + index.validate() + index.remove_item("first") + assert index.ep is None + index.validate() + + +def test_validate_rejects_self_edge_and_nonmax_entry_point(): + index = make_manual_index() + index.graphs[0]["a"].add("a") + with pytest.raises(IndexInvariantError, match="self-edge"): + index.validate() + index.graphs[0]["a"].remove("a") + index.node_levels["d"] = 1 + index.graphs[1] = {"d": set()} + with pytest.raises(IndexInvariantError, match="entry point.*maximum"): + index.validate() diff --git a/tests/unit/test_hnsw_rigour.py b/tests/unit/test_hnsw_rigour.py index 9984a1b..94fae83 100644 --- a/tests/unit/test_hnsw_rigour.py +++ b/tests/unit/test_hnsw_rigour.py @@ -36,7 +36,11 @@ def test_diversity_heuristic_does_not_only_choose_nearest(): "diverse": [0.940, -0.342], }.items(): index.nodes[item_id] = np.asarray(vector, dtype=np.float32) - index._normalized_nodes[item_id] = index.nodes[item_id] / np.linalg.norm(index.nodes[item_id]) + index._normalized_nodes[item_id] = index.nodes[item_id] / np.linalg.norm( + index.nodes[item_id] + ) query = np.array([1.0, 0.0], dtype=np.float32) - candidates = [(index._normalized_distance(query, index._normalized_nodes[i]), i) for i in index.nodes] + candidates = [ + (index._normalized_distance(query, index._normalized_nodes[i]), i) for i in index.nodes + ] assert index._select_neighbors(query, candidates, 2) == ["near", "diverse"] diff --git a/tests/unit/test_metadata_filtering.py b/tests/unit/test_metadata_filtering.py deleted file mode 100644 index 25c8e11..0000000 --- a/tests/unit/test_metadata_filtering.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -Unit tests for metadata filtering functionality. -""" -import pytest -import tempfile -import shutil -import numpy as np -from vecstream import Collection, QueryEngine, IndexManager - -@pytest.fixture -def temp_dir(): - """Create a temporary directory for testing.""" - temp_dir = tempfile.mkdtemp() - yield temp_dir - shutil.rmtree(temp_dir) - -@pytest.fixture -def sample_collection(temp_dir): - """Fixture that provides a sample collection with various metadata.""" - collection = Collection("test", temp_dir) - - # Add vectors with rich metadata for testing filtering - collection.add_vector("doc1", [1.0, 0.0, 0.0], { - "category": "electronics", - "price": 100, - "in_stock": True, - "tags": ["laptop", "computer"], - "details": { - "brand": "Dell", - "color": "black", - "weight": 2.5 - } - }) - - collection.add_vector("doc2", [0.9, 0.1, 0.0], { - "category": "electronics", - "price": 200, - "in_stock": False, - "tags": ["phone", "mobile"], - "details": { - "brand": "Apple", - "color": "silver", - "weight": 0.2 - } - }) - - collection.add_vector("doc3", [0.0, 1.0, 0.0], { - "category": "clothing", - "price": 50, - "in_stock": True, - "tags": ["shirt", "apparel"], - "details": { - "brand": "Nike", - "color": "blue", - "size": "M" - } - }) - - collection.add_vector("doc4", [0.0, 0.0, 1.0], { - "category": "books", - "price": 15, - "in_stock": True, - "tags": ["fiction", "novel"], - "details": { - "author": "Jane Doe", - "pages": 300 - } - }) - - collection.add_vector("doc5", [0.7, 0.7, 0.0], { - "category": "electronics", - "price": 150, - "in_stock": True, - "tags": ["headphones", "audio"], - "details": { - "brand": "Sony", - "color": "black", - "wireless": True - } - }) - - return collection - -@pytest.fixture -def query_engine(sample_collection): - """Fixture that provides a QueryEngine with IndexManager for the sample collection.""" - index_manager = IndexManager(sample_collection.store) - return QueryEngine(index_manager) - -def test_matches_filter_simple(): - """Test the _matches_filter function with simple key-value filters.""" - from vecstream.query_engine import QueryEngine - query_engine = QueryEngine(None) # We only need the _matches_filter method - - metadata = { - "category": "electronics", - "price": 100, - "in_stock": True - } - - # Test match - assert query_engine._matches_filter(metadata, {"category": "electronics"}) is True - - # Test no match - assert query_engine._matches_filter(metadata, {"category": "books"}) is False - - # Test multiple conditions (AND) - assert query_engine._matches_filter(metadata, { - "category": "electronics", - "price": 100 - }) is True - - assert query_engine._matches_filter(metadata, { - "category": "electronics", - "price": 200 - }) is False - -def test_matches_filter_nested(): - """Test the _matches_filter function with nested dot notation filters.""" - from vecstream.query_engine import QueryEngine - query_engine = QueryEngine(None) # We only need the _matches_filter method - - metadata = { - "category": "electronics", - "details": { - "brand": "Sony", - "color": "black", - "specs": { - "weight": 1.5, - "dimensions": { - "height": 10, - "width": 20 - } - } - } - } - - # Test simple nested field - assert query_engine._matches_filter(metadata, {"details.brand": "Sony"}) is True - assert query_engine._matches_filter(metadata, {"details.brand": "Apple"}) is False - - # Test deeply nested field - assert query_engine._matches_filter(metadata, {"details.specs.weight": 1.5}) is True - assert query_engine._matches_filter(metadata, { - "details.specs.dimensions.height": 10 - }) is True - - # Test combined nested fields - assert query_engine._matches_filter(metadata, { - "category": "electronics", - "details.color": "black" - }) is True - - # Test nested field that doesn't exist - assert query_engine._matches_filter(metadata, {"details.nonexistent": "value"}) is False - assert query_engine._matches_filter(metadata, {"nonexistent.field": "value"}) is False - -def test_collection_search_with_filter(sample_collection): - """Test searching in a collection with metadata filtering.""" - query = [0.5, 0.5, 0.0] - - # Test simple filter - filter1 = {"category": "electronics"} - results1 = sample_collection.search_similar(query, k=5, filter_metadata=filter1) - - # Should return electronics items - assert len(results1) > 0 - result_ids = [id for id, _ in results1] - # Check that at least some of the expected electronics items are returned - electronics_items = set(result_ids).intersection({"doc1", "doc2", "doc5"}) - assert len(electronics_items) > 0 - - # Test combined filter if supported - try: - filter2 = {"category": "electronics", "in_stock": True} - results2 = sample_collection.search_similar(query, k=5, filter_metadata=filter2) - - # Should return in-stock electronics - result_ids = [id for id, _ in results2] - assert len(results2) > 0 - except Exception: - # If complex filtering not supported, this is okay - pass - - # Test nested filter if supported - try: - filter3 = {"details.brand": "Sony"} - results3 = sample_collection.search_similar(query, k=5, filter_metadata=filter3) - - # Should return Sony products - assert len(results3) > 0 - except Exception: - # If nested filtering not supported, this is okay - pass - -def test_query_engine_search_with_filter(query_engine, sample_collection): - """Test searching with the QueryEngine and metadata filtering.""" - query = [0.5, 0.5, 0.0] - - # First, update the index - query_engine.index_manager.update_index() - - # Test simple filter - filter1 = {"price": 100} - results1 = query_engine.search(query, k=5, filter_metadata=filter1) - - # Results may be empty depending on implementation - this is acceptable - if len(results1) > 0: - # Check that results have valid similarity scores - for _, score in results1: - assert score >= 0.0, "Expected positive similarity" - - # Test filter with threshold - filter2 = {"category": "electronics"} - threshold_value = 0.7 - results2 = query_engine.search(query, k=5, filter_metadata=filter2, threshold=threshold_value) - - # For this test we just verify the format of results is correct - for id, similarity in results2: - assert isinstance(id, str) - # Check that similarity is a numeric value (float or numpy.float32/64) - assert hasattr(similarity, "real"), "Similarity should be a numeric value" - assert float(similarity) >= 0.0, "Similarity should be non-negative" - -def test_filter_with_threshold(sample_collection): - """Test filtering with similarity threshold.""" - query = [0.9, 0.1, 0.0] # Very similar to doc1/doc2 - - # Apply filter with high threshold - filter1 = {"category": "electronics"} - results = sample_collection.search_similar( - query, k=5, threshold=0.95, filter_metadata=filter1 - ) - - # Should return only doc1 and doc2 (high similarity electronics) - assert len(results) == 2 - assert results[0][0] in ["doc1", "doc2"] - assert results[1][0] in ["doc1", "doc2"] - - # All results should have similarity >= threshold - for _, similarity in results: - assert similarity >= 0.95 - -def test_empty_filter(sample_collection): - """Test searching with an empty filter.""" - query = [0.5, 0.5, 0.0] - - # Search with empty filter (should match everything) - results = sample_collection.search_similar(query, k=5, filter_metadata={}) - - # Should return at least one item - assert len(results) > 0 - - # Check all results have valid format - for id, similarity in results: - assert isinstance(id, str) - assert isinstance(similarity, float) - assert similarity >= 0.0 \ No newline at end of file diff --git a/tests/unit/test_numerics.py b/tests/unit/test_numerics.py index 590f077..0320618 100644 --- a/tests/unit/test_numerics.py +++ b/tests/unit/test_numerics.py @@ -23,3 +23,24 @@ def test_zero_vector_similarity_is_zero(): store.add_vector("zero", [0, 0]) store.add_vector("unit", [1, 0]) assert dict(store.exact_search([0, 0], k=2, threshold=-1)) == {"zero": 0.0, "unit": 0.0} + + +@pytest.mark.parametrize( + "vector", + [ + [1e-40, -1e-40], + [np.finfo(np.float32).max, -np.finfo(np.float32).max], + [1.0, 1.0 + np.finfo(np.float32).eps], + [-1.0, 0.0], + ], +) +def test_extreme_finite_float32_vectors_remain_searchable(vector): + from vecstream import HNSWIndex + + index = HNSWIndex(2, M=2, ef_construction=4, seed=2) + index.add_item("value", vector) + index.add_item("duplicate", vector) + index.validate() + results = index.search(vector, k=2, ef_search=10) + assert {item_id for item_id, _ in results} == {"value", "duplicate"} + assert all(np.isfinite(score) for _, score in results) diff --git a/tests/unit/test_vector_store.py b/tests/unit/test_vector_store.py index 1ad3b9f..67fbb59 100644 --- a/tests/unit/test_vector_store.py +++ b/tests/unit/test_vector_store.py @@ -1,16 +1,19 @@ """ Unit tests for the VectorStore class. """ -import pytest + import numpy as np + from vecstream import VectorStore + def test_vector_store_initialization(): """Test that VectorStore initializes correctly.""" store = VectorStore() assert store.vectors == {} assert store.dimension is None + def test_add_vector(vector_store, sample_vectors): """Test adding vectors to the store.""" vec_id, vector = sample_vectors[0] @@ -19,25 +22,27 @@ def test_add_vector(vector_store, sample_vectors): np.testing.assert_array_equal(vector_store.vectors[vec_id], vector) assert vector_store.dimension == len(vector) + def test_add_multiple_vectors(vector_store, sample_vectors): """Test adding multiple vectors to the store.""" for vec_id, vector in sample_vectors: vector_store.add_vector(vec_id, vector) - + assert len(vector_store.vectors) == len(sample_vectors) for vec_id, vector in sample_vectors: assert vec_id in vector_store.vectors np.testing.assert_array_equal(vector_store.vectors[vec_id], vector) + def test_search_similar(vector_store, sample_vectors): """Test searching for similar vectors.""" # Add all sample vectors for vec_id, vector in sample_vectors: vector_store.add_vector(vec_id, vector) - + # Search for a vector similar to vec1 query = [1.0, 0.1, 0.1] results = vector_store.search_similar(query, k=2) - + assert len(results) == 2 - assert results[0][0] == "vec1" # Most similar should be vec1 \ No newline at end of file + assert results[0][0] == "vec1" # Most similar should be vec1 diff --git a/vecstream/__init__.py b/vecstream/__init__.py index 7272555..c4c87f4 100644 --- a/vecstream/__init__.py +++ b/vecstream/__init__.py @@ -1,17 +1,7 @@ -""" -VecStream - a compact vector-search engine. -""" +"""VecStream: compact exact and HNSW vector search.""" -__version__ = "0.4.0" - -from .vector_store import VectorStore from .binary_store import BinaryVectorStore -from .persistent_store import PersistentVectorStore -from .index_manager import IndexManager -from .query_engine import QueryEngine -from .hnsw_index import HNSWIndex from .collections import Collection, CollectionManager -from .client import ClientAPI from .errors import ( CorruptStoreError, DimensionMismatchError, @@ -20,21 +10,19 @@ InvalidVectorError, UnsupportedFormatVersionError, ) +from .hnsw_index import HNSWIndex +from .vector_store import VectorStore __all__ = [ - "VectorStore", "BinaryVectorStore", - "PersistentVectorStore", - "IndexManager", - "QueryEngine", - "HNSWIndex", "Collection", "CollectionManager", - "ClientAPI", "CorruptStoreError", "DimensionMismatchError", + "HNSWIndex", "IndexInvariantError", "InvalidManifestError", "InvalidVectorError", "UnsupportedFormatVersionError", + "VectorStore", ] diff --git a/vecstream/binary_store.py b/vecstream/binary_store.py index a0c4608..8d5b3b3 100644 --- a/vecstream/binary_store.py +++ b/vecstream/binary_store.py @@ -48,7 +48,9 @@ def _load_store(self) -> None: try: pointer = json.loads(self.current_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - raise CorruptStoreError(f"cannot read checkpoint pointer {self.current_file}: {exc}") from exc + raise CorruptStoreError( + f"cannot read checkpoint pointer {self.current_file}: {exc}" + ) from exc candidates = [pointer.get("current"), pointer.get("previous")] failures = [] for generation in filter(None, candidates): @@ -70,7 +72,11 @@ def _load_generation(self, generation: str) -> None: f"format version {manifest.get('format_version')!r} is not supported" ) required = {"dimension", "dtype", "metric", "vector_count"} - if not required.issubset(manifest) or manifest["dtype"] != "float32" or manifest["metric"] != "cosine": + if ( + not required.issubset(manifest) + or manifest["dtype"] != "float32" + or manifest["metric"] != "cosine" + ): raise InvalidManifestError("manifest fields, dtype, or metric are invalid") try: ids = json.loads((directory / "ids.json").read_text(encoding="utf-8")) @@ -79,9 +85,14 @@ def _load_generation(self, generation: str) -> None: except (OSError, ValueError, json.JSONDecodeError) as exc: raise CorruptStoreError(f"checkpoint component is unreadable: {exc}") from exc expected_shape = (manifest["vector_count"], manifest["dimension"]) - if vectors.dtype != np.float32 or vectors.shape != expected_shape or len(ids) != manifest["vector_count"]: + if ( + vectors.dtype != np.float32 + or vectors.shape != expected_shape + or len(ids) != manifest["vector_count"] + ): raise CorruptStoreError( - f"vector checkpoint mismatch: expected {expected_shape} float32, got {vectors.shape} {vectors.dtype}" + "vector checkpoint mismatch: " + f"expected {expected_shape} float32, got {vectors.shape} {vectors.dtype}" ) if len(ids) != len(set(ids)) or not isinstance(metadata, dict): raise CorruptStoreError("IDs must be unique and metadata must be an object") @@ -98,8 +109,11 @@ def _save_store(self) -> None: directory.mkdir() ids = list(self.vectors) dimension = self.dimension or 0 - matrix = (np.stack([self.vectors[item_id] for item_id in ids]).astype(np.float32) - if ids else np.empty((0, dimension), dtype=np.float32)) + matrix = ( + np.stack([self.vectors[item_id] for item_id in ids]).astype(np.float32) + if ids + else np.empty((0, dimension), dtype=np.float32) + ) try: vectors_path = directory / "vectors.npy" with vectors_path.open("wb") as handle: @@ -108,13 +122,16 @@ def _save_store(self) -> None: os.fsync(handle.fileno()) _write_json(directory / "ids.json", ids) _write_json(directory / "metadata.json", self.metadata) - _write_json(directory / "manifest.json", { - "format_version": FORMAT_VERSION, - "dimension": dimension, - "dtype": "float32", - "metric": "cosine", - "vector_count": len(ids), - }) + _write_json( + directory / "manifest.json", + { + "format_version": FORMAT_VERSION, + "dimension": dimension, + "dtype": "float32", + "metric": "cosine", + "vector_count": len(ids), + }, + ) dir_fd = os.open(directory, os.O_RDONLY) try: os.fsync(dir_fd) @@ -128,8 +145,9 @@ def _save_store(self) -> None: shutil.rmtree(directory, ignore_errors=True) raise - def add_vector(self, id: str, vector: Sequence[float] | np.ndarray, - metadata: Optional[dict] = None) -> None: + def add_vector( + self, id: str, vector: Sequence[float] | np.ndarray, metadata: Optional[dict] = None + ) -> None: old_vector = self.vectors.get(id) old_metadata = self.metadata.get(id) super().add_vector(id, vector) @@ -175,5 +193,7 @@ def get_store_size(self) -> Tuple[int, int]: if self._generation is None: return 0, 0 directory = self.root / self._generation - return (directory.joinpath("vectors.npy").stat().st_size, - directory.joinpath("metadata.json").stat().st_size) + return ( + directory.joinpath("vectors.npy").stat().st_size, + directory.joinpath("metadata.json").stat().st_size, + ) diff --git a/vecstream/cli.py b/vecstream/cli.py deleted file mode 100644 index fd4ae6d..0000000 --- a/vecstream/cli.py +++ /dev/null @@ -1,541 +0,0 @@ -""" -VecStream CLI - A lightweight vector database with similarity search. -""" - -import os -import sys -import json -import click -import time -from pathlib import Path -from rich.console import Console -from rich import print as rprint -from rich.table import Table -from rich.progress import track -from rich.panel import Panel -from sentence_transformers import SentenceTransformer -import numpy as np - -from .vector_store import VectorStore -from .binary_store import BinaryVectorStore -from .collections import CollectionManager, Collection -from .index_manager import IndexManager -from .query_engine import QueryEngine -from .hnsw_index import HNSWIndex -from .persistent_store import PersistentVectorStore - - -def get_default_store_path(): - """Get the default storage path based on the OS.""" - if os.name == "nt": # Windows - base_dir = os.path.expandvars("%APPDATA%") - return os.path.join(base_dir, "VecStream", "store") - else: # Unix-like - home = str(Path.home()) - return os.path.join(home, ".vecstream", "store") - - -class VecStreamCLI: - def __init__(self, store_path=None, use_hnsw=True): - """Initialize CLI with binary storage. - - Args: - store_path: Optional path to store the vector database. - If not provided, uses the default OS-specific path. - use_hnsw: Whether to use HNSW indexing for better performance - """ - if store_path is None: - store_path = get_default_store_path() - - # Ensure storage directory exists - os.makedirs(store_path, exist_ok=True) - - # Initialize collection manager - self.collection_manager = CollectionManager(store_path, use_hnsw=use_hnsw) - - # Default collection - try: - self.default_collection = self.collection_manager.get_collection("default") - except KeyError: - # Create default collection if it doesn't exist - self.default_collection = self.collection_manager.create_collection( - "default" - ) - - # Store reference to the binary store for backward compatibility - self.store = self.default_collection.store - - # Initialize index and query engine on default collection - self.index_manager = IndexManager(self.store, use_hnsw=use_hnsw) - self.query_engine = QueryEngine(self.index_manager) - - self.model = None - self.store_path = store_path - self.use_hnsw = use_hnsw - - def load_model(self): - """Load the sentence transformer model if not already loaded.""" - if self.model is None: - rprint("[bold cyan]Loading embedding model...[/bold cyan]") - self.model = SentenceTransformer("all-MiniLM-L6-v2") - - def embed_text(self, text): - """Embed text using the sentence transformer model.""" - self.load_model() - return self.model.encode(text).tolist() - - def add_document(self, text, id, collection_name=None, metadata=None): - """Add a document with its embedding to the store.""" - # Get or create the collection - collection = self._get_collection(collection_name) - - # Generate embeddings - embedding = self.embed_text(text) - - # Create default metadata if none provided - if metadata is None: - metadata = {"text": text, "timestamp": time.time()} - elif isinstance(metadata, str): - # Try to parse as JSON - try: - metadata = json.loads(metadata) - except json.JSONDecodeError: - metadata = { - "text": text, - "metadata": metadata, - "timestamp": time.time(), - } - else: - # Ensure text is included in metadata - if "text" not in metadata: - metadata["text"] = text - - # Add to collection - collection.add_vector(id, embedding, metadata) - - # Update index if using default collection - if collection_name is None or collection_name == "default": - self.index_manager.update_index() - - return id - - def search( - self, query_text, collection_name=None, k=5, threshold=0.0, filter_metadata=None - ): - """Search for similar documents to the query text.""" - # Get the collection - collection = self._get_collection(collection_name) - - # Generate embedding for the query - query_embedding = self.embed_text(query_text) - - # Perform search with filtering if needed - if filter_metadata: - # Convert string filter to dict if provided as string - if isinstance(filter_metadata, str): - try: - filter_metadata = json.loads(filter_metadata) - except json.JSONDecodeError: - rprint(f"[bold red]Error: Invalid filter format.[/bold red]") - return [] - - # Use HNSW index directly if available - if collection.use_hnsw and collection.hnsw_index is not None: - results = collection.search_similar( - query_embedding, - k=k, - threshold=threshold, - filter_metadata=filter_metadata, - ) - # Use the query engine otherwise - elif collection_name is None or collection_name == "default": - results = self.query_engine.search( - query_embedding, - k=k, - threshold=threshold, - filter_metadata=filter_metadata, - ) - else: - # Fall back to standard search with manual filtering - all_results = collection.store.search_similar( - query_embedding, k=len(collection.store.vectors) - ) - results = [] - for id, score in all_results: - if score >= threshold: - _, meta = collection.store.get_vector_with_metadata(id) - if meta and self._matches_filter(meta, filter_metadata): - results.append((id, score)) - if len(results) >= k: - break - else: - # No filtering needed - if collection.use_hnsw and collection.hnsw_index is not None: - results = collection.search_similar( - query_embedding, k=k, threshold=threshold - ) - elif collection_name is None or collection_name == "default": - results = self.query_engine.search( - query_embedding, k=k, threshold=threshold - ) - else: - results = collection.store.search_similar( - query_embedding, k=k, threshold=threshold - ) - - return results - - def _matches_filter(self, metadata, filter_query): - """Check if metadata matches the filter query.""" - for key, value in filter_query.items(): - # Handle nested keys with dot notation - if "." in key: - parts = key.split(".") - current = metadata - match = True - - for part in parts[:-1]: - if part not in current or not isinstance(current[part], dict): - match = False - break - current = current[part] - - if not match or parts[-1] not in current: - return False - - # Check value comparison for the last part - if current[parts[-1]] != value: - return False - else: - # Simple key-value match - if key not in metadata or metadata[key] != value: - return False - - return True - - def get_document(self, id, collection_name=None): - """Get a document by ID.""" - collection = self._get_collection(collection_name) - try: - vector, metadata = collection.store.get_vector_with_metadata(id) - return vector, metadata - except KeyError: - return None, None - - def remove_document(self, id, collection_name=None): - """Remove a document by ID.""" - collection = self._get_collection(collection_name) - try: - collection.remove_vector(id) - # Update index if using default collection - if collection_name is None or collection_name == "default": - self.index_manager.update_index() - return True - except KeyError: - return False - - def _get_collection(self, collection_name=None): - """Get a collection by name, falling back to default.""" - if collection_name is None: - return self.default_collection - - try: - return self.collection_manager.get_collection(collection_name) - except KeyError: - # Create collection if it doesn't exist - return self.collection_manager.create_collection( - collection_name, use_hnsw=self.use_hnsw - ) - - def list_collections(self): - """List all available collections.""" - return self.collection_manager.list_collections() - - def get_collection_stats(self, collection_name): - """Get statistics for a collection.""" - try: - return self.collection_manager.get_collection_stats(collection_name) - except KeyError: - return None - - def delete_collection(self, collection_name): - """Delete a collection.""" - if collection_name == "default": - rprint("[bold red]Cannot delete default collection.[/bold red]") - return False - - try: - self.collection_manager.delete_collection(collection_name) - return True - except KeyError: - return False - - -@click.group() -@click.option("--db-path", help="Custom path for vector database storage.") -@click.option( - "--use-hnsw/--no-hnsw", - default=True, - help="Whether to use HNSW indexing for better performance.", -) -@click.pass_context -def cli(ctx, db_path, use_hnsw): - """VecStream - A lightweight vector database with similarity search.""" - ctx.ensure_object(dict) - ctx.obj["cli"] = VecStreamCLI(db_path, use_hnsw=use_hnsw) - - -@cli.command() -@click.argument("text") -@click.argument("id") -@click.option("--collection", "-c", help="Collection to add the document to.") -@click.option("--metadata", "-m", help="JSON metadata to associate with the document.") -@click.pass_context -def add(ctx, text, id, collection, metadata): - """Add a document with TEXT and ID to the database.""" - cli_instance = ctx.obj["cli"] - - try: - if metadata: - try: - metadata_dict = json.loads(metadata) - except json.JSONDecodeError: - rprint(f"[bold red]Error: Invalid JSON metadata.[/bold red]") - return - else: - metadata_dict = {"text": text, "timestamp": time.time()} - - cli_instance.add_document(text, id, collection, metadata_dict) - collection_info = f" in collection '{collection}'" if collection else "" - rprint( - f"[bold green]Added document with ID '{id}'{collection_info}[/bold green]" - ) - except Exception as e: - rprint(f"[bold red]Error adding document: {str(e)}[/bold red]") - - -@cli.command() -@click.argument("query") -@click.option("--k", default=5, help="Number of results to return.") -@click.option("--threshold", default=0.0, help="Minimum similarity threshold (0-1).") -@click.option("--collection", "-c", help="Collection to search in.") -@click.option("--filter", "-f", help="JSON metadata filter to apply to search results.") -@click.pass_context -def search(ctx, query, k, threshold, collection, filter): - """Search for documents similar to QUERY.""" - cli_instance = ctx.obj["cli"] - - try: - filter_dict = None - if filter: - try: - filter_dict = json.loads(filter) - except json.JSONDecodeError: - rprint(f"[bold red]Error: Invalid JSON filter.[/bold red]") - return - - results = cli_instance.search(query, collection, k, threshold, filter_dict) - - if not results: - rprint("[yellow]No matching documents found.[/yellow]") - return - - console = Console() - collection_info = f" in '{collection}'" if collection else "" - table = Table(title=f"Search Results{collection_info}", show_header=True) - table.add_column("ID", style="cyan") - table.add_column("Similarity", style="green") - table.add_column("Text", style="white") - - for id, similarity in results: - _, metadata = cli_instance.get_document(id, collection) - text = metadata.get("text", "N/A") if metadata else "N/A" - # Truncate text if too long - if len(text) > 60: - text = text[:57] + "..." - table.add_row(id, f"{similarity:.4f}", text) - - console.print(table) - except Exception as e: - rprint(f"[bold red]Error searching: {str(e)}[/bold red]") - - -@cli.command() -@click.argument("id") -@click.option("--collection", "-c", help="Collection to get the document from.") -@click.pass_context -def get(ctx, id, collection): - """Get a document by ID.""" - cli_instance = ctx.obj["cli"] - - try: - _, metadata = cli_instance.get_document(id, collection) - - if metadata is None: - collection_info = f" in collection '{collection}'" if collection else "" - rprint( - f"[yellow]Document with ID '{id}'{collection_info} not found.[/yellow]" - ) - return - - console = Console() - collection_info = f" from '{collection}'" if collection else "" - panel = Panel( - json.dumps(metadata, indent=2), - title=f"Document '{id}'{collection_info}", - border_style="green", - ) - console.print(panel) - except Exception as e: - rprint(f"[bold red]Error retrieving document: {str(e)}[/bold red]") - - -@cli.command() -@click.argument("id") -@click.option("--collection", "-c", help="Collection to remove the document from.") -@click.pass_context -def remove(ctx, id, collection): - """Remove a document by ID.""" - cli_instance = ctx.obj["cli"] - - try: - success = cli_instance.remove_document(id, collection) - - if success: - collection_info = f" from collection '{collection}'" if collection else "" - rprint( - f"[bold green]Removed document with ID '{id}'{collection_info}[/bold green]" - ) - else: - collection_info = f" in collection '{collection}'" if collection else "" - rprint( - f"[yellow]Document with ID '{id}'{collection_info} not found.[/yellow]" - ) - except Exception as e: - rprint(f"[bold red]Error removing document: {str(e)}[/bold red]") - - -@cli.command() -@click.option( - "--collection", - "-c", - help="Collection to display info for (omit for all collections).", -) -@click.pass_context -def info(ctx, collection): - """Display information about the vector database.""" - cli_instance = ctx.obj["cli"] - - try: - # If collection specified, show collection info - if collection: - stats = cli_instance.get_collection_stats(collection) - if not stats: - rprint(f"[yellow]Collection '{collection}' not found.[/yellow]") - return - - console = Console() - table = Table(title=f"Collection: {collection}", show_header=True) - table.add_column("Property", style="cyan") - table.add_column("Value", style="green") - - for k, v in stats.items(): - if k != "document_count": - table.add_row(f"{k.replace('_', ' ').title()}", str(v)) - - # Format sizes - vec_size_mb = stats.get("vectors_size_bytes", 0) / (1024 * 1024) - meta_size_mb = stats.get("metadata_size_bytes", 0) / (1024 * 1024) - total_size_mb = stats.get("total_size_bytes", 0) / (1024 * 1024) - - table.add_row("vectors_size", f"{vec_size_mb:.2f} MB") - table.add_row("metadata_size", f"{meta_size_mb:.2f} MB") - table.add_row("total_size", f"{total_size_mb:.2f} MB") - - console.print(table) - - # Show list of collections - else: - collections = cli_instance.list_collections() - - console = Console() - table = Table(title="VecStream Collections", show_header=True) - table.add_column("Collection", style="cyan") - table.add_column("Vectors", style="green") - table.add_column("Size", style="yellow") - table.add_column("HNSW Index", style="blue") - - for coll_name in collections: - stats = cli_instance.get_collection_stats(coll_name) - if stats: - vector_count = stats.get("vector_count", 0) - total_size_mb = stats.get("total_size_bytes", 0) / (1024 * 1024) - using_hnsw = "Yes" if stats.get("using_hnsw", False) else "No" - - table.add_row( - coll_name, - str(vector_count), - f"{total_size_mb:.2f} MB", - using_hnsw, - ) - - console.print(table) - - # Display collection list header - rprint("\n[bold]Available Collections:[/bold]") - - # Display storage path - rprint(f"\n[bold]Storage Location:[/bold] {cli_instance.store_path}") - except Exception as e: - rprint(f"[bold red]Error displaying information: {str(e)}[/bold red]") - - -@cli.command() -@click.argument("name") -@click.option( - "--use-hnsw/--no-hnsw", default=True, help="Whether to use HNSW indexing." -) -@click.pass_context -def create_collection(ctx, name, use_hnsw): - """Create a new collection.""" - cli_instance = ctx.obj["cli"] - - try: - collection = cli_instance.collection_manager.create_collection( - name, use_hnsw=use_hnsw - ) - rprint(f"[bold green]Created collection '{name}'[/bold green]") - except ValueError as e: - rprint(f"[bold red]{str(e)}[/bold red]") - except Exception as e: - rprint(f"[bold red]Error creating collection: {str(e)}[/bold red]") - - -@cli.command() -@click.argument("name") -@click.pass_context -def delete_collection(ctx, name): - """Delete a collection.""" - cli_instance = ctx.obj["cli"] - - try: - if name == "default": - rprint("[bold red]Cannot delete default collection.[/bold red]") - return - - success = cli_instance.delete_collection(name) - if success: - rprint(f"[bold green]Deleted collection '{name}'[/bold green]") - else: - rprint(f"[yellow]Collection '{name}' not found.[/yellow]") - except Exception as e: - rprint(f"[bold red]Error deleting collection: {str(e)}[/bold red]") - - -def main(): - """Main entry point for the CLI.""" - cli(obj={}) - - -if __name__ == "__main__": - main() diff --git a/vecstream/client.py b/vecstream/client.py deleted file mode 100644 index beceae0..0000000 --- a/vecstream/client.py +++ /dev/null @@ -1,86 +0,0 @@ -import socket -import json -import numpy as np -import struct - - -class ClientAPI: - def __init__(self, host="127.0.0.1", port=6379): - self.host = host - self.port = port - - def _send_request(self, request): - """Send a request to the server and get the response.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.connect((self.host, self.port)) - - # Convert request to JSON and encode - data = json.dumps(request).encode("utf-8") - - # Send message length first - sock.send(struct.pack(">I", len(data))) - - # Send the data - sock.send(data) - - # Receive response length - length_data = sock.recv(4) - if not length_data: - return {"status": "error", "message": "Connection closed"} - - msg_length = struct.unpack(">I", length_data)[0] - - # Receive the response in chunks - chunks = [] - bytes_received = 0 - while bytes_received < msg_length: - chunk = sock.recv(min(msg_length - bytes_received, 8192)) - if not chunk: - break - chunks.append(chunk) - bytes_received += len(chunk) - - response_data = b"".join(chunks) - return json.loads(response_data.decode("utf-8")) - - def add_vector(self, id, vector): - """Add a vector to the database.""" - request = { - "command": "add", - "id": id, - "vector": vector.tolist() if isinstance(vector, np.ndarray) else vector, - } - return self._send_request(request) - - def get_vector(self, id): - """Retrieve a vector by ID.""" - request = {"command": "get", "id": id} - response = self._send_request(request) - if response["status"] == "success": - return np.array(response["vector"]) - print(f"Error getting vector: {response['message']}") # Add error logging - return None - - def remove_vector(self, id): - """Remove a vector from the database.""" - request = {"command": "remove", "id": id} - return self._send_request(request) - - def search_similar(self, query_vector, k=10, metric="cosine"): - """Search for similar vectors.""" - request = { - "command": "search", - "query_vector": ( - query_vector.tolist() - if isinstance(query_vector, np.ndarray) - else query_vector - ), - "k": k, - "metric": metric, - } - return self._send_request(request) - - def clear_database(self): - """Clear all vectors from the database.""" - request = {"command": "clear"} - return self._send_request(request) diff --git a/vecstream/collections.py b/vecstream/collections.py index b391654..c6d1ffd 100644 --- a/vecstream/collections.py +++ b/vecstream/collections.py @@ -2,15 +2,15 @@ Collections module for managing multiple vector collections/namespaces. """ -import os import json -from typing import Dict, List, Tuple, Optional, Any, Set +import os import shutil import tempfile +from typing import Any, Dict, List, Optional, Tuple from .binary_store import BinaryVectorStore -from .hnsw_index import HNSWIndex from .errors import CorruptStoreError +from .hnsw_index import HNSWIndex class Collection: @@ -41,7 +41,7 @@ def __init__( # HNSW index settings self.use_hnsw = use_hnsw self.hnsw_params = hnsw_params or {} - self.hnsw_index = None + self.hnsw_index: Optional[HNSWIndex] = None # Initialize HNSW index if required if use_hnsw and self.store.dimension is not None: @@ -63,6 +63,7 @@ def _init_hnsw_index(self) -> None: # Add all existing vectors to the index for id, vector in self.store.vectors.items(): + assert self.hnsw_index is not None self.hnsw_index.add_item(id, vector) def add_vector( @@ -132,7 +133,7 @@ def search_similar( # When only using threshold filtering (no metadata), still use a larger k # to ensure we don't miss potential matches that meet the threshold search_k = max(k * 10, 100) - + results = self.hnsw_index.search(query, k=search_k, ef_search=ef_search) # Apply metadata filtering if needed @@ -170,9 +171,7 @@ def search_similar( # No filtering needed return self.store.search_similar(query, k=k, threshold=threshold) - def _matches_filter( - self, metadata: Dict[str, Any], filter_query: Dict[str, Any] - ) -> bool: + def _matches_filter(self, metadata: Dict[str, Any], filter_query: Dict[str, Any]) -> bool: """Check if metadata matches the filter query. Args: @@ -208,9 +207,7 @@ def _matches_filter( return True - def get_vector_with_metadata( - self, id: str - ) -> Tuple[List[float], Optional[Dict[str, Any]]]: + def get_vector_with_metadata(self, id: str) -> Tuple[List[float], Optional[Dict[str, Any]]]: """Get a vector and its metadata. Args: @@ -319,14 +316,10 @@ def create_collection( # Use defaults if not specified use_hnsw_flag = self.use_hnsw if use_hnsw is None else use_hnsw - hnsw_params_dict = ( - dict(self.default_hnsw_params) if hnsw_params is None else hnsw_params - ) + hnsw_params_dict = dict(self.default_hnsw_params) if hnsw_params is None else hnsw_params # Create and store collection - collection = Collection( - name, self.base_storage_dir, use_hnsw_flag, hnsw_params_dict - ) + collection = Collection(name, self.base_storage_dir, use_hnsw_flag, hnsw_params_dict) self.collections[name] = collection # Save metadata diff --git a/vecstream/hnsw_index.py b/vecstream/hnsw_index.py index e93ec98..2494c60 100644 --- a/vecstream/hnsw_index.py +++ b/vecstream/hnsw_index.py @@ -15,8 +15,14 @@ class HNSWIndex: """Approximate cosine search using the HNSW graph algorithm.""" - def __init__(self, dim: int, M: int = 16, ef_construction: int = 200, - ml: int | None = None, seed: int | None = None) -> None: + def __init__( + self, + dim: int, + M: int = 16, + ef_construction: int = 200, + ml: int | None = None, + seed: int | None = None, + ) -> None: if dim < 1 or M < 2 or ef_construction < M: raise ValueError("dim >= 1, M >= 2, and ef_construction >= M are required") self.dim, self.M, self.M_max0 = dim, M, 2 * M @@ -69,7 +75,9 @@ def _search_layer(self, q: np.ndarray, ep: str, ef: int, level: int) -> List[Tup heapq.heappop(results) return sorted([(-neg_distance, item_id) for neg_distance, item_id in results]) - def _select_neighbors(self, q: np.ndarray, candidates: List[Tuple[float, str]], M: int) -> List[str]: + def _select_neighbors( + self, q: np.ndarray, candidates: List[Tuple[float, str]], M: int + ) -> List[str]: """Select close but directionally diverse neighbors (Algorithm 4).""" by_id = {item_id: distance for distance, item_id in candidates} ordered = sorted(by_id.items(), key=lambda pair: (pair[1], pair[0])) @@ -77,7 +85,10 @@ def _select_neighbors(self, q: np.ndarray, candidates: List[Tuple[float, str]], rejected: List[str] = [] for candidate_id, query_distance in ordered: diverse = all( - self._normalized_distance(self._normalized_nodes[candidate_id], self._normalized_nodes[chosen]) > query_distance + self._normalized_distance( + self._normalized_nodes[candidate_id], self._normalized_nodes[chosen] + ) + > query_distance for chosen in selected ) (selected if diverse else rejected).append(candidate_id) @@ -92,7 +103,12 @@ def _prune(self, node_id: str, level: int) -> None: if len(graph[node_id]) <= limit: return candidates = [ - (self._normalized_distance(self._normalized_nodes[node_id], self._normalized_nodes[neighbor]), neighbor) + ( + self._normalized_distance( + self._normalized_nodes[node_id], self._normalized_nodes[neighbor] + ), + neighbor, + ) for neighbor in graph[node_id] ] keep = set(self._select_neighbors(self._normalized_nodes[node_id], candidates, limit)) @@ -146,10 +162,15 @@ def remove_item(self, id: str) -> None: del self.nodes[id] del self._normalized_nodes[id] del self.node_levels[id] - self.ep = max(self.node_levels, key=lambda item_id: self.node_levels[item_id]) if self.nodes else None + self.ep = ( + max(self.node_levels, key=lambda item_id: self.node_levels[item_id]) + if self.nodes + else None + ) - def search(self, query: Sequence[float] | np.ndarray, k: int = 10, - ef_search: int | None = None) -> List[Tuple[str, float]]: + def search( + self, query: Sequence[float] | np.ndarray, k: int = 10, ef_search: int | None = None + ) -> List[Tuple[str, float]]: if k < 1: raise ValueError("k must be at least 1") q = normalize(as_float32_vector(query, self.dim)) @@ -169,8 +190,19 @@ def validate(self) -> None: raise IndexInvariantError("entry point must exist iff graph is non-empty") if self.ep is not None and self.ep not in self.nodes: raise IndexInvariantError("entry point references a nonexistent node") - if set(self.nodes) != set(self.node_levels) or set(self.nodes) != set(self._normalized_nodes): - raise IndexInvariantError("nodes, normalized nodes, and node_levels contain different IDs") + if self.ep is not None: + maximum_level = max(self.node_levels.values()) + if self.node_levels[self.ep] != maximum_level: + raise IndexInvariantError( + f"entry point {self.ep!r} is at level {self.node_levels[self.ep]}, " + f"but maximum node level is {maximum_level}" + ) + if set(self.nodes) != set(self.node_levels) or set(self.nodes) != set( + self._normalized_nodes + ): + raise IndexInvariantError( + "nodes, normalized nodes, and node_levels contain different IDs" + ) for item_id, max_level in self.node_levels.items(): if max_level < 0: raise IndexInvariantError(f"node {item_id!r} has a negative level") @@ -183,11 +215,32 @@ def validate(self) -> None: if item_id not in self.nodes or level > self.node_levels[item_id]: raise IndexInvariantError(f"invalid node {item_id!r} at level {level}") if len(neighbors) > limit: - raise IndexInvariantError(f"degree {len(neighbors)} exceeds {limit} at level {level}") + raise IndexInvariantError( + f"degree {len(neighbors)} exceeds {limit} at level {level}" + ) for neighbor in neighbors: + if neighbor == item_id: + raise IndexInvariantError( + f"self-edge for node {item_id!r} at level {level}" + ) if neighbor not in self.nodes: raise IndexInvariantError(f"edge references missing node {neighbor!r}") if item_id not in graph.get(neighbor, set()): - raise IndexInvariantError(f"edge {item_id!r}-{neighbor!r} is not bidirectional") - if any(not np.all(np.isfinite(vector)) for vector in self.nodes.values()): - raise IndexInvariantError("stored vectors must be finite") + raise IndexInvariantError( + f"edge {item_id!r}-{neighbor!r} is not bidirectional" + ) + for item_id, vector in self.nodes.items(): + if vector.shape != (self.dim,) or not np.all(np.isfinite(vector)): + raise IndexInvariantError( + f"raw vector for node {item_id!r} must be finite with shape ({self.dim},)" + ) + normalized = self._normalized_nodes[item_id] + if normalized.shape != (self.dim,) or not np.all(np.isfinite(normalized)): + raise IndexInvariantError( + f"normalized vector for node {item_id!r} is malformed or non-finite" + ) + norm = float(np.linalg.norm(normalized.astype(np.float64))) + if not (np.isclose(norm, 0.0, atol=1e-7) or np.isclose(norm, 1.0, atol=1e-5)): + raise IndexInvariantError( + f"normalized vector for node {item_id!r} has norm {norm}, expected 0 or 1" + ) diff --git a/vecstream/index_manager.py b/vecstream/index_manager.py deleted file mode 100644 index fede0bf..0000000 --- a/vecstream/index_manager.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -IndexManager class for managing vector indices for efficient similarity search. -""" - -import numpy as np -from typing import List, Tuple, Optional, Dict, Any -from .vector_store import VectorStore -from .hnsw_index import HNSWIndex - - -class IndexManager: - """A class for managing vector indices for efficient similarity search.""" - - def __init__( - self, - store: VectorStore, - use_hnsw: bool = True, - hnsw_params: Optional[Dict[str, Any]] = None, - ): - """Initialize the index manager. - - Args: - store: VectorStore instance to manage indices for - use_hnsw: Whether to use HNSW indexing (default: True) - hnsw_params: Optional parameters for HNSW index - """ - self.store = store - self.use_hnsw = use_hnsw - self.hnsw_params = hnsw_params or {"M": 16, "ef_construction": 200} - - # Initialize indices - self.index = None # Standard index (normalized vectors) - self.hnsw_index = None # HNSW index - self.indexed_ids = [] - - def create_index(self) -> None: - """Create or recreate the search index.""" - if not self.store.vectors: - return - - # Get all vectors and their IDs - vectors = [] - self.indexed_ids = [] - for id, vector in self.store.vectors.items(): - vectors.append(vector) - self.indexed_ids.append(id) - - # Convert to numpy array and normalize for standard index - vectors_array = np.array(vectors) - norms = np.linalg.norm(vectors_array, axis=1, keepdims=True) - norms[norms == 0] = 1 # Avoid division by zero - self.index = vectors_array / norms - - # Create HNSW index if enabled - if self.use_hnsw and self.store.dimension is not None: - self._create_hnsw_index() - - def _create_hnsw_index(self) -> None: - """Create HNSW index with current vectors.""" - if self.store.dimension is None: - return - - # Create new HNSW index with parameters - M = self.hnsw_params.get("M", 16) - ef_construction = self.hnsw_params.get("ef_construction", 200) - ml = self.hnsw_params.get("ml", None) - - self.hnsw_index = HNSWIndex( - dim=self.store.dimension, M=M, ef_construction=ef_construction, ml=ml - ) - - # Add all vectors to the index - for id, vector in self.store.vectors.items(): - # Convert to numpy array if needed - if not isinstance(vector, np.ndarray): - vector = np.array(vector, dtype=np.float32) - self.hnsw_index.add_item(id, vector) - - def update_index(self) -> None: - """Update the index with new vectors.""" - if self.index is None: - self.create_index() - return - - # Check for new vectors - current_ids = set(self.store.vectors.keys()) - indexed_ids = set(self.indexed_ids) - - if current_ids != indexed_ids: - # HNSW index can be updated incrementally, but for simplicity - # we'll recreate both indices for consistency when the vector set changes - self.create_index() - - def search( - self, - query: List[float], - k: int = 5, - threshold: float = 0.0, - ef_search: Optional[int] = None, - ) -> List[Tuple[str, float]]: - """Search the index for similar vectors. - - Args: - query: Query vector to find similar vectors for - k: Number of similar vectors to return - threshold: Minimum similarity score (0 to 1) for results - ef_search: HNSW search parameter (only used with HNSW) - - Returns: - List of (id, similarity) tuples, sorted by similarity (highest first) - """ - if (self.index is None and self.hnsw_index is None) or not self.indexed_ids: - return [] - - # Use HNSW index if available for better performance - if self.use_hnsw and self.hnsw_index is not None: - # Convert query to numpy array - query_array = np.array(query, dtype=np.float32) - - # Use HNSW index for search - results = self.hnsw_index.search(query_array, k=k, ef_search=ef_search) - - # Filter by threshold if needed - if threshold > 0: - results = [(id, sim) for id, sim in results if sim >= threshold] - - return results - else: - # Fall back to standard search - return self._search_standard(query, k, threshold) - - def _search_standard( - self, query: List[float], k: int = 5, threshold: float = 0.0 - ) -> List[Tuple[str, float]]: - """Search using standard normalized dot product. - - Args: - query: Query vector - k: Number of results to return - threshold: Minimum similarity threshold - - Returns: - List of (id, similarity) tuples - """ - # Normalize query vector - query_array = np.array(query, dtype=np.float32) - query_norm = np.linalg.norm(query_array) - if query_norm > 0: - query_array = query_array / query_norm - - # Compute similarities - similarities = np.dot(self.index, query_array) - - # Filter by threshold and get top k results - results = [] - for idx, sim in enumerate(similarities): - if sim >= threshold: - id = self.indexed_ids[idx] - results.append((id, float(sim))) - - # Sort by similarity (highest first) and return top k - results.sort(key=lambda x: x[1], reverse=True) - return results[:k] diff --git a/vecstream/persistent_store.py b/vecstream/persistent_store.py deleted file mode 100644 index 8a45fc4..0000000 --- a/vecstream/persistent_store.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -PersistentVectorStore class for persistent vector storage. -""" - -import os -import json -import numpy as np -from typing import List, Tuple, Dict, Optional -from .vector_store import VectorStore - - -class PersistentVectorStore(VectorStore): - """A class for persistent vector storage that extends VectorStore.""" - - def __init__(self, filepath: str): - """Initialize a persistent vector store. - - Args: - filepath: Path to the file where vectors will be stored - """ - super().__init__() - self.filepath = filepath - if os.path.exists(filepath): - self.load() - - def add_vector(self, id: str, vector: List[float]) -> None: - """Add a vector to the store and save to disk. - - Args: - id: Unique identifier for the vector - vector: List of float values representing the vector - - Raises: - ValueError: If vector dimensions don't match existing vectors - """ - super().add_vector(id, vector) - self.save() - - def remove_vector(self, id: str) -> None: - """Remove a vector from the store and update the file. - - Args: - id: The vector's identifier - - Raises: - KeyError: If the ID is not found - """ - super().remove_vector(id) - self.save() - - def save(self) -> None: - """Save the current state to disk.""" - data = { - "dimension": self.dimension, - "vectors": {id: vector.tolist() for id, vector in self.vectors.items()}, - } - - # Create directory if it doesn't exist - parent = os.path.dirname(self.filepath) - if parent: - os.makedirs(parent, exist_ok=True) - - # Save to file - with open(self.filepath, "w") as f: - json.dump(data, f) - - def load(self) -> None: - """Load the state from disk.""" - if not os.path.exists(self.filepath): - return - - with open(self.filepath, "r") as f: - data = json.load(f) - - self.dimension = data.get("dimension") - self.vectors = { - id: np.array(vector, dtype=np.float32) - for id, vector in data.get("vectors", {}).items() - } diff --git a/vecstream/query_engine.py b/vecstream/query_engine.py deleted file mode 100644 index 6ac4f7e..0000000 --- a/vecstream/query_engine.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -QueryEngine class for high-level vector query operations. -""" - -from typing import List, Tuple, Optional, Dict, Any -from .index_manager import IndexManager -import numpy as np - -from .vector_store import as_float32_vector, normalize - - -class QueryEngine: - """A class for high-level vector query operations.""" - - def __init__(self, index_manager: IndexManager): - """Initialize the query engine. - - Args: - index_manager: IndexManager instance to use for queries - """ - self.index_manager = index_manager - - def search( - self, - query: List[float], - k: int = 5, - threshold: float = 0.0, - filter_metadata: Optional[Dict[str, Any]] = None, - ) -> List[Tuple[str, float]]: - """Search for similar vectors with optional filtering. - - Args: - query: Query vector to find similar vectors for - k: Number of similar vectors to return - threshold: Minimum similarity score (0 to 1) for results - filter_metadata: Optional metadata filters to apply - - Returns: - List of (id, similarity) tuples, sorted by similarity (highest first) - """ - # Update index before searching - self.index_manager.update_index() - - # If we don't need to filter by metadata, use standard search - if filter_metadata is None: - return self.index_manager.search(query, k, threshold) - - # Get all results above threshold for filtering - # We need to retrieve more results than k because some might be filtered out - get_count = min(max(k * 4, 100), len(self.index_manager.store.vectors)) - results = self.index_manager.search(query, get_count, threshold) - - # Filter results by metadata - filtered_results = [] - for id, similarity in results: - _, metadata = self.index_manager.store.get_vector_with_metadata(id) - if metadata and self._matches_filter(metadata, filter_metadata): - filtered_results.append((id, similarity)) - # Stop once we have k results - if len(filtered_results) >= k: - break - - return filtered_results[:k] - - def _matches_filter( - self, metadata: Dict[str, Any], filter_query: Dict[str, Any] - ) -> bool: - """Check if metadata matches the filter query. - - Args: - metadata: Vector metadata - filter_query: Filter criteria - - Returns: - True if metadata matches filter, False otherwise - """ - for key, value in filter_query.items(): - # Handle nested keys with dot notation (e.g., "user.name") - if "." in key: - parts = key.split(".") - current = metadata - match = True - - for part in parts[:-1]: - if part not in current or not isinstance(current[part], dict): - match = False - break - current = current[part] - - if not match or parts[-1] not in current: - return False - - # Check value comparison for the last part - if current[parts[-1]] != value: - return False - else: - # Simple key-value match - if key not in metadata or metadata[key] != value: - return False - - return True - - def vector_similarity(self, vec1, vec2, metric="cosine"): - """ - Calculate the similarity between two vectors using the specified metric. - - Parameters: - ----------- - vec1 : array-like - First vector - vec2 : array-like - Second vector - metric : str - Similarity metric ('cosine', 'euclidean', etc.) - - Returns: - -------- - float - Similarity score (higher is more similar for cosine, lower for distance metrics) - """ - vec1 = as_float32_vector(vec1) - vec2 = as_float32_vector(vec2, len(vec1)) - - if metric == "cosine": - return float(np.dot(normalize(vec1), normalize(vec2))) - elif metric == "euclidean": - return -float(np.linalg.norm(vec1 - vec2)) - else: - raise ValueError(f"Unsupported metric: {metric}") diff --git a/vecstream/server.py b/vecstream/server.py deleted file mode 100644 index f42d714..0000000 --- a/vecstream/server.py +++ /dev/null @@ -1,127 +0,0 @@ -import socket -import json -import numpy as np -import struct -from threading import Thread -from .persistent_store import PersistentVectorStore -from .index_manager import IndexManager -from .query_engine import QueryEngine - - -class VectorDBServer: - def __init__(self, host="127.0.0.1", port=6379, storage_path="vector_db"): - self.host = host - self.port = port - self.store = PersistentVectorStore(storage_path) - self.index_manager = IndexManager(self.store) - self.query_engine = QueryEngine(self.index_manager) - self.running = False - - def start(self): - """Start the server.""" - self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.server_socket.bind((self.host, self.port)) - self.server_socket.listen(5) - self.running = True - - print(f"Server running on {self.host}:{self.port}") - - try: - while self.running: - client_socket, address = self.server_socket.accept() - print(f"Connection from {address}") - client_thread = Thread(target=self.handle_client, args=(client_socket,)) - client_thread.daemon = True - client_thread.start() - finally: - self.server_socket.close() - - def stop(self): - """Stop the server.""" - self.running = False - self.server_socket.close() - - def handle_client(self, client_socket): - """Handle client connections.""" - try: - while True: - # Receive message length first - length_data = client_socket.recv(4) - if not length_data: - break - - msg_length = struct.unpack(">I", length_data)[0] - - # Receive the full message in chunks - chunks = [] - bytes_received = 0 - while bytes_received < msg_length: - chunk = client_socket.recv(min(msg_length - bytes_received, 8192)) - if not chunk: - break - chunks.append(chunk) - bytes_received += len(chunk) - - if not chunks: - break - - data = b"".join(chunks) - - try: - request = json.loads(data.decode("utf-8")) - response = self.handle_request(request) - - # Send response with length prefix - response_data = json.dumps(response).encode("utf-8") - client_socket.send(struct.pack(">I", len(response_data))) - client_socket.sendall(response_data) - - except Exception as e: - error_response = {"status": "error", "message": str(e)} - response_data = json.dumps(error_response).encode("utf-8") - client_socket.send(struct.pack(">I", len(response_data))) - client_socket.sendall(response_data) - finally: - client_socket.close() - - def handle_request(self, request): - """Handle different types of requests.""" - command = request.get("command") - - if command == "add": - self.store.add_vector(request["id"], request["vector"]) - return {"status": "success", "message": "Vector added"} - - elif command == "get": - try: - return {"status": "success", "vector": self.store.get_vector(request["id"])} - except KeyError: - return {"status": "error", "message": "Vector not found"} - - elif command == "remove": - try: - self.store.remove_vector(request["id"]) - return {"status": "success", "message": "Vector removed"} - except KeyError: - return {"status": "error", "message": "Vector not found"} - - elif command == "search": - self.index_manager.update_index() - results = self.query_engine.search( - np.array(request["query_vector"]), - k=request.get("k", 10), - ) - return { - "status": "success", - "results": [(str(id), float(score)) for id, score in results], - } - - elif command == "clear": - self.store.vectors.clear() - self.store.dimension = None - self.store.save() - return {"status": "success", "message": "Database cleared"} - - else: - return {"status": "error", "message": f"Unknown command: {command}"} diff --git a/vecstream/vector_store.py b/vecstream/vector_store.py index ec5ae07..1899a83 100644 --- a/vecstream/vector_store.py +++ b/vecstream/vector_store.py @@ -9,7 +9,9 @@ from .errors import DimensionMismatchError, InvalidVectorError -def as_float32_vector(vector: Sequence[float] | np.ndarray, dimension: int | None = None) -> np.ndarray: +def as_float32_vector( + vector: Sequence[float] | np.ndarray, dimension: int | None = None +) -> np.ndarray: """Convert and validate a finite 1-D vector. Zero-vector cosine is 0.0.""" try: value: np.ndarray = np.asarray(vector, dtype=np.float32) @@ -28,10 +30,12 @@ def as_float32_vector(vector: Sequence[float] | np.ndarray, dimension: int | Non def normalize(vector: np.ndarray) -> np.ndarray: """Return a normalized float32 copy; a zero vector remains all-zero.""" - norm = float(np.linalg.norm(vector)) + # Accumulate in float64: squaring a finite float32 near its maximum can + # overflow in float32, while subnormal values can underflow to zero. + norm = float(np.linalg.norm(vector.astype(np.float64))) if norm == 0.0: return np.zeros_like(vector, dtype=np.float32) - return np.asarray(vector / norm, dtype=np.float32) + return np.asarray(vector.astype(np.float64) / norm, dtype=np.float32) class VectorStore: @@ -71,8 +75,13 @@ def search_similar( query_value = normalize(as_float32_vector(query, self.dimension)) ids = list(self.vectors) matrix = np.stack([self.vectors[item_id] for item_id in ids]) - norms = np.linalg.norm(matrix, axis=1, keepdims=True) - normalized = np.divide(matrix, norms, out=np.zeros_like(matrix), where=norms != 0) + norms = np.linalg.norm(matrix.astype(np.float64), axis=1, keepdims=True) + normalized = np.divide( + matrix, + norms, + out=np.zeros_like(matrix), + where=norms != 0, + ) scores = normalized @ query_value eligible = np.flatnonzero(scores >= threshold) order = eligible[np.argsort(-scores[eligible], kind="stable")][:k]