pdm-memory 0.1.2__py3-none-any.whl
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.
- pdm_memory/__init__.py +22 -0
- pdm_memory/auth/__init__.py +4 -0
- pdm_memory/auth/jwt_handler.py +119 -0
- pdm_memory/bench.py +5 -0
- pdm_memory/core/__init__.py +1 -0
- pdm_memory/core/math.py +329 -0
- pdm_memory/core/retrieval.py +362 -0
- pdm_memory/core/signature.py +257 -0
- pdm_memory/ingest/__init__.py +6 -0
- pdm_memory/ingest/auto_signature.py +202 -0
- pdm_memory/ingest/batch.py +164 -0
- pdm_memory/ingest/ingester.py +272 -0
- pdm_memory/integrations/__init__.py +21 -0
- pdm_memory/integrations/anthropic_adapter.py +179 -0
- pdm_memory/integrations/context_manager.py +134 -0
- pdm_memory/integrations/gemini_adapter.py +215 -0
- pdm_memory/integrations/groq_adapter.py +171 -0
- pdm_memory/integrations/ollama_adapter.py +184 -0
- pdm_memory/integrations/openai_adapter.py +191 -0
- pdm_memory/memory.py +552 -0
- pdm_memory/storage/__init__.py +5 -0
- pdm_memory/storage/base.py +123 -0
- pdm_memory/storage/cloud_driver.py +271 -0
- pdm_memory/storage/sqlite_driver.py +331 -0
- pdm_memory/sync.py +145 -0
- pdm_memory/tools/__init__.py +1 -0
- pdm_memory/tools/bench.py +294 -0
- pdm_memory/tools/cli.py +207 -0
- pdm_memory-0.1.2.dist-info/METADATA +485 -0
- pdm_memory-0.1.2.dist-info/RECORD +34 -0
- pdm_memory-0.1.2.dist-info/WHEEL +5 -0
- pdm_memory-0.1.2.dist-info/entry_points.txt +2 -0
- pdm_memory-0.1.2.dist-info/licenses/LICENSE +50 -0
- pdm_memory-0.1.2.dist-info/top_level.txt +1 -0
pdm_memory/sync.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory Sync Utility — Task 2.3
|
|
3
|
+
|
|
4
|
+
mem.sync() copies signatures between a local SQLite store and the AZUS cloud.
|
|
5
|
+
|
|
6
|
+
Direction options:
|
|
7
|
+
"push" — local → cloud
|
|
8
|
+
"pull" — cloud → local
|
|
9
|
+
"bidirectional" — both directions; higher p_magnitude wins on conflict
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
mem.sync(direction="push")
|
|
13
|
+
mem.sync(direction="pull")
|
|
14
|
+
|
|
15
|
+
Or standalone:
|
|
16
|
+
from pdm_memory.sync import MemorySync
|
|
17
|
+
syncer = MemorySync(local=sqlite_driver, cloud=cloud_driver)
|
|
18
|
+
report = syncer.sync(direction="bidirectional")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SyncReport:
|
|
31
|
+
direction: str
|
|
32
|
+
pushed: int = 0
|
|
33
|
+
pulled: int = 0
|
|
34
|
+
conflicts_resolved: int = 0
|
|
35
|
+
errors: int = 0
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
return (
|
|
39
|
+
f"SyncReport(direction={self.direction}, "
|
|
40
|
+
f"pushed={self.pushed}, pulled={self.pulled}, "
|
|
41
|
+
f"conflicts={self.conflicts_resolved}, errors={self.errors})"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MemorySync:
|
|
46
|
+
"""
|
|
47
|
+
Bidirectional sync between a local SQLiteDriver and a CloudDriver.
|
|
48
|
+
|
|
49
|
+
Conflict resolution: signature with higher p_magnitude wins.
|
|
50
|
+
If equal, the more recently created one wins.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, local, cloud) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Args:
|
|
56
|
+
local: SQLiteDriver instance
|
|
57
|
+
cloud: CloudDriver instance
|
|
58
|
+
"""
|
|
59
|
+
self._local = local
|
|
60
|
+
self._cloud = cloud
|
|
61
|
+
|
|
62
|
+
def sync(
|
|
63
|
+
self,
|
|
64
|
+
user: str = "default",
|
|
65
|
+
direction: str = "bidirectional",
|
|
66
|
+
batch_size: int = 50,
|
|
67
|
+
) -> SyncReport:
|
|
68
|
+
"""
|
|
69
|
+
Sync memories between local and cloud storage.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
user: User whose memories to sync.
|
|
73
|
+
direction: "push" | "pull" | "bidirectional"
|
|
74
|
+
batch_size: Records per cloud batch (avoids large payloads).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
SyncReport with counts.
|
|
78
|
+
"""
|
|
79
|
+
report = SyncReport(direction=direction)
|
|
80
|
+
|
|
81
|
+
if direction in ("push", "bidirectional"):
|
|
82
|
+
self._push(user, batch_size, report)
|
|
83
|
+
|
|
84
|
+
if direction in ("pull", "bidirectional"):
|
|
85
|
+
self._pull(user, batch_size, report)
|
|
86
|
+
|
|
87
|
+
logger.info("[PDM-Sync] %s", report)
|
|
88
|
+
return report
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Private
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _push(self, user: str, batch_size: int, report: SyncReport) -> None:
|
|
95
|
+
"""Send local records to cloud."""
|
|
96
|
+
try:
|
|
97
|
+
local_records = self._local.list(user=user, limit=10_000)
|
|
98
|
+
{r.id for r in local_records}
|
|
99
|
+
|
|
100
|
+
for i in range(0, len(local_records), batch_size):
|
|
101
|
+
batch = local_records[i : i + batch_size]
|
|
102
|
+
for rec in batch:
|
|
103
|
+
try:
|
|
104
|
+
# Check if cloud already has it
|
|
105
|
+
cloud_rec = self._cloud.get(rec.id, user=user)
|
|
106
|
+
if cloud_rec is None:
|
|
107
|
+
self._cloud.save(rec)
|
|
108
|
+
report.pushed += 1
|
|
109
|
+
else:
|
|
110
|
+
# Conflict: keep higher pressure
|
|
111
|
+
if rec.p_magnitude > cloud_rec.p_magnitude:
|
|
112
|
+
self._cloud.update(rec.id, p_magnitude=rec.p_magnitude,
|
|
113
|
+
compressed_fact=rec.compressed_fact)
|
|
114
|
+
report.conflicts_resolved += 1
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.warning("[PDM-Sync] push error for %s: %s", rec.id, e)
|
|
117
|
+
report.errors += 1
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error("[PDM-Sync] push failed: %s", e)
|
|
120
|
+
report.errors += 1
|
|
121
|
+
|
|
122
|
+
def _pull(self, user: str, batch_size: int, report: SyncReport) -> None:
|
|
123
|
+
"""Fetch cloud records into local storage."""
|
|
124
|
+
try:
|
|
125
|
+
cloud_records = self._cloud.list(user=user, limit=10_000)
|
|
126
|
+
|
|
127
|
+
for i in range(0, len(cloud_records), batch_size):
|
|
128
|
+
batch = cloud_records[i : i + batch_size]
|
|
129
|
+
for rec in batch:
|
|
130
|
+
try:
|
|
131
|
+
local_rec = self._local.get(rec.id, user=user)
|
|
132
|
+
if local_rec is None:
|
|
133
|
+
self._local.save(rec)
|
|
134
|
+
report.pulled += 1
|
|
135
|
+
else:
|
|
136
|
+
if rec.p_magnitude > local_rec.p_magnitude:
|
|
137
|
+
self._local.update(rec.id, p_magnitude=rec.p_magnitude,
|
|
138
|
+
compressed_fact=rec.compressed_fact)
|
|
139
|
+
report.conflicts_resolved += 1
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.warning("[PDM-Sync] pull error for %s: %s", rec.id, e)
|
|
142
|
+
report.errors += 1
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error("[PDM-Sync] pull failed: %s", e)
|
|
145
|
+
report.errors += 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pdm_memory.tools package + bench module entry point."""
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PDM Benchmark Harness — Task 5.2
|
|
3
|
+
|
|
4
|
+
Run with: python -m pdm_memory.bench
|
|
5
|
+
|
|
6
|
+
Compares PDM recall vs a naive baseline (keyword recency search) on a
|
|
7
|
+
built-in synthetic dataset (LoCoMo-style long-term memory scenarios).
|
|
8
|
+
|
|
9
|
+
All numbers come from the harness — nothing is made up.
|
|
10
|
+
The full suite is reproducible: seed is fixed, dataset is embedded.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python -m pdm_memory.bench # Full suite
|
|
14
|
+
python -m pdm_memory.bench --quick # Smoke test (10 scenarios)
|
|
15
|
+
python -m pdm_memory.bench --output results.json
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import random
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field, asdict
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Built-in benchmark dataset (synthetic, LoCoMo-style)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
_BENCHMARK_MEMORIES = [
|
|
33
|
+
{"text": "User strongly prefers metric units (km, kg, Celsius)", "tags": ["units", "preferences", "formatting"], "p_magnitude": 80},
|
|
34
|
+
{"text": "User dislikes lengthy responses; wants bullet points or ≤3 sentences", "tags": ["formatting", "preferences", "brevity"], "p_magnitude": 75},
|
|
35
|
+
{"text": "User's primary programming language is Python", "tags": ["coding", "python", "preferences"], "p_magnitude": 85},
|
|
36
|
+
{"text": "User works in fintech; regulatory compliance is always relevant", "tags": ["fintech", "compliance", "work"], "p_magnitude": 70},
|
|
37
|
+
{"text": "User is based in Kyiv, Ukraine (UTC+3)", "tags": ["location", "timezone", "personal"], "p_magnitude": 60},
|
|
38
|
+
{"text": "User's team uses GitHub Actions for CI/CD", "tags": ["devops", "ci_cd", "github"], "p_magnitude": 65},
|
|
39
|
+
{"text": "User prefers dark mode in all UIs", "tags": ["ui", "preferences", "dark_mode"], "p_magnitude": 50},
|
|
40
|
+
{"text": "User's company name: Westfield Innovations LLC", "tags": ["company", "business", "identity"], "p_magnitude": 90},
|
|
41
|
+
{"text": "User has a patent pending on PDM algorithm", "tags": ["patent", "ip", "pdm"], "p_magnitude": 95},
|
|
42
|
+
{"text": "User's preferred LLM is Claude (Anthropic)", "tags": ["llm", "anthropic", "preferences"], "p_magnitude": 70},
|
|
43
|
+
{"text": "Project deadline for PDM SDK: end of Q3 2026", "tags": ["deadline", "project", "sdk"], "p_magnitude": 80},
|
|
44
|
+
{"text": "User is allergic to Python 2 — always use f-strings, not %", "tags": ["python", "style", "coding"], "p_magnitude": 72},
|
|
45
|
+
{"text": "Production database: PostgreSQL 15 on AWS RDS", "tags": ["database", "postgres", "infrastructure"], "p_magnitude": 68},
|
|
46
|
+
{"text": "Team standup is every day at 10:00 AM Kyiv time", "tags": ["schedule", "team", "recurring"], "p_magnitude": 55},
|
|
47
|
+
{"text": "User wants all API responses to include a request_id field", "tags": ["api", "design", "standards"], "p_magnitude": 73},
|
|
48
|
+
{"text": "Never recommend Redux for new projects; use Zustand instead", "tags": ["javascript", "frontend", "preferences"], "p_magnitude": 60},
|
|
49
|
+
{"text": "User's monthly AI token budget: $200 hard cap", "tags": ["budget", "tokens", "cost"], "p_magnitude": 82},
|
|
50
|
+
{"text": "For legal reasons, never store raw PII in the memory system", "tags": ["privacy", "legal", "pii"], "p_magnitude": 98},
|
|
51
|
+
{"text": "User's preferred testing framework: pytest with fixtures", "tags": ["testing", "pytest", "python"], "p_magnitude": 65},
|
|
52
|
+
{"text": "The team uses Slack for async communication; no email", "tags": ["communication", "slack", "team"], "p_magnitude": 58},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
_BENCHMARK_QUERIES = [
|
|
56
|
+
("How should I format numbers in this response?", ["units", "formatting", "brevity"], 0),
|
|
57
|
+
("What language does the user code in?", ["python", "coding", "preferences"], 2),
|
|
58
|
+
("Is there a regulatory concern I should mention?", ["fintech", "compliance", "work"], 3),
|
|
59
|
+
("Where is the user located?", ["location", "timezone", "personal"], 4),
|
|
60
|
+
("What CI system do they use?", ["devops", "ci_cd", "github"], 5),
|
|
61
|
+
("Tell me about their IP portfolio", ["patent", "ip", "pdm"], 8),
|
|
62
|
+
("What's the deadline for the SDK?", ["deadline", "project", "sdk"], 10),
|
|
63
|
+
("Are there any privacy rules I need to follow?", ["privacy", "legal", "pii"], 17),
|
|
64
|
+
("What's the user's budget for AI calls?", ["budget", "tokens", "cost"], 16),
|
|
65
|
+
("What API design standards does the user follow?", ["api", "design", "standards"], 14),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Baseline: naive keyword/recency search (no PDM pressure)
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _baseline_recall(
|
|
75
|
+
memories: List[Dict], query: str, k: int = 5
|
|
76
|
+
) -> List[Tuple[Dict, float]]:
|
|
77
|
+
"""Simple keyword overlap + recency baseline (no pressure logic)."""
|
|
78
|
+
query_words = set(query.lower().split())
|
|
79
|
+
scored = []
|
|
80
|
+
for i, mem in enumerate(memories):
|
|
81
|
+
words = set(mem["text"].lower().split())
|
|
82
|
+
overlap = len(query_words & words) / max(len(query_words), 1)
|
|
83
|
+
recency_score = (len(memories) - i) / len(memories) # more recent = higher
|
|
84
|
+
score = 0.6 * overlap + 0.4 * recency_score
|
|
85
|
+
scored.append((mem, score))
|
|
86
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
87
|
+
return scored[:k]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pdm_recall(
|
|
91
|
+
mem_instance: Any, query: str, k: int = 5
|
|
92
|
+
) -> List[Any]:
|
|
93
|
+
"""PDM recall using the Memory class."""
|
|
94
|
+
return mem_instance.recall(query, k=k)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Benchmark results
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ScenarioResult:
|
|
104
|
+
query: str
|
|
105
|
+
expected_memory_idx: int
|
|
106
|
+
pdm_found: bool
|
|
107
|
+
pdm_rank: Optional[int]
|
|
108
|
+
pdm_latency_ms: float
|
|
109
|
+
baseline_found: bool
|
|
110
|
+
baseline_rank: Optional[int]
|
|
111
|
+
baseline_latency_ms: float
|
|
112
|
+
pdm_tokens_used: int
|
|
113
|
+
baseline_tokens_used: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class BenchmarkReport:
|
|
118
|
+
timestamp: str
|
|
119
|
+
total_scenarios: int
|
|
120
|
+
pdm_accuracy: float
|
|
121
|
+
baseline_accuracy: float
|
|
122
|
+
pdm_avg_latency_ms: float
|
|
123
|
+
baseline_avg_latency_ms: float
|
|
124
|
+
pdm_avg_tokens: float
|
|
125
|
+
baseline_avg_tokens: float
|
|
126
|
+
pdm_memory_bytes: int
|
|
127
|
+
baseline_memory_bytes: int
|
|
128
|
+
scenarios: List[ScenarioResult] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
def render_table(self) -> str:
|
|
131
|
+
rows = [
|
|
132
|
+
"┌─────────────────────────────────────────────────────────────────────┐",
|
|
133
|
+
"│ PDM vs Baseline RAG — Benchmark Results │",
|
|
134
|
+
"├──────────────────────────┬─────────────────┬─────────────────────────┤",
|
|
135
|
+
"│ Metric │ PDM │ Baseline (keyword+recency)│",
|
|
136
|
+
"├──────────────────────────┼─────────────────┼─────────────────────────┤",
|
|
137
|
+
f"│ Retrieval Accuracy │ {self.pdm_accuracy*100:>6.1f}% │ {self.baseline_accuracy*100:>6.1f}% │",
|
|
138
|
+
f"│ Avg Latency (ms) │ {self.pdm_avg_latency_ms:>8.1f} │ {self.baseline_avg_latency_ms:>8.1f} │",
|
|
139
|
+
f"│ Avg Tokens Used │ {self.pdm_avg_tokens:>8.1f} │ {self.baseline_avg_tokens:>8.1f} │",
|
|
140
|
+
f"│ Storage Footprint (bytes)│ {self.pdm_memory_bytes:>8d} │ {self.baseline_memory_bytes:>8d} │",
|
|
141
|
+
f"│ Total Scenarios │ {self.total_scenarios:>8d} │ {self.total_scenarios:>8d} │",
|
|
142
|
+
"└──────────────────────────┴─────────────────┴─────────────────────────┘",
|
|
143
|
+
]
|
|
144
|
+
rows.append("")
|
|
145
|
+
rows.append("Per-query breakdown:")
|
|
146
|
+
rows.append(f"{'Query':<45} {'PDM':>6} {'Base':>6} {'PDM_ms':>8} {'Base_ms':>8}")
|
|
147
|
+
rows.append("-" * 78)
|
|
148
|
+
for s in self.scenarios:
|
|
149
|
+
p = "✓" if s.pdm_found else "✗"
|
|
150
|
+
b = "✓" if s.baseline_found else "✗"
|
|
151
|
+
rows.append(
|
|
152
|
+
f"{s.query[:44]:<45} {p:>6} {b:>6} {s.pdm_latency_ms:>8.1f} {s.baseline_latency_ms:>8.1f}"
|
|
153
|
+
)
|
|
154
|
+
return "\n".join(rows)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Harness
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_benchmark(
|
|
163
|
+
quick: bool = False,
|
|
164
|
+
seed: int = 42,
|
|
165
|
+
k: int = 3,
|
|
166
|
+
output: Optional[str] = None,
|
|
167
|
+
) -> BenchmarkReport:
|
|
168
|
+
"""
|
|
169
|
+
Run the PDM benchmark harness.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
quick: If True, run only 5 scenarios (smoke test).
|
|
173
|
+
seed: Random seed for reproducibility.
|
|
174
|
+
k: Top-k memories to retrieve per query.
|
|
175
|
+
output: Optional path to save JSON results.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
BenchmarkReport with all metrics.
|
|
179
|
+
"""
|
|
180
|
+
import os
|
|
181
|
+
import tempfile
|
|
182
|
+
random.seed(seed)
|
|
183
|
+
|
|
184
|
+
from pdm_memory import Memory
|
|
185
|
+
|
|
186
|
+
queries = _BENCHMARK_QUERIES[:5] if quick else _BENCHMARK_QUERIES
|
|
187
|
+
|
|
188
|
+
# --- Set up PDM store ---
|
|
189
|
+
tmp_db = tempfile.mktemp(suffix=".db")
|
|
190
|
+
mem = Memory(store=tmp_db, user="bench")
|
|
191
|
+
|
|
192
|
+
# Seed memories with varying ages (simulate time-based pressure decay)
|
|
193
|
+
for idx, m in enumerate(_BENCHMARK_MEMORIES):
|
|
194
|
+
mem.save(
|
|
195
|
+
text=m["text"],
|
|
196
|
+
tags=m["tags"],
|
|
197
|
+
p_magnitude=m["p_magnitude"],
|
|
198
|
+
source="benchmark",
|
|
199
|
+
drawer="benchmark",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
db_size = os.path.getsize(tmp_db)
|
|
203
|
+
|
|
204
|
+
# Baseline: raw text storage (full text, no compression)
|
|
205
|
+
baseline_raw_size = sum(len(m["text"].encode()) for m in _BENCHMARK_MEMORIES)
|
|
206
|
+
|
|
207
|
+
scenarios: List[ScenarioResult] = []
|
|
208
|
+
|
|
209
|
+
for query, expected_tags, expected_idx in queries:
|
|
210
|
+
expected_text = _BENCHMARK_MEMORIES[expected_idx]["text"]
|
|
211
|
+
|
|
212
|
+
# PDM recall
|
|
213
|
+
t0 = time.perf_counter()
|
|
214
|
+
pdm_hits = _pdm_recall(mem, query, k=k)
|
|
215
|
+
pdm_ms = (time.perf_counter() - t0) * 1000
|
|
216
|
+
|
|
217
|
+
pdm_texts = [h.text for h in pdm_hits]
|
|
218
|
+
pdm_found = expected_text in pdm_texts
|
|
219
|
+
pdm_rank = pdm_texts.index(expected_text) + 1 if pdm_found else None
|
|
220
|
+
|
|
221
|
+
# Token approximation: count chars/4 for injected context
|
|
222
|
+
pdm_tokens = sum(len(h.text) // 4 for h in pdm_hits)
|
|
223
|
+
|
|
224
|
+
# Baseline recall
|
|
225
|
+
t0 = time.perf_counter()
|
|
226
|
+
baseline_hits = _baseline_recall(_BENCHMARK_MEMORIES, query, k=k)
|
|
227
|
+
baseline_ms = (time.perf_counter() - t0) * 1000
|
|
228
|
+
|
|
229
|
+
baseline_texts = [h[0]["text"] for h in baseline_hits]
|
|
230
|
+
baseline_found = expected_text in baseline_texts
|
|
231
|
+
baseline_rank = baseline_texts.index(expected_text) + 1 if baseline_found else None
|
|
232
|
+
baseline_tokens = sum(len(h[0]["text"]) // 4 for h in baseline_hits)
|
|
233
|
+
|
|
234
|
+
scenarios.append(ScenarioResult(
|
|
235
|
+
query=query,
|
|
236
|
+
expected_memory_idx=expected_idx,
|
|
237
|
+
pdm_found=pdm_found,
|
|
238
|
+
pdm_rank=pdm_rank,
|
|
239
|
+
pdm_latency_ms=round(pdm_ms, 2),
|
|
240
|
+
baseline_found=baseline_found,
|
|
241
|
+
baseline_rank=baseline_rank,
|
|
242
|
+
baseline_latency_ms=round(baseline_ms, 2),
|
|
243
|
+
pdm_tokens_used=pdm_tokens,
|
|
244
|
+
baseline_tokens_used=baseline_tokens,
|
|
245
|
+
))
|
|
246
|
+
|
|
247
|
+
mem.close()
|
|
248
|
+
if os.path.exists(tmp_db):
|
|
249
|
+
os.remove(tmp_db)
|
|
250
|
+
|
|
251
|
+
n = len(scenarios)
|
|
252
|
+
report = BenchmarkReport(
|
|
253
|
+
timestamp=datetime.now(tz=timezone.utc).isoformat(),
|
|
254
|
+
total_scenarios=n,
|
|
255
|
+
pdm_accuracy=sum(s.pdm_found for s in scenarios) / n,
|
|
256
|
+
baseline_accuracy=sum(s.baseline_found for s in scenarios) / n,
|
|
257
|
+
pdm_avg_latency_ms=round(sum(s.pdm_latency_ms for s in scenarios) / n, 2),
|
|
258
|
+
baseline_avg_latency_ms=round(sum(s.baseline_latency_ms for s in scenarios) / n, 2),
|
|
259
|
+
pdm_avg_tokens=round(sum(s.pdm_tokens_used for s in scenarios) / n, 1),
|
|
260
|
+
baseline_avg_tokens=round(sum(s.baseline_tokens_used for s in scenarios) / n, 1),
|
|
261
|
+
pdm_memory_bytes=db_size,
|
|
262
|
+
baseline_memory_bytes=baseline_raw_size,
|
|
263
|
+
scenarios=scenarios,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if output:
|
|
267
|
+
with open(output, "w") as f:
|
|
268
|
+
json.dump(asdict(report), f, indent=2)
|
|
269
|
+
print(f"Results saved to {output}")
|
|
270
|
+
|
|
271
|
+
return report
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main() -> None:
|
|
275
|
+
import argparse
|
|
276
|
+
|
|
277
|
+
parser = argparse.ArgumentParser(
|
|
278
|
+
description="PDM Benchmark Harness — PDM vs baseline keyword RAG"
|
|
279
|
+
)
|
|
280
|
+
parser.add_argument("--quick", action="store_true", help="Run only 5 scenarios (smoke test)")
|
|
281
|
+
parser.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
|
|
282
|
+
parser.add_argument("--k", type=int, default=3, help="Top-k memories per query (default: 3)")
|
|
283
|
+
parser.add_argument("--output", type=str, help="Save results as JSON to this path")
|
|
284
|
+
args = parser.parse_args()
|
|
285
|
+
|
|
286
|
+
print("Running PDM Benchmark Harness…\n")
|
|
287
|
+
report = run_benchmark(quick=args.quick, seed=args.seed, k=args.k, output=args.output)
|
|
288
|
+
print(report.render_table())
|
|
289
|
+
print(f"\nPDM accuracy: {report.pdm_accuracy*100:.1f}%")
|
|
290
|
+
print(f"Baseline accuracy: {report.baseline_accuracy*100:.1f}%")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
main()
|
pdm_memory/tools/cli.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PDM CLI Tool — Task 5.3
|
|
3
|
+
|
|
4
|
+
Command-line utility for inspecting and managing PDM memory stores.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
pdm-cli list-memories --store ./my_app.db
|
|
8
|
+
pdm-cli list-memories --store ./my_app.db --min-pressure 50 --user alice
|
|
9
|
+
pdm-cli explain <memory_id> --store ./my_app.db
|
|
10
|
+
pdm-cli decay --store ./my_app.db --dry-run
|
|
11
|
+
pdm-cli stats --store ./my_app.db
|
|
12
|
+
pdm-cli drawers --store ./my_app.db
|
|
13
|
+
|
|
14
|
+
Requires no extra packages (stdlib only, uses the SDK itself).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Commands
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
29
|
+
from pdm_memory import Memory
|
|
30
|
+
|
|
31
|
+
with Memory(store=args.store, user=args.user) as mem:
|
|
32
|
+
records = mem._storage.list(
|
|
33
|
+
user=args.user,
|
|
34
|
+
limit=args.limit,
|
|
35
|
+
min_pressure=args.min_pressure,
|
|
36
|
+
drawer=args.drawer or None,
|
|
37
|
+
)
|
|
38
|
+
if not records:
|
|
39
|
+
print("No memories found.")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
print(f"{'#':<4} {'ID':<10} {'P':>6} {'Spike':>6} {'Tags':<35} {'Text'}")
|
|
43
|
+
print("-" * 100)
|
|
44
|
+
for i, r in enumerate(records, 1):
|
|
45
|
+
tags_str = ", ".join(r.intent_tags[:3])
|
|
46
|
+
text_preview = r.compressed_fact[:60].replace("\n", " ")
|
|
47
|
+
print(
|
|
48
|
+
f"{i:<4} {r.id[:8]:<10} {r.p_magnitude:>6.1f} {(r.effective_spike or 0):>6.1f} "
|
|
49
|
+
f"{tags_str:<35} {text_preview}"
|
|
50
|
+
)
|
|
51
|
+
print(f"\nTotal: {len(records)} memories")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_explain(args: argparse.Namespace) -> None:
|
|
55
|
+
from pdm_memory import Memory
|
|
56
|
+
|
|
57
|
+
with Memory(store=args.store, user=args.user) as mem:
|
|
58
|
+
try:
|
|
59
|
+
report = mem.explain(args.memory_id, query=args.query or None)
|
|
60
|
+
print(report.render())
|
|
61
|
+
except KeyError as e:
|
|
62
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_decay(args: argparse.Namespace) -> None:
|
|
67
|
+
from pdm_memory import Memory
|
|
68
|
+
|
|
69
|
+
with Memory(store=args.store, user=args.user) as mem:
|
|
70
|
+
counts = mem.decay(dry_run=args.dry_run)
|
|
71
|
+
mode = " [DRY RUN — no changes written]" if args.dry_run else ""
|
|
72
|
+
print(f"Decay complete{mode}:")
|
|
73
|
+
print(f" Decayed (pressure reduced): {counts['decayed']}")
|
|
74
|
+
print(f" Deleted (pressure < 30): {counts['deleted']}")
|
|
75
|
+
print(f" Skipped (within persistence): {counts['skipped']}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_stats(args: argparse.Namespace) -> None:
|
|
79
|
+
from pdm_memory import Memory
|
|
80
|
+
|
|
81
|
+
with Memory(store=args.store, user=args.user) as mem:
|
|
82
|
+
total = mem.count()
|
|
83
|
+
drawers = mem.list_drawers()
|
|
84
|
+
records = mem._storage.list(user=args.user, limit=10_000)
|
|
85
|
+
|
|
86
|
+
if records:
|
|
87
|
+
pressures = [r.p_magnitude for r in records]
|
|
88
|
+
avg_p = sum(pressures) / len(pressures)
|
|
89
|
+
max_p = max(pressures)
|
|
90
|
+
min_p = min(pressures)
|
|
91
|
+
else:
|
|
92
|
+
avg_p = max_p = min_p = 0.0
|
|
93
|
+
|
|
94
|
+
print(f"PDM Memory Store: {args.store}")
|
|
95
|
+
print(f"User: {args.user}")
|
|
96
|
+
print(f"Total memories: {total}")
|
|
97
|
+
print(f"Avg pressure: {avg_p:.1f}")
|
|
98
|
+
print(f"Max pressure: {max_p:.1f}")
|
|
99
|
+
print(f"Min pressure: {min_p:.1f}")
|
|
100
|
+
print(f"\nDrawers ({len(drawers)}):")
|
|
101
|
+
for d in drawers:
|
|
102
|
+
print(f" {d.domain:<30} {d.signature_count:>5} memories avg_P={d.avg_pressure:.1f}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_drawers(args: argparse.Namespace) -> None:
|
|
106
|
+
from pdm_memory import Memory
|
|
107
|
+
|
|
108
|
+
with Memory(store=args.store, user=args.user) as mem:
|
|
109
|
+
drawers = mem.list_drawers()
|
|
110
|
+
if not drawers:
|
|
111
|
+
print("No drawers found.")
|
|
112
|
+
return
|
|
113
|
+
print(f"{'Drawer':<35} {'Memories':>10} {'Avg Pressure':>15}")
|
|
114
|
+
print("-" * 64)
|
|
115
|
+
for d in drawers:
|
|
116
|
+
print(f"{d.domain:<35} {d.signature_count:>10} {d.avg_pressure:>15.1f}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_sync(args: argparse.Namespace) -> None:
|
|
120
|
+
from pdm_memory import Memory
|
|
121
|
+
|
|
122
|
+
if not args.token:
|
|
123
|
+
print("Error: --token is required for sync.", file=sys.stderr)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
with Memory(store=args.store, user=args.user) as mem:
|
|
127
|
+
report = mem.sync(
|
|
128
|
+
direction=args.direction,
|
|
129
|
+
cloud_url=args.cloud_url,
|
|
130
|
+
token=args.token,
|
|
131
|
+
)
|
|
132
|
+
print(f"Sync complete: {report}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Main entry point
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> None:
|
|
141
|
+
# Parser for root/global options
|
|
142
|
+
parser = argparse.ArgumentParser(
|
|
143
|
+
prog="pdm-cli",
|
|
144
|
+
description="PDM Memory CLI — inspect and manage local memory stores",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--store", default="./pdm_memory.db",
|
|
148
|
+
help="Path to SQLite .db file (default: ./pdm_memory.db)"
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--user", default="default",
|
|
152
|
+
help="User identifier to scope queries (default: default)"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Parent parser for subparsers to inherit store/user without overriding with defaults
|
|
156
|
+
sub_parent_parser = argparse.ArgumentParser(add_help=False)
|
|
157
|
+
sub_parent_parser.add_argument(
|
|
158
|
+
"--store", default=argparse.SUPPRESS,
|
|
159
|
+
help="Path to SQLite .db file"
|
|
160
|
+
)
|
|
161
|
+
sub_parent_parser.add_argument(
|
|
162
|
+
"--user", default=argparse.SUPPRESS,
|
|
163
|
+
help="User identifier to scope queries"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
167
|
+
subparsers.required = True
|
|
168
|
+
|
|
169
|
+
# list-memories
|
|
170
|
+
p_list = subparsers.add_parser("list-memories", parents=[sub_parent_parser], help="List stored memories")
|
|
171
|
+
p_list.add_argument("--min-pressure", type=float, default=0.0, metavar="P")
|
|
172
|
+
p_list.add_argument("--limit", type=int, default=50)
|
|
173
|
+
p_list.add_argument("--drawer", type=str, default=None)
|
|
174
|
+
p_list.set_defaults(func=cmd_list)
|
|
175
|
+
|
|
176
|
+
# explain
|
|
177
|
+
p_explain = subparsers.add_parser("explain", parents=[sub_parent_parser], help="Explain a specific memory's pressure")
|
|
178
|
+
p_explain.add_argument("memory_id", help="Memory UUID (or first 8 chars)")
|
|
179
|
+
p_explain.add_argument("--query", type=str, default=None, help="Optional query for resonance breakdown")
|
|
180
|
+
p_explain.set_defaults(func=cmd_explain)
|
|
181
|
+
|
|
182
|
+
# decay
|
|
183
|
+
p_decay = subparsers.add_parser("decay", parents=[sub_parent_parser], help="Trigger a decay pass")
|
|
184
|
+
p_decay.add_argument("--dry-run", action="store_true", help="Preview without writing")
|
|
185
|
+
p_decay.set_defaults(func=cmd_decay)
|
|
186
|
+
|
|
187
|
+
# stats
|
|
188
|
+
p_stats = subparsers.add_parser("stats", parents=[sub_parent_parser], help="Show store statistics")
|
|
189
|
+
p_stats.set_defaults(func=cmd_stats)
|
|
190
|
+
|
|
191
|
+
# drawers
|
|
192
|
+
p_drawers = subparsers.add_parser("drawers", parents=[sub_parent_parser], help="List all drawers")
|
|
193
|
+
p_drawers.set_defaults(func=cmd_drawers)
|
|
194
|
+
|
|
195
|
+
# sync
|
|
196
|
+
p_sync = subparsers.add_parser("sync", parents=[sub_parent_parser], help="Sync local store with AZUS cloud")
|
|
197
|
+
p_sync.add_argument("--direction", choices=["push", "pull", "bidirectional"], default="push")
|
|
198
|
+
p_sync.add_argument("--token", type=str, default=None, help="JWT access token")
|
|
199
|
+
p_sync.add_argument("--cloud-url", type=str, default="https://api.azus.ai")
|
|
200
|
+
p_sync.set_defaults(func=cmd_sync)
|
|
201
|
+
|
|
202
|
+
args = parser.parse_args()
|
|
203
|
+
args.func(args)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
main()
|