pycontextdb 0.4.1__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/CHANGELOG.md +7 -0
  2. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/PKG-INFO +1 -1
  3. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/__init__.py +1 -1
  4. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/client.py +3 -2
  5. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/dynamics/retrieval.py +28 -16
  6. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/store/base.py +26 -6
  7. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/store/postgres_store.py +28 -8
  8. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/store/sqlite_store.py +2 -0
  9. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/pyproject.toml +1 -1
  10. pycontextdb-0.4.2/tests/unit/test_pg_sql.py +228 -0
  11. pycontextdb-0.4.2/tests/unit/test_retrieval.py +84 -0
  12. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_store.py +39 -2
  13. pycontextdb-0.4.1/tests/unit/test_pg_sql.py +0 -94
  14. pycontextdb-0.4.1/tests/unit/test_retrieval.py +0 -30
  15. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/.github/CODEOWNERS +0 -0
  16. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/.github/copilot-instructions.md +0 -0
  17. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/.github/pull_request_template.md +0 -0
  18. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/.github/workflows/ci.yml +0 -0
  19. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/.github/workflows/publish.yml +0 -0
  20. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/.gitignore +0 -0
  21. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/.pre-commit-config.yaml +0 -0
  22. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/COMMERCIAL.md +0 -0
  23. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/CONTRIBUTING.md +0 -0
  24. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/LICENSE +0 -0
  25. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/NOTICE +0 -0
  26. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/OPEN_CORE.md +0 -0
  27. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/README.md +0 -0
  28. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/ai-tools/.cursorrules +0 -0
  29. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/ai-tools/CLAUDE.md +0 -0
  30. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/ai-tools/README.md +0 -0
  31. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/ai-tools/llms-full.txt +0 -0
  32. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/ai-tools/llms.txt +0 -0
  33. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/assets/README.md +0 -0
  34. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/assets/architecture.png +0 -0
  35. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/assets/benchmarks.png +0 -0
  36. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/assets/hero.png +0 -0
  37. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/assets/performance.png +0 -0
  38. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/assets/problem.png +0 -0
  39. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/assets/quickstart.png +0 -0
  40. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/benchmarks/__init__.py +0 -0
  41. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/benchmarks/run_benchmarks.py +0 -0
  42. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/benchmarks/trust_bakeoff.py +0 -0
  43. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/agents/__init__.py +0 -0
  44. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/agents/memory_bus.py +0 -0
  45. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/agents/rl_manager.py +0 -0
  46. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/cli.py +0 -0
  47. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/connectors/__init__.py +0 -0
  48. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/connectors/base.py +0 -0
  49. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/connectors/postgres.py +0 -0
  50. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/core/__init__.py +0 -0
  51. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/core/clock.py +0 -0
  52. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/core/config.py +0 -0
  53. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/core/exceptions.py +0 -0
  54. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/core/models.py +0 -0
  55. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/core/policy.py +0 -0
  56. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/core/slots.py +0 -0
  57. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/dynamics/__init__.py +0 -0
  58. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/dynamics/evolution.py +0 -0
  59. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/dynamics/formation.py +0 -0
  60. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/dynamics/salience.py +0 -0
  61. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/dynamics/trust.py +0 -0
  62. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/graphs/__init__.py +0 -0
  63. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/graphs/base.py +0 -0
  64. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/graphs/causal.py +0 -0
  65. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/graphs/entity.py +0 -0
  66. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/graphs/semantic.py +0 -0
  67. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/graphs/temporal.py +0 -0
  68. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/__init__.py +0 -0
  69. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/act.py +0 -0
  70. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/autogen.py +0 -0
  71. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/crewai.py +0 -0
  72. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/langchain.py +0 -0
  73. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/livekit.py +0 -0
  74. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/openai_tools.py +0 -0
  75. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/pipecat.py +0 -0
  76. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/integrations/prompting.py +0 -0
  77. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/mcp.py +0 -0
  78. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/memory/__init__.py +0 -0
  79. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/memory/experiential.py +0 -0
  80. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/memory/factual.py +0 -0
  81. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/memory/working.py +0 -0
  82. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/pool.py +0 -0
  83. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/privacy/__init__.py +0 -0
  84. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/privacy/audit.py +0 -0
  85. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/privacy/injection.py +0 -0
  86. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/privacy/pii_detector.py +0 -0
  87. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/privacy/retention.py +0 -0
  88. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/py.typed +0 -0
  89. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/serve.py +0 -0
  90. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/store/__init__.py +0 -0
  91. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/store/factory.py +0 -0
  92. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/store/pg_sql.py +0 -0
  93. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/store/vector_index.py +0 -0
  94. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/utils/__init__.py +0 -0
  95. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/utils/embeddings.py +0 -0
  96. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/utils/llm.py +0 -0
  97. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/contextdb/utils/migrations.py +0 -0
  98. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/api.md +0 -0
  99. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/architecture.md +0 -0
  100. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/connectors.md +0 -0
  101. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/index.md +0 -0
  102. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/memory_evolution.md +0 -0
  103. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/migrations.md +0 -0
  104. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/multi_tenant.md +0 -0
  105. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/postgres.md +0 -0
  106. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/prompts.md +0 -0
  107. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/quickstart.md +0 -0
  108. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/serve.md +0 -0
  109. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/docs/trust_policy.md +0 -0
  110. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/examples/customer_support.py +0 -0
  111. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/examples/multi_agent_team.py +0 -0
  112. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/examples/phone_agent.py +0 -0
  113. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/examples/research_assistant.py +0 -0
  114. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/legal/CLA-entity.md +0 -0
  115. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/legal/CLA-individual.md +0 -0
  116. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/legal/signed/README.md +0 -0
  117. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/mkdocs.yml +0 -0
  118. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/scripts/check_open_core_boundary.py +0 -0
  119. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/scripts/check_release.py +0 -0
  120. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/__init__.py +0 -0
  121. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/conftest.py +0 -0
  122. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/evals/__init__.py +0 -0
  123. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/evals/test_trust_model.py +0 -0
  124. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/integration/__init__.py +0 -0
  125. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/integration/test_client.py +0 -0
  126. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/integration/test_langchain_integration.py +0 -0
  127. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/pg_util.py +0 -0
  128. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/test_version.py +0 -0
  129. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/__init__.py +0 -0
  130. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_audit.py +0 -0
  131. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_config.py +0 -0
  132. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_connectors.py +0 -0
  133. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_decision_scope.py +0 -0
  134. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_embeddings.py +0 -0
  135. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_evolution.py +0 -0
  136. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_formation.py +0 -0
  137. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_graphs.py +0 -0
  138. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_host_api.py +0 -0
  139. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_init.py +0 -0
  140. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_memory_bus.py +0 -0
  141. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_memory_evolution.py +0 -0
  142. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_models.py +0 -0
  143. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_open_core_boundary.py +0 -0
  144. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_pii_detector.py +0 -0
  145. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_postgres_concurrency.py +0 -0
  146. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_postgres_connector.py +0 -0
  147. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_postgres_memory_evolution.py +0 -0
  148. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_retention.py +0 -0
  149. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_scoped_id_access.py +0 -0
  150. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_serve.py +0 -0
  151. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_trust_policy.py +0 -0
  152. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_vector_index.py +0 -0
  153. {pycontextdb-0.4.1 → pycontextdb-0.4.2}/tests/unit/test_vector_scope.py +0 -0
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.2 — 2026-09-20
6
+
7
+ - Recall now bounds sibling composition before deep-copying Postgres cache
8
+ entries and borrows read-only candidate references while ranking. Final
9
+ results remain detached, and entity priority, temporal validity, user scope,
10
+ and composition limits are unchanged.
11
+
5
12
  ## 0.4.1 — 2026-08-24
