pycontextdb 0.4.1__tar.gz → 0.4.3__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.3}/CHANGELOG.md +15 -0
  2. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/PKG-INFO +1 -1
  3. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/__init__.py +1 -1
  4. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/client.py +3 -2
  5. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/core/models.py +26 -0
  6. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/dynamics/retrieval.py +29 -17
  7. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/store/base.py +26 -6
  8. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/store/postgres_store.py +80 -27
  9. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/store/sqlite_store.py +2 -0
  10. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/store/vector_index.py +60 -13
  11. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/pyproject.toml +1 -1
  12. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/evals/test_trust_model.py +5 -4
  13. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_models.py +52 -0
  14. pycontextdb-0.4.3/tests/unit/test_pg_sql.py +291 -0
  15. pycontextdb-0.4.3/tests/unit/test_retrieval.py +84 -0
  16. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_store.py +39 -2
  17. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_vector_index.py +31 -0
  18. pycontextdb-0.4.1/tests/unit/test_pg_sql.py +0 -94
  19. pycontextdb-0.4.1/tests/unit/test_retrieval.py +0 -30
  20. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/.github/CODEOWNERS +0 -0
  21. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/.github/copilot-instructions.md +0 -0
  22. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/.github/pull_request_template.md +0 -0
  23. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/.github/workflows/ci.yml +0 -0
  24. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/.github/workflows/publish.yml +0 -0
  25. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/.gitignore +0 -0
  26. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/.pre-commit-config.yaml +0 -0
  27. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/COMMERCIAL.md +0 -0
  28. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/CONTRIBUTING.md +0 -0
  29. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/LICENSE +0 -0
  30. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/NOTICE +0 -0
  31. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/OPEN_CORE.md +0 -0
  32. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/README.md +0 -0
  33. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/ai-tools/.cursorrules +0 -0
  34. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/ai-tools/CLAUDE.md +0 -0
  35. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/ai-tools/README.md +0 -0
  36. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/ai-tools/llms-full.txt +0 -0
  37. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/ai-tools/llms.txt +0 -0
  38. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/assets/README.md +0 -0
  39. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/assets/architecture.png +0 -0
  40. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/assets/benchmarks.png +0 -0
  41. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/assets/hero.png +0 -0
  42. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/assets/performance.png +0 -0
  43. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/assets/problem.png +0 -0
  44. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/assets/quickstart.png +0 -0
  45. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/benchmarks/__init__.py +0 -0
  46. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/benchmarks/run_benchmarks.py +0 -0
  47. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/benchmarks/trust_bakeoff.py +0 -0
  48. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/agents/__init__.py +0 -0
  49. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/agents/memory_bus.py +0 -0
  50. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/agents/rl_manager.py +0 -0
  51. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/cli.py +0 -0
  52. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/connectors/__init__.py +0 -0
  53. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/connectors/base.py +0 -0
  54. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/connectors/postgres.py +0 -0
  55. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/core/__init__.py +0 -0
  56. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/core/clock.py +0 -0
  57. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/core/config.py +0 -0
  58. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/core/exceptions.py +0 -0
  59. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/core/policy.py +0 -0
  60. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/core/slots.py +0 -0
  61. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/dynamics/__init__.py +0 -0
  62. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/dynamics/evolution.py +0 -0
  63. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/dynamics/formation.py +0 -0
  64. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/dynamics/salience.py +0 -0
  65. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/dynamics/trust.py +0 -0
  66. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/graphs/__init__.py +0 -0
  67. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/graphs/base.py +0 -0
  68. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/graphs/causal.py +0 -0
  69. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/graphs/entity.py +0 -0
  70. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/graphs/semantic.py +0 -0
  71. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/graphs/temporal.py +0 -0
  72. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/__init__.py +0 -0
  73. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/act.py +0 -0
  74. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/autogen.py +0 -0
  75. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/crewai.py +0 -0
  76. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/langchain.py +0 -0
  77. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/livekit.py +0 -0
  78. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/openai_tools.py +0 -0
  79. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/pipecat.py +0 -0
  80. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/integrations/prompting.py +0 -0
  81. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/mcp.py +0 -0
  82. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/memory/__init__.py +0 -0
  83. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/memory/experiential.py +0 -0
  84. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/memory/factual.py +0 -0
  85. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/memory/working.py +0 -0
  86. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/pool.py +0 -0
  87. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/privacy/__init__.py +0 -0
  88. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/privacy/audit.py +0 -0
  89. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/privacy/injection.py +0 -0
  90. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/privacy/pii_detector.py +0 -0
  91. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/privacy/retention.py +0 -0
  92. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/py.typed +0 -0
  93. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/serve.py +0 -0
  94. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/store/__init__.py +0 -0
  95. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/store/factory.py +0 -0
  96. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/store/pg_sql.py +0 -0
  97. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/utils/__init__.py +0 -0
  98. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/utils/embeddings.py +0 -0
  99. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/utils/llm.py +0 -0
  100. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/contextdb/utils/migrations.py +0 -0
  101. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/api.md +0 -0
  102. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/architecture.md +0 -0
  103. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/connectors.md +0 -0
  104. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/index.md +0 -0
  105. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/memory_evolution.md +0 -0
  106. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/migrations.md +0 -0
  107. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/multi_tenant.md +0 -0
  108. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/postgres.md +0 -0
  109. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/prompts.md +0 -0
  110. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/quickstart.md +0 -0
  111. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/serve.md +0 -0
  112. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/docs/trust_policy.md +0 -0
  113. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/examples/customer_support.py +0 -0
  114. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/examples/multi_agent_team.py +0 -0
  115. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/examples/phone_agent.py +0 -0
  116. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/examples/research_assistant.py +0 -0
  117. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/legal/CLA-entity.md +0 -0
  118. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/legal/CLA-individual.md +0 -0
  119. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/legal/signed/README.md +0 -0
  120. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/mkdocs.yml +0 -0
  121. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/scripts/check_open_core_boundary.py +0 -0
  122. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/scripts/check_release.py +0 -0
  123. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/__init__.py +0 -0
  124. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/conftest.py +0 -0
  125. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/evals/__init__.py +0 -0
  126. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/integration/__init__.py +0 -0
  127. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/integration/test_client.py +0 -0
  128. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/integration/test_langchain_integration.py +0 -0
  129. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/pg_util.py +0 -0
  130. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/test_version.py +0 -0
  131. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/__init__.py +0 -0
  132. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_audit.py +0 -0
  133. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_config.py +0 -0
  134. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_connectors.py +0 -0
  135. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_decision_scope.py +0 -0
  136. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_embeddings.py +0 -0
  137. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_evolution.py +0 -0
  138. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_formation.py +0 -0
  139. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_graphs.py +0 -0
  140. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_host_api.py +0 -0
  141. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_init.py +0 -0
  142. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_memory_bus.py +0 -0
  143. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_memory_evolution.py +0 -0
  144. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_open_core_boundary.py +0 -0
  145. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_pii_detector.py +0 -0
  146. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_postgres_concurrency.py +0 -0
  147. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_postgres_connector.py +0 -0
  148. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_postgres_memory_evolution.py +0 -0
  149. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_retention.py +0 -0
  150. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_scoped_id_access.py +0 -0
  151. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_serve.py +0 -0
  152. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_trust_policy.py +0 -0
  153. {pycontextdb-0.4.1 → pycontextdb-0.4.3}/tests/unit/test_vector_scope.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.3 — 2026-09-20
