From fe51914902683c8cd3938bd4c6e53f73c5a1b471 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 11:42:03 -0700 Subject: [PATCH] feat(vector): add VectorStore ABC and VectorMatch dataclass --- circuitforge_core/vector/__init__.py | 3 + circuitforge_core/vector/base.py | 44 +++++++++++++ tests/test_vector/__init__.py | 0 tests/test_vector/test_base.py | 94 ++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 circuitforge_core/vector/__init__.py create mode 100644 circuitforge_core/vector/base.py create mode 100644 tests/test_vector/__init__.py create mode 100644 tests/test_vector/test_base.py diff --git a/circuitforge_core/vector/__init__.py b/circuitforge_core/vector/__init__.py new file mode 100644 index 0000000..f6e5e4b --- /dev/null +++ b/circuitforge_core/vector/__init__.py @@ -0,0 +1,3 @@ +from .base import VectorMatch, VectorStore + +__all__ = ["VectorMatch", "VectorStore"] diff --git a/circuitforge_core/vector/base.py b/circuitforge_core/vector/base.py new file mode 100644 index 0000000..2b8171f --- /dev/null +++ b/circuitforge_core/vector/base.py @@ -0,0 +1,44 @@ +""" +circuitforge_core.vector.base — VectorStore ABC and shared types. + +Concrete implementations: LocalSQLiteVecStore (local), QdrantStore (cloud Paid tier). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class VectorMatch: + """A single result from a vector similarity search.""" + + id: str + score: float # lower is better (cosine / L2 distance) + metadata: dict = field(default_factory=dict) + + +class VectorStore(ABC): + """Abstract interface for vector storage backends.""" + + @abstractmethod + def upsert(self, id: str, vector: list[float], metadata: dict) -> None: + """Insert or replace a vector and its metadata.""" + + @abstractmethod + def query( + self, + vector: list[float], + top_k: int = 10, + filter_metadata: dict | None = None, + ) -> list[VectorMatch]: + """Return the top_k nearest vectors. Optional metadata filter applied post-search.""" + + @abstractmethod + def delete(self, id: str) -> None: + """Remove a single vector by string ID.""" + + @abstractmethod + def delete_where(self, filter_metadata: dict) -> int: + """Remove all vectors whose metadata matches all key-value pairs. Returns count.""" diff --git a/tests/test_vector/__init__.py b/tests/test_vector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_vector/test_base.py b/tests/test_vector/test_base.py new file mode 100644 index 0000000..c9197f0 --- /dev/null +++ b/tests/test_vector/test_base.py @@ -0,0 +1,94 @@ +# tests/test_vector/test_base.py +"""Tests for VectorStore ABC and VectorMatch.""" + +from __future__ import annotations + +import pytest + +from circuitforge_core.vector.base import VectorMatch, VectorStore + + +class _ConcreteStore(VectorStore): + """Minimal in-memory implementation for testing the ABC contract.""" + + def __init__(self) -> None: + self._data: dict[str, tuple[list[float], dict]] = {} + + def upsert(self, id: str, vector: list[float], metadata: dict) -> None: + self._data[id] = (vector, metadata) + + def query( + self, + vector: list[float], + top_k: int = 10, + filter_metadata: dict | None = None, + ) -> list[VectorMatch]: + results = [ + VectorMatch(id=k, score=0.0, metadata=v[1]) for k, v in self._data.items() + ] + if filter_metadata: + results = [ + r + for r in results + if all(r.metadata.get(k) == val for k, val in filter_metadata.items()) + ] + return results[:top_k] + + def delete(self, id: str) -> None: + self._data.pop(id, None) + + def delete_where(self, filter_metadata: dict) -> int: + to_remove = [ + k + for k, (_, meta) in self._data.items() + if all(meta.get(fk) == fv for fk, fv in filter_metadata.items()) + ] + for k in to_remove: + del self._data[k] + return len(to_remove) + + +def test_vector_match_is_frozen(): + match = VectorMatch(id="a", score=0.1, metadata={}) + with pytest.raises(Exception): + match.score = 0.5 # type: ignore[misc] + + +def test_upsert_and_query(): + store = _ConcreteStore() + store.upsert("chunk-1", [0.1, 0.2], {"doc_id": "book-a", "page": 1}) + results = store.query([0.1, 0.2]) + assert len(results) == 1 + assert results[0].id == "chunk-1" + assert results[0].metadata["page"] == 1 + + +def test_query_filter_metadata(): + store = _ConcreteStore() + store.upsert("c1", [0.1], {"doc_id": "book-a"}) + store.upsert("c2", [0.2], {"doc_id": "book-b"}) + results = store.query([0.1], filter_metadata={"doc_id": "book-a"}) + assert len(results) == 1 + assert results[0].id == "c1" + + +def test_delete(): + store = _ConcreteStore() + store.upsert("x", [0.1], {}) + store.delete("x") + assert store.query([0.1]) == [] + + +def test_delete_where(): + store = _ConcreteStore() + store.upsert("c1", [0.1], {"doc_id": "book-a"}) + store.upsert("c2", [0.2], {"doc_id": "book-a"}) + store.upsert("c3", [0.3], {"doc_id": "book-b"}) + count = store.delete_where({"doc_id": "book-a"}) + assert count == 2 + assert len(store.query([0.1])) == 1 + + +def test_cannot_instantiate_abc_directly(): + with pytest.raises(TypeError): + VectorStore() # type: ignore[abstract]