Merge remote-tracking branch 'upstream/main' into fix/AttributError_in_filter_by_metadata_on_persistent_backend

# Conflicts:
#	semantica/vector_store/vector_store.py
This commit is contained in:
TaherTadpatri
2026-08-10 12:55:04 +05:30
17 changed files with 1042 additions and 54 deletions
+7
View File
@@ -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
+2
View File
@@ -2,6 +2,8 @@
<img src="Semantica Logo.png" alt="Semantica" width="420"/>
<a href="https://trendshift.io/repositories/18986?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-18986" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/18986" alt="semantica-agi%2Fsemantica | Trendshift" width="250" height="55"/></a>
### Graph-Native Infrastructure for Context and Accountable AI Systems
#### *The Open Source Palantir for AI Agents*
+3 -3
View File
@@ -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": {
+2 -1
View File
@@ -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",
+26 -5
View File
@@ -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,
}
)
+7 -3
View File
@@ -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)
+2
View File
@@ -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
+12 -1
View File
@@ -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,
}
)
+10 -1
View File
@@ -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,
}
)
@@ -414,6 +414,8 @@ class SQLiteVecStore:
"id": vec_id,
"score": similarity,
"metadata": json.loads(meta_json) if meta_json else {},
"vector": None,
"distance": None,
}
)
+85 -33
View File
@@ -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.01.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,
}
)
+5 -7
View File
@@ -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,
}
)
@@ -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."""
@@ -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}"
+122
View File
@@ -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)
@@ -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()
@@ -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')