6
+
7
+ - High-cardinality Postgres recall now indexes cached IDs by user and entity,
8
+ maps vector IDs directly to candidate positions, and detaches only mutable
9
+ result fields at the caller boundary. This avoids whole-cache scope scans and
10
+ generic deep copies while preserving scoped ranking and returned-item
11
+ isolation.
12
+
13
+ ## 0.4.2 — 2026-09-20
14
+
15
+ - Recall now bounds sibling composition before deep-copying Postgres cache
16
+ entries and borrows read-only candidate references while ranking. Final
17
+ results remain detached, and entity priority, temporal validity, user scope,
18
+ and composition limits are unchanged.
19
+
5
20
  ## 0.4.1 — 2026-08-24
6
21
 
7
22
  - 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.3
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.3"
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:
@@ -16,6 +16,7 @@ Design notes:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ from copy import deepcopy
19
20
  from datetime import datetime, timedelta, timezone
20
21
  from enum import Enum
21
22
  from typing import TYPE_CHECKING, Annotated, Any, Literal
@@ -373,6 +374,31 @@ class MemoryItem(BaseModel):
373
374
  return not (self.valid_until is not None and moment >= self.valid_until)
374
375
 
375
376
 
377
+ def _detached_memory_copy(item: MemoryItem) -> MemoryItem:
378
+ """Detach mutable fields without deep-copying immutable scalar fields."""
379
+
380
+ return item.model_copy(
381
+ update={
382
+ "embedding": (
383
+ list(item.embedding) if item.embedding is not None else None
384
+ ),
385
+ "metadata": deepcopy(item.metadata),
386
+ "pii_annotations": [
387
+ annotation.model_copy(deep=True)
388
+ for annotation in item.pii_annotations
389
+ ],
390
+ "retention_policy": (
391
+ item.retention_policy.model_copy(deep=True)
392
+ if item.retention_policy is not None
393
+ else None
394
+ ),
395
+ "entity_mentions": list(item.entity_mentions),
396
+ "tags": list(item.tags),
397
+ "corroborated_by": list(item.corroborated_by),
398
+ }
399
+ )
400
+
401
+
376
402
  _MemoryEvolutionId = Annotated[str, Field(min_length=1, max_length=256)]
