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/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* 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": { 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 3e3bec4d..2e39266f 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) @@ -147,7 +167,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( { @@ -155,6 +175,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 9ac98faf..2a213859 100644 --- a/semantica/vector_store/milvus_store.py +++ b/semantica/vector_store/milvus_store.py @@ -197,9 +197,13 @@ 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 + # 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 cfc112fd..b171ff48 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -446,6 +446,8 @@ class PgVectorStore: "id": vec_id, "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 13c04d53..f58f61dc 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -194,8 +194,19 @@ class PineconeIndex: results.append( { "id": match.id, - "score": 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 318aa973..d9624842 100644 --- a/semantica/vector_store/qdrant_store.py +++ b/semantica/vector_store/qdrant_store.py @@ -164,8 +164,17 @@ class QdrantCollection: results.append( { "id": result.id, - "score": 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/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py index fc6dc0f0..646353ec 100644 --- a/semantica/vector_store/sqlite_vec_store.py +++ b/semantica/vector_store/sqlite_vec_store.py @@ -414,6 +414,8 @@ class SQLiteVecStore: "id": vec_id, "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 239f958f..a45d40ef 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,24 @@ from ..embeddings import EmbeddingGenerator from .hybrid_similarity import HybridSimilarityCalculator from .decision_embedding_pipeline import DecisionEmbeddingPipeline +class SearchResult(TypedDict): + """Canonical schema returned by VectorStore.search_vectors(). + + Required fields (always present): + 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. + distance – raw native distance value preserved for backends that expose it + (FAISS L2, Weaviate cosine), otherwise None. + """ + + id: Union[str, int] + score: float + metadata: Dict[str, Any] + vector: Optional[Any] # np.ndarray | None + distance: Optional[float] + class VectorStore: """ @@ -644,7 +662,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. @@ -697,11 +715,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, @@ -1009,16 +1029,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) @@ -1042,8 +1069,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: @@ -1053,6 +1080,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 +1099,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: @@ -1096,10 +1135,18 @@ 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 + 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 @@ -1123,28 +1170,32 @@ 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 == "inmemory": - results = [] - - for vector_id, metadata in self.metadata.items(): - if _matches_filter(metadata, filters): - results.append({ - "id": vector_id, - "metadata": metadata, - "vector": self.vectors.get(vector_id) - }) - - if len(results) >= limit: - break - - return results - elif self._backend_store is not None and hasattr(self._backend_store, "filter_by_metadata"): - return self._backend_store.filter_by_metadata(filters=filters, limit=limit) - else: + if self._backend_store is not None: + if hasattr(self._backend_store, "filter_by_metadata"): + return self._backend_store.filter_by_metadata(filters=filters, limit=limit) raise NotImplementedError( - f"Backend store {type(self._backend_store).__name__ if self._backend_store else self.backend} does not implement filter_by_metadata" + 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 backends " + "that implement filter_by_metadata. Pass a query string to use search_decisions() " + "instead, which is supported by all backends." ) + results = [] + + for vector_id, metadata in self.metadata.items(): + if _matches_filter(metadata, filters): + results.append({ + "id": vector_id, + "metadata": metadata, + "vector": self.vectors.get(vector_id) + }) + + if len(results) >= limit: + break + + return results + def _matches_filter(metadata: Dict[str, Any], filters: Dict[str, Any]) -> bool: """Check if metadata dictionary matches filter criteria.""" @@ -1183,7 +1234,6 @@ def _matches_filter(metadata: Dict[str, Any], filters: Dict[str, Any]) -> bool: return True - class VectorIndexer: """Vector indexing engine.""" @@ -1305,6 +1355,8 @@ class VectorRetriever: "id": ids[idx], "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 b5551221..63f30475 100644 --- a/semantica/vector_store/weaviate_store.py +++ b/semantica/vector_store/weaviate_store.py @@ -177,14 +177,12 @@ 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 - - ( - 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_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_decision_embedding_pipeline.py b/tests/vector_store/test_decision_embedding_pipeline.py index 17d47ddc..ed65236d 100644 --- a/tests/vector_store/test_decision_embedding_pipeline.py +++ b/tests/vector_store/test_decision_embedding_pipeline.py @@ -533,3 +533,460 @@ 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" + + +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}" 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) 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..cdae2337 --- /dev/null +++ b/tests/vector_store/test_search_result_schema.py @@ -0,0 +1,295 @@ +""" +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 | int + score : float + metadata : dict (always a dict, {} when none stored) + vector : any (np.ndarray | None; None when backend doesn't return vectors) + distance : float | None (preserved from FAISS, Weaviate, Milvus; None 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.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") + + +# --------------------------------------------------------------------------- +# 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"}) + + @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 +# --------------------------------------------------------------------------- + +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"], {}) + + @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 +# --------------------------------------------------------------------------- + +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')