6
13
 
7
14
  - NOOP audit entries now carry the existing memory ID and canonical slot
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pycontextdb
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: The unified context layer for AI agents — memory you can act on without treating wishes as facts
5
5
  Project-URL: Homepage, https://github.com/atomsai/contextdb
6
6
  Project-URL: Documentation, https://contextdb.dev
@@ -41,7 +41,7 @@ from contextdb.core.policy import TrustPolicy
41
41
  from contextdb.pool import ContextDBPool
42
42
  from contextdb.utils.embeddings import EmbeddingProvider
43
43
 
44
- __version__ = "0.4.1"
44
+ __version__ = "0.4.2"
45
45
 
46
46
  __all__ = [
47
47
  "Clock",
@@ -1928,6 +1928,9 @@ class ContextDB:
1928
1928
  siblings = await store.list_by_entities(
1929
1929
  entity_keys,
1930
1930
  user_id=user_id,
1931
+ exclude_ids=seen,
1932
+ valid_at=moment,
1933
+ limit=extra,
1931
1934
  )
1932
1935
  siblings_by_entity: dict[str, list[MemoryItem]] = {}
1933
1936
  for sibling in siblings:
@@ -1940,8 +1943,6 @@ class ContextDB:
1940
1943
  if not item.entity_key:
1941
1944
  continue
1942
1945
  for sibling in siblings_by_entity.get(item.entity_key, []):
1943
- if sibling.id in seen or not sibling.is_valid_at(moment):
1944
- continue
1945
1946
  seen.add(sibling.id)
1946
1947
  extras.append(sibling)
1947
1948
  if len(extras) >= extra:
@@ -15,7 +15,7 @@ heuristics below get ~80% of the signal for zero cost.
15
15
  from __future__ import annotations
16
16
 
17
17
  import re
18
- from dataclasses import dataclass, field
18
+ from dataclasses import dataclass, field, replace
19
19
  from datetime import datetime
20
20
  from typing import TYPE_CHECKING
21
21
 
@@ -166,7 +166,10 @@ class RetrievalEngine:
166
166
  """RRF-fuse per-graph rankings, then multiply by salience."""
167
167
  weights = self.classifier.classify(query)
168
168
  seed_items = await self.store.search_by_embedding(
169
- query_embedding, top_k=top_k * 2, user_id=user_id
169
+ query_embedding,
170
+ top_k=top_k * 2,
171
+ user_id=user_id,
172
+ copy_items=False,
170
173
  )
171
174
  semantic_ranking = [(item.id, 1.0 / (rank + 1)) for rank, item in enumerate(seed_items)]
172
175
  rankings: dict[str, list[tuple[str, float]]] = {"semantic": semantic_ranking}
@@ -201,25 +204,34 @@ class RetrievalEngine:
201
204
  if not fused:
202
205
  # No fusion signal (empty store or zero weights): rank the seeds
203
206
  # directly, still applying salience.
204
- return [
207
+ scored = [
205
208
  self._score(
206
209
  item, 1.0 / (rank + 1), per_graph_ranks, now, seed_cosine.get(item.id, 0.0)
207
210
  )
208
211
  for rank, item in enumerate(seed_items[:top_k])
209
212
  ]
210
-
211
- scored: list[ScoredMemory] = []
212
- for mid, rrf_score in fused:
213
- item = seed_by_id.get(mid)
214
- if item is None:
215
- item = await self.store.get_raw(mid)
216
- if item is None:
217
- continue
218
- scored.append(
219
- self._score(item, rrf_score, per_graph_ranks, now, seed_cosine.get(mid, 0.0))
220
- )
221
- scored.sort(key=lambda s: s.final_score, reverse=True)
222
- return scored[:top_k]
213
+ else:
214
+ scored = []
215
+ for mid, rrf_score in fused:
216
+ item = seed_by_id.get(mid)
217
+ if item is None:
218
+ item = await self.store.get_raw(mid)
219
+ if item is None:
220
+ continue
221
+ scored.append(
222
+ self._score(
223
+ item,
224
+ rrf_score,
225
+ per_graph_ranks,
226
+ now,
227
+ seed_cosine.get(mid, 0.0),
228
+ )
229
+ )
230
+ scored.sort(key=lambda s: s.final_score, reverse=True)
231
+ return [
232
+ replace(score, item=score.item.model_copy(deep=True))
233
+ for score in scored[:top_k]
234
+ ]
223
235
 
224
236
  def _score(
225
237
  self,
@@ -12,6 +12,7 @@ from contextdb.core.models import MemoryConsistencyToken, MemoryStatus
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from contextlib import AbstractAsyncContextManager
15
+ from datetime import datetime
15
16
 
16
17
  from contextdb.core.models import MemoryItem, MemoryType
17
18
 
@@ -54,11 +55,15 @@ class BaseStore(ABC):
54
55
  top_k: int = 10,
55
56
  filters: dict[str, object] | None = None,
56
57
  user_id: str | None = None,
58
+ *,
59
+ copy_items: bool = True,
57
60
  ) -> list[MemoryItem]:
58
61
  """Return top-k most similar memories by cosine similarity.
59
62
 
60
63
  ``user_id`` filters the result. A store constructed with a fixed
61
64
  user scope ignores a conflicting per-call value and stays scoped.
65
+ ``copy_items=False`` is reserved for internal read-only ranking;
66
+ public callers receive detached items by default.
62
67
  """
63
68
 
64
69
  @abstractmethod
@@ -148,16 +153,31 @@ class BaseStore(ABC):
148
153
  entity_keys: list[str],
149
154
  status: MemoryStatus | None = MemoryStatus.ACTIVE,
150
155
  user_id: str | None = None,
156
+ *,
157
+ exclude_ids: set[str] | None = None,
158
+ valid_at: datetime | None = None,
159
+ limit: int | None = None,
151
160
  ) -> list[MemoryItem]:
161
+ """List scoped entity memories with optional bounded composition filters."""
162
+
163
+ if limit is not None and limit <= 0:
164
+ return []
165
+ excluded = exclude_ids or set()
152
166
  items: list[MemoryItem] = []
153
167
  for entity_key in dict.fromkeys(entity_keys):
154
- items.extend(
155
- await self.list_by_entity(
156
- entity_key,
157
- status=status,
158
- user_id=user_id,
159
- )
168
+ candidates = await self.list_by_entity(
169
+ entity_key,
170
+ status=status,
171
+ user_id=user_id,
160
172
  )
173
+ for item in candidates:
174
+ if item.id in excluded:
175
+ continue
176
+ if valid_at is not None and not item.is_valid_at(valid_at):
177
+ continue
178
+ items.append(item)
179
+ if limit is not None and len(items) >= limit:
180
+ return items
161
181
  return items
162
182
 
163
183
  async def list_pending_consolidation(self, limit: int = 100) -> list[MemoryItem]:
@@ -940,6 +940,8 @@ class PostgresStore(BaseStore):
940
940
  top_k: int = 10,
941
941
  filters: dict[str, object] | None = None,
942
942
  user_id: str | None = None,
943
+ *,
944
+ copy_items: bool = True,
943
945
  ) -> list[MemoryItem]:
944
946
  index = await self._ensure_index()
945
947
  query = np.asarray(embedding, dtype=np.float32)
@@ -968,7 +970,7 @@ class PostgresStore(BaseStore):
968
970
  continue
969
971
  if filters and not _passes_filters(item, filters):
970
972
  continue
971
- results.append(item.model_copy(deep=True))
973
+ results.append(item.model_copy(deep=True) if copy_items else item)
972
974
  if len(results) >= top_k:
973
975
  break
974
976
  return results
@@ -1047,22 +1049,40 @@ class PostgresStore(BaseStore):
1047
1049
  entity_keys: list[str],
1048
1050
  status: MemoryStatus | None = MemoryStatus.ACTIVE,
1049
1051
  user_id: str | None = None,
1052
+ *,
1053
+ exclude_ids: set[str] | None = None,
1054
+ valid_at: datetime | None = None,
1055
+ limit: int | None = None,
1050
1056
  ) -> list[MemoryItem]:
