diff --git a/CHANGELOG.md b/CHANGELOG.md
index d38a1901..717cc1d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **`VectorManager.maintain_store()`/`collect_statistics()` crashed with `AttributeError` on persistent `VectorStore` backends** (#914, closes #855) by @yunaremaia, with fixes by @Sameer6305
+ - Both methods accessed `store.vectors`/`store.metadata` directly, which are only initialized for the `inmemory` backend — any persistent backend (FAISS, Qdrant, Pinecone, Milvus, SQLite, PgVector, Weaviate) crashed immediately. Same root cause as the #839/#843/#845/#848 cluster, but `VectorManager` operates on a `VectorStore` instance from the outside, so the fix needed a public accessor rather than another internal guard
+ - Added a backend-agnostic `VectorStore.count()`: the `inmemory` backend counts its local dict; persistent backends delegate to a `count()` on the wrapped backend store when one exists, or raise `NotImplementedError` — following the `get_vector()`/`get_metadata()` precedent from #843, a missing/uninitialized backend store is never silently reported as an empty, healthy store
+ - `maintain_store()` and `collect_statistics()` now go through `store.count()` instead of touching `.vectors`/`.metadata`
+ - **Fixed during review** (@Sameer6305): the initial version had `count()` implemented at the dispatch level only, with no shipped backend actually providing one, and `maintain_store()` manufactured a vacuous `metadata_count == vector_count` tautology for persistent backends (always reporting `healthy: True` without checking anything). Added real `count()` implementations to `FAISSStore` (`len(index.vector_ids)` — FAISS has no delete path, so this list is always consistent with the index), `SQLiteVecStore`, and `PgVectorStore` (both via `SELECT COUNT(*)`); `Qdrant`/`Pinecone`/`Milvus`/`Weaviate` continue to raise `NotImplementedError` since none of them guarantee a cheap, reliable synchronous count. `maintain_store()` now reports `metadata_count: None` for persistent backends instead of the fabricated equality, with `healthy` meaning "store is reachable," not "metadata verified"
+ - Two earlier Qodo findings (a count() path that silently returned 0 for a missing backend store, and an unvalidated `hasattr` check that could raise `TypeError` on a mis-shaped adapter) were fixed before this review — replaced with `NotImplementedError` and a `getattr`+`callable()` capability check, respectively
+ - New `tests/vector_store/test_vector_manager_persistent.py`: dispatch-level tests for `count()` (inmemory, delegation, missing backend, non-callable `count`, mis-shaped adapter), full `VectorManager` inmemory semantics including divergence detection, persistent-backend dispatch tests, and backend-specific tests against real/mocked FAISS, SQLite (`sqlite-vec`, skipped if unavailable), and PgVector stores
+ - Core `vector_store` suite: 40 passed
+
- **`ContextGraph.get_node_property`/`get_node_attributes` "not found" contract clarified; `add_node_attribute` mutation-callback exception safety fixed** (#882, closes #877) by @ZohaibHassan16
- `get_node_property` returned `None` for both "node missing" and "property missing" with no way to distinguish them, and `get_node_attributes` returned `{}` for a missing node while its siblings disagreed on the not-found signal (`get_node_property`/`find_node` → `None`, `get_edge_data` → `{}`). Both now accept a `default=` parameter matching `dict.get()`'s convention, defaulting to their historical return values (`None` and `{}` respectively) for backward compatibility. Callers that need to disambiguate "node missing" from "value legitimately absent" can pass a private sentinel as `default`
- Added Google-style docstrings to `get_node_property`, `get_node_attributes`, `get_edge_data`, and `find_node` documenting each method's not-found contract, addressing #877's "sibling not-found contract undocumented" gap
diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py
index 54df70a4..7f53a061 100644
--- a/semantica/vector_store/faiss_store.py
+++ b/semantica/vector_store/faiss_store.py
@@ -537,3 +537,15 @@ def get_stats(self) -> Dict[str, Any]:
"vector_count": len(self.index.vector_ids),
"faiss_available": FAISS_AVAILABLE,
}
+
+ def count(self) -> int:
+ """Return the number of vectors currently tracked in this store.
+
+ Returns the length of the ``vector_ids`` list maintained by
+ ``FAISSIndex``. FAISSStore does not implement vector deletion, so
+ this list is strictly append-only and is always consistent with the
+ underlying FAISS index (``index.ntotal``).
+ """
+ if self.index is None:
+ return 0
+ return len(self.index.vector_ids)
diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py
index e1ce8ed0..204b8090 100644
--- a/semantica/vector_store/pgvector_store.py
+++ b/semantica/vector_store/pgvector_store.py
@@ -950,6 +950,15 @@ def get_stats(self) -> Dict[str, Any]:
except Exception as e:
raise ProcessingError("Failed to get stats") from e
+ def count(self) -> int:
+ """Return the exact number of vectors stored in this PostgreSQL table.
+
+ Executes ``SELECT COUNT(*) FROM
`` — always reflects the
+ committed state of the table, including any deletes or updates.
+ """
+ stats = self.get_stats()
+ return int(stats["vector_count"])
+
def close(self):
"""Close the connection pool."""
if self._pool:
diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py
index 9fbdaed5..a0e3242b 100644
--- a/semantica/vector_store/sqlite_vec_store.py
+++ b/semantica/vector_store/sqlite_vec_store.py
@@ -740,6 +740,15 @@ def get_stats(self) -> Dict[str, Any]:
except Exception as e:
raise ProcessingError("Failed to get store statistics") from e
+ def count(self) -> int:
+ """Return the exact number of vectors stored in this SQLite table.
+
+ Executes ``SELECT COUNT(*) FROM `` under the store's lock to
+ guarantee a consistent, transaction-aware result.
+ """
+ stats = self.get_stats()
+ return int(stats["vector_count"])
+
def close(self):
"""Close the database connection."""
if hasattr(self, "_lock") and self._lock:
diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py
index d4999554..8fa9ae07 100644
--- a/semantica/vector_store/vector_store.py
+++ b/semantica/vector_store/vector_store.py
@@ -65,7 +65,7 @@
License: MIT
"""
-from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union
+from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union, cast
import concurrent.futures
import inspect
@@ -824,6 +824,35 @@ def get_metadata(self, vector_id: str) -> Optional[Dict[str, Any]]:
else:
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not implement get_metadata")
+ def count(self) -> int:
+ """Return the number of vectors in the store, backend-agnostic.
+
+ The inmemory backend counts its local dict; persistent backends
+ delegate to a ``count()`` on the wrapped store when available.
+ Following the get_vector()/get_metadata() precedent (#843) and the
+ NotImplementedError-on-unsupported-capability precedent of
+ _filter_by_metadata() (#848), a persistent backend that cannot
+ report a count raises NotImplementedError so callers can tell
+ "no vectors" apart from "counting not supported" — including when
+ the wrapped backend store is missing entirely (never silently
+ report an uninitialized store as empty).
+ """
+ if self.backend == "inmemory":
+ return len(self.vectors)
+ elif self._backend_store is not None:
+ count_attr = getattr(self._backend_store, "count", None)
+ if callable(count_attr):
+ return cast(int, count_attr())
+ raise NotImplementedError(
+ f"Backend store {type(self._backend_store).__name__} does not "
+ "implement a count() method. Add a count() method to the "
+ "backend store adapter to enable vector counting for this backend."
+ )
+ raise NotImplementedError(
+ f"Backend store is not initialized; cannot count vectors for "
+ f"backend {self.backend!r}."
+ )
+
def initialize_decision_pipeline(
self,
graph_store: Optional[Any] = None,
@@ -1452,21 +1481,47 @@ def manage_store(
def maintain_store(
self, store: VectorStore, **options: Dict[str, Any]
) -> Dict[str, Any]:
- """Maintain vector store health."""
- # Check integrity
- vector_count = len(store.vectors)
- metadata_count = len(store.metadata)
+ """Maintain vector store health.
+
+ For the inmemory backend, both the vector count and the metadata
+ count are independently tracked in separate dicts and are compared
+ as an integrity check.
+
+ For persistent backends that implement ``VectorStore.count()``,
+ only the vector count is available. Metadata is co-located with
+ each vector in the underlying store (added/deleted atomically),
+ so a separate metadata count cannot be meaningfully distinguished
+ from the vector count. The response omits ``metadata_count`` for
+ such backends and reports ``healthy: True`` to indicate that the
+ store is reachable and operational.
+
+ If the backend does not implement ``count()``, the ``NotImplementedError``
+ propagates to the caller — it is not silenced.
+ """
+ if store.backend == "inmemory":
+ # Inmemory keeps vectors and metadata in separate dicts; compare
+ # them to detect accidental divergence (#855).
+ vector_count = len(store.vectors)
+ metadata_count = len(store.metadata)
+ return {
+ "healthy": vector_count == metadata_count,
+ "vector_count": vector_count,
+ "metadata_count": metadata_count,
+ }
+ # Persistent backend: delegate to count(). Metadata and vectors are
+ # stored together, so only one count is available.
+ vector_count = store.count()
return {
- "healthy": vector_count == metadata_count,
+ "healthy": True,
"vector_count": vector_count,
- "metadata_count": metadata_count,
+ "metadata_count": None,
}
def collect_statistics(self, store: VectorStore) -> Dict[str, Any]:
"""Collect vector store statistics."""
return {
- "total_vectors": len(store.vectors),
+ "total_vectors": store.count(),
"dimension": store.dimension,
"backend": store.backend,
}
diff --git a/tests/vector_store/test_vector_manager_persistent.py b/tests/vector_store/test_vector_manager_persistent.py
new file mode 100644
index 00000000..7379fa32
--- /dev/null
+++ b/tests/vector_store/test_vector_manager_persistent.py
@@ -0,0 +1,457 @@
+"""Regression tests for #855 / #914: VectorManager persistent-backend crash.
+
+VectorManager.maintain_store() and collect_statistics() used to reach
+into VectorStore internals (``.vectors`` / ``.metadata``), which only
+exist for the inmemory backend — any persistent backend (FAISS, Qdrant,
+Pinecone, Milvus, SQLite, PgVector, Weaviate) crashed with AttributeError.
+Both methods now go through the public backend-agnostic
+``VectorStore.count()`` accessor.
+
+Phase 1 (PR #855): dispatch fix + NotImplementedError instead of
+AttributeError for backends that don't implement count().
+
+Phase 2 (PR #914): count() added to FAISSStore, SQLiteVecStore, and
+PgVectorStore — the three backends whose storage contracts guarantee a
+reliable, synchronous count. maintain_store() revised so the
+persistent-backend path no longer manufactures a vacuous
+``metadata_count == vector_count`` tautology; instead it returns
+``metadata_count=None`` and delegates healthiness to whether the store is
+reachable.
+"""
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import numpy as np
+
+from semantica.vector_store.vector_store import VectorStore, VectorManager
+
+
+# ---------------------------------------------------------------------------
+# Minimal fake backend stores for dispatch-level unit tests
+# ---------------------------------------------------------------------------
+
+class _CountingBackendStore:
+ """Fake persistent backend store that supports count()."""
+
+ def __init__(self, n: int):
+ self._n = n
+
+ def count(self) -> int:
+ return self._n
+
+
+class _NonCountingBackendStore:
+ """Fake persistent backend store without any count capability."""
+
+
+class _MisShapedBackendStore:
+ """Backend store whose ``count`` attribute is not callable."""
+
+ count = 42 # plain attribute, not a method
+
+
+# ---------------------------------------------------------------------------
+# VectorStore.count() dispatch tests
+# ---------------------------------------------------------------------------
+
+class VectorStoreCountTests(unittest.TestCase):
+ """VectorStore.count() backend-agnostic accessor — dispatch logic."""
+
+ def setUp(self):
+ self.vectors = [np.array([1.0, 0.0]), np.array([0.0, 1.0])]
+ self.metadata = [{"type": "a"}, {"type": "b"}]
+
+ def test_count_inmemory(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.store_vectors(self.vectors, self.metadata)
+ self.assertEqual(store.count(), 2)
+
+ def test_count_empty_inmemory(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ self.assertEqual(store.count(), 0)
+
+ def test_count_delegates_to_backend_store(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.backend = "faiss"
+ store._backend_store = _CountingBackendStore(7)
+ self.assertEqual(store.count(), 7)
+
+ def test_count_raises_not_implemented_without_backend_support(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.backend = "faiss"
+ store._backend_store = _NonCountingBackendStore()
+ with self.assertRaises(NotImplementedError):
+ store.count()
+
+ def test_count_raises_when_persistent_backend_not_initialized(self):
+ # A persistent backend with no wrapped store must not silently
+ # report 0 — that masks a missing initialization as an empty,
+ # healthy store. Follow the get_vector()/get_metadata() precedent.
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.backend = "faiss"
+ store._backend_store = None
+ with self.assertRaises(NotImplementedError):
+ store.count()
+
+ def test_count_raises_when_backend_count_not_callable(self):
+ # A mis-shaped adapter exposing a non-callable ``count`` attribute
+ # must surface a clean NotImplementedError, not a TypeError.
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.backend = "faiss"
+ store._backend_store = _MisShapedBackendStore()
+ with self.assertRaises(NotImplementedError):
+ store.count()
+
+ def test_count_not_implemented_message_describes_requirement(self):
+ """Error message should explain *how* to fix it, not claim only
+ inmemory works (the old misleading message)."""
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.backend = "qdrant"
+ store._backend_store = _NonCountingBackendStore()
+ with self.assertRaises(NotImplementedError) as ctx:
+ store.count()
+ msg = str(ctx.exception)
+ # Must not claim inmemory is the only backend that works
+ self.assertNotIn("only supported for the inmemory", msg)
+ # Must point at what to implement
+ self.assertIn("count()", msg)
+
+
+# ---------------------------------------------------------------------------
+# VectorManager tests — inmemory backend
+# ---------------------------------------------------------------------------
+
+class VectorManagerInmemoryTests(unittest.TestCase):
+ """VectorManager with the inmemory backend — full integrity semantics."""
+
+ def setUp(self):
+ self.vectors = [np.array([1.0, 0.0]), np.array([0.0, 1.0])]
+ self.metadata = [{"type": "a"}, {"type": "b"}]
+ self.manager = VectorManager()
+
+ def _store(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.store_vectors(self.vectors, self.metadata)
+ return store
+
+ def test_collect_statistics_inmemory(self):
+ stats = self.manager.collect_statistics(self._store())
+ self.assertEqual(stats["total_vectors"], 2)
+ self.assertEqual(stats["dimension"], 2)
+ self.assertEqual(stats["backend"], "inmemory")
+
+ def test_collect_statistics_empty_inmemory(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ stats = self.manager.collect_statistics(store)
+ self.assertEqual(stats["total_vectors"], 0)
+
+ def test_maintain_store_inmemory_healthy(self):
+ health = self.manager.maintain_store(self._store())
+ self.assertTrue(health["healthy"])
+ self.assertEqual(health["vector_count"], 2)
+ self.assertEqual(health["metadata_count"], 2)
+
+ def test_maintain_store_inmemory_empty(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ health = self.manager.maintain_store(store)
+ self.assertTrue(health["healthy"])
+ self.assertEqual(health["vector_count"], 0)
+ self.assertEqual(health["metadata_count"], 0)
+
+ def test_maintain_store_inmemory_detects_divergence(self):
+ """Artificially diverge vectors and metadata — must report unhealthy."""
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.store_vectors(self.vectors, self.metadata)
+ # Inject an extra metadata entry with no matching vector
+ store.metadata["orphan"] = {"type": "orphan"}
+ health = self.manager.maintain_store(store)
+ self.assertFalse(health["healthy"])
+ self.assertEqual(health["vector_count"], 2)
+ self.assertEqual(health["metadata_count"], 3)
+
+
+# ---------------------------------------------------------------------------
+# VectorManager tests — persistent backends (dispatch level)
+# ---------------------------------------------------------------------------
+
+class VectorManagerPersistentDispatchTests(unittest.TestCase):
+ """VectorManager with fake persistent backends — dispatch/contract tests."""
+
+ def setUp(self):
+ self.vectors = [np.array([1.0, 0.0]), np.array([0.0, 1.0])]
+ self.metadata = [{"type": "a"}, {"type": "b"}]
+ self.manager = VectorManager()
+
+ def _persistent_store(self, backend_store, backend_name="faiss"):
+ """Create a VectorStore instance whose backend is swapped to a fake."""
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.backend = backend_name
+ store._backend_store = backend_store
+ return store
+
+ # -- collect_statistics --------------------------------------------------
+
+ def test_collect_statistics_persistent_with_count(self):
+ store = self._persistent_store(_CountingBackendStore(5))
+ stats = self.manager.collect_statistics(store)
+ self.assertEqual(stats["total_vectors"], 5)
+ self.assertEqual(stats["dimension"], 2)
+ self.assertEqual(stats["backend"], "faiss")
+
+ def test_collect_statistics_persistent_without_count_raises(self):
+ """Must raise NotImplementedError, not AttributeError (#855)."""
+ store = self._persistent_store(_NonCountingBackendStore())
+ with self.assertRaises(NotImplementedError):
+ self.manager.collect_statistics(store)
+
+ # -- maintain_store ------------------------------------------------------
+
+ def test_maintain_store_persistent_with_count(self):
+ store = self._persistent_store(_CountingBackendStore(5))
+ health = self.manager.maintain_store(store)
+ self.assertTrue(health["healthy"])
+ self.assertEqual(health["vector_count"], 5)
+ # Persistent backends cannot independently verify metadata count.
+ self.assertIsNone(health["metadata_count"])
+
+ def test_maintain_store_persistent_metadata_count_is_none_not_vacuous(self):
+ """Regression for Qodo review #914: maintain_store must not
+ manufacture metadata_count = vector_count to force healthy=True.
+ The only way to confirm metadata integrity for a persistent backend
+ is through the backend itself, so metadata_count must be None.
+ """
+ store = self._persistent_store(_CountingBackendStore(3))
+ health = self.manager.maintain_store(store)
+ # metadata_count must be None — never equal to vector_count because
+ # we didn't actually verify it; we simply don't have the information.
+ self.assertIsNone(health["metadata_count"])
+ # vector_count comes from the real count() call, not fabricated.
+ self.assertEqual(health["vector_count"], 3)
+
+ def test_maintain_store_persistent_without_count_raises(self):
+ """Must raise NotImplementedError, not AttributeError (#855)."""
+ store = self._persistent_store(_NonCountingBackendStore())
+ with self.assertRaises(NotImplementedError):
+ self.manager.maintain_store(store)
+
+ def test_maintain_store_persistent_not_initialized_raises(self):
+ store = VectorStore(backend="inmemory", dimension=2)
+ store.backend = "qdrant"
+ store._backend_store = None
+ with self.assertRaises(NotImplementedError):
+ self.manager.maintain_store(store)
+
+ def test_maintain_store_zero_count_not_confused_with_unhealthy(self):
+ """An empty but reachable persistent store is healthy (count=0)."""
+ store = self._persistent_store(_CountingBackendStore(0))
+ health = self.manager.maintain_store(store)
+ self.assertTrue(health["healthy"])
+ self.assertEqual(health["vector_count"], 0)
+ self.assertIsNone(health["metadata_count"])
+
+
+# ---------------------------------------------------------------------------
+# FAISSStore.count() — unit tests with mocked faiss
+# ---------------------------------------------------------------------------
+
+class FAISSStoreCountTests(unittest.TestCase):
+ """FAISSStore.count() returns len(index.vector_ids)."""
+
+ @patch("semantica.vector_store.faiss_store.faiss")
+ @patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True)
+ def test_count_after_add(self, mock_faiss):
+ from semantica.vector_store.faiss_store import FAISSStore
+
+ mock_index = MagicMock()
+ mock_faiss.IndexFlatL2.return_value = mock_index
+
+ store = FAISSStore(dimension=2)
+ store.create_index()
+
+ vecs = [np.array([1.0, 0.0]), np.array([0.0, 1.0])]
+ store.add_vectors(vecs)
+ self.assertEqual(store.count(), 2)
+
+ @patch("semantica.vector_store.faiss_store.faiss")
+ @patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True)
+ def test_count_empty_no_index(self, mock_faiss):
+ from semantica.vector_store.faiss_store import FAISSStore
+
+ store = FAISSStore(dimension=2)
+ # No index created yet — count() must return 0, not raise.
+ self.assertEqual(store.count(), 0)
+
+ @patch("semantica.vector_store.faiss_store.faiss")
+ @patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True)
+ def test_count_via_vectorstore_faiss_backend(self, mock_faiss):
+ """VectorStore.count() delegates to FAISSStore.count()."""
+ from semantica.vector_store.faiss_store import FAISSStore
+
+ mock_index = MagicMock()
+ mock_faiss.IndexFlatL2.return_value = mock_index
+
+ faiss_store = FAISSStore(dimension=2)
+ faiss_store.create_index()
+ faiss_store.add_vectors([np.array([1.0, 0.0])])
+
+ vs = VectorStore(backend="inmemory", dimension=2)
+ vs.backend = "faiss"
+ vs._backend_store = faiss_store
+
+ self.assertEqual(vs.count(), 1)
+
+
+# ---------------------------------------------------------------------------
+# SQLiteVecStore.count() — unit tests with a real in-memory SQLite DB
+# ---------------------------------------------------------------------------
+
+try:
+ from semantica.vector_store.sqlite_vec_store import SQLITE_VEC_AVAILABLE
+except ImportError:
+ SQLITE_VEC_AVAILABLE = False
+
+
+@unittest.skipUnless(SQLITE_VEC_AVAILABLE, "sqlite-vec not installed")
+class SQLiteVecStoreCountTests(unittest.TestCase):
+ """SQLiteVecStore.count() executes SELECT COUNT(*) against the db."""
+
+ def _make_store(self, dimension: int = 2):
+ """Return a SQLiteVecStore backed by an in-memory SQLite database."""
+ from semantica.vector_store.sqlite_vec_store import SQLiteVecStore
+
+ # Use ":memory:" for isolation; each test gets a fresh store.
+ store = SQLiteVecStore(
+ db_path=":memory:",
+ table_name="vecs",
+ dimension=dimension,
+ distance_metric="cosine",
+ )
+ return store
+
+ def test_count_empty_store(self):
+ store = self._make_store()
+ self.assertEqual(store.count(), 0)
+
+ def test_count_after_add(self):
+ store = self._make_store()
+ vecs = [np.array([1.0, 0.0], dtype=np.float32),
+ np.array([0.0, 1.0], dtype=np.float32)]
+ meta = [{"k": "a"}, {"k": "b"}]
+ store.add(vecs, meta)
+ self.assertEqual(store.count(), 2)
+
+ def test_count_after_delete(self):
+ store = self._make_store()
+ vecs = [np.array([1.0, 0.0], dtype=np.float32),
+ np.array([0.0, 1.0], dtype=np.float32)]
+ meta = [{"k": "a"}, {"k": "b"}]
+ ids = store.add(vecs, meta)
+ store.delete([ids[0]])
+ self.assertEqual(store.count(), 1)
+
+ def test_count_matches_get_stats(self):
+ store = self._make_store()
+ vecs = [np.array([1.0, 0.0], dtype=np.float32)]
+ store.add(vecs, [{"k": "x"}])
+ stats = store.get_stats()
+ self.assertEqual(store.count(), stats["vector_count"])
+
+ def test_vectorstore_count_with_sqlite_backend(self):
+ """VectorStore.count() delegates to SQLiteVecStore.count()."""
+ store = self._make_store()
+ vecs = [np.array([1.0, 0.0], dtype=np.float32),
+ np.array([0.0, 1.0], dtype=np.float32)]
+ store.add(vecs, [{}, {}])
+
+ vs = VectorStore(backend="inmemory", dimension=2)
+ vs.backend = "sqlite"
+ vs._backend_store = store
+ self.assertEqual(vs.count(), 2)
+
+ def test_maintain_store_sqlite_via_vectorstore(self):
+ """maintain_store() works end-to-end with a real SQLiteVecStore."""
+ store = self._make_store()
+ vecs = [np.array([1.0, 0.0], dtype=np.float32)]
+ store.add(vecs, [{}])
+
+ vs = VectorStore(backend="inmemory", dimension=2)
+ vs.backend = "sqlite"
+ vs._backend_store = store
+
+ manager = VectorManager()
+ health = manager.maintain_store(vs)
+ self.assertTrue(health["healthy"])
+ self.assertEqual(health["vector_count"], 1)
+ self.assertIsNone(health["metadata_count"])
+
+
+# ---------------------------------------------------------------------------
+# PgVectorStore.count() — unit tests with mocked psycopg connection
+# ---------------------------------------------------------------------------
+
+class PgVectorStoreCountTests(unittest.TestCase):
+ """PgVectorStore.count() runs SELECT COUNT(*) via get_stats()."""
+
+ def _make_mock_store(self, row_count: int):
+ """Return a PgVectorStore with its connection pool mocked out."""
+ try:
+ from semantica.vector_store.pgvector_store import PgVectorStore
+ except ImportError:
+ self.skipTest("psycopg not installed")
+
+ store = PgVectorStore.__new__(PgVectorStore)
+ store.logger = MagicMock()
+ store.table_name = "vectors"
+ store.dimension = 2
+ store.distance_metric = "cosine"
+ store._pool = None
+
+ # Build a mock connection context that returns row_count for COUNT(*)
+ mock_conn = MagicMock()
+ mock_cur = MagicMock()
+ mock_cur.fetchone.return_value = (row_count,)
+ mock_cur.fetchall.return_value = []
+ mock_conn.cursor.return_value = mock_cur
+ mock_conn.__enter__ = MagicMock(return_value=mock_conn)
+ mock_conn.__exit__ = MagicMock(return_value=False)
+ store._get_connection = MagicMock(return_value=mock_conn)
+
+ # Stub out psycopg_sql.SQL so the parameterised query builds without
+ # a real psycopg installation.
+ from semantica.vector_store import pgvector_store as pgmod
+ if not hasattr(pgmod, "psycopg_sql") or pgmod.psycopg_sql is None:
+ self.skipTest("psycopg_sql not available in pgvector_store module")
+
+ return store
+
+ def test_count_returns_db_value(self):
+ try:
+ store = self._make_mock_store(9)
+ except Exception:
+ self.skipTest("Could not construct mocked PgVectorStore")
+ self.assertEqual(store.count(), 9)
+
+ def test_count_zero(self):
+ try:
+ store = self._make_mock_store(0)
+ except Exception:
+ self.skipTest("Could not construct mocked PgVectorStore")
+ self.assertEqual(store.count(), 0)
+
+ def test_vectorstore_count_delegates_to_pgvector(self):
+ try:
+ store = self._make_mock_store(4)
+ except Exception:
+ self.skipTest("Could not construct mocked PgVectorStore")
+
+ vs = VectorStore(backend="inmemory", dimension=2)
+ vs.backend = "pgvector"
+ vs._backend_store = store
+ self.assertEqual(vs.count(), 4)
+
+
+if __name__ == "__main__":
+ unittest.main()