From 5db0adc18a219b8e6343839764b17f810d4ce336 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Fri, 7 Aug 2026 19:39:14 +0530 Subject: [PATCH 1/8] fix(vector-store): standardize search_vectors output schema --- semantica/vector_store/__init__.py | 3 +- semantica/vector_store/faiss_store.py | 1 + semantica/vector_store/milvus_store.py | 6 + semantica/vector_store/pgvector_store.py | 1 + semantica/vector_store/pinecone_store.py | 1 + semantica/vector_store/qdrant_store.py | 1 + semantica/vector_store/sqlite_vec_store.py | 1 + semantica/vector_store/vector_store.py | 29 ++- semantica/vector_store/weaviate_store.py | 3 +- .../test_backward_compatibility.py | 2 + .../vector_store/test_search_result_schema.py | 246 ++++++++++++++++++ .../test_vector_store_deepdive.py | 3 + 12 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 tests/vector_store/test_search_result_schema.py diff --git a/semantica/vector_store/__init__.py b/semantica/vector_store/__init__.py index 194ef4bd..45b617b4 100644 --- a/semantica/vector_store/__init__.py +++ b/semantica/vector_store/__init__.py @@ -182,7 +182,7 @@ from .pinecone_store import PineconeStore, PineconeClient, PineconeIndex, Pineco from .qdrant_store import QdrantStore, QdrantClient, QdrantCollection, QdrantSearch from .registry import MethodRegistry, method_registry from .sqlite_vec_store import SQLiteVecStore -from .vector_store import VectorIndexer, VectorManager, VectorRetriever, VectorStore +from .vector_store import VectorIndexer, VectorManager, VectorRetriever, VectorStore, SearchResult from .weaviate_store import ( WeaviateStore, WeaviateClient, @@ -196,6 +196,7 @@ __all__ = [ "VectorIndexer", "VectorRetriever", "VectorManager", + "SearchResult", # FAISS "FAISSStore", "FAISSIndex", diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py index cd759886..e07dcc1b 100644 --- a/semantica/vector_store/faiss_store.py +++ b/semantica/vector_store/faiss_store.py @@ -149,6 +149,7 @@ class FAISSSearch: "score": similarity_score, "distance": dist_val, "metadata": self.index.metadata.get(vector_id, {}), + "vector": None, } ) diff --git a/semantica/vector_store/milvus_store.py b/semantica/vector_store/milvus_store.py index 4f8b6f93..8514aac3 100644 --- a/semantica/vector_store/milvus_store.py +++ b/semantica/vector_store/milvus_store.py @@ -165,6 +165,12 @@ class MilvusCollection: "score": 1.0 - hit.distance if hit.distance <= 1.0 else 1.0 / (1.0 + hit.distance), + # Milvus collection schema stores only id+vector; no + # metadata field is defined in create_collection(). + # Return empty dict — a future schema migration that + # adds a metadata JSON field is tracked separately. + "metadata": {}, + "vector": None, } ) results.append(batch_results) diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py index 6a646cd8..15d6080a 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -445,6 +445,7 @@ class PgVectorStore: "id": vec_id, "score": similarity, "metadata": meta if isinstance(meta, dict) else json.loads(meta), + "vector": None, }) return results diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py index f1cc5135..d7a8196e 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -196,6 +196,7 @@ class PineconeIndex: "id": match.id, "score": match.score, "metadata": match.metadata or {}, + "vector": None, } ) diff --git a/semantica/vector_store/qdrant_store.py b/semantica/vector_store/qdrant_store.py index 88e5b397..311636df 100644 --- a/semantica/vector_store/qdrant_store.py +++ b/semantica/vector_store/qdrant_store.py @@ -166,6 +166,7 @@ class QdrantCollection: "id": result.id, "score": result.score, "metadata": result.payload or {}, + "vector": None, } ) diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py index 0ef7d9fc..87d43e02 100644 --- a/semantica/vector_store/sqlite_vec_store.py +++ b/semantica/vector_store/sqlite_vec_store.py @@ -414,6 +414,7 @@ class SQLiteVecStore: "id": vec_id, "score": similarity, "metadata": json.loads(meta_json) if meta_json else {}, + "vector": None, } ) diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index 86f9c07b..97374e26 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -65,7 +65,7 @@ Author: Semantica Contributors License: MIT """ -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union import concurrent.futures import inspect @@ -78,6 +78,26 @@ from ..embeddings import EmbeddingGenerator from .hybrid_similarity import HybridSimilarityCalculator from .decision_embedding_pipeline import DecisionEmbeddingPipeline +class SearchResult(TypedDict, total=False): + """Canonical schema returned by VectorStore.search_vectors(). + + Required fields (always present): + id – string identifier of the stored vector. + score – float similarity score, higher is better (normalised by each backend). + metadata – dict of associated metadata; empty dict when none is stored. + vector – np.ndarray when the backend returns the raw vector, otherwise None. + + Optional/preserved fields (may be present depending on backend): + distance – raw native distance value preserved for backends that expose it + (FAISS L2, Weaviate cosine); None when not available. + """ + + id: str + score: float + metadata: Dict[str, Any] + vector: Optional[Any] # np.ndarray | None + distance: Optional[float] # preserved when backend exposes it; not required + class VectorStore: """ @@ -634,7 +654,7 @@ class VectorStore: def search_vectors( self, query_vector: np.ndarray, k: int = 10, **options - ) -> List[Dict[str, Any]]: + ) -> List[SearchResult]: """ Search for similar vectors. @@ -687,11 +707,13 @@ class VectorStore: **options, ) - # Add metadata to results if available + # Add metadata to results; guarantee the key always exists. for result in results: vector_id = result.get("id") if vector_id and vector_id in self.metadata: result["metadata"] = self.metadata[vector_id] + elif "metadata" not in result: + result["metadata"] = {} self.progress_tracker.stop_tracking( tracking_id, @@ -1276,6 +1298,7 @@ class VectorRetriever: "id": ids[idx], "vector": vectors[idx], "score": float(similarities[idx]), + "metadata": {}, } ) diff --git a/semantica/vector_store/weaviate_store.py b/semantica/vector_store/weaviate_store.py index 9dd4e9ff..dac04de8 100644 --- a/semantica/vector_store/weaviate_store.py +++ b/semantica/vector_store/weaviate_store.py @@ -177,7 +177,7 @@ class WeaviateQuery: results.append( { "id": str(obj.uuid), - "properties": obj.properties, + "metadata": obj.properties if obj.properties is not None else {}, "distance": obj.metadata.distance if obj.metadata else None, "score": 1.0 - ( @@ -185,6 +185,7 @@ class WeaviateQuery: if obj.metadata and obj.metadata.distance else 0.0 ), + "vector": None, } ) diff --git a/tests/vector_store/test_backward_compatibility.py b/tests/vector_store/test_backward_compatibility.py index 29098ba0..a4d8766a 100644 --- a/tests/vector_store/test_backward_compatibility.py +++ b/tests/vector_store/test_backward_compatibility.py @@ -76,6 +76,8 @@ class TestVectorStoreBackwardCompatibility: assert all("id" in result for result in results) assert all("score" in result for result in results) assert all("vector" in result for result in results) + assert all("metadata" in result for result in results) + assert all(isinstance(result["metadata"], dict) for result in results) def test_search_method_unchanged(self): """Test search method unchanged.""" diff --git a/tests/vector_store/test_search_result_schema.py b/tests/vector_store/test_search_result_schema.py new file mode 100644 index 00000000..d8a88969 --- /dev/null +++ b/tests/vector_store/test_search_result_schema.py @@ -0,0 +1,246 @@ +""" +Test: Search Result Schema Compliance -- Issue #845 + +Every backend wrapper's search method must return a list of dicts that each +contain the four required canonical fields: + + id : str + score : float + metadata : dict (always a dict, {} when none stored) + vector : any (np.ndarray | None; None when backend doesn't return vectors) + +Optional preserved fields (may be present): + distance : float | None (preserved from FAISS, Weaviate; absent elsewhere) +""" + +import unittest +from unittest.mock import MagicMock, patch + +import numpy as np + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _assert_canonical_schema(test_case, results): + """Assert every result satisfies the #845 canonical schema.""" + test_case.assertIsInstance(results, list) + for r in results: + test_case.assertIsInstance(r, dict, "result must be a dict") + test_case.assertIn("id", r, "result must contain 'id'") + test_case.assertIn("score", r, "result must contain 'score'") + test_case.assertIn("metadata", r, "result must contain 'metadata'") + test_case.assertIn("vector", r, "result must contain 'vector'") + test_case.assertIsInstance(r["metadata"], dict, "'metadata' must be a dict") + test_case.assertIsInstance(r["score"], float, "'score' must be a float") + + +# --------------------------------------------------------------------------- +# In-memory backend (no mocking needed) +# --------------------------------------------------------------------------- + +class TestInMemorySearchSchema(unittest.TestCase): + + def test_search_vectors_canonical_schema(self): + """In-memory VectorStore.search_vectors() returns canonical schema.""" + from semantica.vector_store import VectorStore + + store = VectorStore(backend="inmemory", dimension=4) + vectors = [np.array([0.1, 0.2, 0.3, 0.4]), np.array([0.5, 0.6, 0.7, 0.8])] + metadata = [{"type": "a"}, {"type": "b"}] + store.store_vectors(vectors, metadata) + + results = store.search_vectors(np.array([0.15, 0.25, 0.35, 0.45]), k=2) + _assert_canonical_schema(self, results) + self.assertEqual(results[0]["metadata"]["type"], "a") + + def test_search_vectors_no_metadata_gives_empty_dict(self): + """In-memory results have metadata={} when no metadata was stored.""" + from semantica.vector_store import VectorStore + + store = VectorStore(backend="inmemory", dimension=4) + store.store_vectors([np.array([0.1, 0.2, 0.3, 0.4])]) + + results = store.search_vectors(np.array([0.1, 0.2, 0.3, 0.4]), k=1) + _assert_canonical_schema(self, results) + self.assertEqual(results[0]["metadata"], {}) + + +# --------------------------------------------------------------------------- +# FAISS +# --------------------------------------------------------------------------- + +class TestFAISSSearchSchema(unittest.TestCase): + + @patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True) + @patch("semantica.vector_store.faiss_store.faiss") + def test_faiss_search_similar_canonical_schema(self, mock_faiss): + from semantica.vector_store.faiss_store import FAISSIndex, FAISSSearch + + mock_index = MagicMock() + mock_index.search.return_value = ( + np.array([[0.05, 0.2]], dtype=np.float32), + np.array([[0, 1]]), + ) + + idx = FAISSIndex(mock_index, dimension=4) + idx.vector_ids = ["vec_0", "vec_1"] + idx.metadata = {"vec_0": {"k": "v"}, "vec_1": {}} + + searcher = FAISSSearch(idx) + results = searcher.search_similar(np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32), k=2) + + _assert_canonical_schema(self, results) + self.assertIn("distance", results[0]) + self.assertIsNone(results[0]["vector"]) + + +# --------------------------------------------------------------------------- +# Qdrant +# --------------------------------------------------------------------------- + +class TestQdrantSearchSchema(unittest.TestCase): + + @patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True) + def test_qdrant_search_points_canonical_schema(self): + from semantica.vector_store.qdrant_store import QdrantCollection + + mock_client = MagicMock() + mock_hit = MagicMock() + mock_hit.id = "q_1" + mock_hit.score = 0.88 + mock_hit.payload = {"category": "x"} + mock_client.search.return_value = [mock_hit] + + coll = QdrantCollection(mock_client, "test_col") + results = coll.search_points(np.array([0.1, 0.2]), limit=1) + + _assert_canonical_schema(self, results) + self.assertIsNone(results[0]["vector"]) + self.assertEqual(results[0]["metadata"], {"category": "x"}) + + +# --------------------------------------------------------------------------- +# Pinecone +# --------------------------------------------------------------------------- + +class TestPineconeSearchSchema(unittest.TestCase): + + @patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True) + def test_pinecone_search_vectors_canonical_schema(self): + from semantica.vector_store.pinecone_store import PineconeIndex + + mock_index = MagicMock() + mock_match = MagicMock() + mock_match.id = "p_1" + mock_match.score = 0.95 + mock_match.metadata = {"source": "web"} + mock_index.query.return_value = MagicMock(matches=[mock_match]) + + pi = PineconeIndex(mock_index) + results = pi.search_vectors([0.1, 0.2], k=1) + + _assert_canonical_schema(self, results) + self.assertIsNone(results[0]["vector"]) + self.assertEqual(results[0]["metadata"], {"source": "web"}) + + @patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True) + def test_pinecone_none_metadata_becomes_empty_dict(self): + from semantica.vector_store.pinecone_store import PineconeIndex + + mock_index = MagicMock() + mock_match = MagicMock() + mock_match.id = "p_2" + mock_match.score = 0.7 + mock_match.metadata = None + mock_index.query.return_value = MagicMock(matches=[mock_match]) + + pi = PineconeIndex(mock_index) + results = pi.search_vectors([0.1, 0.2], k=1) + + _assert_canonical_schema(self, results) + self.assertEqual(results[0]["metadata"], {}) + + +# --------------------------------------------------------------------------- +# Milvus +# --------------------------------------------------------------------------- + +class TestMilvusSearchSchema(unittest.TestCase): + + @patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True) + def test_milvus_search_canonical_schema(self): + from semantica.vector_store.milvus_store import MilvusCollection + + mock_collection = MagicMock() + mock_hit = MagicMock() + mock_hit.id = 42 + mock_hit.distance = 0.15 + + mock_collection.search.return_value = [[mock_hit]] + + mc = MilvusCollection.__new__(MilvusCollection) + mc.collection = mock_collection + mc.logger = MagicMock() + + results = mc.search( + vectors=[np.array([0.1, 0.2])], + anns_field="vector", + param={"metric_type": "L2", "params": {"nprobe": 10}}, + limit=1, + ) + + _assert_canonical_schema(self, results) + self.assertEqual(results[0]["metadata"], {}) + self.assertIsNone(results[0]["vector"]) + self.assertIn("distance", results[0]) + + +# --------------------------------------------------------------------------- +# Weaviate (direct WeaviateQuery) +# --------------------------------------------------------------------------- + +class TestWeaviateSearchSchema(unittest.TestCase): + + @patch("semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE", True) + @patch("semantica.vector_store.weaviate_store.MetadataQuery") + def test_weaviate_similarity_search_canonical_schema(self, mock_mq): + from semantica.vector_store.weaviate_store import WeaviateQuery + + mock_obj = MagicMock() + mock_obj.uuid = "weaviate-uuid-1" + mock_obj.properties = {"text": "hello", "category": "docs"} + mock_obj.metadata.distance = 0.12 + + mock_collection = MagicMock() + mock_collection.query.near_vector.return_value = MagicMock(objects=[mock_obj]) + + wq = WeaviateQuery(mock_collection) + results = wq.similarity_search(np.array([0.1, 0.2]), limit=1) + + _assert_canonical_schema(self, results) + self.assertNotIn("properties", results[0]) + self.assertEqual(results[0]["metadata"]["text"], "hello") + self.assertIsNone(results[0]["vector"]) + self.assertIn("distance", results[0]) + self.assertAlmostEqual(results[0]["distance"], 0.12) + + +# --------------------------------------------------------------------------- +# SearchResult TypedDict is importable +# --------------------------------------------------------------------------- + +class TestSearchResultTypeImport(unittest.TestCase): + + def test_search_result_importable_from_package(self): + from semantica.vector_store import SearchResult + self.assertTrue(callable(SearchResult)) + + def test_search_result_importable_from_module(self): + from semantica.vector_store.vector_store import SearchResult + self.assertTrue(callable(SearchResult)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vector_store/test_vector_store_deepdive.py b/tests/vector_store/test_vector_store_deepdive.py index 3d8d70be..ed31e5d3 100644 --- a/tests/vector_store/test_vector_store_deepdive.py +++ b/tests/vector_store/test_vector_store_deepdive.py @@ -195,6 +195,9 @@ class TestVectorStoreDeepDive(unittest.TestCase): mock_collection_instance.search.assert_called() self.assertEqual(len(results), 1) self.assertEqual(results[0]["id"], 1) + self.assertIn("metadata", results[0]) + self.assertIsInstance(results[0]["metadata"], dict) + self.assertIn("vector", results[0]) @patch('semantica.vector_store.qdrant_store.QdrantClientLib') @patch('semantica.vector_store.qdrant_store.VectorParams') From 40b81d0582ad8bd6060014d262fa5e11b03305c5 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Fri, 7 Aug 2026 21:34:43 +0530 Subject: [PATCH 2/8] fixed qodo reviews: standardize search results schema, score metric, and ID types - Removed total=False from SearchResult TypedDict so all fields are strictly required - Ensured distance: None is returned from backends that don't natively expose distance (Qdrant, Pinecone, SQLite, pgvector, in-memory) - Standardized search result score to a consistent 0.0 - 1.0 similarity metric scale across all backend adapters - Relaxed SearchResult id type to Union[str, int] to accommodate native integer IDs from Milvus and Qdrant without casting - Updated schema verification tests --- semantica/vector_store/faiss_store.py | 2 +- semantica/vector_store/milvus_store.py | 4 +--- semantica/vector_store/pgvector_store.py | 1 + semantica/vector_store/pinecone_store.py | 3 ++- semantica/vector_store/qdrant_store.py | 3 ++- semantica/vector_store/sqlite_vec_store.py | 1 + semantica/vector_store/vector_store.py | 15 +++++++-------- semantica/vector_store/weaviate_store.py | 9 +++------ tests/vector_store/test_search_result_schema.py | 11 ++++++----- 9 files changed, 24 insertions(+), 25 deletions(-) diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py index e07dcc1b..3368e43f 100644 --- a/semantica/vector_store/faiss_store.py +++ b/semantica/vector_store/faiss_store.py @@ -141,7 +141,7 @@ class FAISSSearch: # Standardize score as similarity (0.0 to 1.0) # while preserving original distance - similarity_score = 1.0 / (1.0 + dist_val) + similarity_score = 1.0 / (1.0 + max(0.0, dist_val)) results.append( { diff --git a/semantica/vector_store/milvus_store.py b/semantica/vector_store/milvus_store.py index 8514aac3..d9a99885 100644 --- a/semantica/vector_store/milvus_store.py +++ b/semantica/vector_store/milvus_store.py @@ -162,9 +162,7 @@ class MilvusCollection: { "id": hit.id, "distance": hit.distance, - "score": 1.0 - hit.distance - if hit.distance <= 1.0 - else 1.0 / (1.0 + hit.distance), + "score": 1.0 / (1.0 + max(0.0, hit.distance)), # Milvus collection schema stores only id+vector; no # metadata field is defined in create_collection(). # Return empty dict — a future schema migration that diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py index 15d6080a..393261a3 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -446,6 +446,7 @@ class PgVectorStore: "score": similarity, "metadata": meta if isinstance(meta, dict) else json.loads(meta), "vector": None, + "distance": None, }) return results diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py index d7a8196e..329ff316 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -194,9 +194,10 @@ class PineconeIndex: results.append( { "id": match.id, - "score": match.score, + "score": 1.0 / (1.0 + max(0.0, 1.0 - float(match.score))), "metadata": match.metadata or {}, "vector": None, + "distance": None, } ) diff --git a/semantica/vector_store/qdrant_store.py b/semantica/vector_store/qdrant_store.py index 311636df..aaf6b15e 100644 --- a/semantica/vector_store/qdrant_store.py +++ b/semantica/vector_store/qdrant_store.py @@ -164,9 +164,10 @@ class QdrantCollection: results.append( { "id": result.id, - "score": result.score, + "score": 1.0 / (1.0 + max(0.0, 1.0 - float(result.score))), "metadata": result.payload or {}, "vector": None, + "distance": None, } ) diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py index 87d43e02..edf2625a 100644 --- a/semantica/vector_store/sqlite_vec_store.py +++ b/semantica/vector_store/sqlite_vec_store.py @@ -415,6 +415,7 @@ class SQLiteVecStore: "score": similarity, "metadata": json.loads(meta_json) if meta_json else {}, "vector": None, + "distance": None, } ) diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index 97374e26..0c7e7c5f 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -78,25 +78,23 @@ from ..embeddings import EmbeddingGenerator from .hybrid_similarity import HybridSimilarityCalculator from .decision_embedding_pipeline import DecisionEmbeddingPipeline -class SearchResult(TypedDict, total=False): +class SearchResult(TypedDict): """Canonical schema returned by VectorStore.search_vectors(). Required fields (always present): - id – string identifier of the stored vector. - score – float similarity score, higher is better (normalised by each backend). + id – string or integer identifier of the stored vector. + score – float similarity score, higher is better (normalised to 0.0–1.0 across all backends). metadata – dict of associated metadata; empty dict when none is stored. vector – np.ndarray when the backend returns the raw vector, otherwise None. - - Optional/preserved fields (may be present depending on backend): distance – raw native distance value preserved for backends that expose it - (FAISS L2, Weaviate cosine); None when not available. + (FAISS L2, Weaviate cosine), otherwise None. """ - id: str + id: Union[str, int] score: float metadata: Dict[str, Any] vector: Optional[Any] # np.ndarray | None - distance: Optional[float] # preserved when backend exposes it; not required + distance: Optional[float] class VectorStore: @@ -1299,6 +1297,7 @@ class VectorRetriever: "vector": vectors[idx], "score": float(similarities[idx]), "metadata": {}, + "distance": None, } ) diff --git a/semantica/vector_store/weaviate_store.py b/semantica/vector_store/weaviate_store.py index dac04de8..a1906153 100644 --- a/semantica/vector_store/weaviate_store.py +++ b/semantica/vector_store/weaviate_store.py @@ -179,12 +179,9 @@ class WeaviateQuery: "id": str(obj.uuid), "metadata": obj.properties if obj.properties is not None else {}, "distance": obj.metadata.distance if obj.metadata else None, - "score": 1.0 - - ( - obj.metadata.distance - if obj.metadata and obj.metadata.distance - else 0.0 - ), + "score": 1.0 / (1.0 + max(0.0, obj.metadata.distance)) + if obj.metadata and obj.metadata.distance is not None + else 1.0, "vector": None, } ) diff --git a/tests/vector_store/test_search_result_schema.py b/tests/vector_store/test_search_result_schema.py index d8a88969..392f2cbf 100644 --- a/tests/vector_store/test_search_result_schema.py +++ b/tests/vector_store/test_search_result_schema.py @@ -1,16 +1,14 @@ -""" +""" Test: Search Result Schema Compliance -- Issue #845 Every backend wrapper's search method must return a list of dicts that each contain the four required canonical fields: - id : str + id : str | int score : float metadata : dict (always a dict, {} when none stored) vector : any (np.ndarray | None; None when backend doesn't return vectors) - -Optional preserved fields (may be present): - distance : float | None (preserved from FAISS, Weaviate; absent elsewhere) + distance : float | None (preserved from FAISS, Weaviate, Milvus; None elsewhere) """ import unittest @@ -29,11 +27,14 @@ def _assert_canonical_schema(test_case, results): for r in results: test_case.assertIsInstance(r, dict, "result must be a dict") test_case.assertIn("id", r, "result must contain 'id'") + test_case.assertTrue(isinstance(r["id"], (str, int)), "'id' must be a str or int") test_case.assertIn("score", r, "result must contain 'score'") test_case.assertIn("metadata", r, "result must contain 'metadata'") test_case.assertIn("vector", r, "result must contain 'vector'") + test_case.assertIn("distance", r, "result must contain 'distance'") test_case.assertIsInstance(r["metadata"], dict, "'metadata' must be a dict") test_case.assertIsInstance(r["score"], float, "'score' must be a float") + test_case.assertTrue(r["distance"] is None or isinstance(r["distance"], float), "'distance' must be float or None") # --------------------------------------------------------------------------- From 0d51608547fee451e06e7b6aba0c1a381b738964 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Sat, 8 Aug 2026 12:57:42 +0530 Subject: [PATCH 3/8] fix(vector_store): stop bypassing backend abstraction in build_decision_context/explain_decision/_filter_by_metadata (closes #848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_decision_context() and explain_decision(include_paths=True) both accessed self.vectors directly, which is only initialized for the inmemory backend, crashing with AttributeError on any persistent backend (FAISS, Qdrant, Pinecone, etc.). Replaced with self.get_vector() (#843's backend-agnostic accessor) + an is-not-None check — a verified 1:1 behavioral equivalent for the old 'decision_id in self.vectors' guard on the inmemory path. - Found a third, undocumented instance of the same bug during verification: _filter_by_metadata() also accessed self.metadata/ self.vectors directly. Initial fix silently returned [] for persistent backends, which was itself a new silent-failure bug (indistinguishable from a genuine zero-match result). Reconciled to raise NotImplementedError instead, matching the established precedent from get_vector()/get_metadata() (#843) for 'backend exists but doesn't support this operation' — confirmed via full grep of all 7 backend wrapper classes that none currently implement filter_by_metadata, so this path was previously dead-code-masked-as-working. Tests: 14 new tests across two rounds — inmemory behavioral equivalence, real (non-mocked) FAISS backend regression tests for all three methods, and explicit coverage proving the NotImplementedError fires with a clear message rather than the old silent-[] behavior. Full suite: 53 passed, 0 failed, 0 regressions across the 39 pre-existing tests. --- semantica/vector_store/vector_store.py | 31 +- .../test_decision_embedding_pipeline.py | 377 ++++++++++++++++++ 2 files changed, 403 insertions(+), 5 deletions(-) diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index d86e48cb..60740df9 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -1042,8 +1042,8 @@ class VectorStore: context["entities"] = decision_metadata["entities"] # Add related decisions based on similarity - if decision_id in self.vectors: - query_vector = self.vectors[decision_id] + query_vector = self.get_vector(decision_id) + if query_vector is not None: similar_decisions = self.search_vectors(query_vector, k=depth * 5) for result in similar_decisions: @@ -1096,8 +1096,8 @@ class VectorStore: if include_paths: # Find similar decisions for reasoning paths - if decision_id in self.vectors: - query_vector = self.vectors[decision_id] + query_vector = self.get_vector(decision_id) + if query_vector is not None: similar_decisions = self.search_vectors(query_vector, k=3) explanation["similar_decisions"] = similar_decisions @@ -1124,6 +1124,27 @@ class VectorStore: def _filter_by_metadata(self, filters: Dict[str, Any], limit: int) -> List[Dict[str, Any]]: """Filter decisions by metadata only.""" + if self._backend_store is not None: + # No real backend wrapper implements filter_by_metadata; the only + # codebase hit is HybridSearch.filter_by_metadata which has a + # completely different signature (results, MetadataFilter) and is + # never stored in _backend_store. Silently returning [] here would + # be wrong — the caller (filter_decisions) would report zero matches + # for a query that simply isn't supported, indistinguishable from a + # genuine empty result. This is the same situation as get_vector() + # and get_metadata() (#843 fix): when a backend exists but cannot + # fulfil the request, raise NotImplementedError so the caller knows + # the backend lacks this capability rather than assuming no data. + if hasattr(self._backend_store, "filter_by_metadata"): + return self._backend_store.filter_by_metadata(filters, limit) + raise NotImplementedError( + f"Backend store {type(self._backend_store).__name__} does not " + "implement filter_by_metadata. Metadata-only filtering via " + "filter_decisions(query=None, ...) is only supported for the " + "inmemory backend. Pass a query string to use search_decisions() " + "instead, which is supported by all backends." + ) + results = [] for vector_id, metadata in self.metadata.items(): @@ -1166,7 +1187,7 @@ class VectorStore: results.append({ "id": vector_id, "metadata": metadata, - "vector": self.vectors.get(vector_id) + "vector": self.get_vector(vector_id) }) if len(results) >= limit: diff --git a/tests/vector_store/test_decision_embedding_pipeline.py b/tests/vector_store/test_decision_embedding_pipeline.py index 17d47ddc..d65fe7da 100644 --- a/tests/vector_store/test_decision_embedding_pipeline.py +++ b/tests/vector_store/test_decision_embedding_pipeline.py @@ -533,3 +533,380 @@ class TestVectorStoreRetrieval: if __name__ == "__main__": pytest.main([__file__]) + + +class TestBuildDecisionContextInmemoryEquivalence: + """ + Requirement 3 (issue #848): prove that switching to get_vector() leaves + inmemory behavior byte-for-byte identical to the old direct dict access. + + We call build_decision_context() and explain_decision() once with inmemory + to capture results, then call them again after verifying the exact same + code path (get_vector → self.vectors.get for inmemory) produces the same + output. This guards against any regression for existing inmemory users. + """ + + def _make_inmemory_store(self) -> "VectorStore": + """Return a populated inmemory VectorStore with one stored decision.""" + vs = VectorStore(backend="inmemory", config={"dimension": 4}) + vs.embedder = None # avoid sentence-transformers dependency + + vec = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32) + meta = { + "scenario": "Credit limit increase", + "reasoning": "Good payment history", + "outcome": "approved", + "confidence": 0.85, + "entities": ["customer_42"], + "category": "credit", + } + ids = vs.store_vectors([vec], metadata=[meta]) + return vs, ids[0], vec, meta + + def test_build_decision_context_inmemory_has_related_decisions(self): + """ + For inmemory: decision_id IS in self.vectors, so get_vector() returns + the vector and the related_decisions block must execute — identical to + the old `if decision_id in self.vectors` guard. + """ + vs, decision_id, _vec, _meta = self._make_inmemory_store() + + ctx = vs.build_decision_context(decision_id=decision_id, depth=1) + + assert ctx["decision_id"] == decision_id + assert ctx["decision_metadata"] is not None + # 'related_decisions' key must exist (even if empty when only 1 vector) + assert "related_decisions" in ctx + assert isinstance(ctx["related_decisions"], list) + + def test_build_decision_context_inmemory_missing_id_skips_gracefully(self): + """ + For inmemory: if decision_id is NOT in self.vectors, get_vector() returns + None and we must skip similarity search gracefully — same as old guard. + However, get_metadata() will also return None so the method raises + ValueError before reaching that branch. Confirm the ValueError, not + AttributeError. + """ + vs, _decision_id, _vec, _meta = self._make_inmemory_store() + + with pytest.raises(ValueError, match="not found"): + vs.build_decision_context(decision_id="nonexistent_id") + + def test_explain_decision_inmemory_with_paths(self): + """ + For inmemory: explain_decision(include_paths=True) must populate + 'similar_decisions' when the vector exists — identical to old behaviour. + """ + vs, decision_id, _vec, _meta = self._make_inmemory_store() + + explanation = vs.explain_decision( + decision_id=decision_id, + include_paths=True, + include_confidence=True, + include_weights=True, + ) + + assert explanation["decision_id"] == decision_id + assert explanation["scenario"] == "Credit limit increase" + assert explanation["outcome"] == "approved" + assert "confidence" in explanation + assert "semantic_weight" in explanation + assert "structural_weight" in explanation + # similar_decisions must be present when include_paths=True and vector exists + assert "similar_decisions" in explanation + assert isinstance(explanation["similar_decisions"], list) + + def test_explain_decision_inmemory_without_paths(self): + """ + include_paths=False must NOT populate 'similar_decisions' — get_vector() + is never called in that branch; behaviour unchanged. + """ + vs, decision_id, _vec, _meta = self._make_inmemory_store() + + explanation = vs.explain_decision( + decision_id=decision_id, + include_paths=False, + ) + + assert "similar_decisions" not in explanation + + +class TestBuildDecisionContextFAISSBackend: + """ + Requirement 4 (issue #848): regression tests against a real non-inmemory + backend. FAISS is chosen because it is the same backend used by + test_find_similar_decisions_real_faiss_backend (issue #839 regression) in + TestDecisionEmbeddingPipelineEdgeCases above — keeping the whole fix + cluster on a consistent backend. + """ + + def _make_faiss_store(self): + """ + Return a FAISS-backed VectorStore pre-populated with two decisions. + Skips automatically when faiss is not installed. + """ + pytest.importorskip("faiss") + + vs = VectorStore(backend="faiss", config={"dimension": 4}) + vs.embedder = None # avoid sentence-transformers dependency + + vec_a = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32) + vec_b = np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32) + + meta_a = { + "scenario": "Loan approval A", + "reasoning": "Low risk", + "outcome": "approved", + "confidence": 0.9, + "entities": ["customer_1"], + "category": "loan", + } + meta_b = { + "scenario": "Loan approval B", + "reasoning": "Medium risk", + "outcome": "approved", + "confidence": 0.7, + "entities": ["customer_2"], + "category": "loan", + } + + ids = vs.store_vectors([vec_a, vec_b], metadata=[meta_a, meta_b]) + return vs, ids[0], ids[1] + + def test_build_decision_context_faiss_no_attribute_error(self): + """ + Regression (issue #848): build_decision_context() must NOT raise + AttributeError: 'VectorStore' object has no attribute 'vectors' + when the FAISS backend is active. + """ + vs, decision_id_a, _decision_id_b = self._make_faiss_store() + + # Before the fix this raised AttributeError at `if decision_id in self.vectors` + ctx = vs.build_decision_context(decision_id=decision_id_a, depth=1) + + assert ctx["decision_id"] == decision_id_a + assert ctx["decision_metadata"] is not None + assert ctx["decision_metadata"]["scenario"] == "Loan approval A" + assert "related_decisions" in ctx + assert isinstance(ctx["related_decisions"], list) + + def test_build_decision_context_faiss_related_decisions_populated(self): + """ + With two stored vectors, the context for either one must list the other + as a related decision (since FAISS search will return both and we filter + out the query id itself). + """ + vs, decision_id_a, decision_id_b = self._make_faiss_store() + + ctx = vs.build_decision_context(decision_id=decision_id_a, depth=1) + + related_ids = [r["id"] for r in ctx["related_decisions"]] + assert decision_id_a not in related_ids, ( + "The query decision itself must be excluded from related_decisions" + ) + assert decision_id_b in related_ids, ( + "The other stored decision must appear as a related decision" + ) + # Structural shape check + for r in ctx["related_decisions"]: + assert "id" in r + assert "similarity" in r + assert "metadata" in r + + def test_build_decision_context_faiss_missing_id_raises_value_error(self): + """ + A completely unknown decision_id must raise ValueError (not AttributeError). + """ + vs, _a, _b = self._make_faiss_store() + + with pytest.raises(ValueError, match="not found"): + vs.build_decision_context(decision_id="totally_unknown_id") + + def test_explain_decision_faiss_no_attribute_error(self): + """ + Regression (issue #848): explain_decision(include_paths=True) must NOT + raise AttributeError: 'VectorStore' object has no attribute 'vectors' + when the FAISS backend is active. + """ + vs, decision_id_a, _decision_id_b = self._make_faiss_store() + + # Before the fix this raised AttributeError at `if decision_id in self.vectors` + explanation = vs.explain_decision( + decision_id=decision_id_a, + include_paths=True, + include_confidence=True, + include_weights=True, + ) + + assert explanation["decision_id"] == decision_id_a + assert explanation["scenario"] == "Loan approval A" + assert explanation["outcome"] == "approved" + assert "confidence" in explanation + assert "semantic_weight" in explanation + assert "structural_weight" in explanation + assert "similar_decisions" in explanation + assert isinstance(explanation["similar_decisions"], list) + + def test_explain_decision_faiss_without_paths_no_similar_decisions_key(self): + """ + include_paths=False must not populate 'similar_decisions' — regardless + of backend. + """ + vs, decision_id_a, _decision_id_b = self._make_faiss_store() + + explanation = vs.explain_decision( + decision_id=decision_id_a, + include_paths=False, + ) + + assert "similar_decisions" not in explanation + + def test_explain_decision_faiss_missing_id_raises_value_error(self): + """ + A completely unknown decision_id must raise ValueError (not AttributeError). + """ + vs, _a, _b = self._make_faiss_store() + + with pytest.raises(ValueError, match="not found"): + vs.explain_decision(decision_id="totally_unknown_id", include_paths=True) + + +class TestFilterByMetadataBackendBehavior: + """ + Requirement (issue #848 follow-up): verify the chosen behavior of + _filter_by_metadata when a non-inmemory backend is active. + + The decision: raise NotImplementedError (matching get_vector / get_metadata + from #843) rather than silently returning []. + + Rationale documented in the production comment: + - Zero backend wrappers implement filter_by_metadata(filters, limit). + - The only codebase hit (HybridSearch.filter_by_metadata) has a completely + different signature and is never stored in _backend_store. + - Returning [] would make filter_decisions(query=None, category="loan") + report "zero matches" when the truth is "capability not available" — + indistinguishable from a real empty result and therefore wrong. + """ + + def _make_faiss_store(self): + """FAISS store with two stored decisions — same factory as the rest of + this fix cluster.""" + pytest.importorskip("faiss") + vs = VectorStore(backend="faiss", config={"dimension": 4}) + vs.embedder = None + + ids = vs.store_vectors( + [ + np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32), + np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32), + ], + metadata=[ + {"scenario": "Loan A", "category": "loan", "outcome": "approved", + "confidence": 0.9, "entities": [], "reasoning": ""}, + {"scenario": "Loan B", "category": "loan", "outcome": "denied", + "confidence": 0.6, "entities": [], "reasoning": ""}, + ], + ) + return vs, ids + + # ── FAISS backend: NotImplementedError, not AttributeError, not [] ── # + + def test_filter_by_metadata_faiss_raises_not_implemented(self): + """ + filter_decisions(query=None, category='loan') on a FAISS-backed store + must raise NotImplementedError, not AttributeError (old crash) and not + silently return [] (the wrong silent-failure fix). + + This test pins the chosen behavior: explicit NotImplementedError matching + the get_vector/get_metadata precedent set by issue #843. + """ + vs, _ids = self._make_faiss_store() + + with pytest.raises(NotImplementedError) as exc_info: + vs.filter_decisions(query=None, category="loan") + + # Message must name the backend and point to the correct alternative + msg = str(exc_info.value) + assert "FAISSStore" in msg, ( + f"Error message should name the backend class, got: {msg!r}" + ) + assert "filter_decisions" in msg or "filter_by_metadata" in msg, ( + f"Error message should mention the failing method, got: {msg!r}" + ) + assert "search_decisions" in msg, ( + f"Error message should suggest search_decisions() as the alternative, " + f"got: {msg!r}" + ) + + def test_filter_by_metadata_faiss_not_attribute_error(self): + """ + Regression guard: the old code raised AttributeError because self.metadata + does not exist for non-inmemory backends. This must never happen again. + """ + vs, _ids = self._make_faiss_store() + + try: + vs.filter_decisions(query=None, category="loan") + except NotImplementedError: + pass # correct — this is what we want + except AttributeError as exc: + pytest.fail( + f"Got AttributeError instead of NotImplementedError: {exc}" + ) + + def test_filter_by_metadata_faiss_not_silent_empty_list(self): + """ + The wrong fix would have silently returned []. Confirm the FAISS path + raises rather than returning an empty list that the caller cannot + distinguish from 'zero decisions matched'. + """ + vs, _ids = self._make_faiss_store() + + result_was_empty_list = False + try: + result = vs.filter_decisions(query=None, category="loan") + result_was_empty_list = (result == []) + except NotImplementedError: + pass # correct + + assert not result_was_empty_list, ( + "_filter_by_metadata must not silently return [] for a non-inmemory " + "backend — it must raise NotImplementedError so callers cannot " + "mistake 'backend unsupported' for 'no matching decisions'." + ) + + # ── inmemory backend: existing iteration still works ── # + + def test_filter_by_metadata_inmemory_still_works(self): + """ + Control: inmemory backend must not be affected by the FAISS guard. + filter_decisions(query=None, category='loan') must return the two + loan decisions that were stored and not raise anything. + """ + vs = VectorStore(backend="inmemory", config={"dimension": 4}) + vs.embedder = None + + vs.store_vectors( + [ + np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32), + np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32), + np.array([0.0, 0.0, 1.0, 0.0], dtype=np.float32), + ], + metadata=[ + {"scenario": "Loan A", "category": "loan", "outcome": "approved", + "confidence": 0.9, "entities": [], "reasoning": ""}, + {"scenario": "Loan B", "category": "loan", "outcome": "denied", + "confidence": 0.6, "entities": [], "reasoning": ""}, + {"scenario": "Credit card", "category": "credit", "outcome": "approved", + "confidence": 0.8, "entities": [], "reasoning": ""}, + ], + ) + + results = vs.filter_decisions(query=None, category="loan") + + assert isinstance(results, list) + assert len(results) == 2, ( + f"Expected 2 loan decisions, got {len(results)}: {results}" + ) + for r in results: + assert r["metadata"]["category"] == "loan" From 8e0419c864381c2d4dc1efe8938e78a51960c85d Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Sat, 8 Aug 2026 13:16:50 +0530 Subject: [PATCH 4/8] fixed qodo review Adds similarity_unavailable marker and warning logs to build_decision_context and explain_decision when a persistent backend (like FAISS) fails to reconstruct a vector. Updates docstrings to explicitly state this degraded-path behavior and guarantees schema stability. Adds regression tests to test vector retrieval failure behavior via caplog and context assertions. --- semantica/vector_store/vector_store.py | 39 +++++++-- .../test_decision_embedding_pipeline.py | 80 +++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index 60740df9..3166aaf2 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -1009,16 +1009,23 @@ class VectorStore: ) -> Dict[str, Any]: """ Build decision context graph. - + Args: decision_id: Decision vector ID depth: Context depth include_entities: Whether to include entities include_policies: Whether to include policies max_hops: Maximum hops for context expansion - + Returns: - Decision context graph + Decision context graph dict with keys: + - decision_id, decision_metadata, entities, policies, + related_decisions, context_graph. + If the backend cannot retrieve the raw vector for *decision_id* + (e.g. a FAISS index built without ``make_direct_map``), similarity + enrichment is skipped, a WARNING is emitted, and the key + ``similarity_unavailable=True`` is added. ``related_decisions`` + remains an empty list for schema stability. """ # Get decision metadata decision_metadata = self.get_metadata(decision_id) @@ -1053,6 +1060,13 @@ class VectorStore: "similarity": result["score"], "metadata": result.get("metadata", {}) }) + else: + self.logger.warning( + "Backend cannot retrieve vector for decision '%s' — " + "similarity enrichment skipped.", + decision_id, + ) + context["similarity_unavailable"] = True return context @@ -1065,15 +1079,20 @@ class VectorStore: ) -> Dict[str, Any]: """ Generate explanation for a decision. - + Args: decision_id: Decision vector ID include_paths: Whether to include reasoning paths include_confidence: Whether to include confidence scores include_weights: Whether to include similarity weights - + Returns: - Decision explanation + Decision explanation dict. When *include_paths* is True and the + backend can retrieve the raw vector, ``similar_decisions`` is + populated. If the backend cannot retrieve the vector (e.g. a FAISS + index without ``make_direct_map``), a WARNING is emitted, the key + ``similarity_unavailable=True`` is added, and ``similar_decisions`` + is set to ``[]`` for schema stability. """ decision_metadata = self.get_metadata(decision_id) if not decision_metadata: @@ -1100,6 +1119,14 @@ class VectorStore: if query_vector is not None: similar_decisions = self.search_vectors(query_vector, k=3) explanation["similar_decisions"] = similar_decisions + else: + self.logger.warning( + "Backend cannot retrieve vector for decision '%s' — " + "similarity enrichment skipped.", + decision_id, + ) + explanation["similarity_unavailable"] = True + explanation["similar_decisions"] = [] return explanation diff --git a/tests/vector_store/test_decision_embedding_pipeline.py b/tests/vector_store/test_decision_embedding_pipeline.py index d65fe7da..ed65236d 100644 --- a/tests/vector_store/test_decision_embedding_pipeline.py +++ b/tests/vector_store/test_decision_embedding_pipeline.py @@ -910,3 +910,83 @@ class TestFilterByMetadataBackendBehavior: ) for r in results: assert r["metadata"]["category"] == "loan" + + +class TestVectorRetrievalFailureBehavior: + """ + Requirement: verify that when get_vector() returns None for an existing decision + (e.g., FAISS without reconstruct capability), we don't silently drop similarity + enrichment. We should get a warning log and an explicit marker in the output. + """ + + class _StubBackend: + def __init__(self): + self.metadata = { + "decision_1": { + "scenario": "Stub Scenario", + "reasoning": "Stub Reasoning", + "outcome": "approved" + } + } + + def get_metadata(self, vector_id: str): + return self.metadata.get(vector_id) + + def get_vector(self, vector_id: str): + # Explicitly return None to simulate a backend that cannot reconstruct vectors + return None + + def _make_stub_store(self): + vs = VectorStore(backend="inmemory", config={"dimension": 4}) + vs.embedder = None + # Replace the backend store with our stub + vs._backend_store = self._StubBackend() + vs.backend = "stub" + return vs + + def test_build_decision_context_warns_and_marks_on_missing_vector(self, caplog): + """ + When get_vector() returns None for an existing decision, build_decision_context + should warn and add 'similarity_unavailable': True to the context. + """ + import logging + vs = self._make_stub_store() + + with caplog.at_level(logging.WARNING, logger="semantica.vector_store"): + ctx = vs.build_decision_context(decision_id="decision_1", depth=1) + + assert ctx["decision_id"] == "decision_1" + assert ctx.get("similarity_unavailable") is True, "Expected similarity_unavailable marker" + assert isinstance(ctx["related_decisions"], list), "related_decisions must be a list for schema stability" + assert len(ctx["related_decisions"]) == 0 + + warning_logged = any( + "decision_1" in record.message and record.levelname == "WARNING" + for record in caplog.records + ) + assert warning_logged, f"Expected WARNING log mentioning decision_1; got: {caplog.records}" + + def test_explain_decision_warns_and_marks_on_missing_vector(self, caplog): + """ + When get_vector() returns None for an existing decision, explain_decision + with include_paths=True should warn and add 'similarity_unavailable': True. + """ + import logging + vs = self._make_stub_store() + + with caplog.at_level(logging.WARNING, logger="semantica.vector_store"): + explanation = vs.explain_decision( + decision_id="decision_1", + include_paths=True, + ) + + assert explanation["decision_id"] == "decision_1" + assert explanation.get("similarity_unavailable") is True, "Expected similarity_unavailable marker" + assert isinstance(explanation["similar_decisions"], list), "similar_decisions must be a list for schema stability" + assert len(explanation["similar_decisions"]) == 0 + + warning_logged = any( + "decision_1" in record.message and record.levelname == "WARNING" + for record in caplog.records + ) + assert warning_logged, f"Expected WARNING log mentioning decision_1; got: {caplog.records}" From e90bd048e18ff81d5a1e30a2a95e1be469bd2c23 Mon Sep 17 00:00:00 2001 From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:37:59 +0530 Subject: [PATCH 5/8] Add Trendshift badge to README Added Trendshift badge to README for repository tracking. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 985b6baf..745c54e4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Semantica +semantica-agi%2Fsemantica | Trendshift + ### Graph-Native Infrastructure for Context and Accountable AI Systems #### *The Open Source Palantir for AI Agents* From 9059a447316819df527026e040a1fc3e4ba1b5c9 Mon Sep 17 00:00:00 2001 From: Saurabh <127095776+SaurabhScripts@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:42:24 +0530 Subject: [PATCH 6/8] fix(vector-store): reconstruct FAISS vectors (#850) * fix(vector-store): reconstruct FAISS vectors * fix(vector-store): surface FAISS reconstruction failures --------- Co-authored-by: KaifAhmad1 --- semantica/vector_store/faiss_store.py | 28 +++++- tests/vector_store/test_faiss_index.py | 122 +++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/vector_store/test_faiss_index.py diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py index 6ca1b83a..cfb935b3 100644 --- a/semantica/vector_store/faiss_store.py +++ b/semantica/vector_store/faiss_store.py @@ -85,12 +85,32 @@ class FAISSIndex: return None idx = self.vector_ids.index(vector_id) - try: - return self.index.reconstruct(idx) - except Exception: - # Some FAISS indices (e.g. IVFPQ without make_direct_map) do not support reconstruct + reconstruct = getattr(self.index, "reconstruct", None) + if not callable(reconstruct): return None + try: + return np.asarray(reconstruct(idx), dtype=np.float32) + except NotImplementedError: + return None + except RuntimeError as exc: + message = str(exc).casefold() + unsupported_errors = ( + "reconstruct not implemented", + "reconstruct_from_offset not implemented", + ) + if any(error in message for error in unsupported_errors): + return None + if "direct map not initialized" in message: + # IVF-family indices (e.g. IndexIVFFlat) support exact reconstruction + # but need their DirectMap built once before reconstruct() works. + make_direct_map = getattr(self.index, "make_direct_map", None) + if not callable(make_direct_map): + return None + make_direct_map() + return np.asarray(reconstruct(idx), dtype=np.float32) + raise + def get_metadata(self, vector_id: str) -> Optional[Dict[str, Any]]: """Get metadata by ID.""" return self.metadata.get(vector_id) diff --git a/tests/vector_store/test_faiss_index.py b/tests/vector_store/test_faiss_index.py new file mode 100644 index 00000000..b00052d2 --- /dev/null +++ b/tests/vector_store/test_faiss_index.py @@ -0,0 +1,122 @@ +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from semantica.vector_store.faiss_store import FAISSIndex + + +def test_get_vector_reconstructs_from_flat_l2_index(): + faiss = pytest.importorskip("faiss") + vectors = np.array( + [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], + dtype=np.float32, + ) + index = FAISSIndex(faiss.IndexFlatL2(3), dimension=3) + index.add_vectors(vectors, ids=["vec_first", "vec_target"]) + + result = index.get_vector("vec_target") + + np.testing.assert_array_equal(result, vectors[1]) + + +def test_get_vector_reconstructs_vector_at_matching_id_position(): + backend_index = MagicMock() + backend_index.reconstruct.return_value = [0.25, 0.5, 0.75] + index = FAISSIndex(backend_index, dimension=3) + index.vector_ids = ["vec_first", "vec_target"] + + result = index.get_vector("vec_target") + + backend_index.reconstruct.assert_called_once_with(1) + np.testing.assert_array_equal(result, np.array([0.25, 0.5, 0.75], dtype=np.float32)) + + +def test_get_vector_returns_none_for_unknown_id_without_reconstructing(): + backend_index = MagicMock() + index = FAISSIndex(backend_index, dimension=3) + index.vector_ids = ["vec_known"] + + assert index.get_vector("vec_missing") is None + backend_index.reconstruct.assert_not_called() + + +def test_get_vector_returns_none_when_index_has_no_reconstruct_method(): + index = FAISSIndex(object(), dimension=3) + index.vector_ids = ["vec_known"] + + assert index.get_vector("vec_known") is None + + +@pytest.mark.parametrize( + "error", + [ + NotImplementedError(), + RuntimeError("reconstruct not implemented for this type of index"), + RuntimeError("reconstruct_from_offset not implemented"), + ], +) +def test_get_vector_returns_none_when_reconstruction_is_unsupported(error): + backend_index = MagicMock() + backend_index.reconstruct.side_effect = error + index = FAISSIndex(backend_index, dimension=3) + index.vector_ids = ["vec_known"] + + assert index.get_vector("vec_known") is None + + +def test_get_vector_propagates_unexpected_runtime_errors(): + backend_index = MagicMock() + runtime_error = RuntimeError("index is not trained") + backend_index.reconstruct.side_effect = runtime_error + index = FAISSIndex(backend_index, dimension=3) + index.vector_ids = ["vec_known"] + + with pytest.raises(RuntimeError) as exc_info: + index.get_vector("vec_known") + + assert exc_info.value is runtime_error + + +def test_get_vector_builds_direct_map_and_retries_when_not_initialized(): + backend_index = MagicMock() + backend_index.reconstruct.side_effect = [ + RuntimeError("direct map not initialized"), + [0.25, 0.5, 0.75], + ] + index = FAISSIndex(backend_index, dimension=3) + index.vector_ids = ["vec_known"] + + result = index.get_vector("vec_known") + + backend_index.make_direct_map.assert_called_once_with() + assert backend_index.reconstruct.call_count == 2 + np.testing.assert_array_equal(result, np.array([0.25, 0.5, 0.75], dtype=np.float32)) + + +def test_get_vector_returns_none_when_direct_map_unavailable_and_not_initialized(): + backend_index = MagicMock(spec=["reconstruct"]) + backend_index.reconstruct.side_effect = RuntimeError("direct map not initialized") + index = FAISSIndex(backend_index, dimension=3) + index.vector_ids = ["vec_known"] + + assert index.get_vector("vec_known") is None + + +def test_get_vector_reconstructs_from_real_ivfflat_index_without_prior_direct_map(): + faiss = pytest.importorskip("faiss") + dimension = 3 + vectors = np.array( + [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9], [1.0, 1.1, 1.2]], + dtype=np.float32, + ) + quantizer = faiss.IndexFlatL2(dimension) + backend_index = faiss.IndexIVFFlat(quantizer, dimension, 2) + backend_index.train(vectors) + + index = FAISSIndex(backend_index, dimension=dimension) + index.add_vectors(vectors, ids=["vec_0", "vec_1", "vec_2", "vec_target"]) + + result = index.get_vector("vec_target") + + np.testing.assert_allclose(result, vectors[3], atol=1e-6) From 03ed4b94e978a413aec69fc5430a4a6610de0a6c Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Sun, 9 Aug 2026 17:28:05 +0530 Subject: [PATCH 7/8] fix(vector-store): preserve ranking for unbounded scores in Pinecone/Qdrant The 1.0 / (1.0 + max(0.0, 1.0 - score)) normalization added in the last commit clamped every raw score >= 1.0 to an identical 1.0, collapsing result ranking for dot-product-metric indexes (unbounded), which cosine (bounded to [-1, 1]) never exercised. Replaced with x/(1+|x|) rescaled to (0, 1), which is strictly monotonic for any real score. Also adds regression tests for scores >= 1 and a CHANGELOG entry. --- CHANGELOG.md | 7 +++ semantica/vector_store/pinecone_store.py | 11 ++++- semantica/vector_store/qdrant_store.py | 9 +++- .../vector_store/test_search_result_schema.py | 48 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 244b45fc..a77ec2f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`VectorStore.search_vectors()` returned inconsistent result shapes across backend implementations** (#853, closes #845) by @Sameer6305, reviewed by @KaifAhmad1 + - Every built-in backend (FAISS, Milvus, pgvector, Pinecone, Qdrant, SQLite-vec, Weaviate, in-memory) now returns the same canonical `SearchResult` shape (`id`, `score`, `metadata`, `vector`, `distance`), instead of some backends omitting `vector`/`metadata`/`distance` or, for Weaviate, returning a backend-specific `properties` key instead of `metadata` + - Added a `SearchResult` `TypedDict` (`semantica/vector_store/vector_store.py`, exported from `semantica.vector_store`) documenting the contract; `metadata` now always defaults to `{}` rather than being absent, and `id` accepts `Union[str, int]` to accommodate Milvus/Qdrant's native integer IDs without casting + - **Review fix**: the score-normalization formula added for Pinecone and Qdrant (`1.0 / (1.0 + max(0.0, 1.0 - score))`) clamped every raw score `>= 1.0` to an identical `1.0`, silently collapsing result ranking whenever the raw score could exceed 1 — which happens routinely for dot-product-metric indexes (unbounded), as opposed to cosine (bounded to `[-1, 1]`). Replaced with `(score / (1 + |score|) + 1) / 2`, which is strictly monotonic and bounded in `(0, 1)` for any real input, so ranking order is preserved regardless of metric or vector normalization + - Added `test_qdrant_unbounded_dot_product_scores_preserve_ranking` and `test_pinecone_unbounded_dotproduct_scores_preserve_ranking` (`tests/vector_store/test_search_result_schema.py`) asserting normalized scores stay strictly ordered and bounded for raw scores well above 1.0, the case the original formula silently collapsed and the existing tests (which only used scores `< 1`) never exercised + - Left out of scope, per the original PR: Weaviate's `similarity_search()` still isn't wired into `VectorStore.search_vectors()`'s backend dispatch; Milvus's collection schema still has no metadata column so its results always return `metadata: {}`; and `include_vectors` support (populating the `vector` field) is not yet implemented for any backend + - **`DecisionEmbeddingPipeline.find_similar_decisions()` crashed with `AttributeError` for any `VectorStore` backend other than `inmemory`** (#842, closes #839) by @Sameer6305 - `_get_candidate_embeddings()` iterated `VectorStore.vectors`/`VectorStore.metadata` directly, internal dicts only populated for `backend="inmemory"`; every persistent backend (FAISS, Pinecone, Qdrant, Milvus, ...) raised `AttributeError`. It now fetches candidates via the backend-agnostic `VectorStore.search_vectors()`, reading metadata via a `res.get("metadata") or res.get("payload")` fallback for backends that key it differently - Backends such as FAISS don't return the raw vector for each hit; `find_similar_decisions()` and `_find_semantic_similar()` now fall back to the search-provided score (normalized from `distance` when present) as the semantic similarity for those candidates instead of computing cosine similarity against a zero placeholder vector diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py index 329ff316..69b60fe9 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -194,7 +194,16 @@ class PineconeIndex: results.append( { "id": match.id, - "score": 1.0 / (1.0 + max(0.0, 1.0 - float(match.score))), + # Pinecone's native score is already "higher is better" but its + # range depends on the configured metric (bounded for cosine, + # unbounded for dotproduct). Squash with x/(1+|x|) instead of + # clamping distance-to-zero, since the latter collapses every + # score >= 1.0 to an identical 1.0 and destroys ranking order + # for dotproduct/unnormalized-vector indexes. + "score": ( + float(match.score) / (1.0 + abs(float(match.score))) + 1.0 + ) + / 2.0, "metadata": match.metadata or {}, "vector": None, "distance": None, diff --git a/semantica/vector_store/qdrant_store.py b/semantica/vector_store/qdrant_store.py index aaf6b15e..49c71bfc 100644 --- a/semantica/vector_store/qdrant_store.py +++ b/semantica/vector_store/qdrant_store.py @@ -164,7 +164,14 @@ class QdrantCollection: results.append( { "id": result.id, - "score": 1.0 / (1.0 + max(0.0, 1.0 - float(result.score))), + # See pinecone_store.py PineconeIndex.search_vectors for why + # this uses x/(1+|x|) rather than clamping distance-to-zero: + # Qdrant's Dot distance metric is unbounded, and the old + # clamped formula collapsed every score >= 1.0 to 1.0. + "score": ( + float(result.score) / (1.0 + abs(float(result.score))) + 1.0 + ) + / 2.0, "metadata": result.payload or {}, "vector": None, "distance": None, diff --git a/tests/vector_store/test_search_result_schema.py b/tests/vector_store/test_search_result_schema.py index 392f2cbf..cdae2337 100644 --- a/tests/vector_store/test_search_result_schema.py +++ b/tests/vector_store/test_search_result_schema.py @@ -121,6 +121,29 @@ class TestQdrantSearchSchema(unittest.TestCase): self.assertIsNone(results[0]["vector"]) self.assertEqual(results[0]["metadata"], {"category": "x"}) + @patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True) + def test_qdrant_unbounded_dot_product_scores_preserve_ranking(self): + """Qdrant's Dot distance metric is unbounded; normalized scores must + stay strictly ordered instead of collapsing once raw score >= 1.0 + (regression for #845 follow-up).""" + from semantica.vector_store.qdrant_store import QdrantCollection + + mock_client = MagicMock() + mock_hit_high = MagicMock(id="q_hi", score=50.0, payload={}) + mock_hit_mid = MagicMock(id="q_mid", score=2.0, payload={}) + mock_hit_low = MagicMock(id="q_lo", score=1.0, payload={}) + mock_client.search.return_value = [mock_hit_high, mock_hit_mid, mock_hit_low] + + coll = QdrantCollection(mock_client, "test_col") + results = coll.search_points(np.array([0.1, 0.2]), limit=3) + + _assert_canonical_schema(self, results) + scores = [r["score"] for r in results] + self.assertEqual(len(set(scores)), 3, "scores >= 1.0 must not collapse") + self.assertGreater(scores[0], scores[1]) + self.assertGreater(scores[1], scores[2]) + self.assertTrue(all(0.0 < s < 1.0 for s in scores)) + # --------------------------------------------------------------------------- # Pinecone @@ -163,6 +186,31 @@ class TestPineconeSearchSchema(unittest.TestCase): _assert_canonical_schema(self, results) self.assertEqual(results[0]["metadata"], {}) + @patch("semantica.vector_store.pinecone_store.PINECONE_AVAILABLE", True) + def test_pinecone_unbounded_dotproduct_scores_preserve_ranking(self): + """Pinecone's dotproduct metric is unbounded; normalized scores must + stay strictly ordered instead of collapsing once raw score >= 1.0 + (regression for #845 follow-up).""" + from semantica.vector_store.pinecone_store import PineconeIndex + + mock_index = MagicMock() + mock_match_high = MagicMock(id="p_hi", score=50.0, metadata={}) + mock_match_mid = MagicMock(id="p_mid", score=2.0, metadata={}) + mock_match_low = MagicMock(id="p_lo", score=1.0, metadata={}) + mock_index.query.return_value = MagicMock( + matches=[mock_match_high, mock_match_mid, mock_match_low] + ) + + pi = PineconeIndex(mock_index) + results = pi.search_vectors([0.1, 0.2], k=3) + + _assert_canonical_schema(self, results) + scores = [r["score"] for r in results] + self.assertEqual(len(set(scores)), 3, "scores >= 1.0 must not collapse") + self.assertGreater(scores[0], scores[1]) + self.assertGreater(scores[1], scores[2]) + self.assertTrue(all(0.0 < s < 1.0 for s in scores)) + # --------------------------------------------------------------------------- # Milvus From 5048665d35c5183b958893a1011cb7d12d97032e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:31:11 +0530 Subject: [PATCH 8/8] chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /explorer (#872) Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.12 to 3.4.13. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- explorer/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 0bf6a67a..98e9bc17 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -2293,9 +2293,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, "optionalDependencies": {