From 949294262372ad8b21ce49a21d9b75d7bc826d94 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 11:46:24 -0700 Subject: [PATCH] fix(vector): make VectorMatch.metadata immutable; rename id to entry_id --- circuitforge_core/vector/base.py | 29 ++++++++++++++++++++--------- tests/test_vector/test_base.py | 30 ++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/circuitforge_core/vector/base.py b/circuitforge_core/vector/base.py index 2b8171f..d1c6f9b 100644 --- a/circuitforge_core/vector/base.py +++ b/circuitforge_core/vector/base.py @@ -8,22 +8,30 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Mapping @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) + entry_id: str + score: float # lower is better (L2 / cosine distance) + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if isinstance(self.metadata, dict): + object.__setattr__(self, "metadata", MappingProxyType(self.metadata)) class VectorStore(ABC): """Abstract interface for vector storage backends.""" @abstractmethod - def upsert(self, id: str, vector: list[float], metadata: dict) -> None: + def upsert( + self, entry_id: str, vector: list[float], metadata: dict[str, Any] + ) -> None: """Insert or replace a vector and its metadata.""" @abstractmethod @@ -31,14 +39,17 @@ class VectorStore(ABC): self, vector: list[float], top_k: int = 10, - filter_metadata: dict | None = None, + filter_metadata: dict[str, Any] | 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.""" + def delete(self, entry_id: str) -> None: + """Remove a single vector by string ID. No-op if not found.""" @abstractmethod - def delete_where(self, filter_metadata: dict) -> int: - """Remove all vectors whose metadata matches all key-value pairs. Returns count.""" + def delete_where(self, filter_metadata: dict[str, Any]) -> int: + """Remove all vectors whose metadata matches all key-value pairs. Returns count removed. + + Raises ValueError if filter_metadata is empty (would delete entire store). + """ diff --git a/tests/test_vector/test_base.py b/tests/test_vector/test_base.py index c9197f0..dfd89df 100644 --- a/tests/test_vector/test_base.py +++ b/tests/test_vector/test_base.py @@ -1,8 +1,10 @@ -# tests/test_vector/test_base.py """Tests for VectorStore ABC and VectorMatch.""" from __future__ import annotations +from dataclasses import FrozenInstanceError +from types import MappingProxyType + import pytest from circuitforge_core.vector.base import VectorMatch, VectorStore @@ -14,8 +16,8 @@ class _ConcreteStore(VectorStore): 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 upsert(self, entry_id: str, vector: list[float], metadata: dict) -> None: + self._data[entry_id] = (vector, metadata) def query( self, @@ -24,7 +26,8 @@ class _ConcreteStore(VectorStore): filter_metadata: dict | None = None, ) -> list[VectorMatch]: results = [ - VectorMatch(id=k, score=0.0, metadata=v[1]) for k, v in self._data.items() + VectorMatch(entry_id=k, score=0.0, metadata=v[1]) + for k, v in self._data.items() ] if filter_metadata: results = [ @@ -34,8 +37,8 @@ class _ConcreteStore(VectorStore): ] return results[:top_k] - def delete(self, id: str) -> None: - self._data.pop(id, None) + def delete(self, entry_id: str) -> None: + self._data.pop(entry_id, None) def delete_where(self, filter_metadata: dict) -> int: to_remove = [ @@ -49,17 +52,24 @@ class _ConcreteStore(VectorStore): def test_vector_match_is_frozen(): - match = VectorMatch(id="a", score=0.1, metadata={}) - with pytest.raises(Exception): + match = VectorMatch(entry_id="a", score=0.1, metadata={}) + with pytest.raises(FrozenInstanceError): match.score = 0.5 # type: ignore[misc] +def test_vector_match_metadata_is_not_mutable(): + match = VectorMatch(entry_id="a", score=0.1, metadata={"k": "v"}) + assert isinstance(match.metadata, MappingProxyType) + with pytest.raises(TypeError): + match.metadata["k"] = "changed" # type: ignore[index] + + 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].entry_id == "chunk-1" assert results[0].metadata["page"] == 1 @@ -69,7 +79,7 @@ def test_query_filter_metadata(): 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" + assert results[0].entry_id == "c1" def test_delete():