377
403
 
378
404
 
@@ -15,14 +15,14 @@ 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
 
22
22
  import numpy as np
23
23
 
24
24
  from contextdb.core.clock import Clock, utc_now
25
- from contextdb.core.models import MemoryItem
25
+ from contextdb.core.models import MemoryItem, _detached_memory_copy
26
26
  from contextdb.dynamics.salience import (
27
27
  DEFAULT_HALF_LIFE_DAYS,
28
28
  age_in_days,
@@ -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=_detached_memory_copy(score.item))
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]:
@@ -30,6 +30,7 @@ from contextdb.core.models import (
30
30
  MemoryItem,
31
31
  MemoryStatus,
32
32
  MemoryType,
33
+ _detached_memory_copy,
33
34
  )
34
35
  from contextdb.store.base import BaseStore
35
36
  from contextdb.store.pg_sql import split_script, translate_sqlite_sql
@@ -279,6 +280,8 @@ class PostgresStore(BaseStore):
279
280
  self._adapter: _PgAdapter | None = None
280
281
  self._index: VectorIndex | None = vector_index
281
282
  self._index_items: dict[str, MemoryItem] = {}
283
+ self._index_ids_by_user: dict[str | None, set[str]] = {}
284
+ self._index_ids_by_entity: dict[str, dict[str, None]] = {}
282
285
  self._embedding_dim = embedding_dim
283
286
  self._embedding_model_id = embedding_model_id
284
287
  self._index_loaded = False
@@ -379,6 +382,35 @@ class PostgresStore(BaseStore):
379
382
  )
380
383
  )
381
384
 
385
+ def _cache_index_item(self, item: MemoryItem) -> None:
386
+ if item.id in self._index_items:
387
+ self._discard_index_item(item.id)
388
+ self._index_items[item.id] = item
389
+ self._index_ids_by_user.setdefault(item.user_id, set()).add(item.id)
390
+ if item.entity_key is not None:
391
+ self._index_ids_by_entity.setdefault(item.entity_key, {})[item.id] = None
392
+
393
+ def _discard_index_item(self, memory_id: str) -> None:
394
+ item = self._index_items.pop(memory_id, None)
395
+ if item is None:
396
+ return
397
+ user_ids = self._index_ids_by_user.get(item.user_id)
398
+ if user_ids is not None:
399
+ user_ids.discard(memory_id)
400
+ if not user_ids:
401
+ self._index_ids_by_user.pop(item.user_id, None)
402
+ if item.entity_key is not None:
403
+ entity_ids = self._index_ids_by_entity.get(item.entity_key)
404
+ if entity_ids is not None:
405
+ entity_ids.pop(memory_id, None)
406
+ if not entity_ids:
407
+ self._index_ids_by_entity.pop(item.entity_key, None)
408
+
409
+ def _clear_index_items(self) -> None:
410
+ self._index_items.clear()
411
+ self._index_ids_by_user.clear()
412
+ self._index_ids_by_entity.clear()
413
+
382
414
  def _scope_sql(self, user_id: str | None = None) -> tuple[str, list[Any]]:
383
415
  clauses: list[str] = []
384
416
  params: list[Any] = []
@@ -576,7 +608,7 @@ class PostgresStore(BaseStore):
576
608
  """
577
609
  assert self._index is not None
578
610
  self._index.remove(self._index.ids())
579
- self._index_items.clear()
611
+ self._clear_index_items()
580
612
  scope_sql, scope_params = self._scope_sql()
581
613
  sql = (
582
614
  "SELECT * FROM memories "
@@ -608,9 +640,8 @@ class PostgresStore(BaseStore):
608
640
  axis=0,
609
641
  )
610
642
  self._index.add(ids, vectors)
611
- self._index_items.update(
612
- {item.id: item for item in items}
613
- )
643
+ for item in items:
644
+ self._cache_index_item(item)
614
645
  self._index_loaded = True
615
646
  self._loaded_revision = revision
616
647
 
@@ -639,7 +670,7 @@ class PostgresStore(BaseStore):
639
670
  return
640
671
  self._index_loaded = False
641
672
  self._loaded_revision = None
642
- self._index_items.clear()
673
+ self._clear_index_items()
643
674
 
644
675
  async def add(self, item: MemoryItem) -> MemoryItem:
645
676
  async with self.mutation():
@@ -733,9 +764,7 @@ class PostgresStore(BaseStore):
733
764
 
734
765
  def _delta(index: VectorIndex) -> None:
735
766
  index.add([item.id], np.asarray([embedding], dtype=np.float32))
736
- self._index_items[item.id] = item.model_copy(
737
- deep=True
738
- )
767
+ self._cache_index_item(item.model_copy(deep=True))
739
768
 
740
769
  self._sync_index_after_write(revision, _delta)
741
770
  else:
@@ -872,6 +901,7 @@ class PostgresStore(BaseStore):
872
901
 
873
902
  def _delta(index: VectorIndex) -> None:
874
903
  index.remove([memory_id])
904
+ self._discard_index_item(memory_id)
875
905
  if (
876
906
  new_embedding is not None
877
907
  and updated_item.status == MemoryStatus.ACTIVE
@@ -880,9 +910,7 @@ class PostgresStore(BaseStore):
880
910
  [memory_id],
881
911
  np.asarray([new_embedding], dtype=np.float32),
882
912
  )
883
- self._index_items[memory_id] = updated_item
884
- else:
885
- self._index_items.pop(memory_id, None)
913
+ self._cache_index_item(updated_item)
886
914
 
887
915
  self._sync_index_after_write(revision, _delta)
888
916
  else:
@@ -929,7 +957,7 @@ class PostgresStore(BaseStore):
929
957
  index.purge([memory_id])
930
958
  else:
931
959
  index.remove([memory_id])
932
- self._index_items.pop(memory_id, None)
960
+ self._discard_index_item(memory_id)
933
961
 
934
962
  self._sync_index_after_write(revision, _delta)
935
963
  return True
@@ -940,19 +968,25 @@ class PostgresStore(BaseStore):
940
968
  top_k: int = 10,
941
969
  filters: dict[str, object] | None = None,
942
970
  user_id: str | None = None,
971
+ *,
972
+ copy_items: bool = True,
943
973
  ) -> list[MemoryItem]:
944
974
  index = await self._ensure_index()
945
975
  query = np.asarray(embedding, dtype=np.float32)
946
976
  # The revision-gated cache was loaded from this store's fixed
947
977
  # tenant/project scope. Apply the per-call user partition in memory
948
978
  # before ranking so foreign users never compete for top-k.
949
- candidate_ids = {
950
- memory_id
951
- for memory_id, item in self._index_items.items()
952
- if self._item_scope_allows(item, user_id)
953
- }
954
- if not candidate_ids:
979
+ scope = self._resolve_scope(user_id)
980
+ scoped_ids = (
981
+ None if scope is None else self._index_ids_by_user.get(scope, set())
982
+ )
983
+ if scoped_ids is not None and not scoped_ids:
955
984
  return []
985
+ candidate_ids = (
986
+ None
987
+ if scoped_ids is None or len(scoped_ids) == len(self._index_items)
988
+ else scoped_ids
989
+ )
956
990
  # Fetch extra only to allow for post-filter culling.
957
991
  raw = index.search(
958
992
  query,
@@ -968,7 +1002,7 @@ class PostgresStore(BaseStore):
968
1002
  continue
969
1003
  if filters and not _passes_filters(item, filters):
970
1004
  continue
971
- results.append(item.model_copy(deep=True))
1005
+ results.append(_detached_memory_copy(item) if copy_items else item)
972
1006
  if len(results) >= top_k:
973
1007
  break
974
1008
  return results
@@ -1047,22 +1081,41 @@ class PostgresStore(BaseStore):
1047
1081
  entity_keys: list[str],
1048
1082
  status: MemoryStatus | None = MemoryStatus.ACTIVE,
1049
1083
  user_id: str | None = None,
1084
+ *,
1085
+ exclude_ids: set[str] | None = None,
1086
+ valid_at: datetime | None = None,
1087
+ limit: int | None = None,
1050
1088
  ) -> list[MemoryItem]:
1051
1089
  if status != MemoryStatus.ACTIVE:
1052
1090
  return await super().list_by_entities(
1053
1091
  entity_keys,
1054
1092
  status=status,
1055
1093
  user_id=user_id,
1094
+ exclude_ids=exclude_ids,
1095
+ valid_at=valid_at,
1096
+ limit=limit,
1056
1097
  )
1098
+ if limit is not None and limit <= 0:
1099
+ return []
1057
1100
  if not self._index_loaded:
1058
1101
  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
- ]
1102
+ excluded = exclude_ids or set()
1103
+ items: list[MemoryItem] = []
1104
+ for entity_key in dict.fromkeys(entity_keys):
1105
+ for memory_id in self._index_ids_by_entity.get(entity_key, {}):
1106
+ item = self._index_items.get(memory_id)
1107
+ if item is None:
1108
+ continue
1109
+ if not self._item_scope_allows(item, user_id):
1110
+ continue
1111
+ if item.id in excluded:
1112
+ continue
1113
+ if valid_at is not None and not item.is_valid_at(valid_at):
1114
+ continue
1115
+ items.append(_detached_memory_copy(item))
1116
+ if limit is not None and len(items) >= limit:
1117
+ return items
1118
+ return items
1066
1119
 
1067
1120
  async def list_pending_consolidation(self, limit: int = 100) -> list[MemoryItem]:
1068
1121
  sql = (
@@ -1187,7 +1240,7 @@ class PostgresStore(BaseStore):
1187
1240
  await self._pool.close()
1188
1241
  self._pool = None
1189
1242
  self._adapter = None
1190
- self._index_items.clear()
1243
+ self._clear_index_items()
1191
1244
  self._initialized = False
1192
1245
 
1193
1246
 
@@ -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()
@@ -80,6 +80,7 @@ class NumpyIndex(VectorIndex):
80
80
  def __init__(self, dimension: int) -> None:
81
81
  self.dimension = dimension
82
82
  self._ids: list[str] = []
83
+ self._positions_by_id: dict[str, int] = {}
83
84
  self._vectors: NDArray[np.float32] = np.zeros((0, dimension), dtype=np.float32)
84
85
 
85
86
  def add(self, ids: list[str], embeddings: NDArray[np.float32]) -> None:
@@ -87,7 +88,11 @@ class NumpyIndex(VectorIndex):
87
88
  return
88
89
  reshaped = np.asarray(embeddings, dtype=np.float32).reshape(len(ids), self.dimension)
89
90
  normalized = _normalize(reshaped)
91
+ start = len(self._ids)
90
92
  self._ids.extend(ids)
93
+ self._positions_by_id.update(
94
+ {memory_id: start + offset for offset, memory_id in enumerate(ids)}
95
+ )
91
96
  self._vectors = (
92
97
  np.vstack([self._vectors, normalized]) if len(self._vectors) else normalized
93
98
  )
@@ -103,14 +108,26 @@ class NumpyIndex(VectorIndex):
103
108
  if include_ids is not None and not include_ids:
104
109
  return []
105
110
  q = _normalize(np.asarray(query, dtype=np.float32).reshape(1, self.dimension))[0]
106
- scores = self._vectors @ q
107
111
  if include_ids is not None:
108
- allowed = np.fromiter(
109
- (mid in include_ids for mid in self._ids),
110
- dtype=bool,
111
- count=len(self._ids),
112
+ positions = np.asarray(
113
+ sorted(
114
+ position
115
+ for memory_id in include_ids
116
+ if (position := self._positions_by_id.get(memory_id))
117
+ is not None
118
+ ),
119
+ dtype=np.intp,
112
120
  )
113
- scores = np.where(allowed, scores, -np.inf)
121
+ if not len(positions):
122
+ return []
123
+ scores = self._vectors[positions] @ q
124
+ k = min(top_k, len(positions))
125
+ top_idx = np.argsort(-scores)[:k]
126
+ return [
127
+ (self._ids[int(positions[i])], float(scores[i]))
128
+ for i in top_idx
129
+ ]
130
+ scores = self._vectors @ q
114
131
  k = min(top_k, len(self._ids))
115
132
  top_idx = np.argsort(-scores)[:k]
116
133
  return [
@@ -123,6 +140,10 @@ class NumpyIndex(VectorIndex):
123
140
  drop = set(ids)
124
141
  keep = [i for i, mid in enumerate(self._ids) if mid not in drop]
125
142
  self._ids = [self._ids[i] for i in keep]
143
+ self._positions_by_id = {
144
+ memory_id: position
145
+ for position, memory_id in enumerate(self._ids)
146
+ }
126
147
  self._vectors = self._vectors[keep] if keep else np.zeros(
127
148
  (0, self.dimension), dtype=np.float32
128
149
  )
@@ -142,6 +163,10 @@ class NumpyIndex(VectorIndex):
142
163
  payload = pickle.loads(Path(path).read_bytes())
143
164
  self.dimension = int(payload["dimension"])
144
165
  self._ids = list(payload["ids"])
166
+ self._positions_by_id = {
167
+ memory_id: position
168
+ for position, memory_id in enumerate(self._ids)
169
+ }
145
170
  self._vectors = np.asarray(payload["vectors"], dtype=np.float32)
146
171
 
147
172
  def __len__(self) -> int:
@@ -173,6 +198,7 @@ class FAISSIndex(VectorIndex):
173
198
  self.index_type = index_type
174
199
  self._index: Any = faiss.IndexFlatIP(dimension)
175
200
  self._ids: list[str] = []
201
+ self._positions_by_id: dict[str, int] = {}
176
202
  self._removed_ids: set[str] = set()
177
203
 
178
204
  def add(self, ids: list[str], embeddings: NDArray[np.float32]) -> None:
@@ -181,7 +207,11 @@ class FAISSIndex(VectorIndex):
181
207
  reshaped = np.asarray(embeddings, dtype=np.float32).reshape(len(ids), self.dimension)
182
208
  normalized = _normalize(reshaped)
183
209
  self._index.add(normalized)
210
+ start = len(self._ids)
184
211
  self._ids.extend(ids)
212
+ self._positions_by_id.update(
213
+ {memory_id: start + offset for offset, memory_id in enumerate(ids)}
214
+ )
185
215
  # Re-adding a previously tombstoned id clears the tombstone.
186
216
  if self._removed_ids:
187
217
  self._removed_ids.difference_update(ids)
@@ -230,14 +260,18 @@ class FAISSIndex(VectorIndex):
230
260
  IDSelector support for flat indexes varies by version; this path
231
261
  is version-proof.)
232
262
  """