1051
1057
  if status != MemoryStatus.ACTIVE:
1052
1058
  return await super().list_by_entities(
1053
1059
  entity_keys,
1054
1060
  status=status,
1055
1061
  user_id=user_id,
1062
+ exclude_ids=exclude_ids,
1063
+ valid_at=valid_at,
1064
+ limit=limit,
1056
1065
  )
1066
+ if limit is not None and limit <= 0:
1067
+ return []
1057
1068
  if not self._index_loaded:
1058
1069
  await self._ensure_index()
1059
- keys = set(entity_keys)
1060
- return [
1061
- item.model_copy(deep=True)
1062
- for item in self._index_items.values()
1063
- if item.entity_key in keys
1064
- and self._item_scope_allows(item, user_id)
1065
- ]
1070
+ excluded = exclude_ids or set()
1071
+ items: list[MemoryItem] = []
1072
+ for entity_key in dict.fromkeys(entity_keys):
1073
+ for item in self._index_items.values():
1074
+ if item.entity_key != entity_key:
1075
+ continue
1076
+ if not self._item_scope_allows(item, user_id):
1077
+ continue
1078
+ if item.id in excluded:
1079
+ continue
1080
+ if valid_at is not None and not item.is_valid_at(valid_at):
1081
+ continue
1082
+ items.append(item.model_copy(deep=True))
1083
+ if limit is not None and len(items) >= limit:
1084
+ return items
1085
+ return items
1066
1086
 
