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.
@@ -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"]