233
- positions = [
234
- i
235
- for i, mid in enumerate(self._ids)
236
- if mid in include_ids and mid not in self._removed_ids
237
- ]
263
+ positions = sorted(
264
+ position
265
+ for memory_id in include_ids
266
+ if memory_id not in self._removed_ids
267
+ and (position := self._positions_by_id.get(memory_id)) is not None
268
+ )
238
269
  if not positions:
239
270
  return []
240
- snapshot = self._vectors_snapshot()[positions]
271
+ snapshot = np.stack(
272
+ [self._index.reconstruct(position) for position in positions],
273
+ axis=0,
274
+ ).astype(np.float32)
241
275
  q = _normalize(np.asarray(query, dtype=np.float32).reshape(1, self.dimension))[0]
242
276
  scores = snapshot @ q
243
277
  order = np.argsort(-scores)[: min(top_k, len(positions))]
@@ -251,7 +285,11 @@ class FAISSIndex(VectorIndex):
251
285
  """
252
286
  if not ids:
253
287
  return
254
- present = {mid for mid in ids if mid in self._ids}
288
+ present = {
289
+ memory_id
290
+ for memory_id in ids
291
+ if memory_id in self._positions_by_id
292
+ }
255
293
  if not present:
256
294
  return
257
295
  self._removed_ids.update(present)
@@ -284,10 +322,15 @@ class FAISSIndex(VectorIndex):
284
322
  vectors = np.zeros((0, self.dimension), dtype=np.float32)
285
323
  self._index = self._faiss.IndexFlatIP(self.dimension)
286
324
  self._ids = []
325
+ self._positions_by_id = {}
287
326
  self._removed_ids.clear()
288
327
  if len(new_ids):
289
328
  self._index.add(vectors)
290
329
  self._ids = new_ids
330
+ self._positions_by_id = {
331
+ memory_id: position
332
+ for position, memory_id in enumerate(self._ids)
333
+ }
291
334
 
292
335
  def _vectors_snapshot(self) -> NDArray[np.float32]:
293
336
  # Reconstruct all current vectors from the FAISS index.
@@ -306,6 +349,10 @@ class FAISSIndex(VectorIndex):
306
349
  def load(self, path: str) -> None:
307
350
  self._index = self._faiss.read_index(f"{path}.faiss")
308
351
  self._ids = pickle.loads(Path(f"{path}.ids").read_bytes())
352
+ self._positions_by_id = {
353
+ memory_id: position
354
+ for position, memory_id in enumerate(self._ids)
355
+ }
309
356
  self._removed_ids = set()
310
357
 
311
358
  def __len__(self) -> int:
@@ -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.3"
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"
@@ -48,12 +48,13 @@ def write_latency_budgets() -> tuple[float, float]:
48
48
  """``(p50_ms, p95_ms)`` ceilings for the add_fast path.
49
49
 
50
50
  The product claim is p95 < 10ms on a quiet machine. GitHub-hosted
51
- runners have spiked p95 to ~21ms while p50 stayed ~2ms — a noisy
52
- neighbor, not a regression. CI therefore keeps a tight median and a
53
- wider p95 so shared runners cannot red ``main``.
51
+ runners have spiked p95 to ~109ms while p50 stayed ~2ms — a noisy
52
+ neighbor, not a regression. CI therefore keeps the 5ms median gate that
53
+ catches systematic regressions and a bounded 125ms tail envelope for
54
+ scheduler stalls. Local quiet-machine runs still enforce the 10ms claim.
54
55
  """
55
56
  if os.environ.get("CI"):
56
- return (5.0, 50.0)
57
+ return (5.0, 125.0)
57
58
  return (5.0, 10.0)
58
59
 
59
60