275 lines
9.5 KiB
Python
275 lines
9.5 KiB
Python
from dataclasses import FrozenInstanceError
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from tht.adapters.vector.legacy_direct import LegacyDirectVectorStore
|
|
from tht.adapters.vector.qdrant import QdrantVectorStore
|
|
from tht.adapters.vector.thoth_http import ThothHttpVectorStore
|
|
from tht.evidence.model import EvidenceDoc
|
|
from tht.ports.vector import (
|
|
VectorHit,
|
|
VectorReadUnavailable,
|
|
VectorRecord,
|
|
VectorStore,
|
|
VectorWriteRecord,
|
|
VectorWriteUnavailable,
|
|
)
|
|
from tht.vectorstore.records import evidence_records
|
|
|
|
|
|
def test_http_store_reports_reader_without_writer():
|
|
reader = MagicMock()
|
|
store = ThothHttpVectorStore(reader=reader, writer=None)
|
|
|
|
assert store.capabilities.search is True
|
|
assert store.capabilities.upsert is False
|
|
with pytest.raises(VectorWriteUnavailable):
|
|
store.upsert("memory", [])
|
|
|
|
|
|
def test_http_store_supports_writer_without_reader():
|
|
writer = MagicMock()
|
|
store = ThothHttpVectorStore(reader=None, writer=writer, expected_dimension=768)
|
|
|
|
assert store.capabilities.search is False
|
|
assert store.capabilities.existing_hashes is True
|
|
assert store.capabilities.upsert is True
|
|
with pytest.raises(VectorReadUnavailable):
|
|
store.search(["memory"], [0.1], limit=1)
|
|
|
|
|
|
@pytest.mark.parametrize("limit", [True, False, 1.0, 0, -1])
|
|
def test_http_search_requires_a_strict_positive_integer_limit(limit):
|
|
store = ThothHttpVectorStore(reader=MagicMock(), writer=None)
|
|
|
|
with pytest.raises(ValueError, match="positive integer"):
|
|
store.search(["memory"], [0.1], limit=limit)
|
|
|
|
|
|
def test_http_store_keeps_reader_and_writer_operations_separate():
|
|
reader = MagicMock()
|
|
reader.search_similar.return_value = [
|
|
{
|
|
"similarity": 0.75,
|
|
"metadata": {
|
|
"record_key": "m1",
|
|
"kind": "memory",
|
|
"ref": "session:s1",
|
|
"title": "Choice",
|
|
"content": "Use the curated table",
|
|
},
|
|
}
|
|
]
|
|
writer = MagicMock()
|
|
writer.existing_hashes.return_value = {"m1": "abc"}
|
|
writer.upsert_records.return_value = 1
|
|
store = ThothHttpVectorStore(reader=reader, writer=writer)
|
|
|
|
hits = store.search(["memory"], [0.1, 0.2], limit=3, kinds=["memory"])
|
|
assert hits == [
|
|
VectorHit(
|
|
id="m1",
|
|
kind="memory",
|
|
ref="session:s1",
|
|
title="Choice",
|
|
content="Use the curated table",
|
|
metadata={
|
|
"record_key": "m1",
|
|
"kind": "memory",
|
|
"ref": "session:s1",
|
|
"title": "Choice",
|
|
"content": "Use the curated table",
|
|
},
|
|
similarity=0.75,
|
|
)
|
|
]
|
|
reader.search_similar.assert_called_once_with(
|
|
"memory", [0.1, 0.2], 3, kinds=["memory"]
|
|
)
|
|
writer.search_similar.assert_not_called()
|
|
|
|
assert store.existing_hashes("memory", ["memory"]) == {"m1": "abc"}
|
|
writer.existing_hashes.assert_called_once_with("memory", ["memory"])
|
|
|
|
records = [
|
|
VectorWriteRecord(
|
|
record=VectorRecord(
|
|
id="m1",
|
|
kind="memory",
|
|
ref="session:s1",
|
|
title="Choice",
|
|
content="Use the curated table",
|
|
),
|
|
embedding=[0.1, 0.2],
|
|
content_hash="abc",
|
|
)
|
|
]
|
|
assert store.upsert("memory", records) == 1
|
|
writer.upsert_records.assert_called_once()
|
|
reader.upsert_records.assert_not_called()
|
|
|
|
|
|
def test_http_upsert_serializes_a_canonical_builder_record():
|
|
record = evidence_records(
|
|
[EvidenceDoc(id="joins", title="Join guidance", body="Use the curated join")],
|
|
max_chunk_chars=1000,
|
|
)[0]
|
|
writer = MagicMock()
|
|
writer.upsert_records.return_value = 1
|
|
store = ThothHttpVectorStore(reader=MagicMock(), writer=writer)
|
|
|
|
assert store.upsert(
|
|
"evidence",
|
|
[VectorWriteRecord(record=record, embedding=[0.2, 0.3], content_hash="digest")],
|
|
) == 1
|
|
row = writer.upsert_records.call_args.args[1][0]
|
|
assert row["record_key"] == "evidence:joins:0"
|
|
assert row["metadata"]["status"] == "reviewed"
|
|
assert row["embedding"] == [0.2, 0.3]
|
|
assert row["content_hash"] == "digest"
|
|
|
|
|
|
def test_http_upsert_preserves_metadata_named_like_transport_fields():
|
|
record = VectorRecord(
|
|
id="collision",
|
|
kind="memory",
|
|
ref="session:s1",
|
|
title="Collision",
|
|
content="Semantic metadata must survive",
|
|
metadata={"embedding": "semantic embedding", "content_hash": "semantic hash"},
|
|
)
|
|
writer = MagicMock()
|
|
store = ThothHttpVectorStore(reader=MagicMock(), writer=writer)
|
|
|
|
store.upsert(
|
|
"memory",
|
|
[VectorWriteRecord(record=record, embedding=[0.4], content_hash="transport hash")],
|
|
)
|
|
row = writer.upsert_records.call_args.args[1][0]
|
|
assert row["embedding"] == [0.4]
|
|
assert row["content_hash"] == "transport hash"
|
|
assert row["metadata"]["embedding"] == "semantic embedding"
|
|
assert row["metadata"]["content_hash"] == "semantic hash"
|
|
|
|
|
|
def test_http_store_is_runtime_vector_store():
|
|
store = ThothHttpVectorStore(reader=MagicMock(), writer=None)
|
|
assert isinstance(store, VectorStore)
|
|
|
|
|
|
def test_vector_contract_is_exported_from_public_packages():
|
|
from tht.adapters.vector import QdrantVectorStore as PublicQdrantStore
|
|
from tht.adapters.vector import ThothHttpVectorStore as PublicHttpStore
|
|
from tht.ports import VectorReadUnavailable as PublicVectorReadUnavailable
|
|
from tht.ports import VectorStore as PublicVectorStore
|
|
from tht.ports import VectorWriteRecord as PublicVectorWriteRecord
|
|
|
|
assert PublicQdrantStore is QdrantVectorStore
|
|
assert PublicHttpStore is ThothHttpVectorStore
|
|
assert PublicVectorStore is VectorStore
|
|
assert PublicVectorWriteRecord is VectorWriteRecord
|
|
assert PublicVectorReadUnavailable is VectorReadUnavailable
|
|
|
|
capabilities = store_capabilities = ThothHttpVectorStore(
|
|
reader=MagicMock(), writer=None
|
|
).capabilities
|
|
assert capabilities.search is True
|
|
with pytest.raises(FrozenInstanceError):
|
|
store_capabilities.search = False
|
|
|
|
|
|
def test_http_health_uses_reader_list_tables_and_reports_failure():
|
|
reader = MagicMock()
|
|
store = ThothHttpVectorStore(reader=reader, writer=None)
|
|
assert store.health().ok is True
|
|
|
|
reader.list_tables.side_effect = RuntimeError("offline")
|
|
health = store.health()
|
|
assert health.ok is False
|
|
assert health.detail == "offline"
|
|
|
|
|
|
def test_http_health_reports_read_write_and_dimension_status_independently():
|
|
reader = MagicMock()
|
|
reader.list_tables.return_value = [
|
|
{"table_name": "memory", "vector_dimensions": 768}
|
|
]
|
|
writer = MagicMock()
|
|
writer.list_tables.return_value = [
|
|
{"table_name": "memory", "vector_dimensions": 768}
|
|
]
|
|
store = ThothHttpVectorStore(reader, writer, expected_dimension=768)
|
|
|
|
health = store.health()
|
|
assert health.ok is True
|
|
assert health.read_configured is True
|
|
assert health.read_reachable is True
|
|
assert health.write_configured is True
|
|
assert health.write_reachable is True
|
|
assert health.expected_dimension == 768
|
|
assert health.observed_dimensions == (768,)
|
|
assert health.dimension_compatible is True
|
|
|
|
|
|
def test_http_health_does_not_hide_writer_failure_behind_reader_success():
|
|
reader = MagicMock()
|
|
reader.list_tables.return_value = []
|
|
writer = MagicMock()
|
|
writer.list_tables.side_effect = RuntimeError("writer offline")
|
|
store = ThothHttpVectorStore(reader, writer, expected_dimension=768)
|
|
|
|
health = store.health()
|
|
assert health.ok is False
|
|
assert health.read_reachable is True
|
|
assert health.write_reachable is False
|
|
assert health.write_detail == "writer offline"
|
|
assert health.dimension_compatible is None
|
|
|
|
|
|
def test_http_health_covers_read_only_and_write_only_configuration():
|
|
reader = MagicMock()
|
|
reader.list_tables.return_value = [{"vector_dimensions": 384}]
|
|
read_health = ThothHttpVectorStore(reader, None, expected_dimension=768).health()
|
|
assert read_health.ok is False
|
|
assert read_health.write_configured is False
|
|
assert read_health.write_reachable is None
|
|
assert read_health.dimension_compatible is False
|
|
|
|
writer = MagicMock()
|
|
writer.list_tables.return_value = [{"vector_dimensions": 768}]
|
|
write_health = ThothHttpVectorStore(None, writer, expected_dimension=768).health()
|
|
assert write_health.ok is True
|
|
assert write_health.read_configured is False
|
|
assert write_health.read_reachable is None
|
|
assert write_health.dimension_compatible is True
|
|
|
|
|
|
@pytest.mark.parametrize("limit", [True, False, 1.0, 0, -1])
|
|
def test_legacy_direct_search_requires_a_strict_positive_integer_limit(limit):
|
|
store = LegacyDirectVectorStore(engine=MagicMock())
|
|
|
|
with pytest.raises(ValueError, match="positive integer"):
|
|
store.search(["memory"], [0.1], limit=limit)
|
|
|
|
|
|
def test_qdrant_store_is_runtime_vector_store():
|
|
store = QdrantVectorStore(
|
|
base_url="http://qdrant:6333",
|
|
collection="workspace-semantic",
|
|
workspace_id="demo",
|
|
expected_dimension=1024,
|
|
request=lambda *args, **kwargs: MagicMock(
|
|
ok=True,
|
|
status_code=200,
|
|
text='{"result":{"config":{"params":{"vectors":{"size":1024,"distance":"Cosine"}}},"payload_schema":{}}}',
|
|
json=lambda: {
|
|
"result": {
|
|
"config": {"params": {"vectors": {"size": 1024, "distance": "Cosine"}}},
|
|
"payload_schema": {},
|
|
}
|
|
},
|
|
),
|
|
)
|
|
assert isinstance(store, VectorStore)
|