From f8b7828f61a141b013589123b3521f1d8c939e36 Mon Sep 17 00:00:00 2001 From: mat Date: Sat, 11 Jul 2026 16:13:33 +0200 Subject: [PATCH 1/9] feat(memory): prepare LTM backend foundation for Qdrant --- Makefile | 2 +- dmf/memory/candidate_generation.py | 4 +- dmf/memory/ltm_hooks/chroma_hook.py | 21 ++- dmf/memory/ltm_hooks/codecs.py | 76 +++++++++ dmf/memory/ltm_hooks/factory.py | 117 +++++++++++++ dmf/memory/ltm_hooks/vector_types.py | 56 +++++++ dmf/memory/temporal_memory.py | 77 +-------- dmf/models/__init__.py | 3 +- dmf/models/ltm_hook.py | 26 +++ poetry.lock | 132 ++++++++++++++- pyproject.toml | 3 + tests/test_candidate_generation.py | 21 +++ tests/test_ltm_codecs.py | 123 ++++++++++++++ tests/test_ltm_hook_factory.py | 235 +++++++++++++++++++++++++++ tests/test_ltm_vector_types.py | 59 +++++++ 15 files changed, 873 insertions(+), 82 deletions(-) create mode 100644 dmf/memory/ltm_hooks/codecs.py create mode 100644 dmf/memory/ltm_hooks/factory.py create mode 100644 dmf/memory/ltm_hooks/vector_types.py create mode 100644 tests/test_ltm_codecs.py create mode 100644 tests/test_ltm_hook_factory.py create mode 100644 tests/test_ltm_vector_types.py diff --git a/Makefile b/Makefile index d1e2696..c5af8f9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ lock: # Install dependencies (including dev) and download the spaCy model install: - poetry install --with dev --no-interaction + poetry install --with dev --extras qdrant --no-interaction poetry run python -m spacy download en_core_web_sm # Verify package metadata diff --git a/dmf/memory/candidate_generation.py b/dmf/memory/candidate_generation.py index bfaf74e..dc07b30 100644 --- a/dmf/memory/candidate_generation.py +++ b/dmf/memory/candidate_generation.py @@ -53,7 +53,7 @@ QUERY_CURRENTNESS_HISTORICAL, QUERY_CURRENTNESS_MIXED, ) -from dmf.models.ltm_hook import LTMHook +from dmf.models.ltm_hook import CardSearchLTMHook, LTMHook from dmf.models.memory import MemoryCard, QueryFrame, RetrievedEvidence from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit @@ -215,7 +215,7 @@ def retrieve( """ if ( self._ltm_hook is not None - and hasattr(self._ltm_hook, "search_cards") + and isinstance(self._ltm_hook, CardSearchLTMHook) and query.query_embedding is not None ): hits = self._ltm_hook.search_cards(query.query_embedding, k=k) diff --git a/dmf/memory/ltm_hooks/chroma_hook.py b/dmf/memory/ltm_hooks/chroma_hook.py index 80ff62a..7531103 100644 --- a/dmf/memory/ltm_hooks/chroma_hook.py +++ b/dmf/memory/ltm_hooks/chroma_hook.py @@ -43,6 +43,12 @@ ChromaConnectionConfig, build_chroma_client, ) +from dmf.memory.ltm_hooks.codecs import ( + build_card_payload, + build_raw_payload, + raw_record_from_payload, +) +from dmf.memory.ltm_hooks.vector_types import cosine_distance_to_similarity from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit from dmf.utils.config import VectorConfig @@ -148,8 +154,9 @@ def archive(self, entry: MemoryEntry) -> None: TypeError: If raw-record or card metadata cannot be serialised. """ raw_record = entry.to_raw_ltm_record() + raw_payload = build_raw_payload(raw_record) metadata = { - "raw_record": json.dumps(raw_record.to_dict(), ensure_ascii=False), + "raw_record": json.dumps(raw_payload["raw_record"], ensure_ascii=False), "record_id": raw_record.record_id, "raw_interaction_id": raw_record.interaction_id, "raw_role": raw_record.role, @@ -171,8 +178,9 @@ def archive(self, entry: MemoryEntry) -> None: piece for piece in [card.kind, card.subject, card.predicate, card.object] if piece ) + card_payload = build_card_payload(card) card_metadata = { - "card": json.dumps(card.to_dict(), ensure_ascii=False), + "card": json.dumps(card_payload["card"], ensure_ascii=False), "card_id": card.card_id, "source_record_id": card.provenance.source_record_id, "kind": card.kind, @@ -228,7 +236,7 @@ def search_raw( RawRecallHit( record=record, distance=float(dist), - similarity_score=1.0 - float(dist), + similarity_score=cosine_distance_to_similarity(float(dist)), rank_hint=idx, ) ) @@ -311,7 +319,7 @@ def search_cards( RawRecallHit( record=record, distance=float(dist), - similarity_score=1.0 - float(dist), + similarity_score=cosine_distance_to_similarity(float(dist)), rank_hint=idx, ) ) @@ -397,10 +405,7 @@ def card_store(self) -> JsonlMemoryCardStore | None: def _deserialize_raw_record(self, meta: dict[str, object]) -> RawLTMRecord: """Hydrate a raw-LTM record from Chroma metadata.""" - raw_payload = meta["raw_record"] - if not isinstance(raw_payload, str) or not raw_payload: - raise ValueError("Missing raw_record metadata") - return RawLTMRecord.from_dict(json.loads(raw_payload)) + return raw_record_from_payload(meta) def _embed_text_payload(self, text: str) -> list[float]: """Return the vector used to index one raw record.""" diff --git a/dmf/memory/ltm_hooks/codecs.py b/dmf/memory/ltm_hooks/codecs.py new file mode 100644 index 0000000..a87ad9d --- /dev/null +++ b/dmf/memory/ltm_hooks/codecs.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Shared payload codecs for LTM backend adapters.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any + +from dmf.models.memory import MemoryCard +from dmf.models.raw_ltm import RawLTMRecord + +LTM_PAYLOAD_SCHEMA_VERSION = 1 +LTM_RECORD_TYPE_RAW = "raw" +LTM_RECORD_TYPE_CARD = "card" + + +def build_raw_payload(record: RawLTMRecord) -> dict[str, object]: + """Return the backend-neutral raw-record payload.""" + return { + "schema_version": LTM_PAYLOAD_SCHEMA_VERSION, + "record_type": LTM_RECORD_TYPE_RAW, + "record_id": record.record_id, + "interaction_id": record.interaction_id, + "role": record.role, + "created_at": record.created_at, + "raw_record": record.to_dict(), + } + + +def raw_record_from_payload(payload: Mapping[str, object]) -> RawLTMRecord: + """Hydrate a raw record from a backend payload. + + ``raw_record`` may be a mapping, as used by document payload stores, or a + JSON string, as used by existing Chroma metadata. + """ + raw_payload = payload["raw_record"] + if isinstance(raw_payload, str): + raw_payload = json.loads(raw_payload) + if not isinstance(raw_payload, Mapping): + raise TypeError("raw_record payload must be a mapping or JSON string") + return RawLTMRecord.from_dict(dict(raw_payload)) + + +def build_card_payload(card: MemoryCard) -> dict[str, object]: + """Return the backend-neutral projected-card payload.""" + card_payload: dict[str, Any] = card.to_dict() + return { + "schema_version": LTM_PAYLOAD_SCHEMA_VERSION, + "record_type": LTM_RECORD_TYPE_CARD, + "card_id": card.card_id, + "source_record_id": card.provenance.source_record_id, + "kind": card.kind, + "card": card_payload, + } diff --git a/dmf/memory/ltm_hooks/factory.py b/dmf/memory/ltm_hooks/factory.py new file mode 100644 index 0000000..3cae22e --- /dev/null +++ b/dmf/memory/ltm_hooks/factory.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Factory for configured long-term-memory hooks.""" + +from __future__ import annotations + +import os + +from dmf.models.ltm_hook import LTMHook, NullLTMHook +from dmf.utils.config import VectorConfig +from dmf.utils.config_loader import LTMSettings +from dmf.utils.constants import LTM_BACKEND_CHROMA, LTM_BACKEND_FILE, LTM_BACKEND_NULL + + +def build_ltm_hook(settings: LTMSettings, vector_config: VectorConfig) -> LTMHook: + """Build the configured long-term-memory hook. + + Args: + settings: LTM backend settings from ``DMFConfig.ltm``. + vector_config: Vector configuration used by vector-backed hooks. + + Returns: + Configured LTM hook. + + Raises: + ValueError: If the backend or a required Chroma server token is invalid. + """ + if not settings.enabled: + return NullLTMHook() + + if settings.storage_type == LTM_BACKEND_FILE: + from dmf.memory.ltm_hooks import FileLTMHook + + return FileLTMHook( + settings.storage_path, + cards_enabled=settings.cards_enabled, + cards_path=settings.cards_path, + ) + + if settings.storage_type == LTM_BACKEND_CHROMA: + from dmf.memory.ltm_hooks import ChromaLTMHook + from dmf.memory.ltm_hooks.chroma_client import ( + ChromaConnectionConfig, + ChromaConnectionMode, + ) + + mode = ChromaConnectionMode(settings.chroma_mode) + auth_token = _resolve_chroma_auth_token(settings, mode) + connection = ChromaConnectionConfig( + mode=mode, + persist_directory=settings.chroma_path, + host=settings.chroma_host, + port=settings.chroma_port, + ssl=settings.chroma_ssl, + tenant=settings.chroma_tenant, + database=settings.chroma_database, + auth_token=auth_token, + ) + return ChromaLTMHook( + collection_name=settings.collection_name, + persist_directory=settings.chroma_path, + distance_threshold=settings.distance_threshold, + vector_config=vector_config, + cards_enabled=settings.cards_enabled, + cards_path=settings.cards_path, + cards_collection_name=settings.cards_collection_name, + connection=connection, + ) + + if settings.storage_type == LTM_BACKEND_NULL: + return NullLTMHook() + + raise ValueError( + "Unsupported ltm.storage_type at runtime: " + f"{settings.storage_type!r}" + ) + + +def _resolve_chroma_auth_token( + settings: LTMSettings, + mode: object, +) -> str | None: + from dmf.memory.ltm_hooks.chroma_client import ChromaConnectionMode + + if mode is not ChromaConnectionMode.SERVER: + return None + if not settings.chroma_auth_token_env: + return None + + env_name = settings.chroma_auth_token_env.strip() + auth_token = os.getenv(env_name) + if auth_token is None or not auth_token.strip(): + raise ValueError( + f"Chroma auth token environment variable {env_name!r} " + "is missing or empty" + ) + return auth_token diff --git a/dmf/memory/ltm_hooks/vector_types.py b/dmf/memory/ltm_hooks/vector_types.py new file mode 100644 index 0000000..050d43f --- /dev/null +++ b/dmf/memory/ltm_hooks/vector_types.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Vector score and shape helpers shared by LTM backends.""" + +from __future__ import annotations + +from collections.abc import Sequence + + +def cosine_distance_to_similarity(distance: float) -> float: + """Convert cosine distance to similarity without clamping.""" + return 1.0 - distance + + +def cosine_similarity_to_distance(score: float) -> float: + """Convert cosine similarity to distance without clamping.""" + return 1.0 - score + + +def distance_threshold_to_min_similarity(threshold: float) -> float: + """Convert a maximum distance threshold into a minimum similarity.""" + return 1.0 - threshold + + +def validate_vector_dimension( + vector: Sequence[float], + expected: int, + *, + field: str, +) -> None: + """Raise when a vector does not match the configured dimensionality.""" + observed = len(vector) + if observed != expected: + raise ValueError( + f"{field} vector dimension mismatch: expected {expected}, observed {observed}" + ) diff --git a/dmf/memory/temporal_memory.py b/dmf/memory/temporal_memory.py index 6d3c859..432e1cb 100644 --- a/dmf/memory/temporal_memory.py +++ b/dmf/memory/temporal_memory.py @@ -93,7 +93,6 @@ from __future__ import annotations -import os import time from collections import deque from dataclasses import dataclass @@ -112,13 +111,14 @@ UNIX_TIMESTAMP_MIN, UTC_RENDER_FORMAT, ) +from dmf.memory.ltm_hooks import factory as _ltm_hook_factory +from dmf.memory.ltm_hooks.factory import build_ltm_hook from dmf.models.analysis import AnalysisReport, MemoryLineage from dmf.models.ltm_hook import LTMHook, NullLTMHook from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import ContextualizedRecallCandidate, RawLTMRecord, RawRecallHit from dmf.models.status import classify_survival_status from dmf.utils.config import DecayConfig, PruningPriorityConfig, VectorConfig -from dmf.utils.constants import LTM_BACKEND_CHROMA, LTM_BACKEND_FILE, LTM_BACKEND_NULL if TYPE_CHECKING: # DMFConfig is only needed for the from_dmf_config factory; importing it @@ -134,6 +134,10 @@ # and reused across all TemporalMemory instances. Thread-safe for reads. _TOKENIZER: tiktoken.Encoding = tiktoken.get_encoding("cl100k_base") +# Backward-compatible patch point for existing tests. LTM environment +# resolution lives in the factory. +os = _ltm_hook_factory.os + # Context section headers (UI strings — not business logic, not configurable) @@ -367,10 +371,7 @@ def from_dmf_config( pruning_priority.rho_* pruning_priority.* PruningPriorityConfig nlp.vector_dim nlp.vector_dim VectorConfig.vector_dim capacity.window_size capacity.window_size VectorConfig.window_size - ltm.enabled + storage_type="file" ltm.* → FileLTMHook(storage_path) - ltm.enabled + storage_type="chroma" ltm.* → ChromaLTMHook(...) - ltm.enabled + storage_type="null" ltm.* → NullLTMHook - ltm.enabled=false ltm.* → NullLTMHook + ltm.* ltm.* → configured LTMHook ====================== ======================== ===================== Parameters @@ -420,73 +421,11 @@ def from_dmf_config( window_size=config.capacity.window_size, ) - # Resolve LTM hook: - # 1. Explicit injection always wins (testing / custom backends). - # 2. ltm.enabled=false → NullLTMHook. - # 3. ltm.enabled + storage_type="file" → FileLTMHook. - # 4. ltm.enabled + storage_type="chroma" → ChromaLTMHook. - # 5. ltm.enabled + storage_type="null" → NullLTMHook. - # The import is deferred to this call site to avoid any future - # circular-import risk if config_loader grows additional imports. resolved_hook: LTMHook if ltm_hook is not None: resolved_hook = ltm_hook - elif not config.ltm.enabled: - resolved_hook = NullLTMHook() - elif config.ltm.storage_type == LTM_BACKEND_FILE: - from dmf.memory.ltm_hooks import FileLTMHook # deferred import - resolved_hook = FileLTMHook( - config.ltm.storage_path, - cards_enabled=config.ltm.cards_enabled, - cards_path=config.ltm.cards_path, - ) - elif config.ltm.storage_type == LTM_BACKEND_CHROMA: - from dmf.memory.ltm_hooks import ChromaLTMHook # deferred import - from dmf.memory.ltm_hooks.chroma_client import ( - ChromaConnectionConfig, - ChromaConnectionMode, - ) - - mode = ChromaConnectionMode(config.ltm.chroma_mode) - auth_token = None - if ( - mode is ChromaConnectionMode.SERVER - and config.ltm.chroma_auth_token_env - ): - env_name = config.ltm.chroma_auth_token_env.strip() - auth_token = os.getenv(env_name) - if auth_token is None or not auth_token.strip(): - raise ValueError( - f"Chroma auth token environment variable {env_name!r} " - "is missing or empty" - ) - - connection = ChromaConnectionConfig( - mode=mode, - persist_directory=config.ltm.chroma_path, - host=config.ltm.chroma_host, - port=config.ltm.chroma_port, - ssl=config.ltm.chroma_ssl, - tenant=config.ltm.chroma_tenant, - database=config.ltm.chroma_database, - auth_token=auth_token, - ) - resolved_hook = ChromaLTMHook( - collection_name=config.ltm.collection_name, - persist_directory=config.ltm.chroma_path, - distance_threshold=config.ltm.distance_threshold, - cards_enabled=config.ltm.cards_enabled, - cards_path=config.ltm.cards_path, - cards_collection_name=config.ltm.cards_collection_name, - connection=connection, - ) - elif config.ltm.storage_type == LTM_BACKEND_NULL: - resolved_hook = NullLTMHook() else: - raise ValueError( - "Unsupported ltm.storage_type at runtime: " - f"{config.ltm.storage_type!r}" - ) + resolved_hook = build_ltm_hook(config.ltm, vector_cfg) return cls( decay_config=decay_cfg, diff --git a/dmf/models/__init__.py b/dmf/models/__init__.py index 4016193..2b07bb3 100644 --- a/dmf/models/__init__.py +++ b/dmf/models/__init__.py @@ -23,7 +23,7 @@ """Canonical data contracts for analysis, memory, and recall.""" from dmf.models.analysis import AnalysisReport, InteractionProvenance, InteractionSignals, MemoryLineage -from dmf.models.ltm_hook import LTMHook, NullLTMHook +from dmf.models.ltm_hook import CardSearchLTMHook, LTMHook, NullLTMHook from dmf.models.memory import ( MemoryEntry, MemoryCard, @@ -41,6 +41,7 @@ "InteractionProvenance", "InteractionSignals", "MemoryLineage", + "CardSearchLTMHook", "LTMHook", "NullLTMHook", "MemoryEntry", diff --git a/dmf/models/ltm_hook.py b/dmf/models/ltm_hook.py index 8e4a460..e330df0 100644 --- a/dmf/models/ltm_hook.py +++ b/dmf/models/ltm_hook.py @@ -36,6 +36,7 @@ from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit + @runtime_checkable class LTMHook(Protocol): """Interface for sending evicted interactions to Long-Term Memory. @@ -95,6 +96,31 @@ def read_all(self) -> list[RawLTMRecord]: """ ... + +@runtime_checkable +class CardSearchLTMHook(Protocol): + """Optional LTM surface for semantic search over projected cards.""" + + def search_cards( + self, + query_vector: list[float], + k: int = 5, + ) -> list[RawRecallHit]: + """Retrieve source raw records for the top-k matching cards. + + Args: + query_vector: Dense embedding used by the backend for card search. + k: Maximum number of card hits requested. + + Returns: + Raw recall hits pointing to source records for matching cards. + + Raises: + Backend-specific exceptions may be raised by concrete stores. + """ + ... + + class NullLTMHook: """No-op LTM hook for environments without a real storage backend. diff --git a/poetry.lock b/poetry.lock index 5305c59..798e36f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1239,6 +1239,23 @@ files = [ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "h2" +version = "4.3.0" +description = "Pure-Python HTTP/2 protocol implementation" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"qdrant\"" +files = [ + {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, + {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, +] + +[package.dependencies] +hpack = ">=4.1,<5" +hyperframe = ">=6.1,<7" + [[package]] name = "hf-xet" version = "1.5.0" @@ -1278,6 +1295,19 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "hpack" +version = "4.2.0" +description = "Pure-Python HPACK header encoding" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"qdrant\"" +files = [ + {file = "hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986"}, + {file = "hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0"}, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1368,6 +1398,7 @@ files = [ [package.dependencies] anyio = "*" certifi = "*" +h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} httpcore = "==1.*" idna = "*" @@ -1414,6 +1445,19 @@ testing = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "duckdb", "fastapi", "fastap torch = ["safetensors[torch]", "torch"] typing = ["types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] +[[package]] +name = "hyperframe" +version = "6.1.0" +description = "Pure-Python HTTP/2 framing" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"qdrant\"" +files = [ + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, +] + [[package]] name = "idna" version = "3.16" @@ -2814,6 +2858,27 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "portalocker" +version = "3.2.0" +description = "Wraps the portalocker recipe for easy usage" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"qdrant\"" +files = [ + {file = "portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968"}, + {file = "portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + +[package.extras] +docs = ["portalocker[tests]"] +redis = ["redis"] +tests = ["coverage-conditional-plugin (>=0.9.0)", "portalocker[redis]", "pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-rerunfailures (>=15.0)", "pytest-timeout (>=2.1.0)", "sphinx (>=6.0.0)", "types-pywin32 (>=310.0.0.20250429)", "types-redis"] + [[package]] name = "posthog" version = "7.15.3" @@ -3413,6 +3478,38 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "pywin32" +version = "312" +description = "Python for Windows Extensions" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"qdrant\" and platform_system == \"Windows\"" +files = [ + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3511,6 +3608,36 @@ files = [ [package.dependencies] pyyaml = "*" +[[package]] +name = "qdrant-client" +version = "1.18.0" +description = "Client library for the Qdrant vector search engine" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"qdrant\"" +files = [ + {file = "qdrant_client-1.18.0-py3-none-any.whl", hash = "sha256:093aa8cf8a420ee3ad2a68b007e1378d7992b2600e0b53c193fc172674f659cd"}, + {file = "qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4"}, +] + +[package.dependencies] +grpcio = ">=1.41.0" +httpx = {version = ">=0.20.0", extras = ["http2"]} +numpy = [ + {version = ">=1.26", markers = "python_version == \"3.12\""}, + {version = ">=2.1.0", markers = "python_version == \"3.13\""}, + {version = ">=2.3.0", markers = "python_version >= \"3.14\""}, +] +portalocker = ">=2.7.0,<4.0" +protobuf = ">=3.20.0" +pydantic = ">=1.10.8,<2.0.dev0 || >2.2.0" +urllib3 = ">=1.26.14,<3" + +[package.extras] +fastembed = ["fastembed (>=0.8,<0.9)"] +fastembed-gpu = ["fastembed-gpu (>=0.8,<0.9)"] + [[package]] name = "regex" version = "2026.5.9" @@ -4901,7 +5028,10 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.1" +[extras] +qdrant = ["qdrant-client"] + [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.15" -content-hash = "f5beaddfecd59d8b40a373e44956e3fdbb2808cb1c26cf70b8f085ae0c8caba9" +content-hash = "cc58366e8460aef4137ff4c250f1ac4a4d858ffb0c5ff92e3d928a5db293ae85" diff --git a/pyproject.toml b/pyproject.toml index 0ed8c4f..7f3c8fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,9 @@ dependencies = [ "pydantic>=2.12.0,<3.0.0", ] +[project.optional-dependencies] +qdrant = ["qdrant-client>=1.17.0,<2.0.0"] + [project.urls] Homepage = "https://github.com/tuouser/dmf" Repository = "https://github.com/tuouser/dmf" diff --git a/tests/test_candidate_generation.py b/tests/test_candidate_generation.py index beca567..6e6f968 100644 --- a/tests/test_candidate_generation.py +++ b/tests/test_candidate_generation.py @@ -41,6 +41,7 @@ from dmf.memory.query_understanding import parse_query_frame from dmf.memory.temporal_memory import TemporalMemory from dmf.models.analysis import AnalysisReport, InteractionProvenance, InteractionSignals +from dmf.models import CardSearchLTMHook from dmf.models.ltm_hook import LTMHook from dmf.models.memory import ( MemoryCard, @@ -400,6 +401,20 @@ def test_card_semantic_retriever_falls_back_to_lexical_when_no_hook() -> None: assert results[0].source == "card_semantic" +def test_card_semantic_retriever_falls_back_when_hook_has_no_card_search() -> None: + hook = _FakeHook([]) + retriever = CardSemanticRetriever(ltm_hook=hook) + query = parse_query_frame("What does Alice prefer?", query_embedding=[0.1, 0.2]) + cards = [_card()] + + results = retriever.retrieve(query, cards=cards, k=5) + + assert not isinstance(hook, CardSearchLTMHook) + assert len(results) == 1 + assert results[0].evidence_id == "card:record:1:0" + assert results[0].source == "card_semantic" + + def test_card_semantic_retriever_falls_back_to_lexical_when_no_embedding() -> None: hook = _HookWithSearchCards([]) retriever = CardSemanticRetriever(ltm_hook=hook) @@ -414,6 +429,12 @@ def test_card_semantic_retriever_falls_back_to_lexical_when_no_embedding() -> No assert results[0].evidence_id == "card:record:1:0" +def test_card_search_ltm_hook_recognizes_structural_implementation() -> None: + hook = _HookWithSearchCards([]) + + assert isinstance(hook, CardSearchLTMHook) + + def test_deterministic_card_semantic_retriever_alias_is_card_semantic_retriever() -> None: """Backward-compat alias should point to the same class.""" assert DeterministicCardSemanticRetriever is CardSemanticRetriever diff --git a/tests/test_ltm_codecs.py b/tests/test_ltm_codecs.py new file mode 100644 index 0000000..f05087a --- /dev/null +++ b/tests/test_ltm_codecs.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import json + +import pytest + +from dmf.memory.ltm_hooks.codecs import ( + LTM_PAYLOAD_SCHEMA_VERSION, + LTM_RECORD_TYPE_CARD, + LTM_RECORD_TYPE_RAW, + build_card_payload, + build_raw_payload, + raw_record_from_payload, +) +from dmf.models.analysis import InteractionProvenance +from dmf.models.memory import ( + MemoryCard, + MemoryCardProvenance, + MemoryCardTimeAnchor, + MemoryCardValidity, +) +from dmf.models.raw_ltm import RawLTMRecord + + +def _raw_record() -> RawLTMRecord: + return RawLTMRecord( + record_id="record:7", + interaction_id=7, + role="user", + text="Alice prefers green tea.", + created_at=123.0, + provenance=InteractionProvenance(role="user", source_turn=7), + ) + + +def _card() -> MemoryCard: + return MemoryCard( + card_id="card:record:7:0", + kind="preference", + subject="Alice", + predicate="prefer", + object="green tea", + qualifiers={"time": "afternoon"}, + time_anchor=MemoryCardTimeAnchor(relative_order=7, turn_id=7), + validity=MemoryCardValidity(status="active"), + provenance=MemoryCardProvenance( + source_record_id="record:7", + speaker_role="user", + source_turn=7, + ), + confidence=0.8, + surface_forms=["Alice prefers green tea."], + ) + + +def test_raw_payload_round_trips_from_mapping() -> None: + record = _raw_record() + payload = build_raw_payload(record) + + assert payload["schema_version"] == LTM_PAYLOAD_SCHEMA_VERSION + assert payload["record_type"] == LTM_RECORD_TYPE_RAW + assert payload["record_id"] == "record:7" + assert payload["interaction_id"] == 7 + assert payload["role"] == "user" + assert payload["created_at"] == 123.0 + assert raw_record_from_payload(payload) == record + + +def test_raw_payload_round_trips_from_json_string() -> None: + record = _raw_record() + payload = build_raw_payload(record) + payload["raw_record"] = json.dumps(payload["raw_record"]) + + assert raw_record_from_payload(payload) == record + + +def test_raw_payload_missing_key_propagates_key_error() -> None: + with pytest.raises(KeyError): + raw_record_from_payload({}) + + +def test_raw_payload_wrong_type_propagates_type_error() -> None: + with pytest.raises(TypeError): + raw_record_from_payload({"raw_record": 123}) + + +def test_raw_payload_invalid_json_propagates_decode_error() -> None: + with pytest.raises(json.JSONDecodeError): + raw_record_from_payload({"raw_record": "not-valid-json"}) + + +def test_card_payload_contains_complete_card_metadata() -> None: + card = _card() + payload = build_card_payload(card) + + assert payload["schema_version"] == LTM_PAYLOAD_SCHEMA_VERSION + assert payload["record_type"] == LTM_RECORD_TYPE_CARD + assert payload["card_id"] == "card:record:7:0" + assert payload["source_record_id"] == "record:7" + assert payload["kind"] == "preference" + assert payload["card"] == card.to_dict() diff --git a/tests/test_ltm_hook_factory.py b/tests/test_ltm_hook_factory.py new file mode 100644 index 0000000..9bfe558 --- /dev/null +++ b/tests/test_ltm_hook_factory.py @@ -0,0 +1,235 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from dmf.memory import temporal_memory +from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook +from dmf.memory.ltm_hooks.chroma_client import ChromaConnectionMode +from dmf.memory.ltm_hooks.factory import build_ltm_hook +from dmf.models.ltm_hook import NullLTMHook +from dmf.utils.config import VectorConfig +from dmf.utils.config_loader import DMFConfig, LTMSettings + + +def test_disabled_ltm_builds_null_hook(tmp_path: Path) -> None: + settings = LTMSettings( + enabled=False, + storage_type="chroma", + chroma_path=str(tmp_path / "must-not-exist"), + ) + + hook = build_ltm_hook(settings, VectorConfig()) + + assert isinstance(hook, NullLTMHook) + assert not (tmp_path / "must-not-exist").exists() + + +def test_explicit_null_ltm_builds_null_hook() -> None: + hook = build_ltm_hook( + LTMSettings(enabled=True, storage_type="null"), + VectorConfig(), + ) + + assert isinstance(hook, NullLTMHook) + + +def test_file_ltm_builds_file_hook_with_card_settings(tmp_path: Path) -> None: + storage_path = tmp_path / "archive" / "ltm.jsonl" + cards_path = tmp_path / "cards" / "cards.jsonl" + settings = LTMSettings( + storage_type="file", + storage_path=str(storage_path), + cards_enabled=True, + cards_path=str(cards_path), + ) + + hook = build_ltm_hook(settings, VectorConfig()) + + assert isinstance(hook, FileLTMHook) + assert hook.path == storage_path + assert hook.card_store is not None + assert hook.card_store.path == cards_path + + +def test_chroma_embedded_ltm_builds_hook_with_connection_and_vector_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + captured: dict[str, Any] = {} + + def fake_init(self: ChromaLTMHook, **kwargs: Any) -> None: + captured.update(kwargs) + + monkeypatch.setattr(ChromaLTMHook, "__init__", fake_init) + vector_config = VectorConfig(model_name="local-test-model", vector_dim=17, window_size=3) + settings = LTMSettings( + storage_type="chroma", + chroma_path=str(tmp_path / "chroma"), + collection_name="raw_collection", + distance_threshold=0.33, + cards_enabled=True, + cards_path=str(tmp_path / "cards.jsonl"), + cards_collection_name="card_collection", + ) + + hook = build_ltm_hook(settings, vector_config) + + assert isinstance(hook, ChromaLTMHook) + assert captured["collection_name"] == "raw_collection" + assert captured["persist_directory"] == str(tmp_path / "chroma") + assert captured["distance_threshold"] == 0.33 + assert captured["vector_config"] is vector_config + assert captured["cards_enabled"] is True + assert captured["cards_path"] == str(tmp_path / "cards.jsonl") + assert captured["cards_collection_name"] == "card_collection" + connection = captured["connection"] + assert connection.mode is ChromaConnectionMode.EMBEDDED + assert connection.persist_directory == str(tmp_path / "chroma") + assert connection.auth_token is None + + +def test_chroma_server_ltm_builds_connection_with_auth_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + env_reads: list[str] = [] + + monkeypatch.setattr( + ChromaLTMHook, + "__init__", + lambda self, **kwargs: captured.update(kwargs), # noqa: ARG005 + ) + monkeypatch.setattr( + temporal_memory.os, + "getenv", + lambda name: env_reads.append(name) or "top-secret-token", + ) + settings = LTMSettings( + storage_type="chroma", + chroma_mode="server", + chroma_host="chroma.internal", + chroma_port=8443, + chroma_ssl=True, + chroma_tenant="tenant-a", + chroma_database="database-a", + chroma_auth_token_env="DMF_CHROMA_TOKEN", + ) + + build_ltm_hook(settings, VectorConfig()) + + connection = captured["connection"] + assert connection.mode is ChromaConnectionMode.SERVER + assert connection.host == "chroma.internal" + assert connection.port == 8443 + assert connection.ssl is True + assert connection.tenant == "tenant-a" + assert connection.database == "database-a" + assert connection.auth_token == "top-secret-token" + assert "top-secret-token" not in repr(connection) + assert env_reads == ["DMF_CHROMA_TOKEN"] + + +def test_chroma_server_missing_auth_token_raises_without_secret_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(temporal_memory.os, "getenv", lambda name: " ") # noqa: ARG005 + settings = LTMSettings( + storage_type="chroma", + chroma_mode="server", + chroma_auth_token_env="DMF_CHROMA_TOKEN", + ) + + with pytest.raises(ValueError, match="DMF_CHROMA_TOKEN") as exc_info: + build_ltm_hook(settings, VectorConfig()) + + assert "Authorization" not in str(exc_info.value) + + +@pytest.mark.parametrize( + ("settings", "expected_type"), + [ + (LTMSettings(enabled=False, storage_type="chroma"), NullLTMHook), + (LTMSettings(storage_type="file"), FileLTMHook), + (LTMSettings(storage_type="null"), NullLTMHook), + ], +) +def test_non_active_chroma_server_config_does_not_read_auth_environment( + monkeypatch: pytest.MonkeyPatch, + settings: LTMSettings, + expected_type: type, +) -> None: + monkeypatch.setattr( + temporal_memory.os, + "getenv", + lambda name: pytest.fail(f"unexpected environment read: {name}"), + ) + settings = LTMSettings( + **{ + **settings.__dict__, + "chroma_mode": "server", + "chroma_auth_token_env": "DMF_CHROMA_TOKEN", + } + ) + + hook = build_ltm_hook(settings, VectorConfig()) + + assert isinstance(hook, expected_type) + + +def test_unknown_storage_type_raises_value_error() -> None: + settings = LTMSettings(storage_type="unsupported") + + with pytest.raises(ValueError, match="Unsupported ltm.storage_type at runtime"): + build_ltm_hook(settings, VectorConfig()) + + +def test_temporal_memory_explicit_hook_bypasses_factory_and_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + temporal_memory, + "build_ltm_hook", + lambda settings, vector_config: pytest.fail("unexpected factory call"), + ) + monkeypatch.setattr( + temporal_memory.os, + "getenv", + lambda name: pytest.fail(f"unexpected environment read: {name}"), + ) + explicit = NullLTMHook() + config = DMFConfig( + ltm=LTMSettings( + storage_type="chroma", + chroma_mode="server", + chroma_auth_token_env="DMF_CHROMA_TOKEN", + ) + ) + + tm = temporal_memory.TemporalMemory.from_dmf_config(config, ltm_hook=explicit) + + assert tm.ltm_hook is explicit diff --git a/tests/test_ltm_vector_types.py b/tests/test_ltm_vector_types.py new file mode 100644 index 0000000..297c78a --- /dev/null +++ b/tests/test_ltm_vector_types.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import pytest + +from dmf.memory.ltm_hooks.vector_types import ( + cosine_distance_to_similarity, + cosine_similarity_to_distance, + distance_threshold_to_min_similarity, + validate_vector_dimension, +) + + +@pytest.mark.parametrize("value", [1.0, 0.3, 0.0, -0.5]) +def test_cosine_score_conversions_use_one_minus_value(value: float) -> None: + assert cosine_distance_to_similarity(value) == pytest.approx(1.0 - value) + assert cosine_similarity_to_distance(value) == pytest.approx(1.0 - value) + assert distance_threshold_to_min_similarity(value) == pytest.approx(1.0 - value) + + +def test_cosine_score_conversions_do_not_clamp_negative_scores() -> None: + assert cosine_distance_to_similarity(-0.5) == pytest.approx(1.5) + assert cosine_similarity_to_distance(-0.5) == pytest.approx(1.5) + assert distance_threshold_to_min_similarity(-0.5) == pytest.approx(1.5) + + +def test_validate_vector_dimension_accepts_matching_size() -> None: + validate_vector_dimension([0.1, 0.2, 0.3], 3, field="query_vector") + + +def test_validate_vector_dimension_rejects_mismatch_with_context() -> None: + with pytest.raises(ValueError) as exc_info: + validate_vector_dimension([0.1, 0.2], 3, field="query_vector") + + message = str(exc_info.value) + assert "query_vector" in message + assert "expected 3" in message + assert "observed 2" in message From 7b71c7eccb4ba2a542e3cdd792e5f058225cd45c Mon Sep 17 00:00:00 2001 From: mat Date: Sat, 11 Jul 2026 16:29:36 +0200 Subject: [PATCH 2/9] feat(memory): add Qdrant raw adapter and config --- dmf/memory/ltm_hooks/qdrant_client.py | 67 ++++++ dmf/memory/ltm_hooks/qdrant_hook.py | 276 +++++++++++++++++++++++ dmf/utils/config_loader.py | 52 ++++- dmf/utils/constants.py | 5 + dmf_settings.toml | 10 +- tests/test_config_loader.py | 172 +++++++++++++- tests/test_qdrant_client.py | 103 +++++++++ tests/test_qdrant_ltm.py | 309 ++++++++++++++++++++++++++ tests/test_verify_config.py | 2 + 9 files changed, 980 insertions(+), 16 deletions(-) create mode 100644 dmf/memory/ltm_hooks/qdrant_client.py create mode 100644 dmf/memory/ltm_hooks/qdrant_hook.py create mode 100644 tests/test_qdrant_client.py create mode 100644 tests/test_qdrant_ltm.py diff --git a/dmf/memory/ltm_hooks/qdrant_client.py b/dmf/memory/ltm_hooks/qdrant_client.py new file mode 100644 index 0000000..21cc592 --- /dev/null +++ b/dmf/memory/ltm_hooks/qdrant_client.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Construction of Qdrant clients for the LTM backend.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +_QDRANT_EXTRA_INSTALL_MESSAGE = ( + "Install the Qdrant backend with: pip install 'dmf-memory[qdrant]'" +) + + +class QdrantConnectionMode(str, Enum): + """Supported Qdrant deployment modes.""" + + MEMORY = "memory" + + +@dataclass(frozen=True) +class QdrantConnectionConfig: + """Connection parameters used by :func:`build_qdrant_client`.""" + + mode: QdrantConnectionMode = QdrantConnectionMode.MEMORY + + +def build_qdrant_client(connection: QdrantConnectionConfig) -> object: + """Build a Qdrant client for the requested deployment mode.""" + if connection.mode == QdrantConnectionMode.MEMORY: + try: + from qdrant_client import QdrantClient # noqa: PLC0415 + except ModuleNotFoundError as exc: + if exc.name == "qdrant_client": + raise ModuleNotFoundError(_QDRANT_EXTRA_INSTALL_MESSAGE) from exc + raise + + return QdrantClient(":memory:") + + raise ValueError(f"Unsupported Qdrant connection mode: {connection.mode!r}") + + +__all__ = [ + "QdrantConnectionConfig", + "QdrantConnectionMode", + "build_qdrant_client", +] diff --git a/dmf/memory/ltm_hooks/qdrant_hook.py b/dmf/memory/ltm_hooks/qdrant_hook.py new file mode 100644 index 0000000..9fc7c06 --- /dev/null +++ b/dmf/memory/ltm_hooks/qdrant_hook.py @@ -0,0 +1,276 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Raw-record Qdrant LTM adapter.""" + +from __future__ import annotations + +import json +import threading +import uuid +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Callable + +from dmf.memory.ltm_hooks.codecs import build_raw_payload, raw_record_from_payload +from dmf.memory.ltm_hooks.qdrant_client import ( + QdrantConnectionConfig, + build_qdrant_client, +) +from dmf.memory.ltm_hooks.vector_types import ( + cosine_similarity_to_distance, + distance_threshold_to_min_similarity, + validate_vector_dimension, +) +from dmf.models.memory import MemoryEntry +from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit +from dmf.utils.config import VectorConfig +from dmf.utils.constants import ( + DEFAULT_LTM_COLLECTION_NAME, + DEFAULT_LTM_DISTANCE_THRESHOLD, +) + +if TYPE_CHECKING: + import numpy as np + +_QDRANT_POINT_NAMESPACE = uuid.UUID("42e1c69e-6a1d-5f58-a650-fbd3d8a7466f") + + +def _raw_point_id(record_id: str) -> str: + """Return the deterministic Qdrant point UUID for a raw record.""" + return str(uuid.uuid5(_QDRANT_POINT_NAMESPACE, f"raw:{record_id}")) + + +def _card_point_id(card_id: str) -> str: + """Return the deterministic Qdrant point UUID for a projected card.""" + return str(uuid.uuid5(_QDRANT_POINT_NAMESPACE, f"card:{card_id}")) + + +# Point IDs use a stable UUIDv5 namespace to keep archive upserts idempotent +# and to keep raw records distinct from projected cards. +class QdrantLTMHook: + """In-memory Qdrant vector LTM store with raw-record retrieval.""" + + def __init__( + self, + collection_name: str = DEFAULT_LTM_COLLECTION_NAME, + distance_threshold: float = DEFAULT_LTM_DISTANCE_THRESHOLD, + vector_config: VectorConfig | None = None, + embed_text: Callable[[str], np.ndarray] | None = None, + connection: QdrantConnectionConfig | None = None, + client: object | None = None, + ) -> None: + self._collection_name = collection_name + self._distance_threshold = distance_threshold + self._lock = threading.Lock() + self._vector_config = vector_config or VectorConfig() + self._embed_text = embed_text + self._embedding_engine = None + + if self._vector_config.vector_dim <= 0: + raise ValueError( + f"Qdrant collection {collection_name!r} expected vector_dim > 0, " + f"observed {self._vector_config.vector_dim}" + ) + + self._client = client if client is not None else build_qdrant_client( + connection or QdrantConnectionConfig() + ) + self._ensure_collection() + + def archive(self, entry: MemoryEntry) -> None: + """Index one evicted raw interaction record into Qdrant.""" + models = _qdrant_models() + raw_record = entry.to_raw_ltm_record() + vector = self._embed_text_payload(raw_record.text) + validate_vector_dimension( + vector, + self._vector_config.vector_dim, + field="raw record", + ) + point = models.PointStruct( + id=_raw_point_id(raw_record.record_id), + vector=vector, + payload=build_raw_payload(raw_record), + ) + + with self._lock: + self._client.upsert( + collection_name=self._collection_name, + points=[point], + wait=True, + ) + + def search_raw( + self, + query_vector: list[float], + k: int = 5, + ) -> list[RawRecallHit]: + """Retrieve top-k raw records by Qdrant cosine similarity.""" + if k <= 0: + return [] + + validate_vector_dimension( + query_vector, + self._vector_config.vector_dim, + field="query", + ) + response = self._client.query_points( + collection_name=self._collection_name, + query=query_vector, + limit=k, + with_payload=True, + with_vectors=False, + score_threshold=distance_threshold_to_min_similarity( + self._distance_threshold + ), + ) + + hits: list[RawRecallHit] = [] + for idx, point in enumerate(response.points): + payload = point.payload + if not isinstance(payload, Mapping): + continue + try: + record = raw_record_from_payload(payload) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + score = float(point.score) + hits.append( + RawRecallHit( + record=record, + similarity_score=score, + distance=cosine_similarity_to_distance(score), + rank_hint=idx, + ) + ) + return hits + + def read_all(self) -> list[RawLTMRecord]: + """Return all archived raw records ordered by source identity.""" + records: list[RawLTMRecord] = [] + offset = None + while True: + page, next_offset = self._client.scroll( + collection_name=self._collection_name, + limit=256, + offset=offset, + with_payload=True, + with_vectors=False, + ) + for point in page: + payload = point.payload + if not isinstance(payload, Mapping): + continue + try: + records.append(raw_record_from_payload(payload)) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + if next_offset is None: + break + offset = next_offset + + records.sort(key=lambda record: (record.interaction_id, record.record_id)) + return records + + def count(self) -> int: + """Return the number of indexed raw records in the collection.""" + return int( + self._client.count( + collection_name=self._collection_name, + exact=True, + ).count + ) + + def clear(self) -> None: + """Delete all raw points while preserving the Qdrant collection.""" + models = _qdrant_models() + self._client.delete( + collection_name=self._collection_name, + points_selector=models.FilterSelector(filter=models.Filter()), + wait=True, + ) + + def _ensure_collection(self) -> None: + models = _qdrant_models() + if not self._client.collection_exists(self._collection_name): + self._client.create_collection( + collection_name=self._collection_name, + vectors_config=models.VectorParams( + size=self._vector_config.vector_dim, + distance=models.Distance.COSINE, + ), + ) + self._validate_collection() + + def _validate_collection(self) -> None: + models = _qdrant_models() + collection = self._client.get_collection(self._collection_name) + vectors = collection.config.params.vectors + expected = ( + f"single vector size={self._vector_config.vector_dim}, " + f"distance={models.Distance.COSINE}" + ) + + if isinstance(vectors, Mapping): + raise ValueError( + f"Qdrant collection {self._collection_name!r} is incompatible: " + f"expected {expected}, observed named vectors {vectors!r}" + ) + + observed_size = getattr(vectors, "size", None) + observed_distance = getattr(vectors, "distance", None) + if ( + observed_size != self._vector_config.vector_dim + or observed_distance != models.Distance.COSINE + ): + observed = f"size={observed_size}, distance={observed_distance}" + raise ValueError( + f"Qdrant collection {self._collection_name!r} is incompatible: " + f"expected {expected}, observed {observed}" + ) + + def _embed_text_payload(self, text: str) -> list[float]: + """Return the vector used to index one raw record.""" + vector = self._get_embedder()(text) + return vector.tolist() + + def _get_embedder(self) -> Callable[[str], np.ndarray]: + """Return the text embedder, lazily initialising the default engine.""" + if self._embed_text is not None: + return self._embed_text + + if self._embedding_engine is None: + from dmf.analysis.embedding_engine import EmbeddingEngine # noqa: PLC0415 + + self._embedding_engine = EmbeddingEngine(self._vector_config) + + return self._embedding_engine.get_embedding + + +def _qdrant_models() -> object: + """Import Qdrant models lazily so module import does not require the extra.""" + from qdrant_client import models # noqa: PLC0415 + + return models + + +__all__ = ["QdrantLTMHook"] diff --git a/dmf/utils/config_loader.py b/dmf/utils/config_loader.py index 73fa4e3..b96344c 100644 --- a/dmf/utils/config_loader.py +++ b/dmf/utils/config_loader.py @@ -97,6 +97,7 @@ DEFAULT_LTM_CHROMA_TENANT, DEFAULT_LTM_COLLECTION_NAME, DEFAULT_LTM_DISTANCE_THRESHOLD, + DEFAULT_LTM_QDRANT_MODE, DEFAULT_LTM_RECALL_LIMIT, DEFAULT_LTM_STORAGE_PATH, DEFAULT_PRUNING_FREQUENCY, @@ -140,9 +141,11 @@ DEFAULT_WINDOW_SIZE, LTM_BACKEND_CHROMA, LTM_BACKEND_FILE, + LTM_BACKEND_QDRANT, LTM_CHROMA_MODE_SERVER, SUPPORTED_LTM_BACKENDS, SUPPORTED_LTM_CHROMA_MODES, + SUPPORTED_LTM_QDRANT_MODES, ) # Default path: project root / dmf_settings.toml @@ -191,7 +194,7 @@ def _validate_memory_tiers(tiers: MemoryTiersSettings) -> None: def _validate_ltm_settings(ltm: LTMSettings) -> None: - """Validate the configured LTM backend and Chroma connection settings.""" + """Validate common LTM settings and the active backend-specific options.""" supported = SUPPORTED_LTM_BACKENDS if ltm.storage_type not in supported: joined = ", ".join(sorted(supported)) @@ -200,9 +203,29 @@ def _validate_ltm_settings(ltm: LTMSettings) -> None: f"got {ltm.storage_type!r}" ) - supported_modes = SUPPORTED_LTM_CHROMA_MODES - if ltm.chroma_mode not in supported_modes: - joined = ", ".join(sorted(supported_modes)) + if ltm.recall_limit < 0: + raise ValueError("ltm.recall_limit must be non-negative") + if not 0.0 <= ltm.distance_threshold <= 2.0: + raise ValueError("ltm.distance_threshold must be within [0.0, 2.0]") + + chroma_is_active = ltm.enabled and ltm.storage_type == LTM_BACKEND_CHROMA + qdrant_is_active = ltm.enabled and ltm.storage_type == LTM_BACKEND_QDRANT + + if qdrant_is_active: + supported_qdrant_modes = SUPPORTED_LTM_QDRANT_MODES + if ltm.qdrant_mode not in supported_qdrant_modes: + joined = ", ".join(sorted(supported_qdrant_modes)) + raise ValueError( + f"ltm.qdrant_mode must be one of {{{joined}}}; " + f"got {ltm.qdrant_mode!r}" + ) + + if not chroma_is_active: + return + + supported_chroma_modes = SUPPORTED_LTM_CHROMA_MODES + if ltm.chroma_mode not in supported_chroma_modes: + joined = ", ".join(sorted(supported_chroma_modes)) raise ValueError( f"ltm.chroma_mode must be one of {{{joined}}}; " f"got {ltm.chroma_mode!r}" @@ -213,12 +236,7 @@ def _validate_ltm_settings(ltm: LTMSettings) -> None: "ltm.chroma_auth_token_env must not contain only whitespace" ) - server_is_active = ( - ltm.enabled - and ltm.storage_type == LTM_BACKEND_CHROMA - and ltm.chroma_mode == LTM_CHROMA_MODE_SERVER - ) - if not server_is_active: + if ltm.chroma_mode != LTM_CHROMA_MODE_SERVER: return if not ltm.chroma_host.strip(): @@ -548,6 +566,7 @@ class LTMSettings: Backend identifier. ``"file"`` → ``FileLTMHook`` (JSONL audit trail, write-only). ``"chroma"`` → ``ChromaLTMHook`` (vector store with active recall). + ``"qdrant"`` → Qdrant vector store (local in-memory mode). ``"null"`` → ``NullLTMHook`` (silent discard, for tests). Default: ``"file"``. storage_path : str @@ -560,6 +579,9 @@ class LTMSettings: Default: ``"data/ltm_chroma"``. chroma_mode : str Chroma connection mode: ``"embedded"`` or ``"server"``. + qdrant_mode : str + Qdrant connection mode. Only volatile ``"memory"`` is currently + supported. chroma_host : str Chroma server hostname. chroma_port : int @@ -573,8 +595,8 @@ class LTMSettings: chroma_auth_token_env : str Optional environment-variable name containing a server Bearer token. collection_name : str - ChromaDB collection name. Changing this creates an independent - namespace — useful for separating sessions or benchmark runs. + Raw-record vector collection name. Changing this creates an + independent namespace for vector-backed LTM sessions or benchmarks. Default: ``"dmf_memory"``. recall_limit : int Maximum number of raw records to retrieve per ``search_raw()`` call @@ -592,12 +614,16 @@ class LTMSettings: cards_path : str Path to the auxiliary memory-card JSONL archive. Default: ``"data/ltm_cards.jsonl"``. + cards_collection_name : str + Structured memory-card vector collection name for vector-backed LTM. + Default: ``"dmf_cards"``. Args: storage_type: See the function signature and surrounding type hints. storage_path: See the function signature and surrounding type hints. chroma_path: See the function signature and surrounding type hints. chroma_mode: See the function signature and surrounding type hints. + qdrant_mode: See the function signature and surrounding type hints. chroma_host: See the function signature and surrounding type hints. chroma_port: See the function signature and surrounding type hints. chroma_ssl: See the function signature and surrounding type hints. @@ -629,6 +655,7 @@ class LTMSettings: cards_enabled: bool = False cards_path: str = DEFAULT_LTM_CARDS_PATH cards_collection_name: str = DEFAULT_LTM_CARDS_COLLECTION_NAME + qdrant_mode: str = DEFAULT_LTM_QDRANT_MODE chroma_mode: str = DEFAULT_LTM_CHROMA_MODE chroma_host: str = DEFAULT_LTM_CHROMA_HOST chroma_port: int = DEFAULT_LTM_CHROMA_PORT @@ -799,6 +826,7 @@ class _LTMSettingsModel(_ConfigSectionModel): storage_type: str = LTMSettings.storage_type storage_path: str = LTMSettings.storage_path chroma_path: str = LTMSettings.chroma_path + qdrant_mode: str = LTMSettings.qdrant_mode chroma_mode: str = LTMSettings.chroma_mode chroma_host: str = LTMSettings.chroma_host chroma_port: int = LTMSettings.chroma_port diff --git a/dmf/utils/constants.py b/dmf/utils/constants.py index 4ff10f1..e1c36e2 100644 --- a/dmf/utils/constants.py +++ b/dmf/utils/constants.py @@ -78,10 +78,12 @@ LTM_BACKEND_FILE = "file" LTM_BACKEND_CHROMA = "chroma" +LTM_BACKEND_QDRANT = "qdrant" LTM_BACKEND_NULL = "null" SUPPORTED_LTM_BACKENDS = frozenset({ LTM_BACKEND_FILE, LTM_BACKEND_CHROMA, + LTM_BACKEND_QDRANT, LTM_BACKEND_NULL, }) @@ -100,6 +102,9 @@ DEFAULT_LTM_CHROMA_TENANT = "default_tenant" DEFAULT_LTM_CHROMA_DATABASE = "default_database" DEFAULT_LTM_CHROMA_AUTH_TOKEN_ENV = "" +LTM_QDRANT_MODE_MEMORY = "memory" +DEFAULT_LTM_QDRANT_MODE = LTM_QDRANT_MODE_MEMORY +SUPPORTED_LTM_QDRANT_MODES = frozenset({LTM_QDRANT_MODE_MEMORY}) DEFAULT_LTM_COLLECTION_NAME = "dmf_memory" DEFAULT_LTM_RECALL_LIMIT = 5 DEFAULT_LTM_DISTANCE_THRESHOLD = 0.7 diff --git a/dmf_settings.toml b/dmf_settings.toml index 7fc1be7..a8ae4a1 100644 --- a/dmf_settings.toml +++ b/dmf_settings.toml @@ -208,12 +208,13 @@ rho_replacement = 0.08 superseded_past_penalty = 0.35 # --------------------------------------------------------------------------- -# Long-Term Memory (FileLTMHook / ChromaLTMHook) +# Long-Term Memory (FileLTMHook / ChromaLTMHook / Qdrant memory mode) # --------------------------------------------------------------------------- [ltm] # Backend type. # "file" → FileLTMHook (JSONL audit trail, write-only). # "chroma" → ChromaLTMHook (vector store + active recall). +# "qdrant" → Qdrant local in-memory vector store (volatile; not persisted). # "null" → NullLTMHook (silent discard, useful in tests). storage_type = "chroma" @@ -234,7 +235,12 @@ chroma_database = "default_database" # Optional name of an environment variable containing the server Bearer token. chroma_auth_token_env = "" -# ChromaDB collection name. Change to start a fresh memory namespace. +# Qdrant connection. Only volatile in-memory local mode is supported for now. +# Process exit discards all Qdrant data; persistent paths, server URLs, ports, +# and API keys are intentionally out of scope. +qdrant_mode = "memory" + +# Raw-record vector collection name. Change to start a fresh memory namespace. collection_name = "dmf_memory" # Maximum number of raw records to retrieve per active-recall search. diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 6164d65..464c22c 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -32,6 +32,7 @@ import pytest +from dmf.utils.constants import LTM_BACKEND_QDRANT, SUPPORTED_LTM_BACKENDS from dmf.utils.config_loader import load_dmf_config @@ -149,6 +150,7 @@ def test_ltm_chroma_connection_defaults_preserve_embedded_mode( ) -> None: cfg = load_dmf_config(_write_toml(tmp_path, "[ltm]")) + assert cfg.ltm.qdrant_mode == "memory" assert cfg.ltm.chroma_mode == "embedded" assert cfg.ltm.chroma_host == "localhost" assert cfg.ltm.chroma_port == 8000 @@ -186,14 +188,37 @@ def test_load_dmf_config_parses_chroma_server_settings(tmp_path: Path) -> None: assert cfg.ltm.chroma_auth_token_env == "DMF_CHROMA_TOKEN" -def test_load_dmf_config_rejects_unknown_chroma_mode_for_any_backend( +def test_supported_ltm_backends_include_qdrant() -> None: + assert LTM_BACKEND_QDRANT in SUPPORTED_LTM_BACKENDS + + +def test_load_dmf_config_parses_explicit_qdrant_storage_type( + tmp_path: Path, +) -> None: + path = _write_toml( + tmp_path, + """ +[ltm] +storage_type = "qdrant" +qdrant_mode = "memory" +""".strip(), + ) + + cfg = load_dmf_config(path) + + assert cfg.ltm.storage_type == "qdrant" + assert cfg.ltm.qdrant_mode == "memory" + + +def test_load_dmf_config_rejects_unknown_chroma_mode_when_chroma_active( tmp_path: Path, ) -> None: path = _write_toml( tmp_path, """ [ltm] -storage_type = "file" +storage_type = "chroma" +enabled = true chroma_mode = "cluster" """.strip(), ) @@ -202,6 +227,100 @@ def test_load_dmf_config_rejects_unknown_chroma_mode_for_any_backend( load_dmf_config(path) +@pytest.mark.parametrize( + ("storage_type", "enabled"), + [("file", True), ("null", True), ("qdrant", True), ("chroma", False)], +) +def test_load_dmf_config_ignores_unknown_chroma_mode_when_chroma_inactive( + tmp_path: Path, + storage_type: str, + enabled: bool, +) -> None: + path = _write_toml( + tmp_path, + "\n".join( + [ + "[ltm]", + f'storage_type = "{storage_type}"', + f"enabled = {str(enabled).lower()}", + 'chroma_mode = "cluster"', + ] + ), + ) + + cfg = load_dmf_config(path) + + assert cfg.ltm.chroma_mode == "cluster" + + +def test_load_dmf_config_rejects_unknown_qdrant_mode_when_qdrant_active( + tmp_path: Path, +) -> None: + path = _write_toml( + tmp_path, + """ +[ltm] +storage_type = "qdrant" +enabled = true +qdrant_mode = "disk" +""".strip(), + ) + + with pytest.raises(ValueError, match=r"ltm.qdrant_mode must be one of"): + load_dmf_config(path) + + +@pytest.mark.parametrize( + ("storage_type", "enabled"), + [("file", True), ("null", True), ("chroma", True), ("qdrant", False)], +) +def test_load_dmf_config_ignores_unknown_qdrant_mode_when_qdrant_inactive( + tmp_path: Path, + storage_type: str, + enabled: bool, +) -> None: + path = _write_toml( + tmp_path, + "\n".join( + [ + "[ltm]", + f'storage_type = "{storage_type}"', + f"enabled = {str(enabled).lower()}", + 'qdrant_mode = "disk"', + ] + ), + ) + + cfg = load_dmf_config(path) + + assert cfg.ltm.qdrant_mode == "disk" + + +def test_load_dmf_config_ignores_invalid_chroma_fields_when_qdrant_active( + tmp_path: Path, +) -> None: + path = _write_toml( + tmp_path, + """ +[ltm] +storage_type = "qdrant" +enabled = true +qdrant_mode = "memory" +chroma_mode = "cluster" +chroma_host = "" +chroma_port = 0 +chroma_tenant = "" +chroma_database = "" +chroma_auth_token_env = " " +""".strip(), + ) + + cfg = load_dmf_config(path) + + assert cfg.ltm.storage_type == "qdrant" + assert cfg.ltm.qdrant_mode == "memory" + + @pytest.mark.parametrize( ("field", "value", "message"), [ @@ -288,6 +407,7 @@ def test_load_dmf_config_rejects_whitespace_auth_env_name(tmp_path: Path) -> Non tmp_path, """ [ltm] +storage_type = "chroma" chroma_auth_token_env = " " """.strip(), ) @@ -296,6 +416,54 @@ def test_load_dmf_config_rejects_whitespace_auth_env_name(tmp_path: Path) -> Non load_dmf_config(path) +def test_load_dmf_config_rejects_negative_ltm_recall_limit(tmp_path: Path) -> None: + path = _write_toml( + tmp_path, + """ +[ltm] +recall_limit = -1 +""".strip(), + ) + + with pytest.raises(ValueError, match="ltm.recall_limit"): + load_dmf_config(path) + + +@pytest.mark.parametrize("threshold", [-0.1, 2.1]) +def test_load_dmf_config_rejects_ltm_distance_threshold_out_of_range( + tmp_path: Path, + threshold: float, +) -> None: + path = _write_toml( + tmp_path, + f""" +[ltm] +distance_threshold = {threshold} +""".strip(), + ) + + with pytest.raises(ValueError, match=r"ltm.distance_threshold"): + load_dmf_config(path) + + +@pytest.mark.parametrize("threshold", [0.0, 2.0]) +def test_load_dmf_config_accepts_ltm_distance_threshold_boundaries( + tmp_path: Path, + threshold: float, +) -> None: + path = _write_toml( + tmp_path, + f""" +[ltm] +distance_threshold = {threshold} +""".strip(), + ) + + cfg = load_dmf_config(path) + + assert cfg.ltm.distance_threshold == threshold + + def test_load_dmf_config_parses_pruning_priority_section(tmp_path: Path) -> None: path = _write_toml( tmp_path, diff --git a/tests/test_qdrant_client.py b/tests/test_qdrant_client.py new file mode 100644 index 0000000..caf4c26 --- /dev/null +++ b/tests/test_qdrant_client.py @@ -0,0 +1,103 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import builtins +from typing import Any + +import pytest +import qdrant_client + +from dmf.memory.ltm_hooks.qdrant_client import ( + QdrantConnectionConfig, + QdrantConnectionMode, + build_qdrant_client, +) + + +def test_memory_factory_builds_in_memory_client(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[Any, ...]] = [] + expected_client = object() + + def fake_qdrant_client(*args: Any) -> object: + calls.append(args) + return expected_client + + monkeypatch.setattr(qdrant_client, "QdrantClient", fake_qdrant_client) + + result = build_qdrant_client(QdrantConnectionConfig()) + + assert result is expected_client + assert calls == [(":memory:",)] + + +def test_unknown_mode_fails_without_constructing_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + qdrant_client, + "QdrantClient", + lambda *args: pytest.fail(f"unexpected QdrantClient: {args}"), + ) + connection = QdrantConnectionConfig(mode="server") # type: ignore[arg-type] + + with pytest.raises(ValueError, match="Unsupported Qdrant connection mode"): + build_qdrant_client(connection) + + +def test_missing_qdrant_extra_has_actionable_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import = builtins.__import__ + + def fake_import(name: str, *args: Any, **kwargs: Any) -> object: + if name == "qdrant_client": + raise ModuleNotFoundError( + "No module named 'qdrant_client'", + name="qdrant_client", + ) + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ModuleNotFoundError, match=r"dmf-memory\[qdrant\]"): + build_qdrant_client(QdrantConnectionConfig(mode=QdrantConnectionMode.MEMORY)) + + +def test_unrelated_import_error_is_not_rewritten( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import = builtins.__import__ + + def fake_import(name: str, *args: Any, **kwargs: Any) -> object: + if name == "qdrant_client": + raise ModuleNotFoundError( + "No module named 'transitive_dependency'", + name="transitive_dependency", + ) + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ModuleNotFoundError, match="transitive_dependency"): + build_qdrant_client(QdrantConnectionConfig()) diff --git a/tests/test_qdrant_ltm.py b/tests/test_qdrant_ltm.py new file mode 100644 index 0000000..735bced --- /dev/null +++ b/tests/test_qdrant_ltm.py @@ -0,0 +1,309 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest +from qdrant_client import QdrantClient, models + +from dmf.memory.ltm_hooks.qdrant_hook import ( + QdrantLTMHook, + _card_point_id, + _raw_point_id, +) +from dmf.models.analysis import AnalysisReport +from dmf.models.memory import MemoryEntry +from dmf.models.raw_ltm import RawRecallHit +from dmf.models.status import SurvivalStatus +from dmf.utils.config import VectorConfig + + +def _make_entry( + interaction_id: int, + text: str, + vector: list[float], + timestamp: float | None = None, +) -> MemoryEntry: + report = AnalysisReport( + info_density=0.7, + sentiment_abs=0.1, + entity_count=2, + is_system_prompt=False, + latency_ms=1.0, + survival_score=0.85, + status=SurvivalStatus.HEALTHY, + ) + return MemoryEntry( + interaction_id=interaction_id, + text=text, + report=report, + vector=np.array(vector, dtype=np.float32), + token_count=6, + timestamp=float(interaction_id if timestamp is None else timestamp), + ) + + +def _hook( + *, + collection_name: str = "test_raw", + distance_threshold: float = 1.0, + client: QdrantClient | None = None, +) -> QdrantLTMHook: + return QdrantLTMHook( + collection_name=collection_name, + distance_threshold=distance_threshold, + vector_config=VectorConfig(vector_dim=2), + embed_text=lambda text: np.array(_EMBEDDINGS[text], dtype=np.float32), + client=client or QdrantClient(":memory:"), + ) + + +_EMBEDDINGS = { + "alpha": [1.0, 0.0], + "beta": [0.0, 1.0], + "edge": [0.8, 0.6], + "negative": [-1.0, 0.0], +} + + +def test_constructor_creates_collection_with_cosine_vector_config() -> None: + client = QdrantClient(":memory:") + + hook = _hook(collection_name="created", client=client) + + info = client.get_collection("created") + assert hook._client is client + assert info.config.params.vectors.size == 2 + assert info.config.params.vectors.distance == models.Distance.COSINE + + +def test_constructor_reuses_existing_collection_without_destruction() -> None: + client = QdrantClient(":memory:") + client.create_collection( + collection_name="shared", + vectors_config=models.VectorParams(size=2, distance=models.Distance.COSINE), + ) + point = models.PointStruct( + id=_raw_point_id("existing"), + vector=[1.0, 0.0], + payload={"raw_record": _make_entry(1, "alpha", [1.0, 0.0]).to_raw_ltm_record().to_dict()}, + ) + client.upsert(collection_name="shared", points=[point], wait=True) + + _hook(collection_name="shared", client=client) + + assert client.count(collection_name="shared", exact=True).count == 1 + + +@pytest.mark.parametrize( + ("vectors_config", "match"), + [ + ( + models.VectorParams(size=3, distance=models.Distance.COSINE), + "size=3", + ), + ( + models.VectorParams(size=2, distance=models.Distance.DOT), + "Distance.DOT|Dot", + ), + ( + {"named": models.VectorParams(size=2, distance=models.Distance.COSINE)}, + "named vectors", + ), + ], +) +def test_constructor_rejects_incompatible_collections( + vectors_config: Any, + match: str, +) -> None: + client = QdrantClient(":memory:") + client.create_collection(collection_name="bad", vectors_config=vectors_config) + + with pytest.raises(ValueError, match=match): + _hook(collection_name="bad", client=client) + + assert client.collection_exists("bad") + + +def test_constructor_rejects_non_positive_vector_dimension() -> None: + with pytest.raises(ValueError, match="vector_dim > 0"): + QdrantLTMHook( + collection_name="invalid_dim", + vector_config=VectorConfig(vector_dim=0), + client=QdrantClient(":memory:"), + ) + + +def test_point_ids_are_stable_and_separate_by_record_type() -> None: + assert _raw_point_id("record:7") == _raw_point_id("record:7") + assert _card_point_id("record:7") == _card_point_id("record:7") + assert _raw_point_id("record:7") != _card_point_id("record:7") + + +def test_archive_is_idempotent_and_preserves_raw_payload_mapping() -> None: + hook = _hook() + entry = _make_entry(7, "alpha", [1.0, 0.0]) + + hook.archive(entry) + hook.archive(entry) + + assert hook.count() == 1 + records = hook.read_all() + assert [record.record_id for record in records] == ["record:7"] + assert records[0].text == "alpha" + + +def test_search_raw_returns_ranking_threshold_and_scores() -> None: + hook = _hook(distance_threshold=0.4) + hook.archive(_make_entry(1, "alpha", [1.0, 0.0])) + hook.archive(_make_entry(2, "beta", [0.0, 1.0])) + hook.archive(_make_entry(3, "edge", [0.8, 0.6])) + + hits = hook.search_raw([1.0, 0.0], k=3) + + assert [hit.record.text for hit in hits] == ["alpha", "edge"] + assert [hit.rank_hint for hit in hits] == [0, 1] + assert hits[0].similarity_score == pytest.approx(1.0) + assert hits[0].distance == pytest.approx(0.0) + assert hits[1].similarity_score == pytest.approx(0.8) + assert hits[1].distance == pytest.approx(0.2) + + +def test_search_raw_includes_threshold_edge() -> None: + hook = _hook(distance_threshold=0.2) + hook.archive(_make_entry(3, "edge", [0.8, 0.6])) + + hits = hook.search_raw([1.0, 0.0], k=1) + + assert [hit.record.text for hit in hits] == ["edge"] + assert hits[0].similarity_score == pytest.approx(0.8) + + +def test_search_raw_does_not_clamp_negative_score() -> None: + hook = _hook(distance_threshold=2.5) + hook.archive(_make_entry(4, "negative", [-1.0, 0.0])) + + hits = hook.search_raw([1.0, 0.0], k=1) + + assert hits == [ + RawRecallHit( + record=hits[0].record, + similarity_score=pytest.approx(-1.0), + distance=pytest.approx(2.0), + rank_hint=0, + source="ltm_raw", + ) + ] + assert hits[0].record.text == "negative" + + +def test_search_raw_validates_query_dimension() -> None: + hook = _hook() + + with pytest.raises(ValueError, match="query vector dimension mismatch"): + hook.search_raw([1.0, 0.0, 0.0], k=1) + + +def test_search_raw_handles_empty_collection_and_non_positive_k() -> None: + hook = _hook() + hook._client.count = lambda **kwargs: pytest.fail("count should not be called") # type: ignore[method-assign] + hook._client.query_points = lambda **kwargs: pytest.fail("query should not be called") # type: ignore[method-assign] + + assert hook.search_raw([1.0, 0.0], k=0) == [] + + hook = _hook() + assert hook.search_raw([1.0, 0.0], k=3) == [] + + +def test_archive_validates_embedding_dimension_before_upsert() -> None: + hook = QdrantLTMHook( + collection_name="bad_embedding", + vector_config=VectorConfig(vector_dim=2), + embed_text=lambda text: np.array([1.0, 0.0, 0.0], dtype=np.float32), + client=QdrantClient(":memory:"), + ) + + with pytest.raises(ValueError, match="raw record vector dimension mismatch"): + hook.archive(_make_entry(1, "alpha", [1.0, 0.0])) + + assert hook.count() == 0 + + +def test_search_raw_skips_malformed_payloads() -> None: + client = QdrantClient(":memory:") + hook = _hook(client=client) + hook.archive(_make_entry(1, "alpha", [1.0, 0.0])) + client.upsert( + collection_name="test_raw", + points=[ + models.PointStruct( + id=_raw_point_id("malformed"), + vector=[0.9, 0.1], + payload={"raw_record": {"record_id": "malformed"}}, + ) + ], + wait=True, + ) + + hits = hook.search_raw([1.0, 0.0], k=5) + + assert [hit.record.record_id for hit in hits] == ["record:1"] + + +def test_read_all_uses_pages_and_orders_records() -> None: + hook = _hook() + for entry in [ + _make_entry(300, "alpha", [1.0, 0.0]), + _make_entry(1, "beta", [0.0, 1.0]), + _make_entry(2, "edge", [0.8, 0.6]), + ]: + hook.archive(entry) + + records = hook.read_all() + + assert [record.interaction_id for record in records] == [1, 2, 300] + + +def test_count_and_clear_preserve_collection() -> None: + hook = _hook() + hook.archive(_make_entry(1, "alpha", [1.0, 0.0])) + + assert hook.count() == 1 + hook.clear() + + assert hook.count() == 0 + assert hook._client.collection_exists("test_raw") + + +def test_backend_errors_are_propagated() -> None: + hook = _hook() + + def fail_query(**kwargs: Any) -> object: + raise RuntimeError("backend failed") + + hook._client.query_points = fail_query # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="backend failed"): + hook.search_raw([1.0, 0.0], k=1) diff --git a/tests/test_verify_config.py b/tests/test_verify_config.py index 05adfb1..5c72b6f 100644 --- a/tests/test_verify_config.py +++ b/tests/test_verify_config.py @@ -55,6 +55,8 @@ def test_nlp_section(self, cfg: DMFConfig) -> None: assert cfg.nlp.vector_dim > 0 def test_ltm_chroma_connection_section(self, cfg: DMFConfig) -> None: + assert cfg.ltm.storage_type == "chroma" + assert cfg.ltm.qdrant_mode == "memory" assert cfg.ltm.chroma_mode == "embedded" assert cfg.ltm.chroma_host == "localhost" assert cfg.ltm.chroma_port == 8000 From d7a7e2ee604dff59f5c763616eefa427e0a8b968 Mon Sep 17 00:00:00 2001 From: mat Date: Sun, 12 Jul 2026 11:22:39 +0200 Subject: [PATCH 3/9] feat(memory): wire Qdrant backend --- dmf/__init__.py | 3 +- dmf/memory/__init__.py | 3 +- dmf/memory/ltm_hooks/__init__.py | 3 +- dmf/memory/ltm_hooks/factory.py | 23 ++++- tests/test_ltm_hook_factory.py | 35 +++++++ tests/test_ltm_hook_imports.py | 6 +- tests/test_ltm_persistence.py | 38 +++++++ tests/test_qdrant_optional_dependency.py | 125 +++++++++++++++++++++++ 8 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 tests/test_qdrant_optional_dependency.py diff --git a/dmf/__init__.py b/dmf/__init__.py index adf5787..273ff6c 100644 --- a/dmf/__init__.py +++ b/dmf/__init__.py @@ -23,7 +23,7 @@ """Deterministic Memory Framework package.""" from dmf.analysis import EmbeddingEngine, InteractionMatrix, NLPEngine, ScoringEngine -from dmf.memory import ChromaLTMHook, FileLTMHook, Memory, TemporalMemory +from dmf.memory import ChromaLTMHook, FileLTMHook, Memory, QdrantLTMHook, TemporalMemory from dmf.models import AnalysisReport from dmf.runtime import InteractionPipeline @@ -41,6 +41,7 @@ "InteractionPipeline", "Memory", "NLPEngine", + "QdrantLTMHook", "ScoringEngine", "TemporalMemory", ] diff --git a/dmf/memory/__init__.py b/dmf/memory/__init__.py index b79722e..d797c80 100644 --- a/dmf/memory/__init__.py +++ b/dmf/memory/__init__.py @@ -50,7 +50,7 @@ expand_card_evidence, render_evidence_context, ) -from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook +from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook, QdrantLTMHook from dmf.memory.query_understanding import QueryUnderstandingParser, parse_query_frame from dmf.memory.temporal_memory import TemporalMemory @@ -79,6 +79,7 @@ "expand_card_evidence", "render_evidence_context", "FileLTMHook", + "QdrantLTMHook", "QueryUnderstandingParser", "TemporalMemory", "parse_query_frame", diff --git a/dmf/memory/ltm_hooks/__init__.py b/dmf/memory/ltm_hooks/__init__.py index 0de22a6..a102180 100644 --- a/dmf/memory/ltm_hooks/__init__.py +++ b/dmf/memory/ltm_hooks/__init__.py @@ -24,5 +24,6 @@ from dmf.memory.ltm_hooks.chroma_hook import ChromaLTMHook from dmf.memory.ltm_hooks.file_hook import FileLTMHook +from dmf.memory.ltm_hooks.qdrant_hook import QdrantLTMHook -__all__ = ["ChromaLTMHook", "FileLTMHook"] +__all__ = ["ChromaLTMHook", "FileLTMHook", "QdrantLTMHook"] diff --git a/dmf/memory/ltm_hooks/factory.py b/dmf/memory/ltm_hooks/factory.py index 3cae22e..8e47bff 100644 --- a/dmf/memory/ltm_hooks/factory.py +++ b/dmf/memory/ltm_hooks/factory.py @@ -29,7 +29,12 @@ from dmf.models.ltm_hook import LTMHook, NullLTMHook from dmf.utils.config import VectorConfig from dmf.utils.config_loader import LTMSettings -from dmf.utils.constants import LTM_BACKEND_CHROMA, LTM_BACKEND_FILE, LTM_BACKEND_NULL +from dmf.utils.constants import ( + LTM_BACKEND_CHROMA, + LTM_BACKEND_FILE, + LTM_BACKEND_NULL, + LTM_BACKEND_QDRANT, +) def build_ltm_hook(settings: LTMSettings, vector_config: VectorConfig) -> LTMHook: @@ -87,6 +92,22 @@ def build_ltm_hook(settings: LTMSettings, vector_config: VectorConfig) -> LTMHoo connection=connection, ) + if settings.storage_type == LTM_BACKEND_QDRANT: + from dmf.memory.ltm_hooks import QdrantLTMHook + from dmf.memory.ltm_hooks.qdrant_client import ( + QdrantConnectionConfig, + QdrantConnectionMode, + ) + + mode = QdrantConnectionMode(settings.qdrant_mode) + connection = QdrantConnectionConfig(mode=mode) + return QdrantLTMHook( + collection_name=settings.collection_name, + distance_threshold=settings.distance_threshold, + vector_config=vector_config, + connection=connection, + ) + if settings.storage_type == LTM_BACKEND_NULL: return NullLTMHook() diff --git a/tests/test_ltm_hook_factory.py b/tests/test_ltm_hook_factory.py index 9bfe558..0788bb9 100644 --- a/tests/test_ltm_hook_factory.py +++ b/tests/test_ltm_hook_factory.py @@ -28,8 +28,10 @@ import pytest from dmf.memory import temporal_memory +import dmf.memory.ltm_hooks as ltm_hooks from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook from dmf.memory.ltm_hooks.chroma_client import ChromaConnectionMode +from dmf.memory.ltm_hooks.qdrant_client import QdrantConnectionMode from dmf.memory.ltm_hooks.factory import build_ltm_hook from dmf.models.ltm_hook import NullLTMHook from dmf.utils.config import VectorConfig @@ -170,6 +172,39 @@ def test_chroma_server_missing_auth_token_raises_without_secret_text( assert "Authorization" not in str(exc_info.value) +def test_qdrant_ltm_builds_hook_with_connection_and_vector_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + class FakeQdrantLTMHook: + def __init__(self, **kwargs: Any) -> None: + captured.update(kwargs) + + monkeypatch.setattr(ltm_hooks, "QdrantLTMHook", FakeQdrantLTMHook) + vector_config = VectorConfig(model_name="local-test-model", vector_dim=13) + settings = LTMSettings( + storage_type="qdrant", + qdrant_mode="memory", + collection_name="qdrant_raw", + distance_threshold=0.27, + cards_enabled=True, + cards_path="cards.jsonl", + cards_collection_name="qdrant_cards", + ) + + hook = build_ltm_hook(settings, vector_config) + + assert isinstance(hook, FakeQdrantLTMHook) + assert captured["collection_name"] == "qdrant_raw" + assert captured["distance_threshold"] == 0.27 + assert captured["vector_config"] is vector_config + assert "cards_enabled" not in captured + assert "cards_path" not in captured + assert "cards_collection_name" not in captured + assert captured["connection"].mode is QdrantConnectionMode.MEMORY + + @pytest.mark.parametrize( ("settings", "expected_type"), [ diff --git a/tests/test_ltm_hook_imports.py b/tests/test_ltm_hook_imports.py index f71ca45..a805d48 100644 --- a/tests/test_ltm_hook_imports.py +++ b/tests/test_ltm_hook_imports.py @@ -32,18 +32,22 @@ import dmf.memory import pytest -from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook +from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook, QdrantLTMHook from dmf.memory.ltm_hooks.chroma_hook import ChromaLTMHook as CanonicalChromaLTMHook from dmf.memory.ltm_hooks.file_hook import FileLTMHook as CanonicalFileLTMHook +from dmf.memory.ltm_hooks.qdrant_hook import QdrantLTMHook as CanonicalQdrantLTMHook def test_canonical_reexports_preserve_class_identity() -> None: assert ChromaLTMHook is CanonicalChromaLTMHook assert FileLTMHook is CanonicalFileLTMHook + assert QdrantLTMHook is CanonicalQdrantLTMHook assert dmf.memory.ChromaLTMHook is CanonicalChromaLTMHook assert dmf.memory.FileLTMHook is CanonicalFileLTMHook + assert dmf.memory.QdrantLTMHook is CanonicalQdrantLTMHook assert dmf.ChromaLTMHook is CanonicalChromaLTMHook assert dmf.FileLTMHook is CanonicalFileLTMHook + assert dmf.QdrantLTMHook is CanonicalQdrantLTMHook @pytest.mark.parametrize( diff --git a/tests/test_ltm_persistence.py b/tests/test_ltm_persistence.py index c62f284..11d2e82 100644 --- a/tests/test_ltm_persistence.py +++ b/tests/test_ltm_persistence.py @@ -98,6 +98,7 @@ import numpy as np import pytest +import dmf.memory.ltm_hooks as ltm_hooks from dmf.memory.ltm_hooks import FileLTMHook from dmf.models.analysis import ( AnalysisReport, @@ -604,6 +605,43 @@ def test_null_hook_when_storage_type_is_explicit_null(self, tmp_path: Path) -> N tm = TemporalMemory.from_dmf_config(cfg) assert isinstance(tm._ltm_hook, NullLTMHook) + def test_qdrant_hook_created_when_storage_type_is_qdrant( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + captured: dict[str, object] = {} + + class FakeQdrantLTMHook: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(ltm_hooks, "QdrantLTMHook", FakeQdrantLTMHook) + cfg = self._dmf_cfg(tmp_path, enabled=True, storage_type="qdrant") + + tm = TemporalMemory.from_dmf_config(cfg) + + assert isinstance(tm._ltm_hook, FakeQdrantLTMHook) + assert captured["collection_name"] == cfg.ltm.collection_name + assert captured["distance_threshold"] == cfg.ltm.distance_threshold + + def test_explicit_hook_overrides_qdrant_config( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setattr( + ltm_hooks, + "QdrantLTMHook", + lambda **kwargs: pytest.fail("unexpected Qdrant construction"), + ) + cfg = self._dmf_cfg(tmp_path, enabled=True, storage_type="qdrant") + explicit = NullLTMHook() + + tm = TemporalMemory.from_dmf_config(cfg, ltm_hook=explicit) + + assert tm._ltm_hook is explicit + # =========================================================================== # Integration — eviction → JSONL archive diff --git a/tests/test_qdrant_optional_dependency.py b/tests/test_qdrant_optional_dependency.py new file mode 100644 index 0000000..f283b4a --- /dev/null +++ b/tests/test_qdrant_optional_dependency.py @@ -0,0 +1,125 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import builtins +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from dmf.memory.ltm_hooks.qdrant_client import ( + QdrantConnectionConfig, + build_qdrant_client, +) + + +def test_imports_remain_safe_without_qdrant_extra() -> None: + repo_root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + env["PYTHONPATH"] = ( + str(repo_root) + if not env.get("PYTHONPATH") + else f"{repo_root}{os.pathsep}{env['PYTHONPATH']}" + ) + code = textwrap.dedent( + """ + import importlib.abc + import sys + + class BlockQdrant(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "qdrant_client" or fullname.startswith("qdrant_client."): + raise ModuleNotFoundError( + "No module named 'qdrant_client'", + name="qdrant_client", + ) + return None + + sys.meta_path.insert(0, BlockQdrant()) + + import dmf + import dmf.memory + from dmf.memory import ChromaLTMHook, FileLTMHook, QdrantLTMHook + from dmf.memory.ltm_hooks import QdrantLTMHook as HookExport + from dmf.memory.ltm_hooks.qdrant_client import ( + QdrantConnectionConfig, + build_qdrant_client, + ) + + assert dmf.memory.ChromaLTMHook is ChromaLTMHook + assert dmf.memory.FileLTMHook is FileLTMHook + assert dmf.QdrantLTMHook is QdrantLTMHook + assert QdrantLTMHook is HookExport + + try: + build_qdrant_client(QdrantConnectionConfig()) + except ModuleNotFoundError as exc: + assert "Install the Qdrant backend" in str(exc) + assert "dmf-memory[qdrant]" in str(exc) + else: + raise AssertionError("expected missing qdrant extra") + """ + ) + + result = subprocess.run( + [sys.executable, "-c", code], + cwd=repo_root, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_qdrant_client_factory_does_not_mask_internal_import_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import = builtins.__import__ + + def fake_import( + name: str, + globals: object | None = None, + locals: object | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + if name == "qdrant_client": + raise ModuleNotFoundError( + "No module named 'qdrant_client_internal'", + name="qdrant_client_internal", + ) + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ModuleNotFoundError) as exc_info: + build_qdrant_client(QdrantConnectionConfig()) + + assert exc_info.value.name == "qdrant_client_internal" + assert "Install the Qdrant backend" not in str(exc_info.value) From 89fca6ff76ebae17606080318f13e1b3bed14591 Mon Sep 17 00:00:00 2001 From: mat Date: Mon, 13 Jul 2026 06:45:23 +0200 Subject: [PATCH 4/9] feat(memory): add Qdrant card support --- dmf/memory/ltm_hooks/factory.py | 3 + dmf/memory/ltm_hooks/qdrant_hook.py | 159 +++++++++++++++++++++-- tests/test_ltm_hook_factory.py | 6 +- tests/test_qdrant_ltm.py | 190 +++++++++++++++++++++++++++- 4 files changed, 344 insertions(+), 14 deletions(-) diff --git a/dmf/memory/ltm_hooks/factory.py b/dmf/memory/ltm_hooks/factory.py index 8e47bff..713050c 100644 --- a/dmf/memory/ltm_hooks/factory.py +++ b/dmf/memory/ltm_hooks/factory.py @@ -105,6 +105,9 @@ def build_ltm_hook(settings: LTMSettings, vector_config: VectorConfig) -> LTMHoo collection_name=settings.collection_name, distance_threshold=settings.distance_threshold, vector_config=vector_config, + cards_enabled=settings.cards_enabled, + cards_path=settings.cards_path, + cards_collection_name=settings.cards_collection_name, connection=connection, ) diff --git a/dmf/memory/ltm_hooks/qdrant_hook.py b/dmf/memory/ltm_hooks/qdrant_hook.py index 9fc7c06..28f13b9 100644 --- a/dmf/memory/ltm_hooks/qdrant_hook.py +++ b/dmf/memory/ltm_hooks/qdrant_hook.py @@ -28,9 +28,16 @@ import threading import uuid from collections.abc import Mapping, Sequence +from pathlib import Path from typing import TYPE_CHECKING, Callable -from dmf.memory.ltm_hooks.codecs import build_raw_payload, raw_record_from_payload +from dmf.memory.card_projection import MemoryCardProjector +from dmf.memory.card_store import JsonlMemoryCardStore +from dmf.memory.ltm_hooks.codecs import ( + build_card_payload, + build_raw_payload, + raw_record_from_payload, +) from dmf.memory.ltm_hooks.qdrant_client import ( QdrantConnectionConfig, build_qdrant_client, @@ -44,6 +51,8 @@ from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit from dmf.utils.config import VectorConfig from dmf.utils.constants import ( + DEFAULT_LTM_CARDS_COLLECTION_NAME, + DEFAULT_LTM_CARDS_PATH, DEFAULT_LTM_COLLECTION_NAME, DEFAULT_LTM_DISTANCE_THRESHOLD, ) @@ -75,15 +84,27 @@ def __init__( distance_threshold: float = DEFAULT_LTM_DISTANCE_THRESHOLD, vector_config: VectorConfig | None = None, embed_text: Callable[[str], np.ndarray] | None = None, + cards_enabled: bool = False, + cards_path: Path | str | None = None, + card_store: JsonlMemoryCardStore | None = None, + cards_collection_name: str = DEFAULT_LTM_CARDS_COLLECTION_NAME, connection: QdrantConnectionConfig | None = None, client: object | None = None, ) -> None: + if cards_enabled and cards_collection_name == collection_name: + raise ValueError("Qdrant raw and card collections must use distinct names") + self._collection_name = collection_name + self._cards_collection_name = cards_collection_name self._distance_threshold = distance_threshold self._lock = threading.Lock() self._vector_config = vector_config or VectorConfig() self._embed_text = embed_text self._embedding_engine = None + self._cards_enabled = cards_enabled + self._card_store = card_store + if self._card_store is None and cards_enabled: + self._card_store = JsonlMemoryCardStore(cards_path or DEFAULT_LTM_CARDS_PATH) if self._vector_config.vector_dim <= 0: raise ValueError( @@ -94,7 +115,10 @@ def __init__( self._client = client if client is not None else build_qdrant_client( connection or QdrantConnectionConfig() ) - self._ensure_collection() + self._ensure_collection(self._collection_name) + if cards_enabled: + self._ensure_collection(self._cards_collection_name) + self._card_projector = MemoryCardProjector() def archive(self, entry: MemoryEntry) -> None: """Index one evicted raw interaction record into Qdrant.""" @@ -111,6 +135,22 @@ def archive(self, entry: MemoryEntry) -> None: vector=vector, payload=build_raw_payload(raw_record), ) + card_points = [] + if self._cards_enabled: + source_vector = entry.vector.tolist() + validate_vector_dimension( + source_vector, + self._vector_config.vector_dim, + field="card source", + ) + for card in self._card_projector.project(entry): + card_points.append( + models.PointStruct( + id=_card_point_id(card.card_id), + vector=source_vector, + payload=build_card_payload(card), + ) + ) with self._lock: self._client.upsert( @@ -118,6 +158,14 @@ def archive(self, entry: MemoryEntry) -> None: points=[point], wait=True, ) + if card_points: + self._client.upsert( + collection_name=self._cards_collection_name, + points=card_points, + wait=True, + ) + if self._card_store is not None: + self._card_store.archive(entry) def search_raw( self, @@ -164,6 +212,92 @@ def search_raw( ) return hits + def search_cards( + self, + query_vector: list[float], + k: int = 5, + ) -> list[RawRecallHit]: + """Retrieve source raw records for the top-k matching projected cards.""" + if not self._cards_enabled: + return [] + if k <= 0: + return [] + + validate_vector_dimension( + query_vector, + self._vector_config.vector_dim, + field="query", + ) + response = self._client.query_points( + collection_name=self._cards_collection_name, + query=query_vector, + limit=k, + with_payload=True, + with_vectors=False, + score_threshold=distance_threshold_to_min_similarity( + self._distance_threshold + ), + ) + + valid_candidates: list[tuple[int, str, float]] = [] + for idx, point in enumerate(response.points): + payload = point.payload + if not isinstance(payload, Mapping): + continue + source_record_id = payload.get("source_record_id") + if not isinstance(source_record_id, str) or not source_record_id: + continue + valid_candidates.append((idx, source_record_id, float(point.score))) + + if not valid_candidates: + return [] + + source_ids = list( + dict.fromkeys(source_id for _, source_id, _ in valid_candidates) + ) + raw_points = self._client.retrieve( + collection_name=self._collection_name, + ids=[_raw_point_id(source_id) for source_id in source_ids], + with_payload=True, + with_vectors=False, + ) + records_by_id: dict[str, RawLTMRecord] = {} + for point in raw_points: + payload = point.payload + if not isinstance(payload, Mapping): + continue + try: + record = raw_record_from_payload(payload) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + records_by_id[record.record_id] = record + + hits: list[RawRecallHit] = [] + for idx, source_id, score in valid_candidates: + record = records_by_id.get(source_id) + if record is None: + continue + hits.append( + RawRecallHit( + record=record, + similarity_score=score, + distance=cosine_similarity_to_distance(score), + rank_hint=idx, + ) + ) + return hits + + def count_cards(self) -> int: + """Return the number of indexed card records, or zero when disabled.""" + if not self._cards_enabled: + return 0 + return int( + self._client.count( + collection_name=self._cards_collection_name, + exact=True, + ).count + ) + def read_all(self) -> list[RawLTMRecord]: """Return all archived raw records ordered by source identity.""" records: list[RawLTMRecord] = [] @@ -209,21 +343,26 @@ def clear(self) -> None: wait=True, ) - def _ensure_collection(self) -> None: + @property + def card_store(self) -> JsonlMemoryCardStore | None: + """Auxiliary JSONL card audit store, when configured.""" + return self._card_store + + def _ensure_collection(self, collection_name: str) -> None: models = _qdrant_models() - if not self._client.collection_exists(self._collection_name): + if not self._client.collection_exists(collection_name): self._client.create_collection( - collection_name=self._collection_name, + collection_name=collection_name, vectors_config=models.VectorParams( size=self._vector_config.vector_dim, distance=models.Distance.COSINE, ), ) - self._validate_collection() + self._validate_collection(collection_name) - def _validate_collection(self) -> None: + def _validate_collection(self, collection_name: str) -> None: models = _qdrant_models() - collection = self._client.get_collection(self._collection_name) + collection = self._client.get_collection(collection_name) vectors = collection.config.params.vectors expected = ( f"single vector size={self._vector_config.vector_dim}, " @@ -232,7 +371,7 @@ def _validate_collection(self) -> None: if isinstance(vectors, Mapping): raise ValueError( - f"Qdrant collection {self._collection_name!r} is incompatible: " + f"Qdrant collection {collection_name!r} is incompatible: " f"expected {expected}, observed named vectors {vectors!r}" ) @@ -244,7 +383,7 @@ def _validate_collection(self) -> None: ): observed = f"size={observed_size}, distance={observed_distance}" raise ValueError( - f"Qdrant collection {self._collection_name!r} is incompatible: " + f"Qdrant collection {collection_name!r} is incompatible: " f"expected {expected}, observed {observed}" ) diff --git a/tests/test_ltm_hook_factory.py b/tests/test_ltm_hook_factory.py index 0788bb9..d4dc706 100644 --- a/tests/test_ltm_hook_factory.py +++ b/tests/test_ltm_hook_factory.py @@ -199,9 +199,9 @@ def __init__(self, **kwargs: Any) -> None: assert captured["collection_name"] == "qdrant_raw" assert captured["distance_threshold"] == 0.27 assert captured["vector_config"] is vector_config - assert "cards_enabled" not in captured - assert "cards_path" not in captured - assert "cards_collection_name" not in captured + assert captured["cards_enabled"] is True + assert captured["cards_path"] == "cards.jsonl" + assert captured["cards_collection_name"] == "qdrant_cards" assert captured["connection"].mode is QdrantConnectionMode.MEMORY diff --git a/tests/test_qdrant_ltm.py b/tests/test_qdrant_ltm.py index 735bced..d478b09 100644 --- a/tests/test_qdrant_ltm.py +++ b/tests/test_qdrant_ltm.py @@ -22,18 +22,23 @@ from __future__ import annotations +from dataclasses import replace +from pathlib import Path from typing import Any import numpy as np import pytest from qdrant_client import QdrantClient, models +from dmf.memory.card_projection import MemoryCardProjector +from dmf.memory.card_store import JsonlMemoryCardStore +from dmf.memory.ltm_hooks.codecs import build_card_payload from dmf.memory.ltm_hooks.qdrant_hook import ( QdrantLTMHook, _card_point_id, _raw_point_id, ) -from dmf.models.analysis import AnalysisReport +from dmf.models.analysis import AnalysisReport, InteractionSignals from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawRecallHit from dmf.models.status import SurvivalStatus @@ -45,6 +50,10 @@ def _make_entry( text: str, vector: list[float], timestamp: float | None = None, + *, + topic_identity: str | None = None, + topic_value: str | None = None, + signals: InteractionSignals | None = None, ) -> MemoryEntry: report = AnalysisReport( info_density=0.7, @@ -54,6 +63,9 @@ def _make_entry( latency_ms=1.0, survival_score=0.85, status=SurvivalStatus.HEALTHY, + signals=signals or InteractionSignals(), + topic_identity=topic_identity, + topic_value=topic_value, ) return MemoryEntry( interaction_id=interaction_id, @@ -68,14 +80,22 @@ def _make_entry( def _hook( *, collection_name: str = "test_raw", + cards_collection_name: str = "test_cards", distance_threshold: float = 1.0, client: QdrantClient | None = None, + cards_enabled: bool = False, + cards_path: Path | str | None = None, + card_store: JsonlMemoryCardStore | None = None, ) -> QdrantLTMHook: return QdrantLTMHook( collection_name=collection_name, distance_threshold=distance_threshold, vector_config=VectorConfig(vector_dim=2), embed_text=lambda text: np.array(_EMBEDDINGS[text], dtype=np.float32), + cards_enabled=cards_enabled, + cards_path=cards_path, + card_store=card_store, + cards_collection_name=cards_collection_name, client=client or QdrantClient(":memory:"), ) @@ -88,6 +108,23 @@ def _hook( } +def _make_card_entry( + interaction_id: int, + text: str, + vector: list[float], + timestamp: float | None = None, +) -> MemoryEntry: + return _make_entry( + interaction_id, + text, + vector, + timestamp, + topic_identity="preference|prefer", + topic_value=text, + signals=InteractionSignals(is_preference=True), + ) + + def test_constructor_creates_collection_with_cosine_vector_config() -> None: client = QdrantClient(":memory:") @@ -307,3 +344,154 @@ def fail_query(**kwargs: Any) -> object: with pytest.raises(RuntimeError, match="backend failed"): hook.search_raw([1.0, 0.0], k=1) + + +def test_cards_disabled_does_not_create_collection_or_return_card_hits() -> None: + client = QdrantClient(":memory:") + hook = _hook(client=client, cards_enabled=False) + + hook.archive(_make_card_entry(10, "alpha", [1.0, 0.0])) + + assert hook.count() == 1 + assert hook.count_cards() == 0 + assert not client.collection_exists("test_cards") + assert hook.search_cards([1.0, 0.0], k=3) == [] + + +def test_cards_require_distinct_collection_name() -> None: + with pytest.raises(ValueError, match="distinct names"): + _hook( + collection_name="same", + cards_collection_name="same", + cards_enabled=True, + ) + + +def test_archive_with_cards_writes_payload_vector_store_and_batch( + tmp_path: Path, +) -> None: + client = QdrantClient(":memory:") + cards_path = tmp_path / "cards.jsonl" + hook = _hook(client=client, cards_enabled=True, cards_path=cards_path) + entry = _make_card_entry(10, "alpha", [1.0, 0.0]) + calls: list[tuple[str, int]] = [] + real_upsert = client.upsert + + def record_upsert(**kwargs: Any) -> object: + calls.append((kwargs["collection_name"], len(kwargs["points"]))) + return real_upsert(**kwargs) + + hook._client.upsert = record_upsert # type: ignore[method-assign] + + hook.archive(entry) + + assert calls == [("test_raw", 1), ("test_cards", 1)] + assert hook.count() == 1 + assert hook.count_cards() == 1 + assert hook.card_store is not None + assert hook.card_store.path == cards_path + assert len(cards_path.read_text(encoding="utf-8").splitlines()) == 1 + + points, _ = client.scroll( + collection_name="test_cards", + limit=10, + with_payload=True, + with_vectors=True, + ) + assert len(points) == 1 + card_payload = points[0].payload + assert card_payload["record_type"] == "card" + assert card_payload["source_record_id"] == "record:10" + assert card_payload["kind"] == "preference" + assert card_payload["card"] == build_card_payload( + MemoryCardProjector().project(entry)[0] + )["card"] + assert points[0].vector == pytest.approx([1.0, 0.0]) + + +def test_archive_with_no_projected_cards_writes_only_raw() -> None: + hook = _hook(cards_enabled=True) + + hook.archive(_make_entry(11, "alpha", [1.0, 0.0])) + + assert hook.count() == 1 + assert hook.count_cards() == 0 + + +def test_search_cards_returns_ranked_source_records() -> None: + hook = _hook(cards_enabled=True, distance_threshold=0.4) + hook.archive(_make_card_entry(10, "alpha", [1.0, 0.0])) + hook.archive(_make_card_entry(20, "beta", [0.0, 1.0])) + + hits = hook.search_cards([1.0, 0.0], k=2) + + assert [hit.record.record_id for hit in hits] == ["record:10"] + assert hits[0].similarity_score == pytest.approx(1.0) + assert hits[0].distance == pytest.approx(0.0) + assert hits[0].rank_hint == 0 + + +def test_search_cards_deduplicates_source_lookup_but_preserves_duplicate_hits() -> None: + client = QdrantClient(":memory:") + hook = _hook(client=client, cards_enabled=True, distance_threshold=0.4) + entry = _make_card_entry(10, "alpha", [1.0, 0.0]) + hook.archive(entry) + card = MemoryCardProjector().project(entry)[0] + duplicate = replace(card, card_id="card:record:10:duplicate") + client.upsert( + collection_name="test_cards", + points=[ + models.PointStruct( + id=_card_point_id(duplicate.card_id), + vector=[0.9, 0.1], + payload=build_card_payload(duplicate), + ) + ], + wait=True, + ) + retrieve_ids: list[list[str]] = [] + real_retrieve = client.retrieve + + def record_retrieve(**kwargs: Any) -> object: + retrieve_ids.append(list(kwargs["ids"])) + return real_retrieve(**kwargs) + + hook._client.retrieve = record_retrieve # type: ignore[method-assign] + + hits = hook.search_cards([1.0, 0.0], k=2) + + assert retrieve_ids == [[_raw_point_id("record:10")]] + assert [hit.record.record_id for hit in hits] == ["record:10", "record:10"] + assert [hit.rank_hint for hit in hits] == [0, 1] + + +def test_search_cards_skips_malformed_payloads_and_orphan_sources() -> None: + client = QdrantClient(":memory:") + hook = _hook(client=client, cards_enabled=True) + hook.archive(_make_card_entry(10, "alpha", [1.0, 0.0])) + client.upsert( + collection_name="test_cards", + points=[ + models.PointStruct( + id=_card_point_id("missing_source"), + vector=[1.0, 0.0], + payload={"record_type": "card"}, + ), + models.PointStruct( + id=_card_point_id("orphan"), + vector=[1.0, 0.0], + payload={"source_record_id": "record:404"}, + ), + ], + wait=True, + ) + + hits = hook.search_cards([1.0, 0.0], k=5) + + assert [hit.record.record_id for hit in hits] == ["record:10"] + + hook.clear() + + assert hook.count() == 0 + assert hook.count_cards() == 3 + assert hook.search_cards([1.0, 0.0], k=5) == [] From 8364fea8fe3ffecbd8b6455260a70bf571381d20 Mon Sep 17 00:00:00 2001 From: mat Date: Mon, 13 Jul 2026 07:02:39 +0200 Subject: [PATCH 5/9] feat(memory): add backend-neutral recall filters --- dmf/memory/ltm_hooks/chroma_filters.py | 75 ++++++++++++ dmf/memory/ltm_hooks/chroma_hook.py | 37 ++++-- dmf/memory/ltm_hooks/file_hook.py | 10 +- dmf/memory/ltm_hooks/qdrant_filters.py | 112 ++++++++++++++++++ dmf/memory/ltm_hooks/qdrant_hook.py | 18 ++- dmf/memory/temporal_memory.py | 17 ++- dmf/models/__init__.py | 2 + dmf/models/ltm_hook.py | 22 +++- dmf/models/recall_filter.py | 128 +++++++++++++++++++++ tests/test_chroma_ltm.py | 68 ++++++++++- tests/test_qdrant_filters.py | 153 +++++++++++++++++++++++++ tests/test_qdrant_ltm.py | 45 ++++++++ tests/test_temporal_memory.py | 43 +++++++ 13 files changed, 711 insertions(+), 19 deletions(-) create mode 100644 dmf/memory/ltm_hooks/chroma_filters.py create mode 100644 dmf/memory/ltm_hooks/qdrant_filters.py create mode 100644 dmf/models/recall_filter.py create mode 100644 tests/test_qdrant_filters.py diff --git a/dmf/memory/ltm_hooks/chroma_filters.py b/dmf/memory/ltm_hooks/chroma_filters.py new file mode 100644 index 0000000..9b6ef38 --- /dev/null +++ b/dmf/memory/ltm_hooks/chroma_filters.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Translate backend-neutral recall filters to Chroma metadata predicates.""" + +from __future__ import annotations + +from typing import Any, Literal + +from dmf.models.recall_filter import RecallFilter + +FilterTarget = Literal["raw", "card"] + + +def build_chroma_where( + recall_filter: RecallFilter | None, + *, + target: FilterTarget, +) -> dict[str, Any] | None: + """Return a Chroma ``where`` mapping for the target collection, or ``None``.""" + if recall_filter is None or recall_filter.is_empty: + return None + + conditions: list[dict[str, Any]] = [] + record_field = "record_id" if target == "raw" else "source_record_id" + if recall_filter.record_ids: + conditions.append({record_field: {"$in": list(recall_filter.record_ids)}}) + if recall_filter.excluded_record_ids: + conditions.append( + {record_field: {"$nin": list(recall_filter.excluded_record_ids)}} + ) + if recall_filter.roles: + conditions.append({"raw_role": {"$in": list(recall_filter.roles)}}) + if recall_filter.interaction_id_min is not None: + conditions.append( + {"raw_interaction_id": {"$gte": recall_filter.interaction_id_min}} + ) + if recall_filter.interaction_id_max is not None: + conditions.append( + {"raw_interaction_id": {"$lte": recall_filter.interaction_id_max}} + ) + if recall_filter.created_at_min is not None: + conditions.append({"raw_created_at": {"$gte": recall_filter.created_at_min}}) + if recall_filter.created_at_max is not None: + conditions.append({"raw_created_at": {"$lte": recall_filter.created_at_max}}) + if recall_filter.card_kinds: + conditions.append({"kind": {"$in": list(recall_filter.card_kinds)}}) + + if not conditions: + return None + if len(conditions) == 1: + return conditions[0] + return {"$and": conditions} + + +__all__ = ["build_chroma_where"] diff --git a/dmf/memory/ltm_hooks/chroma_hook.py b/dmf/memory/ltm_hooks/chroma_hook.py index 7531103..9c1b3d6 100644 --- a/dmf/memory/ltm_hooks/chroma_hook.py +++ b/dmf/memory/ltm_hooks/chroma_hook.py @@ -43,6 +43,7 @@ ChromaConnectionConfig, build_chroma_client, ) +from dmf.memory.ltm_hooks.chroma_filters import build_chroma_where from dmf.memory.ltm_hooks.codecs import ( build_card_payload, build_raw_payload, @@ -51,6 +52,7 @@ from dmf.memory.ltm_hooks.vector_types import cosine_distance_to_similarity from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.utils.config import VectorConfig from dmf.utils.constants import ( DEFAULT_LTM_CARDS_COLLECTION_NAME, @@ -184,6 +186,9 @@ def archive(self, entry: MemoryEntry) -> None: "card_id": card.card_id, "source_record_id": card.provenance.source_record_id, "kind": card.kind, + "raw_role": raw_record.role, + "raw_interaction_id": raw_record.interaction_id, + "raw_created_at": raw_record.created_at, } self._cards_collection.upsert( ids=[card.card_id], @@ -198,6 +203,8 @@ def search_raw( self, query_vector: list[float], k: int = 5, + *, + recall_filter: RecallFilter | None = None, ) -> list[RawRecallHit]: """Retrieve the top-k most relevant raw records by vector similarity. @@ -215,11 +222,15 @@ def search_raw( if k <= 0: return [] - results = self._collection.query( - query_embeddings=[query_vector], - n_results=k, - include=["metadatas", "distances"], - ) + query_kwargs = { + "query_embeddings": [query_vector], + "n_results": k, + "include": ["metadatas", "distances"], + } + where = build_chroma_where(recall_filter, target="raw") + if where is not None: + query_kwargs["where"] = where + results = self._collection.query(**query_kwargs) metadatas: list[dict[str, object]] = results["metadatas"][0] distances: list[float] = results["distances"][0] @@ -246,6 +257,8 @@ def search_cards( self, query_vector: list[float], k: int = 5, + *, + recall_filter: RecallFilter | None = None, ) -> list[RawRecallHit]: """Retrieve the top-k most relevant card hits by vector similarity. @@ -269,11 +282,15 @@ def search_cards( if k <= 0: return [] - results = self._cards_collection.query( - query_embeddings=[query_vector], - n_results=k, - include=["metadatas", "distances"], - ) + query_kwargs = { + "query_embeddings": [query_vector], + "n_results": k, + "include": ["metadatas", "distances"], + } + where = build_chroma_where(recall_filter, target="card") + if where is not None: + query_kwargs["where"] = where + results = self._cards_collection.query(**query_kwargs) metadatas: list[dict[str, object]] = results["metadatas"][0] distances: list[float] = results["distances"][0] diff --git a/dmf/memory/ltm_hooks/file_hook.py b/dmf/memory/ltm_hooks/file_hook.py index 8f3259c..4f65133 100644 --- a/dmf/memory/ltm_hooks/file_hook.py +++ b/dmf/memory/ltm_hooks/file_hook.py @@ -36,6 +36,7 @@ from dmf.memory.card_store import JsonlMemoryCardStore from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.utils.constants import DEFAULT_LTM_RECALL_LIMIT, DEFAULT_TEXT_ENCODING @@ -97,12 +98,19 @@ def archive(self, entry: MemoryEntry) -> None: if self._card_store is not None: self._card_store.archive(entry) - def search_raw(self, query_vector: list[float], k: int = DEFAULT_LTM_RECALL_LIMIT) -> list[RawRecallHit]: + def search_raw( + self, + query_vector: list[float], + k: int = DEFAULT_LTM_RECALL_LIMIT, + *, + recall_filter: RecallFilter | None = None, + ) -> list[RawRecallHit]: """Return no raw search hits for this archival-only backend. Args: query_vector: Ignored query embedding. k: Ignored hit limit. + recall_filter: Ignored metadata filter. Returns: Empty list. diff --git a/dmf/memory/ltm_hooks/qdrant_filters.py b/dmf/memory/ltm_hooks/qdrant_filters.py new file mode 100644 index 0000000..c60db88 --- /dev/null +++ b/dmf/memory/ltm_hooks/qdrant_filters.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Translate backend-neutral recall filters to Qdrant predicates.""" + +from __future__ import annotations + +from typing import Literal + +from dmf.models.recall_filter import RecallFilter + +FilterTarget = Literal["raw", "card"] + + +def build_qdrant_filter( + recall_filter: RecallFilter | None, + *, + target: FilterTarget, +) -> object | None: + """Return a Qdrant Filter for the target collection, or ``None``.""" + if recall_filter is None or recall_filter.is_empty: + return None + + models = _qdrant_models() + must = [] + must_not = [] + + record_field = "record_id" if target == "raw" else "source_record_id" + role_field = "role" if target == "raw" else "raw_role" + interaction_field = "interaction_id" if target == "raw" else "raw_interaction_id" + created_at_field = "created_at" if target == "raw" else "raw_created_at" + if recall_filter.record_ids: + must.append(_match_any(models, record_field, recall_filter.record_ids)) + if recall_filter.excluded_record_ids: + must_not.append( + _match_any(models, record_field, recall_filter.excluded_record_ids) + ) + if recall_filter.roles: + must.append(_match_any(models, role_field, recall_filter.roles)) + if ( + recall_filter.interaction_id_min is not None + or recall_filter.interaction_id_max is not None + ): + must.append( + _range( + models, + interaction_field, + gte=recall_filter.interaction_id_min, + lte=recall_filter.interaction_id_max, + ) + ) + if recall_filter.created_at_min is not None or recall_filter.created_at_max is not None: + must.append( + _range( + models, + created_at_field, + gte=recall_filter.created_at_min, + lte=recall_filter.created_at_max, + ) + ) + if recall_filter.card_kinds: + must.append(_match_any(models, "kind", recall_filter.card_kinds)) + + return models.Filter(must=must or None, must_not=must_not or None) + + +def _match_any(models: object, key: str, values: tuple[str, ...]) -> object: + return models.FieldCondition( + key=key, + match=models.MatchAny(any=list(values)), + ) + + +def _range( + models: object, + key: str, + *, + gte: int | float | None, + lte: int | float | None, +) -> object: + return models.FieldCondition( + key=key, + range=models.Range(gte=gte, lte=lte), + ) + + +def _qdrant_models() -> object: + from qdrant_client import models # noqa: PLC0415 + + return models + + +__all__ = ["build_qdrant_filter"] diff --git a/dmf/memory/ltm_hooks/qdrant_hook.py b/dmf/memory/ltm_hooks/qdrant_hook.py index 28f13b9..a7a4856 100644 --- a/dmf/memory/ltm_hooks/qdrant_hook.py +++ b/dmf/memory/ltm_hooks/qdrant_hook.py @@ -42,6 +42,7 @@ QdrantConnectionConfig, build_qdrant_client, ) +from dmf.memory.ltm_hooks.qdrant_filters import build_qdrant_filter from dmf.memory.ltm_hooks.vector_types import ( cosine_similarity_to_distance, distance_threshold_to_min_similarity, @@ -49,6 +50,7 @@ ) from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.utils.config import VectorConfig from dmf.utils.constants import ( DEFAULT_LTM_CARDS_COLLECTION_NAME, @@ -144,11 +146,19 @@ def archive(self, entry: MemoryEntry) -> None: field="card source", ) for card in self._card_projector.project(entry): + card_payload = build_card_payload(card) + card_payload.update( + { + "raw_role": raw_record.role, + "raw_interaction_id": raw_record.interaction_id, + "raw_created_at": raw_record.created_at, + } + ) card_points.append( models.PointStruct( id=_card_point_id(card.card_id), vector=source_vector, - payload=build_card_payload(card), + payload=card_payload, ) ) @@ -171,6 +181,8 @@ def search_raw( self, query_vector: list[float], k: int = 5, + *, + recall_filter: RecallFilter | None = None, ) -> list[RawRecallHit]: """Retrieve top-k raw records by Qdrant cosine similarity.""" if k <= 0: @@ -187,6 +199,7 @@ def search_raw( limit=k, with_payload=True, with_vectors=False, + query_filter=build_qdrant_filter(recall_filter, target="raw"), score_threshold=distance_threshold_to_min_similarity( self._distance_threshold ), @@ -216,6 +229,8 @@ def search_cards( self, query_vector: list[float], k: int = 5, + *, + recall_filter: RecallFilter | None = None, ) -> list[RawRecallHit]: """Retrieve source raw records for the top-k matching projected cards.""" if not self._cards_enabled: @@ -234,6 +249,7 @@ def search_cards( limit=k, with_payload=True, with_vectors=False, + query_filter=build_qdrant_filter(recall_filter, target="card"), score_threshold=distance_threshold_to_min_similarity( self._distance_threshold ), diff --git a/dmf/memory/temporal_memory.py b/dmf/memory/temporal_memory.py index 432e1cb..a1fdea4 100644 --- a/dmf/memory/temporal_memory.py +++ b/dmf/memory/temporal_memory.py @@ -117,6 +117,7 @@ from dmf.models.ltm_hook import LTMHook, NullLTMHook from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import ContextualizedRecallCandidate, RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.models.status import classify_survival_status from dmf.utils.config import DecayConfig, PruningPriorityConfig, VectorConfig @@ -756,6 +757,7 @@ def get_raw_recall_hits( k: int | None = None, *, active_guard: _ActiveContextGuard | None = None, + recall_filter: RecallFilter | None = None, ) -> list[RawRecallHit]: """Fetch raw recall hits from the configured LTM hook. @@ -763,6 +765,7 @@ def get_raw_recall_hits( query_vector: See the function signature and surrounding type hints. k: See the function signature and surrounding type hints. active_guard: See the function signature and surrounding type hints. + recall_filter: Optional backend-neutral metadata filter. Returns: See the return type annotation. @@ -770,10 +773,16 @@ def get_raw_recall_hits( Raises: None. """ - raw_hits = self._ltm_hook.search_raw( - query_vector.tolist(), - k=k if k is not None else self.config.ltm_recall_limit, - ) + search_k = k if k is not None else self.config.ltm_recall_limit + query_payload = query_vector.tolist() + if recall_filter is None: + raw_hits = self._ltm_hook.search_raw(query_payload, k=search_k) + else: + raw_hits = self._ltm_hook.search_raw( + query_payload, + k=search_k, + recall_filter=recall_filter, + ) hits = self._validate_raw_recall_hits(raw_hits) self._recall_diagnostics["raw_candidates"] = [ self._serialise_raw_recall_hit(hit) diff --git a/dmf/models/__init__.py b/dmf/models/__init__.py index 2b07bb3..ce606aa 100644 --- a/dmf/models/__init__.py +++ b/dmf/models/__init__.py @@ -34,6 +34,7 @@ RetrievedEvidence, ) from dmf.models.raw_ltm import ContextualizedRecallCandidate, RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.models.status import SurvivalStatus, classify_survival_status __all__ = [ @@ -54,6 +55,7 @@ "ContextualizedRecallCandidate", "RawLTMRecord", "RawRecallHit", + "RecallFilter", "SurvivalStatus", "classify_survival_status", ] diff --git a/dmf/models/ltm_hook.py b/dmf/models/ltm_hook.py index e330df0..3251244 100644 --- a/dmf/models/ltm_hook.py +++ b/dmf/models/ltm_hook.py @@ -35,6 +35,7 @@ from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter @runtime_checkable @@ -69,13 +70,20 @@ def archive(self, entry: MemoryEntry) -> None: """ ... - def search_raw(self, query_vector: list[float], k: int = 5) -> list[RawRecallHit]: + def search_raw( + self, + query_vector: list[float], + k: int = 5, + *, + recall_filter: RecallFilter | None = None, + ) -> list[RawRecallHit]: """Retrieve the top-k most relevant raw recall hits from storage. Args: query_vector: Dense embedding used by the backend for similarity search. k: Maximum number of hits to return. + recall_filter: Optional backend-neutral metadata filter. Returns: Raw recall hits ordered by backend relevance. @@ -105,12 +113,15 @@ def search_cards( self, query_vector: list[float], k: int = 5, + *, + recall_filter: RecallFilter | None = None, ) -> list[RawRecallHit]: """Retrieve source raw records for the top-k matching cards. Args: query_vector: Dense embedding used by the backend for card search. k: Maximum number of card hits requested. + recall_filter: Optional backend-neutral metadata filter. Returns: Raw recall hits pointing to source records for matching cards. @@ -148,12 +159,19 @@ def archive(self, entry: MemoryEntry) -> None: """ pass - def search_raw(self, query_vector: list[float], k: int = 5) -> list[RawRecallHit]: + def search_raw( + self, + query_vector: list[float], + k: int = 5, + *, + recall_filter: RecallFilter | None = None, + ) -> list[RawRecallHit]: """Return no raw hits. Args: query_vector: Ignored query embedding. k: Ignored hit limit. + recall_filter: Ignored metadata filter. Returns: Empty list. diff --git a/dmf/models/recall_filter.py b/dmf/models/recall_filter.py new file mode 100644 index 0000000..ea8442f --- /dev/null +++ b/dmf/models/recall_filter.py @@ -0,0 +1,128 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Backend-neutral recall filtering contract.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dmf.models.constants import VALID_CARD_KINDS + + +@dataclass(frozen=True) +class RecallFilter: + """Backend-neutral metadata filter for raw and card recall. + + String sequences are stripped and stored as tuples. Empty strings and + duplicate values are rejected so backend translators receive deterministic + predicates. + """ + + record_ids: tuple[str, ...] = () + excluded_record_ids: tuple[str, ...] = () + roles: tuple[str, ...] = () + interaction_id_min: int | None = None + interaction_id_max: int | None = None + created_at_min: float | None = None + created_at_max: float | None = None + card_kinds: tuple[str, ...] = () + + def __post_init__(self) -> None: + """Validate ranges and normalize string sequences.""" + object.__setattr__( + self, + "record_ids", + _normalize_unique_strings(self.record_ids, field="record_ids"), + ) + object.__setattr__( + self, + "excluded_record_ids", + _normalize_unique_strings( + self.excluded_record_ids, + field="excluded_record_ids", + ), + ) + object.__setattr__( + self, + "roles", + _normalize_unique_strings(self.roles, field="roles"), + ) + card_kinds = _normalize_unique_strings(self.card_kinds, field="card_kinds") + unsupported_kinds = sorted(set(card_kinds) - VALID_CARD_KINDS) + if unsupported_kinds: + raise ValueError(f"Unsupported card_kinds: {unsupported_kinds!r}") + object.__setattr__(self, "card_kinds", card_kinds) + + _validate_range( + minimum=self.interaction_id_min, + maximum=self.interaction_id_max, + field="interaction_id", + ) + _validate_range( + minimum=self.created_at_min, + maximum=self.created_at_max, + field="created_at", + ) + + @property + def is_empty(self) -> bool: + """Return whether the filter contains no backend predicate.""" + return not ( + self.record_ids + or self.excluded_record_ids + or self.roles + or self.interaction_id_min is not None + or self.interaction_id_max is not None + or self.created_at_min is not None + or self.created_at_max is not None + or self.card_kinds + ) + + +def _normalize_unique_strings(values: tuple[str, ...], *, field: str) -> tuple[str, ...]: + normalized: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str): + raise TypeError(f"{field} values must be strings") + text = value.strip() + if not text: + raise ValueError(f"{field} values must be non-empty strings") + if text in seen: + raise ValueError(f"{field} values must be unique") + seen.add(text) + normalized.append(text) + return tuple(normalized) + + +def _validate_range( + *, + minimum: int | float | None, + maximum: int | float | None, + field: str, +) -> None: + if minimum is not None and maximum is not None and minimum > maximum: + raise ValueError(f"{field}_min cannot be greater than {field}_max") + + +__all__ = ["RecallFilter"] diff --git a/tests/test_chroma_ltm.py b/tests/test_chroma_ltm.py index cbc6fd6..70dd3a7 100644 --- a/tests/test_chroma_ltm.py +++ b/tests/test_chroma_ltm.py @@ -35,6 +35,7 @@ from dmf.models.analysis import AnalysisReport from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.models.status import SurvivalStatus from dmf.memory.temporal_memory import TemporalMemory from dmf.utils.config_loader import DMFConfig, LTMSettings @@ -49,6 +50,7 @@ def __init__(self) -> None: "metadatas": [[]], "distances": [[]], } + self.get_calls: list[dict] = [] def upsert(self, **kwargs) -> None: self.upsert_calls.append(kwargs) @@ -60,7 +62,8 @@ def query(self, **kwargs): self.query_calls.append(kwargs) return self._query_result - def get(self, include=None): # noqa: ARG002 + def get(self, **kwargs): # noqa: ARG002 + self.get_calls.append(kwargs) return {"ids": []} def delete(self, ids): # noqa: ARG002 @@ -284,6 +287,69 @@ def test_search_raw_does_not_call_count_before_query(self) -> None: assert len(hits) == 1 assert collection.query_calls[0]["n_results"] == 1 + def test_search_raw_passes_where_when_filter_is_not_empty(self) -> None: + collection = _FakeCollection() + hook = ChromaLTMHook.__new__(ChromaLTMHook) + hook._collection = collection + hook._distance_threshold = 0.7 + + assert hook.search_raw( + [0.1, 0.2], + k=3, + recall_filter=RecallFilter( + record_ids=("record:7",), + roles=("assistant",), + interaction_id_min=7, + ), + ) == [] + + assert collection.query_calls[0]["where"] == { + "$and": [ + {"record_id": {"$in": ["record:7"]}}, + {"raw_role": {"$in": ["assistant"]}}, + {"raw_interaction_id": {"$gte": 7}}, + ] + } + + def test_search_raw_omits_where_for_empty_filter(self) -> None: + collection = _FakeCollection() + hook = ChromaLTMHook.__new__(ChromaLTMHook) + hook._collection = collection + hook._distance_threshold = 0.7 + + assert hook.search_raw([0.1, 0.2], k=3, recall_filter=RecallFilter()) == [] + + assert "where" not in collection.query_calls[0] + + def test_search_cards_passes_card_where_when_filter_is_not_empty(self) -> None: + raw_collection = _FakeCollection() + cards_collection = _FakeCollection() + cards_collection._query_result = { + "documents": [["preference user prefer tea"]], + "metadatas": [[{"source_record_id": "record:7", "kind": "preference"}]], + "distances": [[0.2]], + } + hook = ChromaLTMHook.__new__(ChromaLTMHook) + hook._collection = raw_collection + hook._cards_collection = cards_collection + hook._distance_threshold = 0.7 + + assert hook.search_cards( + [0.1, 0.2], + k=3, + recall_filter=RecallFilter( + record_ids=("record:7",), + card_kinds=("preference",), + ), + ) == [] + + assert cards_collection.query_calls[0]["where"] == { + "$and": [ + {"source_record_id": {"$in": ["record:7"]}}, + {"kind": {"$in": ["preference"]}}, + ] + } + def test_search_raw_skips_records_without_raw_metadata(self) -> None: collection = _FakeCollection() collection._query_result = { diff --git a/tests/test_qdrant_filters.py b/tests/test_qdrant_filters.py new file mode 100644 index 0000000..1d5a2c9 --- /dev/null +++ b/tests/test_qdrant_filters.py @@ -0,0 +1,153 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import pytest + +from dmf.memory.ltm_hooks.chroma_filters import build_chroma_where +from dmf.memory.ltm_hooks.qdrant_filters import build_qdrant_filter +from dmf.models.recall_filter import RecallFilter + + +def test_recall_filter_rejects_invalid_ranges() -> None: + with pytest.raises(ValueError, match="interaction_id_min"): + RecallFilter(interaction_id_min=3, interaction_id_max=2) + + with pytest.raises(ValueError, match="created_at_min"): + RecallFilter(created_at_min=2.0, created_at_max=1.0) + + +def test_recall_filter_normalizes_and_rejects_invalid_strings() -> None: + assert RecallFilter(record_ids=(" record:1 ",)).record_ids == ("record:1",) + + with pytest.raises(ValueError, match="non-empty"): + RecallFilter(roles=(" ",)) + + with pytest.raises(ValueError, match="unique"): + RecallFilter(record_ids=("record:1", "record:1")) + + with pytest.raises(ValueError, match="Unsupported card_kinds"): + RecallFilter(card_kinds=("unsupported",)) + + +def test_empty_filter_produces_no_backend_predicate() -> None: + assert build_qdrant_filter(None, target="raw") is None + assert build_qdrant_filter(RecallFilter(), target="raw") is None + assert build_chroma_where(None, target="raw") is None + assert build_chroma_where(RecallFilter(), target="raw") is None + + +def test_qdrant_raw_filter_combines_must_must_not_and_ranges() -> None: + qfilter = build_qdrant_filter( + RecallFilter( + record_ids=("record:1", "record:2"), + excluded_record_ids=("record:9",), + roles=("assistant", "user"), + interaction_id_min=3, + interaction_id_max=7, + created_at_min=100.0, + created_at_max=200.0, + ), + target="raw", + ) + + assert qfilter is not None + assert [condition.key for condition in qfilter.must] == [ + "record_id", + "role", + "interaction_id", + "created_at", + ] + assert qfilter.must[0].match.any == ["record:1", "record:2"] + assert qfilter.must[1].match.any == ["assistant", "user"] + assert qfilter.must[2].range.gte == 3 + assert qfilter.must[2].range.lte == 7 + assert qfilter.must[3].range.gte == 100.0 + assert qfilter.must[3].range.lte == 200.0 + assert [condition.key for condition in qfilter.must_not] == ["record_id"] + assert qfilter.must_not[0].match.any == ["record:9"] + + +def test_qdrant_card_filter_uses_source_and_card_metadata_fields() -> None: + qfilter = build_qdrant_filter( + RecallFilter( + record_ids=("record:1",), + excluded_record_ids=("record:9",), + roles=("user",), + interaction_id_min=1, + created_at_max=300.0, + card_kinds=("preference",), + ), + target="card", + ) + + assert qfilter is not None + assert [condition.key for condition in qfilter.must] == [ + "source_record_id", + "raw_role", + "raw_interaction_id", + "raw_created_at", + "kind", + ] + assert qfilter.must[0].match.any == ["record:1"] + assert qfilter.must[-1].match.any == ["preference"] + assert [condition.key for condition in qfilter.must_not] == ["source_record_id"] + + +def test_chroma_where_uses_and_for_combined_predicates() -> None: + where = build_chroma_where( + RecallFilter( + record_ids=("record:1",), + excluded_record_ids=("record:9",), + roles=("assistant",), + interaction_id_min=3, + interaction_id_max=7, + created_at_min=100.0, + created_at_max=200.0, + ), + target="raw", + ) + + assert where == { + "$and": [ + {"record_id": {"$in": ["record:1"]}}, + {"record_id": {"$nin": ["record:9"]}}, + {"raw_role": {"$in": ["assistant"]}}, + {"raw_interaction_id": {"$gte": 3}}, + {"raw_interaction_id": {"$lte": 7}}, + {"raw_created_at": {"$gte": 100.0}}, + {"raw_created_at": {"$lte": 200.0}}, + ] + } + + +def test_chroma_card_where_uses_source_record_and_kind() -> None: + assert build_chroma_where( + RecallFilter(record_ids=("record:1",), card_kinds=("preference",)), + target="card", + ) == { + "$and": [ + {"source_record_id": {"$in": ["record:1"]}}, + {"kind": {"$in": ["preference"]}}, + ] + } diff --git a/tests/test_qdrant_ltm.py b/tests/test_qdrant_ltm.py index d478b09..425023d 100644 --- a/tests/test_qdrant_ltm.py +++ b/tests/test_qdrant_ltm.py @@ -41,6 +41,7 @@ from dmf.models.analysis import AnalysisReport, InteractionSignals from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.models.status import SurvivalStatus from dmf.utils.config import VectorConfig @@ -228,6 +229,29 @@ def test_search_raw_returns_ranking_threshold_and_scores() -> None: assert hits[1].distance == pytest.approx(0.2) +def test_search_raw_applies_backend_neutral_filter() -> None: + hook = _hook(distance_threshold=1.0) + hook.archive(_make_entry(1, "alpha", [1.0, 0.0])) + hook.archive(_make_entry(2, "beta", [0.0, 1.0])) + hook.archive(_make_entry(3, "edge", [0.8, 0.6])) + + hits = hook.search_raw( + [1.0, 0.0], + k=3, + recall_filter=RecallFilter( + record_ids=("record:1", "record:3"), + excluded_record_ids=("record:1",), + roles=("unknown",), + interaction_id_min=2, + interaction_id_max=3, + created_at_min=2.0, + created_at_max=3.0, + ), + ) + + assert [hit.record.record_id for hit in hits] == ["record:3"] + + def test_search_raw_includes_threshold_edge() -> None: hook = _hook(distance_threshold=0.2) hook.archive(_make_entry(3, "edge", [0.8, 0.6])) @@ -431,6 +455,27 @@ def test_search_cards_returns_ranked_source_records() -> None: assert hits[0].rank_hint == 0 +def test_search_cards_applies_backend_neutral_filter() -> None: + hook = _hook(cards_enabled=True, distance_threshold=1.0) + hook.archive(_make_card_entry(10, "alpha", [1.0, 0.0])) + hook.archive(_make_card_entry(20, "beta", [0.0, 1.0])) + + hits = hook.search_cards( + [1.0, 0.0], + k=2, + recall_filter=RecallFilter( + record_ids=("record:10", "record:20"), + excluded_record_ids=("record:20",), + roles=("unknown",), + interaction_id_min=10, + interaction_id_max=10, + card_kinds=("preference",), + ), + ) + + assert [hit.record.record_id for hit in hits] == ["record:10"] + + def test_search_cards_deduplicates_source_lookup_but_preserves_duplicate_hits() -> None: client = QdrantClient(":memory:") hook = _hook(client=client, cards_enabled=True, distance_threshold=0.4) diff --git a/tests/test_temporal_memory.py b/tests/test_temporal_memory.py index 265421a..251c62b 100644 --- a/tests/test_temporal_memory.py +++ b/tests/test_temporal_memory.py @@ -132,6 +132,7 @@ from dmf.models.ltm_hook import LTMHook, NullLTMHook from dmf.models.memory import MemoryEntry from dmf.models.raw_ltm import ContextualizedRecallCandidate, RawLTMRecord, RawRecallHit +from dmf.models.recall_filter import RecallFilter from dmf.models.status import SurvivalStatus, classify_survival_status from dmf.utils.config import DecayConfig, PruningPriorityConfig, VectorConfig @@ -215,6 +216,48 @@ def read_all(self) -> list: return [] +def test_get_raw_recall_hits_passes_recall_filter_to_ltm_hook() -> None: + captured: dict[str, object] = {} + + class FilteringHook: + def archive(self, entry: MemoryEntry) -> None: + pass + + def search_raw( + self, + query_vector: list[float], + k: int = 5, + *, + recall_filter: RecallFilter | None = None, + ) -> list[RawRecallHit]: + captured["query_vector"] = query_vector + captured["k"] = k + captured["recall_filter"] = recall_filter + return [] + + def read_all(self) -> list[RawLTMRecord]: + return [] + + recall_filter = RecallFilter(record_ids=("record:1",)) + tm = TemporalMemory( + decay_config=_DECAY_CFG, + vector_config=_VECTOR_CFG, + ltm_hook=FilteringHook(), + ) + + assert tm.get_raw_recall_hits( + np.array([1.0, 0.0], dtype=np.float32), + k=3, + recall_filter=recall_filter, + ) == [] + + assert captured == { + "query_vector": [1.0, 0.0], + "k": 3, + "recall_filter": recall_filter, + } + + class _FakeNLPEngine: """Minimal test double for recall-time contextualization.""" From 94ccf4dd50e8dd89edf630a755590160f047610e Mon Sep 17 00:00:00 2001 From: mat Date: Mon, 13 Jul 2026 07:15:29 +0200 Subject: [PATCH 6/9] test(memory): harden Qdrant LTM integration --- Makefile | 6 +- README.md | 21 ++ docs/api/internals.md | 7 + docs/api/public.md | 23 ++ docs/configuration.md | 31 ++- docs/index.md | 14 +- docs/ltm_backends.md | 141 +++++++++- integrationtest/run_ltm_benchmark.py | 196 ++++++++++---- mkdocs.yml | 2 + tests/test_framework_ltm_regression.py | 346 +++++++++++++++++++++++++ tests/test_ltm_benchmark.py | 62 +++++ tests/test_ltm_persistence.py | 33 +++ tests/test_qdrant_ltm.py | 106 ++++++++ tests/test_vector_ltm_contract.py | 168 ++++++++++++ 14 files changed, 1094 insertions(+), 62 deletions(-) create mode 100644 tests/test_framework_ltm_regression.py create mode 100644 tests/test_vector_ltm_contract.py diff --git a/Makefile b/Makefile index c5af8f9..25415b7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help lock install build test test-integration benchmark-ltm-local chroma-up chroma-down check compile docs-serve docs-build +.PHONY: help lock install build test test-integration benchmark-ltm-local benchmark-ltm-qdrant-local chroma-up chroma-down check compile docs-serve docs-build # Update poetry.lock lock: @@ -33,6 +33,9 @@ test-integration: benchmark-ltm-local: poetry run python -m integrationtest.run_ltm_benchmark +benchmark-ltm-qdrant-local: + poetry run python -m integrationtest.run_ltm_benchmark --backend qdrant + # Stop containers without removing persistent volumes chroma-down: docker compose -f compose.chroma.yml down @@ -61,6 +64,7 @@ help: @echo " make chroma-up Start Chroma 0.6.3 (Docker container)" @echo " make test-integration Run integration tests against the local Chroma server" @echo " make benchmark-ltm-local Run the local DMF LTM + Ollama benchmark" + @echo " make benchmark-ltm-qdrant-local Run the local DMF LTM + Ollama benchmark with Qdrant" @echo " make chroma-down Stop Chroma containers" @echo " make build Build wheel package in dist/" @echo " make docs-serve Start development server for documentation" diff --git a/README.md b/README.md index baf170f..b6e2cb7 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,31 @@ You can install it via pip: pip install dmf-memory ``` +Install the optional Qdrant backend when you want to use local in-memory +Qdrant LTM: + +```bash +pip install 'dmf-memory[qdrant]' +``` + ## Configuration DMF is fully configurable via a [TOML](https://toml.io/en/) file. You can adjust NLP models, temporal decay rates, and pruning priorities to suit your agent's needs. +Minimal Qdrant Local Mode configuration: + +```toml +[ltm] +enabled = true +storage_type = "qdrant" +qdrant_mode = "memory" +``` + +Qdrant Local Mode uses `QdrantClient(":memory:")`: each client has separate +state, and all archived data disappears when the process exits. Persistent +local Qdrant and Qdrant server connections are outside the current release +scope. + For a comprehensive guide on all configuration parameters, please check our [configuration documentation](configuration.md) in MkDocs. ## Development & Makefile diff --git a/docs/api/internals.md b/docs/api/internals.md index 214fa18..cc24359 100644 --- a/docs/api/internals.md +++ b/docs/api/internals.md @@ -19,6 +19,13 @@ for maintainers but are not compatibility guarantees for external consumers. filters: - "!^__" +## Qdrant filter implementation + +::: dmf.memory.ltm_hooks.qdrant_filters + options: + filters: + - "!^__" + ## LTM protocol and null backend ::: dmf.models.ltm_hook diff --git a/docs/api/public.md b/docs/api/public.md index dfeb36b..043edb3 100644 --- a/docs/api/public.md +++ b/docs/api/public.md @@ -22,6 +22,13 @@ filters: - "!^_[^_]" +## Qdrant LTM hook + +::: dmf.memory.ltm_hooks.qdrant_hook.QdrantLTMHook + options: + filters: + - "!^_[^_]" + ## File LTM hook ::: dmf.memory.ltm_hooks.file_hook.FileLTMHook @@ -40,3 +47,19 @@ ## Chroma client factory ::: dmf.memory.ltm_hooks.chroma_client.build_chroma_client + +## Qdrant connection mode + +::: dmf.memory.ltm_hooks.qdrant_client.QdrantConnectionMode + +## Qdrant connection configuration + +::: dmf.memory.ltm_hooks.qdrant_client.QdrantConnectionConfig + +## Qdrant client factory + +::: dmf.memory.ltm_hooks.qdrant_client.build_qdrant_client + +## Recall filter + +::: dmf.models.recall_filter.RecallFilter diff --git a/docs/configuration.md b/docs/configuration.md index 173ee61..1a4da15 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -186,27 +186,48 @@ Configures the **Long-Term Memory** persistence backend. DMF supports multiple b | Parameter | Type | Default | Description | | ----------------------- | -------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `storage_type` | `string` | `"chroma"` | Backend type. `"chroma"` for ChromaDB vector store with active recall, `"file"` for a write-only JSONL audit trail via `FileLTMHook`, `"null"` for silent discard (useful in tests). | +| `storage_type` | `string` | `"chroma"` | Backend type. `"chroma"` for ChromaDB vector store with active recall, `"qdrant"` for volatile in-memory Qdrant Local Mode, `"file"` for a write-only JSONL audit trail via `FileLTMHook`, `"null"` for silent discard (useful in tests). | | `storage_path` | `string` | `"data/ltm_archive.jsonl"` | Path to the JSONL archive file. Used when `storage_type = "file"`. Parent directories are created automatically. | | `chroma_path` | `string` | `"data/ltm_chroma"` | Persistence directory used only in `embedded` mode. It is not created by the server client. | | `chroma_mode` | `string` | `"embedded"` | Chroma deployment: local `embedded` persistence or remote `server`. | +| `qdrant_mode` | `string` | `"memory"` | Qdrant deployment mode. Only `"memory"` is supported and maps to `QdrantClient(":memory:")`; each client has isolated volatile state. | | `chroma_host` | `string` | `"localhost"` | Server hostname. Required and non-empty in active server mode. | | `chroma_port` | `int` | `8000` | Server port in the range 1–65535. | | `chroma_ssl` | `bool` | `false` | Use HTTPS for the server connection. | | `chroma_tenant` | `string` | `"default_tenant"` | Chroma tenant used by embedded and server clients. | | `chroma_database` | `string` | `"default_database"` | Chroma database used by embedded and server clients. | | `chroma_auth_token_env` | `string` | `""` | Name of the environment variable containing an optional server Bearer token. The token itself must never be stored in TOML. | -| `collection_name` | `string` | `"dmf_memory"` | ChromaDB collection name for raw LTM records. Change this to start a fresh memory namespace. | +| `collection_name` | `string` | `"dmf_memory"` | Vector collection name for raw LTM records in Chroma or Qdrant. Change this to start a fresh memory namespace. | | `recall_limit` | `int` | `5` | Maximum number of raw records returned per active-recall search query. | | `distance_threshold` | `float` | `0.7` | Cosine-distance ceiling for recalled raw records, range `[0, 2]`. A value of `0.7` means `cosine_similarity > 0.3`, filtering to related results only. | | `enabled` | `bool` | `true` | Master switch. Set to `false` to disable LTM persistence entirely and fall back to `NullLTMHook`. | | `cards_enabled` | `bool` | `false` | Enables the auxiliary structured memory-card index. Raw LTM remains canonical; cards provide an additional retrieval path. | | `cards_path` | `string` | `"data/ltm_cards.jsonl"` | Path to the memory-card JSONL index file. | -| `cards_collection_name` | `string` | `"dmf_cards"` | ChromaDB collection name for memory cards. | +| `cards_collection_name` | `string` | `"dmf_cards"` | Vector collection name for memory cards in Chroma or Qdrant. Must be distinct from `collection_name` for Qdrant. | + +Minimal Qdrant Local Mode configuration: + +```toml +[ltm] +enabled = true +storage_type = "qdrant" +qdrant_mode = "memory" +``` + +Qdrant Local Mode requires the optional package extra: + +```bash +pip install 'dmf-memory[qdrant]' +``` + +It is volatile: every `QdrantClient(":memory:")` instance has separate state, +and all data disappears when the process exits. Local persistent Qdrant and +Qdrant server mode are not implemented in this release. For deployment examples, direct construction, authentication behavior, retry -semantics, version compatibility, migration guidance, Docker integration, and -the local Ollama benchmark, see [LTM Backends](ltm_backends.md). +semantics, Qdrant Local Mode details, version compatibility, migration +guidance, Docker integration, and the local Ollama benchmark, see +[LTM Backends](ltm_backends.md). --- diff --git a/docs/index.md b/docs/index.md index 446b77d..5e40652 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,8 @@ retrieval, reranking, and prompt-ready context rendering. The recommended entry point is the project configuration. It selects the LTM backend and keeps application wiring independent from whether Chroma runs -embedded or as a separate server. +embedded, as a separate server, or whether Qdrant runs in local in-memory +mode. ```python from dmf.analysis import EmbeddingEngine, ScoringEngine @@ -47,7 +48,9 @@ print(context) `chroma_mode = "embedded"` is the backward-compatible default. Switching to `chroma_mode = "server"` changes the connection strategy without changing the -application code above. +application code above. Selecting `storage_type = "qdrant"` with +`qdrant_mode = "memory"` uses volatile Qdrant Local Mode and also keeps the +application code unchanged. ## Long-term memory backends @@ -56,11 +59,12 @@ DMF includes: - `FileLTMHook`, an append-only JSONL archival backend; - `ChromaLTMHook` in embedded mode, with local persistent storage; - `ChromaLTMHook` in server mode, using a separately managed Chroma service; +- `QdrantLTMHook` in Local Mode, using volatile in-memory Qdrant; - `NullLTMHook`, for disabled persistence and isolated tests. -See [LTM Backends](ltm_backends.md) for deployment modes, authentication, -retry behavior, migration from legacy imports, Docker integration, and the -local Ollama benchmark. +See [LTM Backends](ltm_backends.md) for deployment modes, Qdrant Local Mode +limits, authentication, retry behavior, migration from legacy imports, Docker +integration, and the local Ollama benchmark. ## Next steps diff --git a/docs/ltm_backends.md b/docs/ltm_backends.md index 3e514ea..373a945 100644 --- a/docs/ltm_backends.md +++ b/docs/ltm_backends.md @@ -11,6 +11,7 @@ construction remains available for custom applications and tests. | `FileLTMHook` | No semantic search | Append-only JSONL | Development and audit trails | | Chroma embedded | Semantic search | Local Chroma directory | Single-process applications | | Chroma server | Semantic search | Managed by the Chroma service | Multiple clients and service deployments | +| Qdrant Local Mode | Semantic search | Volatile process memory | Isolated local runs and tests | | `NullLTMHook` | No | No | Disabled LTM and isolated tests | Chroma embedded remains the default connection mode. Existing configurations @@ -20,15 +21,153 @@ that do not define `chroma_mode` continue to use `PersistentClient` and ## Canonical imports ```python -from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook +from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook, QdrantLTMHook from dmf.memory.ltm_hooks.chroma_client import ( ChromaConnectionConfig, ChromaConnectionMode, ) +from dmf.memory.ltm_hooks.qdrant_client import ( + QdrantConnectionConfig, + QdrantConnectionMode, +) +from dmf.models import RecallFilter ``` The same hook classes remain re-exported from `dmf` and `dmf.memory`. +## Qdrant Local Mode + +Install the optional dependency before constructing or configuring the Qdrant +backend: + +```bash +pip install 'dmf-memory[qdrant]' +``` + +Minimal configuration: + +```toml +[ltm] +enabled = true +storage_type = "qdrant" +qdrant_mode = "memory" +collection_name = "dmf_memory" +cards_enabled = false +cards_collection_name = "dmf_cards" +recall_limit = 5 +distance_threshold = 0.7 +``` + +`qdrant_mode = "memory"` builds `QdrantClient(":memory:")`. Each client owns a +separate in-memory store, even when the same collection names are used. Data is +not written to disk and disappears when the Python process exits. Local +persistent Qdrant and Qdrant server connections are intentionally outside the +current release scope. + +Direct construction is available for tests and custom applications: + +```python +from dmf.memory import QdrantLTMHook +from dmf.memory.ltm_hooks.qdrant_client import ( + QdrantConnectionConfig, + QdrantConnectionMode, +) +from dmf.utils.config import VectorConfig + +hook = QdrantLTMHook( + collection_name="dmf_memory", + connection=QdrantConnectionConfig(mode=QdrantConnectionMode.MEMORY), + vector_config=VectorConfig(vector_dim=768), +) +``` + +An already constructed Qdrant client can be passed with `client=...`. Injected +clients are used unchanged; their lifecycle, isolation, and persistence remain +the caller's responsibility. + +### Qdrant raw and card collections + +Raw LTM records remain canonical. Qdrant stores them in `collection_name`; the +payload includes `raw_record` as a JSON mapping, plus top-level fields used for +filtering such as `record_id`, `interaction_id`, `role`, and `created_at`. + +When `cards_enabled = true`, projected memory cards are stored in the separate +`cards_collection_name` collection. The raw and card collection names must be +distinct. Cards initially use the source record vector and include source raw +metadata so card recall can apply the same backend-neutral filters. `clear()` +deletes raw points only; orphaned cards are ignored when their source raw +record is no longer present. + +Qdrant point IDs are deterministic UUIDv5 values derived from the DMF raw +record ID or card ID. The original DMF identity stays in the payload. This +makes repeated archive calls idempotent while keeping raw points and card +points in separate UUID namespaces. + +### Qdrant scoring and thresholds + +Qdrant uses COSINE distance and returns a similarity score for matching +points. DMF preserves that score as `RawRecallHit.similarity_score` and reports +`distance = 1.0 - similarity_score` without clamping. + +The existing `distance_threshold` setting remains the public configuration +surface. For Qdrant queries, DMF converts it to a minimum Qdrant score: + +```text +score_threshold = 1.0 - distance_threshold +``` + +With the default `distance_threshold = 0.7`, Qdrant returns records with +similarity score at least `0.3`. + +### Recall filters + +`RecallFilter` is shared across Chroma, Qdrant, and temporal-memory recall: + +```python +from dmf.models import RecallFilter + +recall_filter = RecallFilter( + roles=("user",), + interaction_id_min=10, + interaction_id_max=50, + excluded_record_ids=("raw:obsolete",), +) +hits = hook.search_raw(query_vector, k=5, recall_filter=recall_filter) +``` + +Supported fields: + +| Field | Meaning | +|---|---| +| `record_ids` | Include only these raw record IDs. For card recall this matches `source_record_id`. | +| `excluded_record_ids` | Exclude these raw record IDs. For card recall this excludes matching sources. | +| `roles` | Match raw `role`; for cards this uses the source raw role. | +| `interaction_id_min` / `interaction_id_max` | Inclusive source interaction ID range. | +| `created_at_min` / `created_at_max` | Inclusive source timestamp range. | +| `card_kinds` | Match projected card kind when searching cards. | + +String tuple values are stripped and must be non-empty and unique. Range +minimums must not exceed their maximums. + +In Qdrant Local Mode, payload indexes are not created. Qdrant may warn that +index creation is a no-op, and metadata filters are evaluated by local +full-scan. This is expected for the in-memory backend. + +### Qdrant errors + +If the Qdrant extra is missing, constructing a Qdrant client raises an +actionable `ModuleNotFoundError` that points to: + +```bash +pip install 'dmf-memory[qdrant]' +``` + +When an existing collection has incompatible vector settings, such as the +wrong dimension, named vectors, or a non-COSINE distance, initialization raises +`ValueError`. DMF never deletes or recreates an incompatible collection +automatically. If raw and card collection names are identical, initialization +also raises `ValueError`. + ## Embedded Chroma Use embedded mode when DMF owns the local Chroma data directory. diff --git a/integrationtest/run_ltm_benchmark.py b/integrationtest/run_ltm_benchmark.py index 63fcf20..b865e90 100644 --- a/integrationtest/run_ltm_benchmark.py +++ b/integrationtest/run_ltm_benchmark.py @@ -1,9 +1,10 @@ -"""Run the opt-in local DMF LTM benchmark against Chroma and Ollama.""" +"""Run the opt-in local DMF LTM benchmark against Chroma/Qdrant and Ollama.""" from __future__ import annotations import argparse import dataclasses +import importlib.metadata import json import os import re @@ -13,7 +14,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, Protocol from urllib.parse import urlsplit from uuid import uuid4 @@ -25,11 +26,16 @@ from dmf.analysis import EmbeddingEngine, NLPEngine, ScoringEngine # noqa: E402 from dmf.memory import Memory, TemporalMemory # noqa: E402 -from dmf.memory.ltm_hooks import ChromaLTMHook # noqa: E402 +from dmf.memory.ltm_hooks import ChromaLTMHook, QdrantLTMHook # noqa: E402 from dmf.memory.ltm_hooks.chroma_client import ( # noqa: E402 ChromaConnectionConfig, ChromaConnectionMode, ) +from dmf.memory.ltm_hooks.qdrant_client import ( # noqa: E402 + QdrantConnectionConfig, + QdrantConnectionMode, +) +from dmf.models.ltm_hook import LTMHook # noqa: E402 from dmf.models.analysis import InteractionProvenance # noqa: E402 from dmf.runtime.pipeline import InteractionPipeline # noqa: E402 from dmf.utils.config import NLPConfig, VectorConfig # noqa: E402 @@ -43,6 +49,9 @@ DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" DEFAULT_CHROMA_HOST = "localhost" DEFAULT_CHROMA_PORT = 8000 +BACKEND_CHROMA = "chroma" +BACKEND_QDRANT = "qdrant" +SUPPORTED_BACKENDS = (BACKEND_CHROMA, BACKEND_QDRANT) MAX_CASES = 10 MAX_RESPONSE_CHARS = 4_096 MAX_RESPONSE_BYTES = 1_000_000 @@ -63,6 +72,18 @@ class BenchmarkError(RuntimeError): TermGroups = tuple[tuple[str, ...], ...] +class BenchmarkLTMHook(LTMHook, Protocol): + """LTM hook surface needed by the benchmark runtime.""" + + def count(self) -> int: + """Return indexed raw-record count.""" + ... + + def clear(self) -> None: + """Delete benchmark records from the backend.""" + ... + + @dataclass(frozen=True) class SeedTurn: """One deterministic interaction ingested before benchmark questions.""" @@ -111,7 +132,7 @@ class BenchmarkDataset: class BenchmarkRuntime: """Public DMF components used by one isolated benchmark run.""" - hook: ChromaLTMHook + hook: BenchmarkLTMHook temporal_memory: TemporalMemory memory: Memory pipeline: InteractionPipeline @@ -466,10 +487,34 @@ def build_benchmark_config( base: DMFConfig, *, collection_name: str, - chroma_host: str, - chroma_port: int, + backend: str = BACKEND_CHROMA, + chroma_host: str = DEFAULT_CHROMA_HOST, + chroma_port: int = DEFAULT_CHROMA_PORT, ) -> DMFConfig: - """Derive a controlled server configuration without mutating root settings.""" + """Derive a controlled benchmark configuration without mutating root settings.""" + if backend not in SUPPORTED_BACKENDS: + raise BenchmarkError(f"Unsupported benchmark backend: {backend!r}") + ltm_kwargs: dict[str, object] = { + "enabled": True, + "storage_type": backend, + "collection_name": collection_name, + "recall_limit": 64, + "distance_threshold": 2.0, + "cards_enabled": False, + } + if backend == BACKEND_CHROMA: + ltm_kwargs.update( + { + "chroma_mode": "server", + "chroma_host": chroma_host, + "chroma_port": chroma_port, + "chroma_ssl": False, + "chroma_auth_token_env": "", + } + ) + else: + ltm_kwargs["qdrant_mode"] = "memory" + return dataclasses.replace( base, decay=dataclasses.replace( @@ -486,17 +531,7 @@ def build_benchmark_config( ), ltm=dataclasses.replace( base.ltm, - enabled=True, - storage_type="chroma", - chroma_mode="server", - chroma_host=chroma_host, - chroma_port=chroma_port, - chroma_ssl=False, - chroma_auth_token_env="", - collection_name=collection_name, - recall_limit=64, - distance_threshold=2.0, - cards_enabled=False, + **ltm_kwargs, ), retrieval=dataclasses.replace( base.retrieval, @@ -520,8 +555,41 @@ def require_local_embedding_cache(repo_root: Path) -> Path: return cache +def build_benchmark_hook( + config: DMFConfig, + embedding_engine: EmbeddingEngine, + vector_config: VectorConfig, +) -> BenchmarkLTMHook: + """Build the selected vector LTM hook for one benchmark run.""" + if config.ltm.storage_type == BACKEND_CHROMA: + connection = ChromaConnectionConfig( + mode=ChromaConnectionMode.SERVER, + host=config.ltm.chroma_host, + port=config.ltm.chroma_port, + ssl=False, + tenant=config.ltm.chroma_tenant, + database=config.ltm.chroma_database, + ) + return ChromaLTMHook( + collection_name=config.ltm.collection_name, + distance_threshold=config.ltm.distance_threshold, + embed_text=embedding_engine.get_embedding, + connection=connection, + ) + if config.ltm.storage_type == BACKEND_QDRANT: + connection = QdrantConnectionConfig(mode=QdrantConnectionMode.MEMORY) + return QdrantLTMHook( + collection_name=config.ltm.collection_name, + distance_threshold=config.ltm.distance_threshold, + vector_config=vector_config, + embed_text=embedding_engine.get_embedding, + connection=connection, + ) + raise BenchmarkError(f"Unsupported benchmark backend: {config.ltm.storage_type!r}") + + def build_runtime(config: DMFConfig) -> BenchmarkRuntime: - """Wire public DMF Pipeline, TemporalMemory, Memory, and server hook APIs.""" + """Wire public DMF Pipeline, TemporalMemory, Memory, and selected hook APIs.""" vector_config = VectorConfig( model_name=config.nlp.model_name, vector_dim=config.nlp.vector_dim, @@ -529,20 +597,7 @@ def build_runtime(config: DMFConfig) -> BenchmarkRuntime: window_size=config.capacity.window_size, ) embedding_engine = EmbeddingEngine(vector_config) - connection = ChromaConnectionConfig( - mode=ChromaConnectionMode.SERVER, - host=config.ltm.chroma_host, - port=config.ltm.chroma_port, - ssl=False, - tenant=config.ltm.chroma_tenant, - database=config.ltm.chroma_database, - ) - hook = ChromaLTMHook( - collection_name=config.ltm.collection_name, - distance_threshold=config.ltm.distance_threshold, - embed_text=embedding_engine.get_embedding, - connection=connection, - ) + hook = build_benchmark_hook(config, embedding_engine, vector_config) nlp_engine = NLPEngine(NLPConfig(spacy_model=config.nlp.spacy_model)) temporal_memory = TemporalMemory.from_dmf_config( config, @@ -611,7 +666,7 @@ def seed_ltm(runtime: BenchmarkRuntime, dataset: BenchmarkDataset) -> dict[str, archived_count = runtime.hook.count() if archived_count <= 0: - raise BenchmarkError("Chroma count is zero after bounded seed pressure") + raise BenchmarkError("LTM count is zero after bounded seed pressure") if missing_seed_ids: raise BenchmarkError( "Seed evidence did not reach LTM after bounded pressure: " @@ -633,13 +688,14 @@ def seed_ltm(runtime: BenchmarkRuntime, dataset: BenchmarkDataset) -> dict[str, unrecoverable.append(case.case_id) if unrecoverable: raise BenchmarkError( - "Archived evidence is not recoverable through Chroma search: " + "Archived evidence is not recoverable through LTM search: " + ", ".join(unrecoverable) ) return { "seed_turn_count": len(dataset.seed), "filler_turn_count": filler_turns, + "count_after_seed": archived_count, "chroma_count_after_seed": archived_count, "all_seed_turns_archived": True, "direct_search_recoverability": recoverability, @@ -753,16 +809,38 @@ def read_git_commit(repo_root: Path) -> str | None: return None -def new_report(dataset: BenchmarkDataset | None, model: str, base_url: str) -> dict[str, object]: +def client_version_for_backend(backend: str) -> str | None: + """Return the installed Python client version for the selected backend.""" + package_name = { + BACKEND_CHROMA: "chromadb", + BACKEND_QDRANT: "qdrant-client", + }.get(backend) + if package_name is None: + raise BenchmarkError(f"Unsupported benchmark backend: {backend!r}") + try: + return importlib.metadata.version(package_name) + except importlib.metadata.PackageNotFoundError: + return None + + +def new_report( + dataset: BenchmarkDataset | None, + model: str, + base_url: str, + *, + backend: str = BACKEND_CHROMA, +) -> dict[str, object]: """Create a sanitized report envelope before infrastructure mutation.""" return { "schema_version": REPORT_SCHEMA_VERSION, "status": "started", + "backend": backend, "benchmark_id": dataset.benchmark_id if dataset else None, "dataset_version": dataset.schema_version if dataset else None, "started_at": datetime.now(UTC).isoformat(), "dmf_commit": read_git_commit(REPO_ROOT), "ollama": {"model": model, "base_url": base_url}, + "ltm": None, "chroma": None, "scorer": { "version": SCORER_VERSION, @@ -798,6 +876,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: type=Path, default=REPO_ROOT / "integrationtest" / "benchmark_cases.json", ) + parser.add_argument( + "--backend", + choices=SUPPORTED_BACKENDS, + default=BACKEND_CHROMA, + help="LTM backend to benchmark (default: chroma)", + ) parser.add_argument("--output", type=Path, default=None) return parser.parse_args(argv) @@ -823,19 +907,23 @@ def main(argv: list[str] | None = None) -> int: raise BenchmarkError("The versioned benchmark dataset must contain exactly 10 cases") base_url = validate_loopback_url(raw_base_url) model = validate_model_name(raw_model) - chroma_host = validate_loopback_host( - os.getenv("CHROMA_HOST", DEFAULT_CHROMA_HOST), field_name="CHROMA_HOST" - ) - chroma_port = parse_port( - os.getenv("CHROMA_PORT", str(DEFAULT_CHROMA_PORT)), field_name="CHROMA_PORT" - ) - report = new_report(dataset, model, base_url) - report["chroma"] = { - "host": chroma_host, - "port": chroma_port, - "tenant": "default_tenant", - "database": "default_database", - } + chroma_host = DEFAULT_CHROMA_HOST + chroma_port = DEFAULT_CHROMA_PORT + if args.backend == BACKEND_CHROMA: + chroma_host = validate_loopback_host( + os.getenv("CHROMA_HOST", DEFAULT_CHROMA_HOST), field_name="CHROMA_HOST" + ) + chroma_port = parse_port( + os.getenv("CHROMA_PORT", str(DEFAULT_CHROMA_PORT)), field_name="CHROMA_PORT" + ) + report = new_report(dataset, model, base_url, backend=args.backend) + if args.backend == BACKEND_CHROMA: + report["chroma"] = { + "host": chroma_host, + "port": chroma_port, + "tenant": "default_tenant", + "database": "default_database", + } require_local_embedding_cache(REPO_ROOT) with OllamaClient(base_url, model) as ollama: @@ -845,12 +933,20 @@ def main(argv: list[str] | None = None) -> int: config = build_benchmark_config( base_config, collection_name=collection_name, + backend=args.backend, chroma_host=chroma_host, chroma_port=chroma_port, ) runtime = build_runtime(config) - report["chroma"]["collection"] = collection_name # type: ignore[index] + report["ltm"] = { + "backend": args.backend, + "client_version": client_version_for_backend(args.backend), + "collection": collection_name, + } + if report["chroma"] is not None: + report["chroma"]["collection"] = collection_name # type: ignore[index] report["seed"] = seed_ltm(runtime, dataset) + report["ltm"]["count_after_seed"] = report["seed"]["count_after_seed"] # type: ignore[index] results, partial_failure = run_cases(runtime, dataset, ollama) report["cases"] = results report["aggregate"] = aggregate_results(results) diff --git a/mkdocs.yml b/mkdocs.yml index 8d1f13e..fce5a4e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,6 +53,8 @@ plugins: python: paths: [.] options: + docstring_options: + warnings: false show_root_heading: true show_source: true diff --git a/tests/test_framework_ltm_regression.py b/tests/test_framework_ltm_regression.py new file mode 100644 index 0000000..bfe83f8 --- /dev/null +++ b/tests/test_framework_ltm_regression.py @@ -0,0 +1,346 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Framework-level LTM regression parity for Chroma and Qdrant backends.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pytest +from qdrant_client import QdrantClient + +import dmf.memory.temporal_memory as temporal_memory_module +from dmf.memory.api import Memory +from dmf.memory.candidate_generation import CandidateGenerationConfig, CandidateGenerator +from dmf.memory.ltm_hooks.chroma_hook import ChromaLTMHook +from dmf.memory.ltm_hooks.qdrant_hook import QdrantLTMHook +from dmf.memory.temporal_memory import TemporalMemory +from dmf.models.analysis import AnalysisReport, InteractionProvenance, InteractionSignals +from dmf.models.memory import MemoryEntry, QueryFrame +from dmf.models.raw_ltm import RawRecallHit +from dmf.models.status import SurvivalStatus +from dmf.utils.config import DecayConfig, VectorConfig +from dmf.utils.config_loader import DMFConfig, LTMSettings, load_dmf_config + + +_VECTOR_CFG = VectorConfig(vector_dim=2, window_size=4) +_TEXT_VECTORS: dict[str, list[float]] = { + "alpha": [1.0, 0.0], + "beta": [0.0, 1.0], + "edge": [0.8, 0.6], + "I prefer green tea.": [1.0, 0.0], + "I prefer black tea.": [0.9, 0.1], + "I live in Rome.": [0.0, 1.0], + "active note": [0.7, 0.3], + "archived note": [1.0, 0.0], +} + + +@dataclass(frozen=True) +class _Backend: + name: str + hook: object + + +class _EmbeddingEngine: + def get_embedding(self, text: str) -> np.ndarray: # noqa: ARG002 + return np.array([1.0, 0.0], dtype=np.float32) + + +class _NLPStub: + def analyze_interaction(self, text: str) -> AnalysisReport: + if "prefer" in text: + return _report( + role="user", + score=0.91, + signals=InteractionSignals(is_preference=True, personal_relevance=1.0), + topic_identity="preference|tea", + topic_value="green" if "green" in text else "black", + ) + if "live" in text: + return _report( + role="user", + score=0.88, + signals=InteractionSignals(is_current_state=True, personal_relevance=1.0), + topic_identity="state|home", + topic_value="Rome", + ) + return _report(role="user", score=0.75) + + +class _FailingHook: + def archive(self, entry: MemoryEntry) -> None: # noqa: ARG002 + pass + + def search_raw( + self, + query_vector: list[float], # noqa: ARG002 + k: int = 5, # noqa: ARG002 + *, + recall_filter=None, # noqa: ANN001, ARG002 + ) -> list[RawRecallHit]: + raise RuntimeError("backend failure") + + def read_all(self) -> list[object]: + raise RuntimeError("backend failure") + + +def _embed(text: str) -> np.ndarray: + return np.array(_TEXT_VECTORS[text], dtype=np.float32) + + +def _report( + *, + role: str, + score: float, + signals: InteractionSignals | None = None, + topic_identity: str | None = None, + topic_value: str | None = None, +) -> AnalysisReport: + return AnalysisReport( + info_density=0.7, + sentiment_abs=0.0, + entity_count=1, + is_system_prompt=False, + latency_ms=0.0, + survival_score=score, + status=SurvivalStatus.HEALTHY if score > 0.6 else SurvivalStatus.CRITICAL, + provenance=InteractionProvenance(role=role, source_turn=1), + signals=signals or InteractionSignals(), + topic_identity=topic_identity, + topic_value=topic_value, + ) + + +def _entry( + interaction_id: int, + text: str, + *, + timestamp: float | None = None, + report: AnalysisReport | None = None, +) -> MemoryEntry: + return MemoryEntry( + interaction_id=interaction_id, + text=text, + report=report or _report(role="user", score=0.8), + vector=_embed(text), + token_count=len(text.split()), + timestamp=float(interaction_id if timestamp is None else timestamp), + ) + + +def _backends(tmp_path: Path, *, cards_enabled: bool = False) -> list[_Backend]: + return [ + _Backend( + "chroma", + ChromaLTMHook( + collection_name="framework_raw", + persist_directory=tmp_path / "chroma", + distance_threshold=0.4, + vector_config=_VECTOR_CFG, + embed_text=_embed, + cards_enabled=cards_enabled, + cards_path=tmp_path / "chroma_cards.jsonl", + ), + ), + _Backend( + "qdrant", + QdrantLTMHook( + collection_name="framework_raw", + cards_collection_name="framework_cards", + distance_threshold=0.4, + vector_config=_VECTOR_CFG, + embed_text=_embed, + cards_enabled=cards_enabled, + cards_path=tmp_path / "qdrant_cards.jsonl", + client=QdrantClient(":memory:"), + ), + ), + ] + + +def _collect(backends: list[_Backend], fn: Callable[[object], object]) -> list[object]: + return [fn(backend.hook) for backend in backends] + + +def test_insertion_before_eviction_is_backend_neutral( + tmp_path: Path, +) -> None: + results = [] + for backend in _backends(tmp_path): + tm = TemporalMemory( + decay_config=DecayConfig(token_budget=10_000, pruning_frequency=999), + vector_config=_VECTOR_CFG, + ltm_hook=backend.hook, + ) + first = tm.add_interaction("active note", _report(role="user", score=0.7), _embed("active note")) + second = tm.add_interaction("archived note", _report(role="user", score=0.9), _embed("archived note")) + results.append( + ( + [(entry.interaction_id, entry.text, entry.token_count) for entry in tm.queue], + [(item["interaction_id"], item["token_count"], item["status_effective"]) for item in tm.get_effective_state()], + first.interaction_id, + second.interaction_id, + backend.hook.count(), + ) + ) + + assert results[0] == results[1] + assert results[0][-1] == 0 + + +def test_eviction_archives_equivalent_raw_records( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(temporal_memory_module.time, "time", lambda: 1000.0) + results = [] + for backend in _backends(tmp_path): + tm = TemporalMemory( + decay_config=DecayConfig(token_budget=1, pruning_frequency=999), + vector_config=_VECTOR_CFG, + ltm_hook=backend.hook, + ) + tm.add_interaction("I prefer green tea.", _report(role="user", score=0.2), _embed("I prefer green tea.")) + tm.add_interaction("I live in Rome.", _report(role="user", score=0.2), _embed("I live in Rome.")) + results.append([record.to_dict() for record in backend.hook.read_all()]) + + assert results[0] == results[1] + assert [record["record_id"] for record in results[0]] == ["record:0", "record:1"] + + +def test_raw_recall_order_and_threshold_are_backend_neutral(tmp_path: Path) -> None: + results = _collect( + _backends(tmp_path), + lambda hook: ( + hook.archive(_entry(1, "alpha")), + hook.archive(_entry(2, "beta")), + hook.archive(_entry(3, "edge")), + [(hit.record.record_id, hit.rank_hint) for hit in hook.search_raw([1.0, 0.0], k=3)], + )[-1], + ) + + assert results[0] == results[1] == [("record:1", 0), ("record:3", 1)] + + +def test_contextualized_recall_and_rendering_are_backend_neutral(tmp_path: Path) -> None: + results = [] + for backend in _backends(tmp_path): + backend.hook.archive(_entry(1, "I prefer green tea.", timestamp=1000.0)) + tm = TemporalMemory( + decay_config=DecayConfig(token_budget=10_000, pruning_frequency=999), + vector_config=_VECTOR_CFG, + ltm_hook=backend.hook, + nlp_engine=_NLPStub(), + ) + tm.add_interaction("active note", _report(role="user", score=0.9), _embed("active note")) + hits = tm.get_raw_recall_hits(np.array([1.0, 0.0], dtype=np.float32), k=2) + contextualized = tm.contextualize_raw_recall_hits(hits) + reranked = tm.rerank_contextualized_recall_candidates(contextualized) + results.append( + ( + [(item.record.record_id, item.report.topic_identity, item.suppression_reason) for item in reranked], + tm.get_full_context(np.array([1.0, 0.0], dtype=np.float32)), + tm.get_recall_diagnostics(), + ) + ) + + assert results[0] == results[1] + assert "I prefer green tea." in results[0][1] + assert "active note" in results[0][1] + + +def test_candidate_generation_merges_raw_and_card_semantic_channels( + tmp_path: Path, +) -> None: + results = [] + for backend in _backends(tmp_path, cards_enabled=True): + backend.hook.archive( + _entry( + 1, + "I prefer green tea.", + report=_report( + role="user", + score=0.9, + signals=InteractionSignals(is_preference=True), + topic_identity="preference|tea", + topic_value="green", + ), + ) + ) + generator = CandidateGenerator( + cards=backend.hook.card_store.read_all(), + ltm_hook=backend.hook, + raw_records=backend.hook.read_all(), + config=CandidateGenerationConfig( + enable_card_symbolic=False, + enable_raw_lexical=False, + card_prefetch_k=2, + raw_prefetch_k=2, + ), + ) + pool = generator.generate(QueryFrame(query_text="tea", query_embedding=[1.0, 0.0])) + results.append([candidate.to_dict() for candidate in pool.candidates]) + + assert results[0] == results[1] + assert results[0][0]["evidence_id"] == "record:1" + assert results[0][0]["source"] == "card_semantic+raw_semantic" + + +def test_structured_memory_api_results_are_backend_neutral(tmp_path: Path) -> None: + results = [] + for backend in _backends(tmp_path): + backend.hook.archive(_entry(1, "I prefer green tea.")) + memory = Memory.from_dmf_config( + DMFConfig(), + TemporalMemory(vector_config=_VECTOR_CFG, ltm_hook=backend.hook), + _EmbeddingEngine(), + ) + results.append([item.to_dict() for item in memory.retrieve("What tea do I prefer?")]) + + assert results[0] == results[1] + assert [item["evidence_id"] for item in results[0]] == ["record:1"] + + +def test_explicit_hook_override_and_default_framework_config() -> None: + explicit = object() + cfg = DMFConfig(ltm=LTMSettings(storage_type="qdrant")) + + tm = TemporalMemory.from_dmf_config(cfg, ltm_hook=explicit) + loaded = load_dmf_config() + + assert tm.ltm_hook is explicit + assert loaded.ltm.storage_type == "chroma" + assert loaded.ltm.recall_limit == 5 + assert loaded.ltm.distance_threshold == 0.7 + + +def test_backend_errors_propagate_from_temporal_and_memory_api() -> None: + tm = TemporalMemory(ltm_hook=_FailingHook()) + + with pytest.raises(RuntimeError, match="backend failure"): + tm.get_raw_recall_hits(np.array([1.0, 0.0], dtype=np.float32)) + + memory = Memory.from_dmf_config(DMFConfig(), tm, _EmbeddingEngine()) + with pytest.raises(RuntimeError, match="backend failure"): + memory.retrieve("What failed?") diff --git a/tests/test_ltm_benchmark.py b/tests/test_ltm_benchmark.py index 2aa7e59..1aed727 100644 --- a/tests/test_ltm_benchmark.py +++ b/tests/test_ltm_benchmark.py @@ -8,10 +8,15 @@ import pytest from integrationtest.run_ltm_benchmark import ( + BACKEND_CHROMA, + BACKEND_QDRANT, BenchmarkError, build_benchmark_config, + client_version_for_backend, build_ollama_messages, load_dataset, + new_report, + parse_args, parse_ollama_models, score_text, validate_dataset, @@ -149,3 +154,60 @@ def test_benchmark_config_forces_server_and_bounded_pressure() -> None: assert config.tiers.healthy_min == 1.0 assert config.decay.lambda_base == 1.0 assert config.decay.inertia_strength == 0.0 + + +def test_parse_args_defaults_to_chroma_backend() -> None: + args = parse_args([]) + + assert args.backend == BACKEND_CHROMA + + +def test_parse_args_accepts_qdrant_backend() -> None: + args = parse_args(["--backend", "qdrant"]) + + assert args.backend == BACKEND_QDRANT + + +def test_parse_args_rejects_unknown_backend() -> None: + with pytest.raises(SystemExit): + parse_args(["--backend", "redis"]) + + +def test_benchmark_config_for_qdrant_uses_memory_without_chroma_server() -> None: + config = build_benchmark_config( + DMFConfig(), + collection_name="dmf_benchmark_test", + backend=BACKEND_QDRANT, + chroma_host="ignored-host", + chroma_port=6553, + ) + + assert config.ltm.storage_type == "qdrant" + assert config.ltm.qdrant_mode == "memory" + assert config.ltm.collection_name == "dmf_benchmark_test" + assert config.ltm.cards_enabled is False + assert config.ltm.chroma_mode == DMFConfig().ltm.chroma_mode + assert config.capacity.token_budget == 48 + + +def test_benchmark_report_contains_backend_and_client_version() -> None: + dataset = load_dataset(DATASET_PATH) + + report = new_report( + dataset, + "qwen2.5:0.5b", + "http://localhost:11434", + backend=BACKEND_QDRANT, + ) + report["ltm"] = { + "backend": BACKEND_QDRANT, + "client_version": client_version_for_backend(BACKEND_QDRANT), + "collection": "dmf_benchmark_test", + "count_after_seed": 3, + } + + assert report["backend"] == BACKEND_QDRANT + assert report["ltm"]["backend"] == BACKEND_QDRANT + assert report["ltm"]["client_version"] + assert report["ltm"]["collection"] == "dmf_benchmark_test" + assert report["ltm"]["count_after_seed"] == 3 diff --git a/tests/test_ltm_persistence.py b/tests/test_ltm_persistence.py index 11d2e82..16881c5 100644 --- a/tests/test_ltm_persistence.py +++ b/tests/test_ltm_persistence.py @@ -625,6 +625,39 @@ def __init__(self, **kwargs: object) -> None: assert captured["collection_name"] == cfg.ltm.collection_name assert captured["distance_threshold"] == cfg.ltm.distance_threshold + def test_qdrant_hook_receives_vector_and_card_settings( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + captured: dict[str, object] = {} + + class FakeQdrantLTMHook: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(ltm_hooks, "QdrantLTMHook", FakeQdrantLTMHook) + cfg = DMFConfig( + ltm=LTMSettings( + storage_type="qdrant", + collection_name="raw_custom", + cards_enabled=True, + cards_path=str(tmp_path / "cards.jsonl"), + cards_collection_name="cards_custom", + ), + nlp=NLPSettings(vector_dim=8), + capacity=CapacitySettings(window_size=5), + ) + + tm = TemporalMemory.from_dmf_config(cfg) + + assert isinstance(tm._ltm_hook, FakeQdrantLTMHook) + assert captured["collection_name"] == "raw_custom" + assert captured["cards_enabled"] is True + assert captured["cards_path"] == str(tmp_path / "cards.jsonl") + assert captured["cards_collection_name"] == "cards_custom" + assert captured["vector_config"] == VectorConfig(vector_dim=8, window_size=5) + def test_explicit_hook_overrides_qdrant_config( self, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_qdrant_ltm.py b/tests/test_qdrant_ltm.py index 425023d..6aacc73 100644 --- a/tests/test_qdrant_ltm.py +++ b/tests/test_qdrant_ltm.py @@ -22,6 +22,7 @@ from __future__ import annotations +import threading from dataclasses import replace from pathlib import Path from typing import Any @@ -194,6 +195,23 @@ def test_constructor_rejects_non_positive_vector_dimension() -> None: ) +def test_constructor_accepts_custom_vector_dimension() -> None: + client = QdrantClient(":memory:") + + hook = QdrantLTMHook( + collection_name="custom_dim", + vector_config=VectorConfig(vector_dim=3), + embed_text=lambda text: np.array([1.0, 0.0, 0.0], dtype=np.float32), + client=client, + ) + + hook.archive(_make_entry(1, "alpha", [1.0, 0.0, 0.0])) + + info = client.get_collection("custom_dim") + assert info.config.params.vectors.size == 3 + assert hook.search_raw([1.0, 0.0, 0.0], k=1)[0].record.record_id == "record:1" + + def test_point_ids_are_stable_and_separate_by_record_type() -> None: assert _raw_point_id("record:7") == _raw_point_id("record:7") assert _card_point_id("record:7") == _card_point_id("record:7") @@ -333,6 +351,34 @@ def test_search_raw_skips_malformed_payloads() -> None: assert [hit.record.record_id for hit in hits] == ["record:1"] +def test_search_raw_skips_payloads_without_raw_record_or_with_wrong_types() -> None: + client = QdrantClient(":memory:") + hook = _hook(client=client) + hook.archive(_make_entry(1, "alpha", [1.0, 0.0])) + client.upsert( + collection_name="test_raw", + points=[ + models.PointStruct( + id=_raw_point_id("missing_raw_record"), + vector=[1.0, 0.0], + payload={"record_id": "missing_raw_record"}, + ), + models.PointStruct( + id=_raw_point_id("wrong_raw_record_type"), + vector=[1.0, 0.0], + payload={"raw_record": 42}, + ), + ], + wait=True, + ) + + hits = hook.search_raw([1.0, 0.0], k=5) + records = hook.read_all() + + assert [hit.record.record_id for hit in hits] == ["record:1"] + assert [record.record_id for record in records] == ["record:1"] + + def test_read_all_uses_pages_and_orders_records() -> None: hook = _hook() for entry in [ @@ -347,6 +393,23 @@ def test_read_all_uses_pages_and_orders_records() -> None: assert [record.interaction_id for record in records] == [1, 2, 300] +def test_read_all_scrolls_multiple_pages() -> None: + hook = QdrantLTMHook( + collection_name="many_records", + vector_config=VectorConfig(vector_dim=2), + embed_text=lambda text: np.array([1.0, 0.0], dtype=np.float32), + client=QdrantClient(":memory:"), + ) + for index in range(260): + hook.archive(_make_entry(index, "alpha", [1.0, 0.0])) + + records = hook.read_all() + + assert len(records) == 260 + assert [record.interaction_id for record in records[:3]] == [0, 1, 2] + assert [record.interaction_id for record in records[-3:]] == [257, 258, 259] + + def test_count_and_clear_preserve_collection() -> None: hook = _hook() hook.archive(_make_entry(1, "alpha", [1.0, 0.0])) @@ -370,6 +433,49 @@ def fail_query(**kwargs: Any) -> object: hook.search_raw([1.0, 0.0], k=1) +def test_archive_concurrent_calls_are_serialized_by_lock() -> None: + hook = QdrantLTMHook( + collection_name="concurrent_raw", + vector_config=VectorConfig(vector_dim=2), + embed_text=lambda text: np.array([1.0, 0.0], dtype=np.float32), + client=QdrantClient(":memory:"), + ) + errors: list[BaseException] = [] + + def archive(index: int) -> None: + try: + hook.archive(_make_entry(index, "alpha", [1.0, 0.0])) + except BaseException as exc: # pragma: no cover - asserted below + errors.append(exc) + + threads = [threading.Thread(target=archive, args=(index,)) for index in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert errors == [] + assert hook.count() == 8 + assert [record.interaction_id for record in hook.read_all()] == list(range(8)) + + +def test_incompatible_collection_error_names_backend_collection_expected_observed() -> None: + client = QdrantClient(":memory:") + client.create_collection( + collection_name="bad_error", + vectors_config=models.VectorParams(size=3, distance=models.Distance.DOT), + ) + + with pytest.raises(ValueError) as exc_info: + _hook(collection_name="bad_error", client=client) + + message = str(exc_info.value) + assert "Qdrant" in message + assert "bad_error" in message + assert "expected" in message + assert "observed" in message + + def test_cards_disabled_does_not_create_collection_or_return_card_hits() -> None: client = QdrantClient(":memory:") hook = _hook(client=client, cards_enabled=False) diff --git a/tests/test_vector_ltm_contract.py b/tests/test_vector_ltm_contract.py new file mode 100644 index 0000000..ee16ca8 --- /dev/null +++ b/tests/test_vector_ltm_contract.py @@ -0,0 +1,168 @@ +# Copyright (c) 2026-present matstech +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# SPDX-License-Identifier: MIT + +"""Shared vector-LTM contract for Chroma and Qdrant backends.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import numpy as np +import pytest +from qdrant_client import QdrantClient + +from dmf.memory.ltm_hooks.chroma_hook import ChromaLTMHook +from dmf.memory.ltm_hooks.qdrant_hook import QdrantLTMHook +from dmf.models.analysis import AnalysisReport, InteractionProvenance +from dmf.models.memory import MemoryEntry +from dmf.models.raw_ltm import RawRecallHit +from dmf.models.status import SurvivalStatus +from dmf.utils.config import VectorConfig + + +_EMBEDDINGS: dict[str, list[float]] = { + "alpha": [1.0, 0.0], + "beta": [0.0, 1.0], + "edge": [0.8, 0.6], +} + + +def _embed(text: str) -> np.ndarray: + return np.array(_EMBEDDINGS[text], dtype=np.float32) + + +def _entry(interaction_id: int, text: str, vector: list[float]) -> MemoryEntry: + return MemoryEntry( + interaction_id=interaction_id, + text=text, + report=AnalysisReport( + info_density=0.7, + sentiment_abs=0.1, + entity_count=1, + is_system_prompt=False, + latency_ms=0.0, + survival_score=0.82, + status=SurvivalStatus.HEALTHY, + provenance=InteractionProvenance(role="user", source_turn=interaction_id), + ), + vector=np.array(vector, dtype=np.float32), + token_count=1, + timestamp=float(interaction_id), + ) + + +@pytest.fixture(params=["chroma", "qdrant"]) +def vector_ltm_hook( + request: pytest.FixtureRequest, + tmp_path: Path, +) -> Callable[..., object]: + backend = str(request.param) + + def build(*, distance_threshold: float = 1.0) -> object: + vector_config = VectorConfig(vector_dim=2) + if backend == "chroma": + return ChromaLTMHook( + collection_name=f"contract_{backend}", + persist_directory=tmp_path / backend, + distance_threshold=distance_threshold, + vector_config=vector_config, + embed_text=_embed, + ) + return QdrantLTMHook( + collection_name=f"contract_{backend}", + distance_threshold=distance_threshold, + vector_config=vector_config, + embed_text=_embed, + client=QdrantClient(":memory:"), + ) + + return build + + +def test_round_trips_raw_records(vector_ltm_hook: Callable[..., object]) -> None: + hook = vector_ltm_hook() + + hook.archive(_entry(7, "alpha", [1.0, 0.0])) + + records = hook.read_all() + assert [record.record_id for record in records] == ["record:7"] + assert records[0].text == "alpha" + assert records[0].role == "user" + assert records[0].provenance.source_turn == 7 + + +def test_archive_is_idempotent(vector_ltm_hook: Callable[..., object]) -> None: + hook = vector_ltm_hook() + entry = _entry(3, "alpha", [1.0, 0.0]) + + hook.archive(entry) + hook.archive(entry) + + assert hook.count() == 1 + assert [record.record_id for record in hook.read_all()] == ["record:3"] + + +def test_known_vector_ranking_and_threshold_edge( + vector_ltm_hook: Callable[..., object], +) -> None: + hook = vector_ltm_hook(distance_threshold=0.200001) + hook.archive(_entry(1, "alpha", [1.0, 0.0])) + hook.archive(_entry(2, "beta", [0.0, 1.0])) + hook.archive(_entry(3, "edge", [0.8, 0.6])) + + hits = hook.search_raw([1.0, 0.0], k=3) + + assert [hit.record.record_id for hit in hits] == ["record:1", "record:3"] + assert [hit.rank_hint for hit in hits] == [0, 1] + assert hits[0].similarity_score == pytest.approx(1.0) + assert hits[0].distance == pytest.approx(0.0) + assert hits[1].similarity_score == pytest.approx(0.8, abs=1e-6) + assert hits[1].distance == pytest.approx(0.2, abs=1e-6) + + +def test_raw_recall_hit_shape(vector_ltm_hook: Callable[..., object]) -> None: + hook = vector_ltm_hook() + hook.archive(_entry(1, "alpha", [1.0, 0.0])) + + hit = hook.search_raw([1.0, 0.0], k=1)[0] + + assert isinstance(hit, RawRecallHit) + assert hit.source == "ltm_raw" + assert hit.record.record_id == "record:1" + assert isinstance(hit.similarity_score, float) + assert isinstance(hit.distance, float) + assert hit.rank_hint == 0 + + +def test_read_all_count_and_clear_contract( + vector_ltm_hook: Callable[..., object], +) -> None: + hook = vector_ltm_hook() + hook.archive(_entry(20, "beta", [0.0, 1.0])) + hook.archive(_entry(10, "alpha", [1.0, 0.0])) + hook.archive(_entry(30, "edge", [0.8, 0.6])) + + assert hook.count() == 3 + assert [record.interaction_id for record in hook.read_all()] == [10, 20, 30] + + hook.clear() + + assert hook.count() == 0 + assert hook.read_all() == [] From c85260eaea8a678e6357cd39a7bb6de6ca5a0535 Mon Sep 17 00:00:00 2001 From: mat Date: Mon, 13 Jul 2026 07:58:36 +0200 Subject: [PATCH 7/9] feat(benchmark): print LTM aggregate summary --- integrationtest/run_ltm_benchmark.py | 296 ++++++++++++++++++++++++++- tests/test_ltm_benchmark.py | 177 ++++++++++++++++ 2 files changed, 468 insertions(+), 5 deletions(-) diff --git a/integrationtest/run_ltm_benchmark.py b/integrationtest/run_ltm_benchmark.py index b865e90..ad5ed53 100644 --- a/integrationtest/run_ltm_benchmark.py +++ b/integrationtest/run_ltm_benchmark.py @@ -45,7 +45,7 @@ DATASET_SCHEMA_VERSION = 1 REPORT_SCHEMA_VERSION = 1 SCORER_VERSION = "term-coverage-v1" -DEFAULT_MODEL = "qwen2.5:0.5b" +DEFAULT_MODEL = "gemma4:e4b" DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" DEFAULT_CHROMA_HOST = "localhost" DEFAULT_CHROMA_PORT = 8000 @@ -464,6 +464,7 @@ def chat(self, question: str, context: str) -> tuple[str, float]: "model": self.model, "messages": build_ollama_messages(question, context), "stream": False, + "think": False, "options": {"temperature": 0, "num_predict": 128}, }, ) @@ -765,14 +766,17 @@ def aggregate_results(results: list[dict[str, object]]) -> dict[str, object]: if isinstance(result.get("ollama_latency_ms"), (int, float)) ] total = len(results) - retrieval_rate = retrieval_successes / total if total else 0.0 + completed_count = len(completed) + retrieval_rate = retrieval_successes / completed_count if completed_count else 0.0 + overall_retrieval_rate = retrieval_successes / total if total else 0.0 mean_answer = sum(answer_scores) / len(answer_scores) if answer_scores else 0.0 return { "case_count": total, - "completed_case_count": len(completed), - "failed_case_count": total - len(completed), + "completed_case_count": completed_count, + "failed_case_count": total - completed_count, "retrieval_success_count": retrieval_successes, "retrieval_success_rate": round(retrieval_rate, 4), + "retrieval_success_rate_overall": round(overall_retrieval_rate, 4), "mean_answer_score": round(mean_answer, 4), "total_ollama_latency_ms": round(sum(latencies), 3), "mean_ollama_latency_ms": round(sum(latencies) / len(latencies), 3) @@ -785,6 +789,281 @@ def aggregate_results(results: list[dict[str, object]]) -> dict[str, object]: } +def _as_mapping(value: object) -> dict[str, object]: + return value if isinstance(value, dict) else {} + + +def _as_sequence(value: object) -> list[object]: + return value if isinstance(value, list) else [] + + +def _format_int(value: object) -> str: + return str(value) if isinstance(value, int) else "-" + + +def _format_float(value: object, *, digits: int = 4, suffix: str = "") -> str: + if not isinstance(value, (int, float)): + return "-" + return f"{float(value):.{digits}f}{suffix}" + + +def _format_rate(value: object) -> str: + if not isinstance(value, (int, float)): + return "-" + return f"{float(value) * 100:.2f}%" + + +def _safe_rate(numerator: int, denominator: int) -> float | None: + if denominator <= 0: + return None + return numerator / denominator + + +def _f1(precision: float | None, recall: float | None) -> float | None: + if precision is None or recall is None or precision + recall == 0: + return None + return 2 * precision * recall / (precision + recall) + + +def _percentile(values: list[float], percentile: float) -> float | None: + if not values: + return None + if len(values) == 1: + return values[0] + ordered = sorted(values) + rank = (len(ordered) - 1) * percentile + lower = int(rank) + upper = min(lower + 1, len(ordered) - 1) + weight = rank - lower + return ordered[lower] * (1 - weight) + ordered[upper] * weight + + +def _score_group_counts(score: object) -> tuple[int, int, int]: + score_map = _as_mapping(score) + matched = len(_as_sequence(score_map.get("matched_groups"))) + missing = len(_as_sequence(score_map.get("missing_groups"))) + promoted_obsolete = len(_as_sequence(score_map.get("promoted_obsolete_groups"))) + return matched, missing, promoted_obsolete + + +def summarize_report(report: dict[str, object]) -> dict[str, object]: + """Compute console summary metrics from the persisted benchmark report shape.""" + cases = [_as_mapping(case) for case in _as_sequence(report.get("cases"))] + completed = [case for case in cases if case.get("error") is None] + aggregate = _as_mapping(report.get("aggregate")) + seed = _as_mapping(report.get("seed")) + ltm = _as_mapping(report.get("ltm")) + + retrieval_matched = 0 + retrieval_missing = 0 + retrieval_obsolete = 0 + answer_matched = 0 + answer_missing = 0 + answer_obsolete = 0 + retrieval_successes = 0 + answer_scores: list[float] = [] + latencies: list[float] = [] + categories: set[str] = set() + + for case in completed: + if isinstance(case.get("category"), str): + categories.add(str(case["category"])) + if case.get("retrieval_success") is True: + retrieval_successes += 1 + matched, missing, obsolete = _score_group_counts(case.get("retrieval_score")) + retrieval_matched += matched + retrieval_missing += missing + retrieval_obsolete += obsolete + matched, missing, obsolete = _score_group_counts(case.get("answer_score")) + answer_matched += matched + answer_missing += missing + answer_obsolete += obsolete + answer_score = _as_mapping(case.get("answer_score")).get("score") + if isinstance(answer_score, (int, float)): + answer_scores.append(float(answer_score)) + latency = case.get("ollama_latency_ms") + if isinstance(latency, (int, float)): + latencies.append(float(latency)) + + retrieval_required = retrieval_matched + retrieval_missing + answer_required = answer_matched + answer_missing + total_case_count = len(cases) + completed_case_count = len(completed) + retrieval_precision = _safe_rate( + retrieval_matched, + retrieval_matched + retrieval_obsolete, + ) + retrieval_recall = _safe_rate(retrieval_matched, retrieval_required) + answer_precision = _safe_rate(answer_matched, answer_matched + answer_obsolete) + answer_recall = _safe_rate(answer_matched, answer_required) + total_latency_ms = sum(latencies) + + return { + "status": report.get("status"), + "backend": report.get("backend"), + "benchmark_id": report.get("benchmark_id"), + "model": _as_mapping(report.get("ollama")).get("model"), + "collection": ltm.get("collection") or _as_mapping(report.get("chroma")).get("collection"), + "case_count": int(aggregate.get("case_count", total_case_count)), + "completed_case_count": int(aggregate.get("completed_case_count", completed_case_count)), + "failed_case_count": int( + aggregate.get("failed_case_count", total_case_count - completed_case_count) + ), + "category_count": len(categories), + "seed_turn_count": seed.get("seed_turn_count"), + "filler_turn_count": seed.get("filler_turn_count"), + "count_after_seed": seed.get("count_after_seed") + or seed.get("chroma_count_after_seed") + or ltm.get("count_after_seed"), + "retrieval_success_count": int( + aggregate.get("retrieval_success_count", retrieval_successes) + ), + "retrieval_success_rate": ( + _safe_rate(retrieval_successes, completed_case_count) + if completed_case_count + else None + ), + "retrieval_success_rate_overall": float( + aggregate.get( + "retrieval_success_rate_overall", + _safe_rate(retrieval_successes, total_case_count) or 0.0, + ) + ), + "retrieval_precision": retrieval_precision, + "retrieval_recall": retrieval_recall, + "retrieval_f1": _f1(retrieval_precision, retrieval_recall), + "retrieval_matched_terms": retrieval_matched, + "retrieval_required_terms": retrieval_required, + "answer_precision": answer_precision, + "answer_recall": answer_recall, + "answer_f1": _f1(answer_precision, answer_recall), + "answer_matched_terms": answer_matched, + "answer_required_terms": answer_required, + "mean_answer_score": float( + aggregate.get( + "mean_answer_score", + sum(answer_scores) / len(answer_scores) if answer_scores else 0.0, + ) + ), + "latency_count": len(latencies), + "total_ollama_latency_ms": float( + aggregate.get("total_ollama_latency_ms", round(total_latency_ms, 3)) + ), + "mean_ollama_latency_ms": aggregate.get( + "mean_ollama_latency_ms", + round(total_latency_ms / len(latencies), 3) if latencies else None, + ), + "min_ollama_latency_ms": min(latencies) if latencies else None, + "p50_ollama_latency_ms": _percentile(latencies, 0.50), + "p95_ollama_latency_ms": _percentile(latencies, 0.95), + "max_ollama_latency_ms": max(latencies) if latencies else None, + "throughput_cases_per_second": ( + len(latencies) / (total_latency_ms / 1_000) if total_latency_ms > 0 else None + ), + } + + +def render_summary_table(summary: dict[str, object]) -> str: + """Render aggregate benchmark metrics as a stable ASCII table.""" + completed = summary.get("completed_case_count") + failed = summary.get("failed_case_count") + case_count = summary.get("case_count") + retrieval_success = summary.get("retrieval_success_count") + rows = [ + ("Status", str(summary.get("status") or "-")), + ("Backend", str(summary.get("backend") or "-")), + ("Model", str(summary.get("model") or "-")), + ("Benchmark", str(summary.get("benchmark_id") or "-")), + ("Collection", str(summary.get("collection") or "-")), + ("Cases", f"{_format_int(completed)} completed / {_format_int(case_count)} total"), + ("Failed cases", _format_int(failed)), + ("Categories", _format_int(summary.get("category_count"))), + ("Seed raw records", _format_int(summary.get("count_after_seed"))), + ("Retrieval success", f"{_format_int(retrieval_success)} cases"), + ( + "Retrieval success rate", + _format_rate(summary.get("retrieval_success_rate")), + ), + ( + "Retrieval success overall", + _format_rate(summary.get("retrieval_success_rate_overall")), + ), + ("Retrieval term precision", _format_rate(summary.get("retrieval_precision"))), + ("Retrieval term recall", _format_rate(summary.get("retrieval_recall"))), + ("Retrieval term F1", _format_rate(summary.get("retrieval_f1"))), + ( + "Retrieval term coverage", + f"{_format_int(summary.get('retrieval_matched_terms'))}/" + f"{_format_int(summary.get('retrieval_required_terms'))}", + ), + ("Answer mean score", _format_float(summary.get("mean_answer_score"))), + ("Answer term precision", _format_rate(summary.get("answer_precision"))), + ("Answer term recall", _format_rate(summary.get("answer_recall"))), + ("Answer term F1", _format_rate(summary.get("answer_f1"))), + ( + "Answer term coverage", + f"{_format_int(summary.get('answer_matched_terms'))}/" + f"{_format_int(summary.get('answer_required_terms'))}", + ), + ("Latency samples", _format_int(summary.get("latency_count"))), + ( + "Ollama latency total", + _format_float(summary.get("total_ollama_latency_ms"), digits=3, suffix=" ms"), + ), + ( + "Ollama latency mean", + _format_float(summary.get("mean_ollama_latency_ms"), digits=3, suffix=" ms"), + ), + ( + "Ollama latency min", + _format_float(summary.get("min_ollama_latency_ms"), digits=3, suffix=" ms"), + ), + ( + "Ollama latency p50", + _format_float(summary.get("p50_ollama_latency_ms"), digits=3, suffix=" ms"), + ), + ( + "Ollama latency p95", + _format_float(summary.get("p95_ollama_latency_ms"), digits=3, suffix=" ms"), + ), + ( + "Ollama latency max", + _format_float(summary.get("max_ollama_latency_ms"), digits=3, suffix=" ms"), + ), + ( + "Throughput", + _format_float( + summary.get("throughput_cases_per_second"), + digits=3, + suffix=" cases/s", + ), + ), + ] + metric_width = max(len(metric) for metric, _ in rows) + value_width = max(len(value) for _, value in rows) + border = f"+-{'-' * metric_width}-+-{'-' * value_width}-+" + lines = [ + "Benchmark aggregate results", + border, + f"| {'Metric'.ljust(metric_width)} | {'Value'.ljust(value_width)} |", + border, + ] + lines.extend( + f"| {metric.ljust(metric_width)} | {value.ljust(value_width)} |" + for metric, value in rows + ) + lines.append(border) + return "\n".join(lines) + + +def load_report(path: Path) -> dict[str, object]: + """Read a benchmark report written by this runner.""" + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise BenchmarkError("Benchmark report root must be a JSON object") + return payload + + def read_git_commit(repo_root: Path) -> str | None: """Read the local Git commit without executing a subprocess.""" try: @@ -898,7 +1177,7 @@ def main(argv: list[str] | None = None) -> int: raw_base_url = os.getenv("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL) raw_model = os.getenv("OLLAMA_MODEL", DEFAULT_MODEL) output_path = args.output or default_output_path() - report = new_report(None, raw_model.strip(), "unvalidated") + report = new_report(None, raw_model.strip(), "unvalidated", backend=args.backend) exit_code = 2 try: @@ -981,6 +1260,13 @@ def main(argv: list[str] | None = None) -> int: print(f"Benchmark status: {report['status']}") print(f"Report: {output_path}") + try: + persisted_report = load_report(output_path) + print(render_summary_table(summarize_report(persisted_report))) + except (BenchmarkError, OSError, json.JSONDecodeError) as exc: + print(f"Benchmark summary unavailable: {type(exc).__name__}", file=sys.stderr) + if exit_code == 0: + return 4 if report["errors"]: for error in report["errors"]: print(f"Error: {error}", file=sys.stderr) diff --git a/tests/test_ltm_benchmark.py b/tests/test_ltm_benchmark.py index 1aed727..389f61e 100644 --- a/tests/test_ltm_benchmark.py +++ b/tests/test_ltm_benchmark.py @@ -11,14 +11,18 @@ BACKEND_CHROMA, BACKEND_QDRANT, BenchmarkError, + OllamaClient, build_benchmark_config, client_version_for_backend, build_ollama_messages, load_dataset, + load_report, new_report, parse_args, parse_ollama_models, + render_summary_table, score_text, + summarize_report, validate_dataset, validate_loopback_host, validate_loopback_url, @@ -130,6 +134,36 @@ def test_ollama_messages_are_stateless_and_contain_only_current_inputs() -> None assert "seed history" not in serialized +def test_ollama_chat_disables_model_thinking() -> None: + captured: dict[str, object] = {} + + class FakeResponse: + content = b'{"message": {"content": "ok"}}' + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return {"message": {"content": "ok"}} + + class FakeClient: + def post(self, path: str, *, json: dict[str, object]) -> FakeResponse: + captured["path"] = path + captured["json"] = json + return FakeResponse() + + client = OllamaClient("http://localhost:11434", "qwen2.5:0.5b") + client._client = FakeClient() # type: ignore[assignment] + + response, latency_ms = client.chat("Domanda?", "Contesto") + + assert response == "ok" + assert latency_ms >= 0 + assert captured["path"] == "/api/chat" + assert captured["json"]["think"] is False # type: ignore[index] + assert captured["json"]["stream"] is False # type: ignore[index] + + def test_parse_ollama_models_validates_shape() -> None: assert parse_ollama_models({"models": [{"name": "qwen2.5:0.5b"}]}) == { "qwen2.5:0.5b" @@ -211,3 +245,146 @@ def test_benchmark_report_contains_backend_and_client_version() -> None: assert report["ltm"]["client_version"] assert report["ltm"]["collection"] == "dmf_benchmark_test" assert report["ltm"]["count_after_seed"] == 3 + + +def test_summary_table_is_computed_from_report_json(tmp_path: Path) -> None: + report = { + "status": "complete", + "backend": BACKEND_QDRANT, + "benchmark_id": "bench-test", + "ollama": {"model": "qwen2.5:0.5b", "base_url": "http://localhost:11434"}, + "ltm": { + "backend": BACKEND_QDRANT, + "collection": "dmf_benchmark_test", + "count_after_seed": 4, + }, + "seed": {"seed_turn_count": 3, "filler_turn_count": 1, "count_after_seed": 4}, + "aggregate": { + "case_count": 2, + "completed_case_count": 2, + "failed_case_count": 0, + "retrieval_success_count": 1, + "retrieval_success_rate": 0.5, + "mean_answer_score": 0.625, + "total_ollama_latency_ms": 300.0, + "mean_ollama_latency_ms": 150.0, + }, + "cases": [ + { + "id": "case-a", + "category": "preference", + "error": None, + "retrieval_success": True, + "ollama_latency_ms": 100.0, + "retrieval_score": { + "matched_groups": [["alpha"], ["beta"]], + "missing_groups": [], + "promoted_obsolete_groups": [], + }, + "answer_score": { + "score": 1.0, + "matched_groups": [["alpha"]], + "missing_groups": [], + "promoted_obsolete_groups": [], + }, + }, + { + "id": "case-b", + "category": "constraint", + "error": None, + "retrieval_success": False, + "ollama_latency_ms": 200.0, + "retrieval_score": { + "matched_groups": [["gamma"]], + "missing_groups": [["delta"]], + "promoted_obsolete_groups": [["old"]], + }, + "answer_score": { + "score": 0.25, + "matched_groups": [["gamma"]], + "missing_groups": [["delta"]], + "promoted_obsolete_groups": [["old"]], + }, + }, + ], + "errors": [], + } + path = tmp_path / "report.json" + path.write_text(json.dumps(report), encoding="utf-8") + + summary = summarize_report(load_report(path)) + table = render_summary_table(summary) + + assert summary["retrieval_precision"] == pytest.approx(0.75) + assert summary["retrieval_recall"] == pytest.approx(0.75) + assert summary["answer_precision"] == pytest.approx(2 / 3) + assert summary["answer_recall"] == pytest.approx(2 / 3) + assert summary["min_ollama_latency_ms"] == 100.0 + assert summary["p50_ollama_latency_ms"] == 150.0 + assert summary["max_ollama_latency_ms"] == 200.0 + assert "Benchmark aggregate results" in table + assert "Retrieval term precision" in table + assert "75.00%" in table + assert "Retrieval success overall" in table + assert "Answer term recall" in table + assert "66.67%" in table + assert "Ollama latency p95" in table + assert "Throughput" in table + + +def test_summary_distinguishes_completed_and_overall_retrieval_rates() -> None: + report = { + "status": "partial_failure", + "backend": BACKEND_QDRANT, + "benchmark_id": "bench-test", + "ollama": {"model": "qwen2.5:0.5b"}, + "ltm": {"collection": "dmf_benchmark_test"}, + "seed": {"count_after_seed": 4}, + "aggregate": { + "case_count": 3, + "completed_case_count": 2, + "failed_case_count": 1, + "retrieval_success_count": 1, + "retrieval_success_rate": 1 / 3, + }, + "cases": [ + { + "id": "case-a", + "category": "preference", + "error": None, + "retrieval_success": True, + "retrieval_score": {"matched_groups": [["a"]], "missing_groups": []}, + "answer_score": {"matched_groups": [["a"]], "missing_groups": []}, + }, + { + "id": "case-b", + "category": "constraint", + "error": None, + "retrieval_success": False, + "retrieval_score": {"matched_groups": [], "missing_groups": [["b"]]}, + "answer_score": {"matched_groups": [], "missing_groups": [["b"]]}, + }, + { + "id": "case-c", + "category": "state", + "error": "BenchmarkError: empty content", + "retrieval_success": False, + }, + ], + } + + summary = summarize_report(report) + table = render_summary_table(summary) + + assert summary["retrieval_success_rate"] == pytest.approx(0.5) + assert summary["retrieval_success_rate_overall"] == pytest.approx(1 / 3) + assert "50.00%" in table + assert "33.33%" in table + + +def test_load_report_rejects_non_object_json(tmp_path: Path) -> None: + path = tmp_path / "report.json" + path.write_text("[]", encoding="utf-8") + + with pytest.raises(BenchmarkError, match="JSON object"): + load_report(path) From 99c100b2de382e5d526bd7bdaf907ed30c2381e3 Mon Sep 17 00:00:00 2001 From: mat Date: Mon, 13 Jul 2026 22:57:38 +0200 Subject: [PATCH 8/9] fix(memory): clear card vectors with raw records --- dmf/memory/ltm_hooks/chroma_hook.py | 76 ++++++++++++++++++++--------- dmf/memory/ltm_hooks/qdrant_hook.py | 20 +++++--- tests/test_chroma_ltm.py | 38 ++++++++++++--- tests/test_qdrant_ltm.py | 20 +++++++- 4 files changed, 115 insertions(+), 39 deletions(-) diff --git a/dmf/memory/ltm_hooks/chroma_hook.py b/dmf/memory/ltm_hooks/chroma_hook.py index 9c1b3d6..27028ef 100644 --- a/dmf/memory/ltm_hooks/chroma_hook.py +++ b/dmf/memory/ltm_hooks/chroma_hook.py @@ -174,27 +174,47 @@ def archive(self, entry: MemoryEntry) -> None: ) if getattr(self, "_cards_collection", None) is not None: cards = self._card_projector.project(entry) - source_vector = entry.vector.tolist() - for card in cards: - card_text = " ".join( - piece for piece in [card.kind, card.subject, card.predicate, card.object] - if piece - ) - card_payload = build_card_payload(card) - card_metadata = { - "card": json.dumps(card_payload["card"], ensure_ascii=False), - "card_id": card.card_id, - "source_record_id": card.provenance.source_record_id, - "kind": card.kind, - "raw_role": raw_record.role, - "raw_interaction_id": raw_record.interaction_id, - "raw_created_at": raw_record.created_at, - } + if cards: + source_vector = entry.vector.tolist() + card_ids: list[str] = [] + card_embeddings: list[list[float]] = [] + card_documents: list[str] = [] + card_metadatas: list[dict[str, object]] = [] + for card in cards: + card_text = " ".join( + piece + for piece in [ + card.kind, + card.subject, + card.predicate, + card.object, + ] + if piece + ) + card_payload = build_card_payload(card) + card_ids.append(card.card_id) + card_embeddings.append(source_vector) + card_documents.append(card_text) + card_metadatas.append( + { + "card": json.dumps( + card_payload["card"], + ensure_ascii=False, + ), + "card_id": card.card_id, + "source_record_id": card.provenance.source_record_id, + "kind": card.kind, + "raw_role": raw_record.role, + "raw_interaction_id": raw_record.interaction_id, + "raw_created_at": raw_record.created_at, + } + ) + self._cards_collection.upsert( - ids=[card.card_id], - embeddings=[source_vector], - documents=[card_text], - metadatas=[card_metadata], + ids=card_ids, + embeddings=card_embeddings, + documents=card_documents, + metadatas=card_metadatas, ) if self._card_store is not None: self._card_store.archive(entry) @@ -396,7 +416,7 @@ def count(self) -> int: return self._collection.count() def clear(self) -> None: - """Delete all indexed records from the raw-record collection. + """Delete all indexed records from the raw and card collections. Returns: None. @@ -404,9 +424,17 @@ def clear(self) -> None: Raises: ChromaDB exceptions may surface during deletion. """ - ids = self._collection.get(include=[])["ids"] - if ids: - self._collection.delete(ids=ids) + with self._lock: + collections = ( + self._collection, + getattr(self, "_cards_collection", None), + ) + for collection in collections: + if collection is None: + continue + ids = collection.get(include=[])["ids"] + if ids: + collection.delete(ids=ids) @property def card_store(self) -> JsonlMemoryCardStore | None: diff --git a/dmf/memory/ltm_hooks/qdrant_hook.py b/dmf/memory/ltm_hooks/qdrant_hook.py index a7a4856..cc579e8 100644 --- a/dmf/memory/ltm_hooks/qdrant_hook.py +++ b/dmf/memory/ltm_hooks/qdrant_hook.py @@ -351,13 +351,21 @@ def count(self) -> int: ) def clear(self) -> None: - """Delete all raw points while preserving the Qdrant collection.""" + """Delete all raw and card points while preserving the collections.""" models = _qdrant_models() - self._client.delete( - collection_name=self._collection_name, - points_selector=models.FilterSelector(filter=models.Filter()), - wait=True, - ) + selector = models.FilterSelector(filter=models.Filter()) + with self._lock: + self._client.delete( + collection_name=self._collection_name, + points_selector=selector, + wait=True, + ) + if self._cards_enabled: + self._client.delete( + collection_name=self._cards_collection_name, + points_selector=selector, + wait=True, + ) @property def card_store(self) -> JsonlMemoryCardStore | None: diff --git a/tests/test_chroma_ltm.py b/tests/test_chroma_ltm.py index 70dd3a7..addc6b2 100644 --- a/tests/test_chroma_ltm.py +++ b/tests/test_chroma_ltm.py @@ -24,6 +24,7 @@ import json import threading +from dataclasses import replace from pathlib import Path import numpy as np @@ -391,18 +392,25 @@ def test_search_raw_skips_records_over_distance_threshold(self) -> None: assert hook.search_raw([0.1, 0.2], k=1) == [] - def test_clear_deletes_existing_ids(self) -> None: + def test_clear_deletes_existing_raw_and_card_ids(self) -> None: collection = _FakeCollection() - deleted: list[list[str]] = [] + cards_collection = _FakeCollection() + raw_deleted: list[list[str]] = [] + card_deleted: list[list[str]] = [] collection.get = lambda include=None: {"ids": ["record:1", "record:2"]} # type: ignore[assignment] - collection.delete = lambda ids: deleted.append(ids) # type: ignore[assignment] + collection.delete = lambda ids: raw_deleted.append(ids) # type: ignore[assignment] + cards_collection.get = lambda include=None: {"ids": ["card:1"]} # type: ignore[assignment] + cards_collection.delete = lambda ids: card_deleted.append(ids) # type: ignore[assignment] hook = ChromaLTMHook.__new__(ChromaLTMHook) hook._collection = collection + hook._cards_collection = cards_collection + hook._lock = threading.Lock() hook.clear() - assert deleted == [["record:1", "record:2"]] + assert raw_deleted == [["record:1", "record:2"]] + assert card_deleted == [["card:1"]] def test_from_dmf_config_passes_card_settings_to_chroma_hook( self, @@ -652,20 +660,34 @@ def test_search_cards_on_disabled_hook_returns_empty(self) -> None: assert hook.search_cards([0.1, 0.2]) == [] - def test_archive_upserts_card_into_cards_collection(self) -> None: + def test_archive_batches_cards_into_one_collection_upsert(self) -> None: hook, main_col, cards_col = self._make_hook_with_fake_collections(cards_enabled=True) assert cards_col is not None entry = _make_entry() + projected = hook._card_projector.project(entry) + assert projected + first_card = projected[0] + + class TwoCardProjector: + def project(self, entry: MemoryEntry) -> list: # noqa: ARG002 + return [ + first_card, + replace(first_card, card_id=f"{first_card.card_id}:second"), + ] + + hook._card_projector = TwoCardProjector() hook.archive(entry) # The main raw record must always be upserted assert len(main_col.upsert_calls) == 1 - # A projectable entry ("Alice booked three tickets to Paris.") should - # produce at least one card upserted to the cards collection. - assert len(cards_col.upsert_calls) >= 1 + assert len(cards_col.upsert_calls) == 1 card_upsert = cards_col.upsert_calls[0] + assert len(card_upsert["ids"]) == 2 + assert len(card_upsert["embeddings"]) == 2 + assert len(card_upsert["documents"]) == 2 + assert len(card_upsert["metadatas"]) == 2 meta = card_upsert["metadatas"][0] assert "card" in meta assert meta["source_record_id"] == "record:7" diff --git a/tests/test_qdrant_ltm.py b/tests/test_qdrant_ltm.py index 6aacc73..00b0ca7 100644 --- a/tests/test_qdrant_ltm.py +++ b/tests/test_qdrant_ltm.py @@ -644,5 +644,23 @@ def test_search_cards_skips_malformed_payloads_and_orphan_sources() -> None: hook.clear() assert hook.count() == 0 - assert hook.count_cards() == 3 + assert hook.count_cards() == 0 assert hook.search_cards([1.0, 0.0], k=5) == [] + + +def test_clear_removes_cards_before_collection_reuse(tmp_path: Path) -> None: + hook = _hook( + cards_enabled=True, + cards_path=tmp_path / "cards.jsonl", + distance_threshold=2.0, + ) + hook.archive(_make_card_entry(10, "alpha", [1.0, 0.0])) + + hook.clear() + hook.archive(_make_card_entry(20, "beta", [0.0, 1.0])) + + assert hook.count() == 1 + assert hook.count_cards() == 1 + assert [ + hit.record.record_id for hit in hook.search_cards([1.0, 0.0], k=1) + ] == ["record:20"] From 4b9126bc5cd3177e79fe7a7608748807413ef4a8 Mon Sep 17 00:00:00 2001 From: mat Date: Fri, 17 Jul 2026 22:00:25 +0200 Subject: [PATCH 9/9] chore: comment qdrant search cards --- dmf/memory/ltm_hooks/qdrant_hook.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dmf/memory/ltm_hooks/qdrant_hook.py b/dmf/memory/ltm_hooks/qdrant_hook.py index cc579e8..7b91a11 100644 --- a/dmf/memory/ltm_hooks/qdrant_hook.py +++ b/dmf/memory/ltm_hooks/qdrant_hook.py @@ -277,6 +277,11 @@ def search_cards( with_payload=True, with_vectors=False, ) + + # Retrieval is deduplicated by raw source, but the result below must + # preserve card rank and duplicate card hits. Qdrant may also omit + # missing points, so joining by record_id is safer than relying on + # response order or zipping raw points with candidates. records_by_id: dict[str, RawLTMRecord] = {} for point in raw_points: payload = point.payload