1067
1087
  async def list_pending_consolidation(self, limit: int = 100) -> list[MemoryItem]:
1068
1088
  sql = (
@@ -821,6 +821,8 @@ class SQLiteStore(BaseStore):
821
821
  top_k: int = 10,
822
822
  filters: dict[str, object] | None = None,
823
823
  user_id: str | None = None,
824
+ *,
825
+ copy_items: bool = True,
824
826
  ) -> list[MemoryItem]:
825
827
  conn = self._require_conn()
826
828
  index = await self._ensure_index()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycontextdb"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "The unified context layer for AI agents — memory you can act on without treating wishes as facts"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1,228 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Any
3
+
4
+ import pytest
5
+
6
+ from contextdb.core.exceptions import ConfigError
7
+ from contextdb.core.models import MemoryItem
8
+ from contextdb.store.factory import (
9
+ is_postgres_url,
10
+ normalize_postgres_url,
11
+ open_store,
12
+ )
13
+ from contextdb.store.pg_sql import qmark_to_dollar, split_script, translate_sqlite_sql
14
+ from contextdb.store.postgres_store import PostgresStore, _PgAdapter
15
+
16
+
17
+ def test_qmark_to_dollar() -> None:
18
+ assert qmark_to_dollar("SELECT * FROM t WHERE a = ? AND b = ?") == (
19
+ "SELECT * FROM t WHERE a = $1 AND b = $2"
20
+ )
21
+
22
+
23
+ def test_insert_or_replace_semantic() -> None:
24
+ sql = translate_sqlite_sql(
25
+ "INSERT OR REPLACE INTO semantic_edges "
26
+ "(source_id, target_id, weight, metadata, created_at) VALUES (?,?,?,?,?)"
27
+ )
28
+ assert "ON CONFLICT (source_id, target_id) DO UPDATE" in sql
29
+ assert "$5" in sql
30
+
31
+
32
+ def test_insert_or_ignore() -> None:
33
+ sql = translate_sqlite_sql("INSERT OR IGNORE INTO memory_entity_edges VALUES (?,?,?,?)")
34
+ assert "ON CONFLICT DO NOTHING" in sql
35
+ assert "$4" in sql
36
+
37
+
38
+ def test_split_script() -> None:
39
+ assert split_script("A;\nB;\n") == ["A", "B"]
40
+
41
+
42
+ def test_postgres_url() -> None:
43
+ assert is_postgres_url("postgresql://localhost/db")
44
+ assert is_postgres_url("postgres://localhost/db")
45
+ assert not is_postgres_url("sqlite:///x.db")
46
+ assert normalize_postgres_url("postgresql+asyncpg://h/db") == "postgresql://h/db"
47
+
48
+
49
+ def test_external_postgres_pool_rejects_sqlite() -> None:
50
+ with pytest.raises(ConfigError, match="requires a PostgreSQL"):
51
+ open_store("sqlite:///:memory:", postgres_pool=object())
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_postgres_store_does_not_close_external_pool() -> None:
56
+ class FakePool:
57
+ def __init__(self) -> None:
58
+ self.closed = 0
59
+
60
+ async def close(self) -> None:
61
+ self.closed += 1
62
+
63
+ pool = FakePool()
64
+ store = PostgresStore("postgresql://example/contextdb", pool=pool)
65
+ await store.close()
66
+ assert pool.closed == 0
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_list_by_entities_filters_and_copies_only_the_requested_limit(
71
+ monkeypatch: pytest.MonkeyPatch,
72
+ ) -> None:
73
+ store = PostgresStore(
74
+ "postgresql://example/contextdb",
75
+ tenant_id="tenant-1",
76
+ agent_id="agent-1",
77
+ pool=object(),
78
+ )
79
+ now = datetime.now(tz=timezone.utc)
80
+ entity_b = [
81
+ MemoryItem(
82
+ content=f"b-{index}",
83
+ entity_key="entity-b",
84
+ user_id="user-1",
85
+ tenant_id="tenant-1",
86
+ agent_id="agent-1",
87
+ )
88
+ for index in range(10)
89
+ ]
90
+ entity_a = [
91
+ MemoryItem(
92
+ content=f"a-{index}",
93
+ entity_key="entity-a",
94
+ user_id="user-1",
95
+ tenant_id="tenant-1",
96
+ agent_id="agent-1",
97
+ metadata={"nested": [index]},
98
+ )
99
+ for index in range(20)
100
+ ]
101
+ future = MemoryItem(
102
+ content="future",
103
+ entity_key="entity-a",
104
+ user_id="user-1",
105
+ tenant_id="tenant-1",
106
+ agent_id="agent-1",
107
+ valid_from=now + timedelta(days=1),
108
+ )
109
+ indexed = [*entity_b, future, *entity_a]
110
+ store._index_items = {item.id: item for item in indexed}
111
+ store._index_loaded = True
112
+
113
+ original_model_copy = MemoryItem.model_copy
114
+ copies = 0
115
+
116
+ def counted_model_copy(
117
+ self: MemoryItem,
118
+ *,
119
+ update: dict[str, Any] | None = None,
120
+ deep: bool = False,
121
+ ) -> MemoryItem:
122
+ nonlocal copies
123
+ copies += 1
124
+ return original_model_copy(self, update=update, deep=deep)
125
+
126
+ monkeypatch.setattr(MemoryItem, "model_copy", counted_model_copy)
127
+ results = await store.list_by_entities(
128
+ ["entity-a", "entity-b"],
129
+ user_id="user-1",
130
+ exclude_ids={entity_a[0].id},
131
+ valid_at=now,
132
+ limit=5,
133
+ )
134
+
135
+ assert len(results) == 5
136
+ assert copies == 5
137
+ assert all(item.entity_key == "entity-a" for item in results)
138
+ assert entity_a[0].id not in {item.id for item in results}
139
+ results[0].metadata["nested"].append(99)
140
+ source = store._index_items[results[0].id]
141
+ assert source.metadata["nested"] != results[0].metadata["nested"]
142
+
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_postgres_embedding_search_can_borrow_read_only_index_items(
146
+ monkeypatch: pytest.MonkeyPatch,
147
+ ) -> None:
148
+ store = PostgresStore(
149
+ "postgresql://example/contextdb",
150
+ tenant_id="tenant-1",
151
+ agent_id="agent-1",
152
+ pool=object(),
153
+ )
154
+ source = MemoryItem(
155
+ content="Thursday is confirmed",
156
+ embedding=[1.0, 0.0],
157
+ user_id="user-1",
158
+ tenant_id="tenant-1",
159
+ agent_id="agent-1",
160
+ metadata={"nested": ["source"]},
161
+ )
162
+ store._index_items = {source.id: source}
163
+
164
+ class FakeIndex:
165
+ def search(
166
+ self,
167
+ _query: object,
168
+ *,
169
+ top_k: int,
170
+ include_ids: set[str],
171
+ ) -> list[tuple[str, float]]:
172
+ assert top_k == 1
173
+ assert include_ids == {source.id}
174
+ return [(source.id, 1.0)]
175
+
176
+ async def ensure_index() -> Any:
177
+ return FakeIndex()
178
+
179
+ monkeypatch.setattr(store, "_ensure_index", ensure_index)
180
+ copied = await store.search_by_embedding(
181
+ [1.0, 0.0],
182
+ top_k=1,
183
+ user_id="user-1",
184
+ )
185
+ borrowed = await store.search_by_embedding(
186
+ [1.0, 0.0],
187
+ top_k=1,
188
+ user_id="user-1",
189
+ copy_items=False,
190
+ )
191
+
192
+ assert copied == [source]
193
+ assert copied[0] is not source
194
+ assert borrowed[0] is source
195
+ copied[0].metadata["nested"].append("caller")
196
+ assert source.metadata == {"nested": ["source"]}
197
+
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_bound_adapter_reuses_advisory_transaction_connection() -> None:
201
+ class NoAcquirePool:
202
+ def acquire(self) -> None:
203
+ raise AssertionError("bound execution must not acquire another connection")
204
+
205
+ class FakeConnection:
206
+ def __init__(self) -> None:
207
+ self.statements: list[str] = []
208
+
209
+ async def fetch(self, sql: str, *params: object) -> list[dict[str, int]]:
210
+ self.statements.append(sql)
211
+ return [{"value": 1}]
212
+
213
+ async def execute(self, sql: str, *params: object) -> str:
214
+ self.statements.append(sql)
215
+ return "UPDATE 1"
216
+
217
+ adapter = _PgAdapter(NoAcquirePool())
218
+ connection = FakeConnection()
219
+ token = adapter.bind(connection)
220
+ try:
221
+ selected = await adapter.execute("SELECT value FROM example")
222
+ updated = await adapter.execute("UPDATE example SET value = ?", (2,))
223
+ finally:
224
+ adapter.reset(token)
225
+
226
+ assert await selected.fetchall() == [{"value": 1}]
227
+ assert updated.rowcount == 1
228
+ assert len(connection.statements) == 2
@@ -0,0 +1,84 @@
1
+ """Tests for multi-graph retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from contextdb.core.models import MemoryItem
8
+ from contextdb.dynamics.retrieval import (
9
+ QueryClassifier,
10
+ RetrievalEngine,
11
+ RetrievalFuser,
12
+ )
13
+
14
+
15
+ def test_query_classifier_weights() -> None:
16
+ qc = QueryClassifier()
17
+ weights_temporal = qc.classify("when did we last talk?")
18
+ assert "temporal" in weights_temporal
19
+ weights_causal = qc.classify("why did the build fail?")
20
+ assert "causal" in weights_causal
21
+ weights_generic = qc.classify("tell me about python")
22
+ assert weights_generic["semantic"] == 1.0
23
+
24
+
25
+ def test_rrf_fusion() -> None:
26
+ fuser = RetrievalFuser(k=60)
27
+ rankings = {
28
+ "semantic": [("a", 0.9), ("b", 0.7), ("c", 0.5)],
29
+ "temporal": [("b", 1.0), ("c", 0.8)],
30
+ }
31
+ weights = {"semantic": 0.5, "temporal": 0.5}
32
+ fused = fuser.fuse(rankings, weights)
33
+ ranked_ids = [mid for mid, _ in fused]
34
+ # "b" appears higher than "a" on aggregate (rank 2 semantic + rank 1 temporal
35
+ # vs. rank 1 semantic only).
36
+ assert ranked_ids[0] == "b"
37
+ assert set(ranked_ids) == {"a", "b", "c"}
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_retrieval_ranks_store_references_but_returns_detached_items() -> None:
42
+ source = MemoryItem(
43
+ content="Thursday is confirmed",
44
+ embedding=[1.0, 0.0],
45
+ metadata={"nested": ["source"]},
46
+ )
47
+
48
+ class ReferenceStore:
49
+ async def search_by_embedding(
50
+ self,
51
+ _embedding: list[float],
52
+ top_k: int = 10,
53
+ filters: dict[str, object] | None = None,
54
+ user_id: str | None = None,
55
+ *,
56
+ copy_items: bool = True,
57
+ ) -> list[MemoryItem]:
58
+ assert top_k == 2
59
+ assert filters is None
60
+ assert user_id == "user-1"
61
+ assert copy_items is False
62
+ return [source]
63
+
64
+ async def get_raw(self, _memory_id: str) -> MemoryItem | None:
65
+ return None
66
+
67
+ engine = RetrievalEngine(
68
+ ReferenceStore(), # type: ignore[arg-type]
69
+ {},
70
+ QueryClassifier(),
71
+ RetrievalFuser(),
72
+ )
73
+ results = await engine.search_scored(
74
+ "visit day",
75
+ [1.0, 0.0],
76
+ top_k=1,
77
+ user_id="user-1",
78
+ )
79
+
80
+ assert len(results) == 1
81
+ assert results[0].item == source
82
+ assert results[0].item is not source
83
+ results[0].item.metadata["nested"].append("caller")
84
+ assert source.metadata == {"nested": ["source"]}
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ from datetime import datetime, timedelta, timezone
6
7
  from pathlib import Path
7
8
 
8
9
  import pytest
@@ -174,6 +175,44 @@ async def test_list_and_count(tmp_store: SQLiteStore) -> None:
174
175
  assert len(items) == 3
175
176
 
176
177
 
178
+ @pytest.mark.asyncio
179
+ async def test_list_by_entities_applies_composition_filters(
180
+ tmp_store: SQLiteStore,
181
+ ) -> None:
182
+ now = datetime.now(tz=timezone.utc)
183
+ first = await tmp_store.add(
184
+ MemoryItem(
185
+ content="first",
186
+ entity_key="customer",
187
+ attribute_key="first",
188
+ )
189
+ )
190
+ second = await tmp_store.add(
191
+ MemoryItem(
192
+ content="second",
193
+ entity_key="customer",
194
+ attribute_key="second",
195
+ )
196
+ )
197
+ await tmp_store.add(
198
+ MemoryItem(
199
+ content="future",
200
+ entity_key="customer",
201
+ attribute_key="future",
202
+ valid_from=now + timedelta(days=1),
203
+ )
204
+ )
205
+
206
+ results = await tmp_store.list_by_entities(
207
+ ["customer"],
208
+ exclude_ids={first.id},
209
+ valid_at=now,
210
+ limit=1,
211
+ )
212
+
213
+ assert [item.id for item in results] == [second.id]
214
+
215
+
177
216
  @pytest.mark.asyncio
178
217
  async def test_search_by_embedding(tmp_store: SQLiteStore) -> None:
179
218
  a = await tmp_store.add(MemoryItem(content="a", embedding=[1.0] + [0.0] * 31))
@@ -249,8 +288,6 @@ async def test_iter_memories_paginates(tmp_store: SQLiteStore) -> None:
249
288
  @pytest.mark.asyncio
250
289
  async def test_delete_older_than(tmp_store: SQLiteStore) -> None:
251
290
  """delete_older_than issues a single SQL delete."""
252
- from datetime import datetime, timezone
253
-
254
291
  item = await tmp_store.add(MemoryItem(content="x", embedding=[0.0] * 32))
255
292
  future = datetime.now(tz=timezone.utc).replace(year=2099).isoformat()
256
293
  removed = await tmp_store.delete_older_than(future, hard=True)