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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PDM Retrieval Engine — pure TAS (Threshold-Adjustment Search) without Django.
|
|
3
|
+
|
|
4
|
+
Ported from companion_api/pdm/threshold_search/engine.py.
|
|
5
|
+
All Django ORM calls removed. Accepts List[SignatureRecord] directly.
|
|
6
|
+
|
|
7
|
+
Three-phase search:
|
|
8
|
+
Phase 1 — Threshold Lowering: θ_eff = θ_base × (1 - α × search_cost)
|
|
9
|
+
Phase 2 — Impedance Matching: coupling_score = weighted tag/domain/regime/pressure
|
|
10
|
+
Phase 3 — Reinforcement delta: Δp = REINF_BASE × log(1 + retrieval_count) × coupling
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import math
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass, field, replace
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
from pdm_memory.core.math import (
|
|
23
|
+
DOMAIN_HALF_LIVES,
|
|
24
|
+
DEFAULT_HALF_LIFE,
|
|
25
|
+
P_MAX,
|
|
26
|
+
calculate_decay_factor,
|
|
27
|
+
calculate_intent_weight,
|
|
28
|
+
calculate_p_effective,
|
|
29
|
+
calculate_v,
|
|
30
|
+
infer_domain,
|
|
31
|
+
infer_regime,
|
|
32
|
+
calculate_incremental_decay,
|
|
33
|
+
)
|
|
34
|
+
from pdm_memory.core.signature import MemoryHit, SignatureRecord
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
WORD_PATTERN = re.compile(r"\b[a-zA-Z]{3,}\b")
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# TAS constants (mirrors threshold_search/engine.py)
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
ALPHA_DEFAULT: float = 0.7 # Threshold-lowering aggressiveness
|
|
45
|
+
THETA_FLOOR: float = 5.0 # Absolute minimum effective threshold
|
|
46
|
+
THETA_BASE_DEFAULT: float = 30.0 # Default base threshold
|
|
47
|
+
|
|
48
|
+
COUPLING_MIN_DEFAULT: float = 0.3 # Minimum coupling score to count as "coupled"
|
|
49
|
+
|
|
50
|
+
W_TAGS: float = 0.50
|
|
51
|
+
W_DOMAIN: float = 0.20
|
|
52
|
+
W_REGIME: float = 0.15
|
|
53
|
+
W_PRESSURE: float = 0.15
|
|
54
|
+
|
|
55
|
+
AUTO_FIRE_THRESHOLD: float = 85.0
|
|
56
|
+
REINF_BASE: float = 2.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Internal result types
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class NodeCoupling:
|
|
66
|
+
signature_id: str
|
|
67
|
+
p_effective: float
|
|
68
|
+
p_magnitude_raw: float
|
|
69
|
+
coupling_score: float
|
|
70
|
+
is_coupled: bool
|
|
71
|
+
auto_fire_eligible: bool
|
|
72
|
+
reinforcement_delta: float = 0.0
|
|
73
|
+
tag_overlap: float = 0.0
|
|
74
|
+
domain_match: float = 0.0
|
|
75
|
+
regime_match: float = 0.0
|
|
76
|
+
pressure_proximity: float = 0.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RetrievalResult:
|
|
81
|
+
"""Full result from the TAS retrieval engine."""
|
|
82
|
+
found: bool
|
|
83
|
+
threshold_used: float
|
|
84
|
+
base_threshold: float
|
|
85
|
+
search_cost: float
|
|
86
|
+
coupled_nodes: List[NodeCoupling] = field(default_factory=list)
|
|
87
|
+
damped_nodes: List[NodeCoupling] = field(default_factory=list)
|
|
88
|
+
top_node: Optional[NodeCoupling] = None
|
|
89
|
+
total_scanned: int = 0
|
|
90
|
+
reinforced_count: int = 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# RetrievalEngine
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RetrievalEngine:
|
|
99
|
+
"""
|
|
100
|
+
Pure PDM Threshold-Adjustment Search (TAS) engine.
|
|
101
|
+
|
|
102
|
+
Standalone — no DB access. Accepts List[SignatureRecord], returns
|
|
103
|
+
List[MemoryHit] ranked by (coupling_score × p_effective) descending.
|
|
104
|
+
|
|
105
|
+
Usage:
|
|
106
|
+
engine = RetrievalEngine()
|
|
107
|
+
hits = engine.recall(
|
|
108
|
+
records=my_signatures,
|
|
109
|
+
query="how should I format the answer?",
|
|
110
|
+
k=5,
|
|
111
|
+
)
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
alpha: float = ALPHA_DEFAULT,
|
|
117
|
+
theta_floor: float = THETA_FLOOR,
|
|
118
|
+
coupling_min: float = COUPLING_MIN_DEFAULT,
|
|
119
|
+
auto_fire_threshold: float = AUTO_FIRE_THRESHOLD,
|
|
120
|
+
reinforcement_base: float = REINF_BASE,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.alpha = max(0.0, min(1.0, alpha))
|
|
123
|
+
self.theta_floor = max(0.0, theta_floor)
|
|
124
|
+
self.coupling_min = max(0.0, min(1.0, coupling_min))
|
|
125
|
+
self.auto_fire_threshold = auto_fire_threshold
|
|
126
|
+
self.reinforcement_base = reinforcement_base
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Public API
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def recall(
|
|
133
|
+
self,
|
|
134
|
+
records: List[SignatureRecord],
|
|
135
|
+
query: Optional[str] = None,
|
|
136
|
+
k: int = 5,
|
|
137
|
+
search_cost: float = 0.5, # 0=TIGHT, 1=LOOSE
|
|
138
|
+
base_threshold: float = THETA_BASE_DEFAULT,
|
|
139
|
+
target_pressure: float = 50.0,
|
|
140
|
+
domain: Optional[str] = None,
|
|
141
|
+
regime: Optional[str] = None,
|
|
142
|
+
) -> List[MemoryHit]:
|
|
143
|
+
"""
|
|
144
|
+
Retrieve top-k memories from a list of SignatureRecords.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
records: All candidate SignatureRecords.
|
|
148
|
+
query: Natural-language recall query.
|
|
149
|
+
k: Maximum number of hits to return.
|
|
150
|
+
search_cost: 0.0 = strict, 1.0 = very loose threshold.
|
|
151
|
+
base_threshold: Starting pressure threshold (default 30).
|
|
152
|
+
target_pressure: Pressure proximity anchor for coupling.
|
|
153
|
+
domain: Optional domain filter (None = all).
|
|
154
|
+
regime: Optional regime filter (None = all).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List[MemoryHit] sorted by p_effective descending, length ≤ k.
|
|
158
|
+
"""
|
|
159
|
+
if not records:
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
now = datetime.now(tz=timezone.utc)
|
|
163
|
+
|
|
164
|
+
# Phase 1: threshold lowering
|
|
165
|
+
theta_eff = self._compute_threshold(base_threshold, search_cost)
|
|
166
|
+
|
|
167
|
+
coupled: List[tuple[NodeCoupling, MemoryHit]] = []
|
|
168
|
+
damped: List[NodeCoupling] = []
|
|
169
|
+
|
|
170
|
+
# Infer domain/regime from query if not specified
|
|
171
|
+
query_tags = self._tokenize_query(query) if query else []
|
|
172
|
+
effective_domain = domain or (infer_domain(query_tags) if query_tags else None)
|
|
173
|
+
effective_regime = regime or (infer_regime(query_tags) if query_tags else None)
|
|
174
|
+
|
|
175
|
+
for rec in records:
|
|
176
|
+
# Apply incremental decay at recall time (Task 1.4)
|
|
177
|
+
rec = self._apply_incremental_decay(rec, now)
|
|
178
|
+
|
|
179
|
+
# Compute live pressure
|
|
180
|
+
domain_key = rec.domain or infer_domain(rec.intent_tags)
|
|
181
|
+
half_life = DOMAIN_HALF_LIVES.get(domain_key, DEFAULT_HALF_LIFE)
|
|
182
|
+
days_since = self._days_since(rec.last_retrieved or rec.created_at, now)
|
|
183
|
+
decay = calculate_decay_factor(days_since, half_life)
|
|
184
|
+
v = calculate_v(rec.validation_prediction_correct, rec.validation_prediction_total)
|
|
185
|
+
i_weight = calculate_intent_weight(rec.intent_tags, query)
|
|
186
|
+
p_eff = calculate_p_effective(
|
|
187
|
+
rec.p_magnitude, v, decay, i_weight, quality=0.80
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Phase 1 gate
|
|
191
|
+
if p_eff < theta_eff:
|
|
192
|
+
coupling = self._compute_coupling(
|
|
193
|
+
rec, query_tags, p_eff, rec.p_magnitude,
|
|
194
|
+
effective_domain, effective_regime, target_pressure,
|
|
195
|
+
)
|
|
196
|
+
damped.append(coupling)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# Phase 2: impedance matching
|
|
200
|
+
coupling = self._compute_coupling(
|
|
201
|
+
rec, query_tags, p_eff, rec.p_magnitude,
|
|
202
|
+
effective_domain, effective_regime, target_pressure,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if coupling.is_coupled:
|
|
206
|
+
hit = MemoryHit.from_record(
|
|
207
|
+
rec, p_eff, decay, i_weight, v,
|
|
208
|
+
coupling_score=coupling.coupling_score,
|
|
209
|
+
tag_overlap=coupling.tag_overlap,
|
|
210
|
+
domain_match=coupling.domain_match,
|
|
211
|
+
regime_match=coupling.regime_match,
|
|
212
|
+
pressure_proximity=coupling.pressure_proximity,
|
|
213
|
+
)
|
|
214
|
+
coupled.append((coupling, hit))
|
|
215
|
+
else:
|
|
216
|
+
damped.append(coupling)
|
|
217
|
+
|
|
218
|
+
# Sort by coupling_score × p_effective descending
|
|
219
|
+
coupled.sort(key=lambda t: t[0].coupling_score * t[1].p_effective, reverse=True)
|
|
220
|
+
|
|
221
|
+
return [hit for _, hit in coupled[:k]]
|
|
222
|
+
|
|
223
|
+
def compute_reinforcement_delta(
|
|
224
|
+
self,
|
|
225
|
+
p_magnitude: float,
|
|
226
|
+
retrieval_count: int,
|
|
227
|
+
coupling_score: float,
|
|
228
|
+
) -> float:
|
|
229
|
+
"""
|
|
230
|
+
Δp = REINF_BASE × (1 + log(1 + retrieval_count)) × coupling_score
|
|
231
|
+
Capped at remaining headroom to P_MAX.
|
|
232
|
+
"""
|
|
233
|
+
retrieval_count = max(0, int(retrieval_count or 0))
|
|
234
|
+
log_factor = 1.0 + math.log(1.0 + retrieval_count)
|
|
235
|
+
delta = self.reinforcement_base * log_factor * coupling_score
|
|
236
|
+
headroom = max(0.0, P_MAX - float(p_magnitude))
|
|
237
|
+
return min(delta, headroom)
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Private helpers
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _compute_threshold(self, theta_base: float, search_cost: float) -> float:
|
|
244
|
+
cost = max(0.0, min(1.0, search_cost))
|
|
245
|
+
theta_eff = theta_base * (1.0 - self.alpha * cost)
|
|
246
|
+
return max(self.theta_floor, min(theta_base, theta_eff))
|
|
247
|
+
|
|
248
|
+
def _compute_coupling(
|
|
249
|
+
self,
|
|
250
|
+
rec: SignatureRecord,
|
|
251
|
+
query_tags: List[str],
|
|
252
|
+
p_eff: float,
|
|
253
|
+
p_raw: float,
|
|
254
|
+
effective_domain: Optional[str],
|
|
255
|
+
effective_regime: Optional[str],
|
|
256
|
+
target_pressure: float,
|
|
257
|
+
) -> NodeCoupling:
|
|
258
|
+
sig_tags = [t.lower() for t in rec.intent_tags]
|
|
259
|
+
cue_tags = [t.lower() for t in query_tags]
|
|
260
|
+
|
|
261
|
+
# Tag overlap
|
|
262
|
+
if cue_tags and sig_tags:
|
|
263
|
+
intersection = sum(1 for t in cue_tags if t in sig_tags)
|
|
264
|
+
tag_overlap = intersection / max(len(cue_tags), len(sig_tags))
|
|
265
|
+
elif not cue_tags:
|
|
266
|
+
tag_overlap = 1.0
|
|
267
|
+
else:
|
|
268
|
+
tag_overlap = 0.0
|
|
269
|
+
|
|
270
|
+
# Domain match
|
|
271
|
+
if effective_domain is None:
|
|
272
|
+
domain_match = 1.0
|
|
273
|
+
elif (rec.domain or "").lower() == effective_domain.lower():
|
|
274
|
+
domain_match = 1.0
|
|
275
|
+
else:
|
|
276
|
+
domain_match = 0.0
|
|
277
|
+
|
|
278
|
+
# Regime match
|
|
279
|
+
sig_regime = infer_regime(sig_tags)
|
|
280
|
+
if effective_regime is None:
|
|
281
|
+
regime_match = 1.0
|
|
282
|
+
elif sig_regime == effective_regime:
|
|
283
|
+
regime_match = 1.0
|
|
284
|
+
elif sig_regime == "neutral" or effective_regime == "neutral":
|
|
285
|
+
regime_match = 0.5
|
|
286
|
+
else:
|
|
287
|
+
regime_match = 0.0
|
|
288
|
+
|
|
289
|
+
# Pressure proximity
|
|
290
|
+
pressure_diff = abs(p_raw - target_pressure)
|
|
291
|
+
pressure_proximity = max(0.0, 1.0 - pressure_diff / 100.0)
|
|
292
|
+
|
|
293
|
+
coupling_score = (
|
|
294
|
+
W_TAGS * tag_overlap
|
|
295
|
+
+ W_DOMAIN * domain_match
|
|
296
|
+
+ W_REGIME * regime_match
|
|
297
|
+
+ W_PRESSURE * pressure_proximity
|
|
298
|
+
)
|
|
299
|
+
coupling_score = max(0.0, min(1.0, coupling_score))
|
|
300
|
+
|
|
301
|
+
return NodeCoupling(
|
|
302
|
+
signature_id=rec.id,
|
|
303
|
+
p_effective=round(p_eff, 3),
|
|
304
|
+
p_magnitude_raw=round(p_raw, 3),
|
|
305
|
+
coupling_score=round(coupling_score, 4),
|
|
306
|
+
is_coupled=coupling_score >= self.coupling_min,
|
|
307
|
+
auto_fire_eligible=p_raw >= self.auto_fire_threshold,
|
|
308
|
+
tag_overlap=round(tag_overlap, 4),
|
|
309
|
+
domain_match=round(domain_match, 4),
|
|
310
|
+
regime_match=round(regime_match, 4),
|
|
311
|
+
pressure_proximity=round(pressure_proximity, 4),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def _apply_incremental_decay(
|
|
316
|
+
rec: SignatureRecord,
|
|
317
|
+
now: datetime,
|
|
318
|
+
) -> SignatureRecord:
|
|
319
|
+
"""
|
|
320
|
+
Apply Task 1.4 incremental decay on read.
|
|
321
|
+
Mutates a copy of the record (does not touch storage).
|
|
322
|
+
"""
|
|
323
|
+
if rec.created_at is None:
|
|
324
|
+
return rec
|
|
325
|
+
|
|
326
|
+
created = rec.created_at
|
|
327
|
+
if created.tzinfo is None:
|
|
328
|
+
created = created.replace(tzinfo=timezone.utc)
|
|
329
|
+
|
|
330
|
+
days_elapsed = (now - created).total_seconds() / 86400.0
|
|
331
|
+
new_p, new_spike = calculate_incremental_decay(
|
|
332
|
+
rec.p_magnitude,
|
|
333
|
+
days_elapsed,
|
|
334
|
+
rec.t_persistence,
|
|
335
|
+
rec.phase_privilege,
|
|
336
|
+
rec.decay_rate,
|
|
337
|
+
)
|
|
338
|
+
# Return a shallow copy with updated pressure (don't write to DB here)
|
|
339
|
+
return replace(
|
|
340
|
+
rec,
|
|
341
|
+
p_magnitude=new_p,
|
|
342
|
+
effective_spike=new_spike,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _days_since(dt: Optional[datetime], now: datetime) -> float:
|
|
347
|
+
if dt is None:
|
|
348
|
+
return 0.0
|
|
349
|
+
if dt.tzinfo is None:
|
|
350
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
351
|
+
return max(0.0, (now - dt).total_seconds() / 86400.0)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _tokenize_query(query: str) -> List[str]:
|
|
355
|
+
"""Extract meaningful tokens from a query string for tag matching."""
|
|
356
|
+
words = WORD_PATTERN.findall(query.lower())
|
|
357
|
+
stopwords = {
|
|
358
|
+
"the", "and", "for", "how", "what", "that", "this", "with",
|
|
359
|
+
"are", "was", "not", "can", "will", "from", "have", "been",
|
|
360
|
+
"should", "would", "could", "which", "when", "where",
|
|
361
|
+
}
|
|
362
|
+
return [w for w in words if w not in stopwords]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PDM Core Dataclasses — framework-agnostic signature and result types.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Storage records
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SignatureRecord:
|
|
20
|
+
"""
|
|
21
|
+
One PDM memory — the unit stored in SQLite or the cloud.
|
|
22
|
+
|
|
23
|
+
All fields mirror the Signature model in companion_api/pdm/models.py
|
|
24
|
+
but carry no Django dependency.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Identity
|
|
28
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
29
|
+
user: str = "default"
|
|
30
|
+
|
|
31
|
+
# Content
|
|
32
|
+
compressed_fact: str = "" # The memory text (≤ 500 chars)
|
|
33
|
+
source: str = "chat" # Where it came from: chat, manual, csv, …
|
|
34
|
+
|
|
35
|
+
# PDM pressure fields
|
|
36
|
+
p_magnitude: float = 50.0 # Importance (0–100)
|
|
37
|
+
t_persistence: float = 30.0 # Days it stays relevant
|
|
38
|
+
phase_privilege: float = 1.0 # Nesting multiplier
|
|
39
|
+
effective_spike: Optional[float] = None # Computed: p × t/30 × phase
|
|
40
|
+
|
|
41
|
+
# Classification
|
|
42
|
+
intent_tags: List[str] = field(default_factory=list)
|
|
43
|
+
question_regime: str = "neutral"
|
|
44
|
+
domain: str = "insight" # Inferred from tags
|
|
45
|
+
|
|
46
|
+
# Retrieval tracking
|
|
47
|
+
retrieval_count: int = 0
|
|
48
|
+
last_retrieved: Optional[datetime] = None
|
|
49
|
+
created_at: Optional[datetime] = None
|
|
50
|
+
|
|
51
|
+
# Validation coefficient fields
|
|
52
|
+
validation_prediction_total: int = 0
|
|
53
|
+
validation_prediction_correct: int = 0
|
|
54
|
+
|
|
55
|
+
# Decay config
|
|
56
|
+
decay_rate: float = 0.9 # Multiplier per day past persistence
|
|
57
|
+
|
|
58
|
+
# Optional: temporal deadline
|
|
59
|
+
t_deadline: Optional[datetime] = None
|
|
60
|
+
urgency_rate: float = 2.0
|
|
61
|
+
|
|
62
|
+
# Optional: drawer (category) name
|
|
63
|
+
drawer_domain: str = "general"
|
|
64
|
+
|
|
65
|
+
# Extra metadata
|
|
66
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if self.created_at is None:
|
|
70
|
+
self.created_at = datetime.utcnow()
|
|
71
|
+
if self.effective_spike is None:
|
|
72
|
+
from pdm_memory.core.math import calculate_effective_spike
|
|
73
|
+
self.effective_spike = calculate_effective_spike(
|
|
74
|
+
self.p_magnitude, self.t_persistence, self.phase_privilege
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Retrieval results
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class MemoryHit:
|
|
85
|
+
"""
|
|
86
|
+
A single memory returned by mem.recall().
|
|
87
|
+
|
|
88
|
+
Carries both the raw record fields and the computed retrieval metrics
|
|
89
|
+
so callers can rank, filter, or explain results.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
# The record
|
|
93
|
+
id: str
|
|
94
|
+
text: str # alias for compressed_fact
|
|
95
|
+
source: str
|
|
96
|
+
drawer: str # drawer_domain
|
|
97
|
+
|
|
98
|
+
# Live pressure metrics
|
|
99
|
+
pressure: float # p_effective at retrieval time
|
|
100
|
+
p_raw: float # raw stored p_magnitude
|
|
101
|
+
p_effective: float # same as pressure (explicit name)
|
|
102
|
+
|
|
103
|
+
# Decay/retrieval info
|
|
104
|
+
decay_factor: float
|
|
105
|
+
intent_weight: float
|
|
106
|
+
v_coefficient: float
|
|
107
|
+
quality: float
|
|
108
|
+
last_reinforced: Optional[datetime]
|
|
109
|
+
retrieval_count: int
|
|
110
|
+
|
|
111
|
+
# Classification
|
|
112
|
+
intent_tags: List[str]
|
|
113
|
+
domain: str
|
|
114
|
+
|
|
115
|
+
# Resonance detail (TAS engine output)
|
|
116
|
+
coupling_score: float = 0.0
|
|
117
|
+
tag_overlap: float = 0.0
|
|
118
|
+
domain_match: float = 0.0
|
|
119
|
+
regime_match: float = 0.0
|
|
120
|
+
pressure_proximity: float = 0.0
|
|
121
|
+
|
|
122
|
+
# Temporal (if deadline exists)
|
|
123
|
+
e_temporal: Optional[float] = None
|
|
124
|
+
is_urgent: bool = False
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_record(
|
|
128
|
+
cls,
|
|
129
|
+
record: SignatureRecord,
|
|
130
|
+
p_effective: float,
|
|
131
|
+
decay_factor: float,
|
|
132
|
+
intent_weight: float,
|
|
133
|
+
v_coefficient: float,
|
|
134
|
+
coupling_score: float = 0.0,
|
|
135
|
+
tag_overlap: float = 0.0,
|
|
136
|
+
domain_match: float = 0.0,
|
|
137
|
+
regime_match: float = 0.0,
|
|
138
|
+
pressure_proximity: float = 0.0,
|
|
139
|
+
) -> "MemoryHit":
|
|
140
|
+
return cls(
|
|
141
|
+
id=record.id,
|
|
142
|
+
text=record.compressed_fact,
|
|
143
|
+
source=record.source,
|
|
144
|
+
drawer=record.drawer_domain,
|
|
145
|
+
pressure=p_effective,
|
|
146
|
+
p_raw=record.p_magnitude,
|
|
147
|
+
p_effective=p_effective,
|
|
148
|
+
decay_factor=decay_factor,
|
|
149
|
+
intent_weight=intent_weight,
|
|
150
|
+
v_coefficient=v_coefficient,
|
|
151
|
+
quality=0.80,
|
|
152
|
+
last_reinforced=record.last_retrieved,
|
|
153
|
+
retrieval_count=record.retrieval_count,
|
|
154
|
+
intent_tags=record.intent_tags,
|
|
155
|
+
domain=record.domain,
|
|
156
|
+
coupling_score=coupling_score,
|
|
157
|
+
tag_overlap=tag_overlap,
|
|
158
|
+
domain_match=domain_match,
|
|
159
|
+
regime_match=regime_match,
|
|
160
|
+
pressure_proximity=pressure_proximity,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Drawer summary
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class DrawerInfo:
|
|
171
|
+
"""Summary of a memory drawer (category)."""
|
|
172
|
+
domain: str
|
|
173
|
+
signature_count: int
|
|
174
|
+
avg_pressure: float
|
|
175
|
+
description: str = ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Explain report
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class ExplainReport:
|
|
185
|
+
"""
|
|
186
|
+
Returned by mem.explain(memory_id).
|
|
187
|
+
Shows exactly why a memory has the pressure it has and how it resonated.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
memory_id: str
|
|
191
|
+
compressed_fact: str
|
|
192
|
+
drawer: str
|
|
193
|
+
source: str
|
|
194
|
+
|
|
195
|
+
# Raw stored values
|
|
196
|
+
p_magnitude: float
|
|
197
|
+
t_persistence: float
|
|
198
|
+
effective_spike: float
|
|
199
|
+
created_at: Optional[datetime]
|
|
200
|
+
last_retrieved: Optional[datetime]
|
|
201
|
+
retrieval_count: int
|
|
202
|
+
|
|
203
|
+
# Computed at explain time
|
|
204
|
+
days_since_retrieved: float
|
|
205
|
+
half_life_days: float
|
|
206
|
+
decay_factor: float
|
|
207
|
+
v_coefficient: float
|
|
208
|
+
intent_weight: Optional[float] # None if no query was given
|
|
209
|
+
quality: float
|
|
210
|
+
p_effective: float
|
|
211
|
+
|
|
212
|
+
# Resonance breakdown
|
|
213
|
+
coupling_score: Optional[float]
|
|
214
|
+
tag_overlap: Optional[float]
|
|
215
|
+
domain_match: Optional[float]
|
|
216
|
+
regime_match: Optional[float]
|
|
217
|
+
pressure_proximity: Optional[float]
|
|
218
|
+
|
|
219
|
+
# Intent tags
|
|
220
|
+
intent_tags: List[str]
|
|
221
|
+
domain: str
|
|
222
|
+
|
|
223
|
+
def render(self) -> str:
|
|
224
|
+
"""Return a human-readable text report."""
|
|
225
|
+
lines = [
|
|
226
|
+
"╔══════════════════════════════════════════════════════",
|
|
227
|
+
"║ PDM Memory Explain Report",
|
|
228
|
+
"╠══════════════════════════════════════════════════════",
|
|
229
|
+
f"║ ID: {self.memory_id}",
|
|
230
|
+
f"║ Fact: {self.compressed_fact[:80]}{'…' if len(self.compressed_fact) > 80 else ''}",
|
|
231
|
+
f"║ Drawer: {self.drawer}",
|
|
232
|
+
f"║ Source: {self.source}",
|
|
233
|
+
f"║ Tags: {', '.join(self.intent_tags)}",
|
|
234
|
+
f"║ Domain: {self.domain}",
|
|
235
|
+
"╠──────────────────────────────────────────────────────",
|
|
236
|
+
"║ Pressure Components:",
|
|
237
|
+
f"║ p_magnitude: {self.p_magnitude:.2f}",
|
|
238
|
+
f"║ V coefficient: {self.v_coefficient:.4f} ({self.retrieval_count} retrievals)",
|
|
239
|
+
f"║ Decay factor: {self.decay_factor:.4f} ({self.days_since_retrieved:.1f}d since retrieved, T½={self.half_life_days}d)",
|
|
240
|
+
f"║ Intent weight: {self.intent_weight if self.intent_weight is not None else 'n/a (no query)'}",
|
|
241
|
+
f"║ Quality: {self.quality:.2f}",
|
|
242
|
+
"║ ─────────────────────────────",
|
|
243
|
+
f"║ P_effective: {self.p_effective:.2f}",
|
|
244
|
+
f"║ Eff. spike: {self.effective_spike:.2f}",
|
|
245
|
+
]
|
|
246
|
+
if self.coupling_score is not None:
|
|
247
|
+
lines += [
|
|
248
|
+
"╠──────────────────────────────────────────────────────",
|
|
249
|
+
"║ Resonance (TAS coupling):",
|
|
250
|
+
f"║ coupling_score: {self.coupling_score:.4f}",
|
|
251
|
+
f"║ tag_overlap: {self.tag_overlap:.4f}",
|
|
252
|
+
f"║ domain_match: {self.domain_match:.4f}",
|
|
253
|
+
f"║ regime_match: {self.regime_match:.4f}",
|
|
254
|
+
f"║ pressure_proximity: {self.pressure_proximity:.4f}",
|
|
255
|
+
]
|
|
256
|
+
lines.append("╚══════════════════════════════════════════════════════")
|
|
257
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""pdm_memory.ingest package."""
|
|
2
|
+
from pdm_memory.ingest.ingester import DataIngester
|
|
3
|
+
from pdm_memory.ingest.auto_signature import AutoSignatureGenerator
|
|
4
|
+
from pdm_memory.ingest.batch import BatchProcessor
|
|
5
|
+
|
|
6
|
+
__all__ = ["DataIngester", "AutoSignatureGenerator", "BatchProcessor"]
|