pdm-memory 0.1.8__tar.gz → 0.2.0__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.
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/PKG-INFO +9 -3
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/README.md +4 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/__init__.py +15 -4
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/retrieval.py +158 -9
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/signature.py +6 -2
- pdm_memory-0.2.0/pdm_memory/io/__init__.py +6 -0
- pdm_memory-0.2.0/pdm_memory/io/json_transfer.py +189 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/memory.py +477 -49
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/models.py +40 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/__init__.py +7 -1
- pdm_memory-0.2.0/pdm_memory/storage/base.py +134 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/cloud_driver.py +76 -5
- pdm_memory-0.2.0/pdm_memory/storage/factory.py +178 -0
- pdm_memory-0.2.0/pdm_memory/storage/postgres_driver.py +353 -0
- pdm_memory-0.2.0/pdm_memory/storage/schema.py +302 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/sqlite_driver.py +110 -120
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/sync.py +5 -4
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/cli.py +88 -1
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/explorer_actions.py +2 -2
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/server.py +33 -12
- pdm_memory-0.2.0/pdm_memory/types.py +19 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/PKG-INFO +9 -3
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/SOURCES.txt +11 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/requires.txt +3 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pyproject.toml +6 -3
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_cli.py +25 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_explorer_console.py +10 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_memory.py +101 -3
- pdm_memory-0.2.0/tests/test_pdm_concurrency.py +124 -0
- pdm_memory-0.2.0/tests/test_pdm_physics.py +197 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_sqlite_driver.py +21 -0
- pdm_memory-0.2.0/tests/test_storage_factory.py +90 -0
- pdm_memory-0.2.0/tests/test_tier2.py +171 -0
- pdm_memory-0.2.0/tests/test_tier3.py +121 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_torsion.py +45 -0
- pdm_memory-0.1.8/pdm_memory/storage/base.py +0 -137
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/LICENSE +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/auth/__init__.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/auth/jwt_handler.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/bench.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/__init__.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/alignment.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/math.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/__init__.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/auto_signature.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/batch.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/ingester.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/__init__.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/anthropic_adapter.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/context_manager.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/gemini_adapter.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/groq_adapter.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/ollama_adapter.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/openai_adapter.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/errors.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/__init__.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/bench.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/static/index.html +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/dependency_links.txt +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/entry_points.txt +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/top_level.txt +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/setup.cfg +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_alignment.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_bench.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_cloud_driver.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_explorer_ui.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_gemini_adapter.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_groq_adapter.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_jwt_auth.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_math.py +0 -0
- {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_ollama_adapter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pdm-memory
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Pressure-Driven Memory (PDM) — persistent, resonance-based memory for AI apps.
|
|
5
5
|
Author: Westfield Innovations LLC
|
|
6
6
|
License: Elastic License 2.0 (ELv2)
|
|
@@ -115,8 +115,8 @@ License: Elastic License 2.0 (ELv2)
|
|
|
115
115
|
**trademark** means trademarks, service marks, and similar rights.
|
|
116
116
|
|
|
117
117
|
Project-URL: Homepage, https://azus.ai
|
|
118
|
-
Project-URL: Documentation, https://azus.ai
|
|
119
|
-
Project-URL: Bug Tracker, https://
|
|
118
|
+
Project-URL: Documentation, https://azus.ai/support
|
|
119
|
+
Project-URL: Bug Tracker, https://azus.ai/support?tab=bugs
|
|
120
120
|
Project-URL: Source, https://github.com/westfield-innovations/pdm-memory
|
|
121
121
|
Project-URL: Repository, https://github.com/westfield-innovations/pdm-memory
|
|
122
122
|
Keywords: ai,memory,llm,rag,pressure-driven,persistent-memory
|
|
@@ -154,6 +154,8 @@ Requires-Dist: groq>=0.9.0; extra == "all"
|
|
|
154
154
|
Provides-Extra: ui
|
|
155
155
|
Requires-Dist: fastapi>=0.110; extra == "ui"
|
|
156
156
|
Requires-Dist: uvicorn[standard]>=0.27; extra == "ui"
|
|
157
|
+
Provides-Extra: postgres
|
|
158
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
157
159
|
Provides-Extra: dev
|
|
158
160
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
159
161
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -175,6 +177,7 @@ Dynamic: license-file
|
|
|
175
177
|
[](https://pypi.org/project/pdm-memory/)
|
|
176
178
|
[](https://github.com/westfield-innovations/pdm-memory/actions)
|
|
177
179
|
[](LICENSE)
|
|
180
|
+
[](https://azus.ai/support)
|
|
178
181
|
|
|
179
182
|
Your LLM forgets everything between conversations. The standard fix — stuff a vector database into the context window — is expensive, slow, and retrieves **what matches words, not what matters**.
|
|
180
183
|
|
|
@@ -184,12 +187,15 @@ Your LLM forgets everything between conversations. The standard fix — stuff a
|
|
|
184
187
|
- 🗄️ **Your storage.** One local file. Your data never leaves your machine. Check the source — there's no phone-home in it.
|
|
185
188
|
- ⚡ **Ten minutes.** `pip install pdm-memory` → three lines → persistent memory.
|
|
186
189
|
|
|
190
|
+
📖 **Documentation:** [azus.ai/support](https://azus.ai/support)
|
|
191
|
+
|
|
187
192
|
**Benchmarks vs standard RAG:** [link] · Run yourself: `python -m pdm_memory.bench`
|
|
188
193
|
|
|
189
194
|
---
|
|
190
195
|
|
|
191
196
|
## Table of Contents
|
|
192
197
|
|
|
198
|
+
0. [Documentation](https://azus.ai/support)
|
|
193
199
|
1. [Privacy Mode](#-privacy-mode-local-sqlite)
|
|
194
200
|
2. [Ecosystem Mode](#-ecosystem-mode-azus-cloud)
|
|
195
201
|
3. [LLM Adapters](#-llm-adapters)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://pypi.org/project/pdm-memory/)
|
|
5
5
|
[](https://github.com/westfield-innovations/pdm-memory/actions)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://azus.ai/support)
|
|
7
8
|
|
|
8
9
|
Your LLM forgets everything between conversations. The standard fix — stuff a vector database into the context window — is expensive, slow, and retrieves **what matches words, not what matters**.
|
|
9
10
|
|
|
@@ -13,12 +14,15 @@ Your LLM forgets everything between conversations. The standard fix — stuff a
|
|
|
13
14
|
- 🗄️ **Your storage.** One local file. Your data never leaves your machine. Check the source — there's no phone-home in it.
|
|
14
15
|
- ⚡ **Ten minutes.** `pip install pdm-memory` → three lines → persistent memory.
|
|
15
16
|
|
|
17
|
+
📖 **Documentation:** [azus.ai/support](https://azus.ai/support)
|
|
18
|
+
|
|
16
19
|
**Benchmarks vs standard RAG:** [link] · Run yourself: `python -m pdm_memory.bench`
|
|
17
20
|
|
|
18
21
|
---
|
|
19
22
|
|
|
20
23
|
## Table of Contents
|
|
21
24
|
|
|
25
|
+
0. [Documentation](https://azus.ai/support)
|
|
22
26
|
1. [Privacy Mode](#-privacy-mode-local-sqlite)
|
|
23
27
|
2. [Ecosystem Mode](#-ecosystem-mode-azus-cloud)
|
|
24
28
|
3. [LLM Adapters](#-llm-adapters)
|
|
@@ -21,7 +21,18 @@ See README.md for full documentation.
|
|
|
21
21
|
|
|
22
22
|
from pdm_memory.memory import Memory
|
|
23
23
|
from pdm_memory.core.signature import MemoryHit, DrawerInfo
|
|
24
|
-
from pdm_memory.models import AlignmentReport, TorsionReport
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
from pdm_memory.models import AlignmentReport, SurfaceReport, TorsionReport
|
|
25
|
+
from pdm_memory.storage.factory import create_storage, register_storage
|
|
26
|
+
|
|
27
|
+
__version__ = "0.2.0"
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Memory",
|
|
30
|
+
"MemoryHit",
|
|
31
|
+
"DrawerInfo",
|
|
32
|
+
"AlignmentReport",
|
|
33
|
+
"SurfaceReport",
|
|
34
|
+
"TorsionReport",
|
|
35
|
+
"create_storage",
|
|
36
|
+
"register_storage",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
|
@@ -21,7 +21,10 @@ import math
|
|
|
21
21
|
import logging
|
|
22
22
|
from dataclasses import dataclass, field
|
|
23
23
|
from datetime import datetime, timezone
|
|
24
|
-
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
24
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pdm_memory.types import TorsionJudge
|
|
25
28
|
|
|
26
29
|
from pdm_memory.core.math import (
|
|
27
30
|
P_MAX,
|
|
@@ -47,10 +50,47 @@ _TOPIC_GATE: float = 0.35
|
|
|
47
50
|
_SMALL_CLUSTER: int = 48
|
|
48
51
|
_NEGATION_TOKENS: frozenset[str] = frozenset(
|
|
49
52
|
{
|
|
50
|
-
|
|
51
|
-
"
|
|
53
|
+
# English (incl. typo / no-apostrophe forms users actually type)
|
|
54
|
+
"not",
|
|
55
|
+
"never",
|
|
56
|
+
"no",
|
|
57
|
+
"false",
|
|
58
|
+
"without",
|
|
59
|
+
"none",
|
|
60
|
+
"neither",
|
|
61
|
+
"nor",
|
|
62
|
+
"cannot",
|
|
63
|
+
"dont",
|
|
64
|
+
"doesnt",
|
|
65
|
+
"didnt",
|
|
66
|
+
"isnt",
|
|
67
|
+
"arent",
|
|
68
|
+
"wasnt",
|
|
69
|
+
"werent",
|
|
70
|
+
"wont",
|
|
71
|
+
"cant",
|
|
72
|
+
"shouldnt",
|
|
73
|
+
"wouldnt",
|
|
74
|
+
"couldnt",
|
|
52
75
|
}
|
|
53
76
|
)
|
|
77
|
+
# Applied before polarity checks — user text is messy
|
|
78
|
+
_CONTRACTION_MAP: Tuple[Tuple[str, str], ...] = (
|
|
79
|
+
("don't", " do not "),
|
|
80
|
+
("doesn't", " does not "),
|
|
81
|
+
("didn't", " did not "),
|
|
82
|
+
("isn't", " is not "),
|
|
83
|
+
("aren't", " are not "),
|
|
84
|
+
("wasn't", " was not "),
|
|
85
|
+
("weren't", " were not "),
|
|
86
|
+
("won't", " will not "),
|
|
87
|
+
("can't", " can not "),
|
|
88
|
+
("shouldn't", " should not "),
|
|
89
|
+
("wouldn't", " would not "),
|
|
90
|
+
("couldn't", " could not "),
|
|
91
|
+
("cannot", " can not "),
|
|
92
|
+
("n't", " not "),
|
|
93
|
+
)
|
|
54
94
|
_ANTONYM_PAIRS: Tuple[Tuple[str, str], ...] = (
|
|
55
95
|
("prefer", "avoid"),
|
|
56
96
|
("likes", "hates"),
|
|
@@ -241,6 +281,9 @@ class RetrievalEngine:
|
|
|
241
281
|
)
|
|
242
282
|
|
|
243
283
|
if coupling.is_coupled:
|
|
284
|
+
if query_tags and self._semantic_query_overlap(rec, query_tags) < 0.12:
|
|
285
|
+
damped.append(coupling)
|
|
286
|
+
continue
|
|
244
287
|
hit = MemoryHit.from_record(
|
|
245
288
|
rec, p_eff, decay, i_weight, v,
|
|
246
289
|
coupling_score=coupling.coupling_score,
|
|
@@ -258,6 +301,37 @@ class RetrievalEngine:
|
|
|
258
301
|
|
|
259
302
|
return [hit for _, hit in coupled[:k]]
|
|
260
303
|
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _semantic_query_overlap(rec: SignatureRecord, query_tags: Sequence[str]) -> float:
|
|
306
|
+
"""
|
|
307
|
+
Tag or fact-token overlap between query and signature.
|
|
308
|
+
|
|
309
|
+
Blocks high-P memories from coupling when tags do not match and the
|
|
310
|
+
fact text shares no meaningful tokens with the query.
|
|
311
|
+
"""
|
|
312
|
+
if not query_tags:
|
|
313
|
+
return 1.0
|
|
314
|
+
cue = {t.lower() for t in query_tags if t}
|
|
315
|
+
sig_tags = {t.lower() for t in (rec.intent_tags or []) if t}
|
|
316
|
+
if RetrievalEngine._tags_overlap(cue, sig_tags):
|
|
317
|
+
return 1.0
|
|
318
|
+
fact_tokens = set(RetrievalEngine._tokenize_query(rec.compressed_fact or ""))
|
|
319
|
+
if not fact_tokens:
|
|
320
|
+
return 0.0
|
|
321
|
+
if RetrievalEngine._tags_overlap(cue, fact_tokens):
|
|
322
|
+
return 1.0
|
|
323
|
+
return len(fact_tokens & cue) / max(len(fact_tokens | cue), 1)
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def _tags_overlap(cue: Set[str], tags: Set[str]) -> bool:
|
|
327
|
+
if cue & tags:
|
|
328
|
+
return True
|
|
329
|
+
for q in cue:
|
|
330
|
+
for t in tags:
|
|
331
|
+
if len(q) >= 4 and (q in t or t in q):
|
|
332
|
+
return True
|
|
333
|
+
return False
|
|
334
|
+
|
|
261
335
|
def compute_reinforcement_delta(
|
|
262
336
|
self,
|
|
263
337
|
p_magnitude: float,
|
|
@@ -371,6 +445,7 @@ class RetrievalEngine:
|
|
|
371
445
|
self,
|
|
372
446
|
records: Sequence[SignatureRecord],
|
|
373
447
|
threshold: float = 0.7,
|
|
448
|
+
judge: Optional["TorsionJudge"] = None,
|
|
374
449
|
) -> List[TorsionReport]:
|
|
375
450
|
"""
|
|
376
451
|
Find Reverse Resonance pairs: high topic similarity + opposing facts/pressure.
|
|
@@ -378,6 +453,8 @@ class RetrievalEngine:
|
|
|
378
453
|
Search space is limited to drawer/domain (or metadata ``cluster_id``) buckets.
|
|
379
454
|
Within a bucket, candidates come from a tag inverted index (shared intent tags).
|
|
380
455
|
Small buckets may also compare all pairs. Never runs blind global N².
|
|
456
|
+
|
|
457
|
+
Optional ``judge`` callback may flag pairs rules-only detection missed (e.g. paraphrase).
|
|
381
458
|
"""
|
|
382
459
|
if threshold < 0.0 or threshold > 1.0:
|
|
383
460
|
raise ValueError("threshold must be in [0.0, 1.0]")
|
|
@@ -392,6 +469,7 @@ class RetrievalEngine:
|
|
|
392
469
|
|
|
393
470
|
reports: List[TorsionReport] = []
|
|
394
471
|
seen_pairs: Set[Tuple[str, str]] = set()
|
|
472
|
+
reported_pairs: Set[Tuple[str, str]] = set()
|
|
395
473
|
|
|
396
474
|
for cluster_key, group in clusters.items():
|
|
397
475
|
if len(group) < 2:
|
|
@@ -405,10 +483,47 @@ class RetrievalEngine:
|
|
|
405
483
|
report = self._score_torsion_pair(a, b, cluster_key=cluster_key)
|
|
406
484
|
if report is not None and report.torsion_score >= threshold:
|
|
407
485
|
reports.append(report)
|
|
486
|
+
reported_pairs.add(pair_key)
|
|
487
|
+
|
|
488
|
+
if judge is not None:
|
|
489
|
+
reports = self._merge_judge_reports(
|
|
490
|
+
clusters, reports, threshold, judge, reported_pairs
|
|
491
|
+
)
|
|
408
492
|
|
|
409
493
|
reports.sort(key=lambda r: r.torsion_score, reverse=True)
|
|
410
494
|
return reports
|
|
411
495
|
|
|
496
|
+
def _merge_judge_reports(
|
|
497
|
+
self,
|
|
498
|
+
clusters: Dict[str, List[SignatureRecord]],
|
|
499
|
+
reports: List[TorsionReport],
|
|
500
|
+
threshold: float,
|
|
501
|
+
judge: "TorsionJudge",
|
|
502
|
+
reported_pairs: Set[Tuple[str, str]],
|
|
503
|
+
) -> List[TorsionReport]:
|
|
504
|
+
"""Append judge-flagged pairs not already reported by rules-only detection."""
|
|
505
|
+
merged = list(reports)
|
|
506
|
+
seen_pairs: Set[Tuple[str, str]] = set()
|
|
507
|
+
for group in clusters.values():
|
|
508
|
+
if len(group) < 2:
|
|
509
|
+
continue
|
|
510
|
+
for a, b in self._torsion_candidate_pairs(group):
|
|
511
|
+
pair_key = (a.id, b.id) if a.id < b.id else (b.id, a.id)
|
|
512
|
+
if pair_key in seen_pairs or pair_key in reported_pairs:
|
|
513
|
+
continue
|
|
514
|
+
seen_pairs.add(pair_key)
|
|
515
|
+
try:
|
|
516
|
+
judged = judge(a, b)
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
logger.warning("[PDM] torsion_judge failed for pair: %s", exc)
|
|
519
|
+
continue
|
|
520
|
+
if judged is None or judged.torsion_score < threshold:
|
|
521
|
+
continue
|
|
522
|
+
reported_pairs.add(pair_key)
|
|
523
|
+
merged.append(judged)
|
|
524
|
+
merged.sort(key=lambda r: r.torsion_score, reverse=True)
|
|
525
|
+
return merged
|
|
526
|
+
|
|
412
527
|
def _torsion_candidate_pairs(
|
|
413
528
|
self,
|
|
414
529
|
group: Sequence[SignatureRecord],
|
|
@@ -550,8 +665,10 @@ class RetrievalEngine:
|
|
|
550
665
|
best_kind, best_strength, best_detail = "factual", strength, detail
|
|
551
666
|
|
|
552
667
|
# Negation / antonym polarity on shared content
|
|
553
|
-
|
|
554
|
-
|
|
668
|
+
norm_a = self._normalize_polarity_text(a.compressed_fact or "")
|
|
669
|
+
norm_b = self._normalize_polarity_text(b.compressed_fact or "")
|
|
670
|
+
tok_a = set(self._tokenize_query(norm_a))
|
|
671
|
+
tok_b = set(self._tokenize_query(norm_b))
|
|
555
672
|
content_a = tok_a - _NEGATION_TOKENS
|
|
556
673
|
content_b = tok_b - _NEGATION_TOKENS
|
|
557
674
|
content_overlap = (
|
|
@@ -559,8 +676,8 @@ class RetrievalEngine:
|
|
|
559
676
|
if content_a or content_b
|
|
560
677
|
else 0.0
|
|
561
678
|
)
|
|
562
|
-
neg_a =
|
|
563
|
-
neg_b =
|
|
679
|
+
neg_a = self._has_negation(norm_a)
|
|
680
|
+
neg_b = self._has_negation(norm_b)
|
|
564
681
|
if neg_a != neg_b and content_overlap >= 0.28:
|
|
565
682
|
strength = min(1.0, 0.45 + 0.55 * content_overlap)
|
|
566
683
|
if strength > best_strength:
|
|
@@ -570,8 +687,8 @@ class RetrievalEngine:
|
|
|
570
687
|
"one affirms, the other negates the shared topic",
|
|
571
688
|
)
|
|
572
689
|
|
|
573
|
-
text_blob_a = f"{' '.join(a.intent_tags or [])} {
|
|
574
|
-
text_blob_b = f"{' '.join(b.intent_tags or [])} {
|
|
690
|
+
text_blob_a = f"{' '.join(a.intent_tags or [])} {norm_a}".lower()
|
|
691
|
+
text_blob_b = f"{' '.join(b.intent_tags or [])} {norm_b}".lower()
|
|
575
692
|
for left, right in _ANTONYM_PAIRS:
|
|
576
693
|
a_has_l, a_has_r = left in text_blob_a, right in text_blob_a
|
|
577
694
|
b_has_l, b_has_r = left in text_blob_b, right in text_blob_b
|
|
@@ -635,6 +752,38 @@ class RetrievalEngine:
|
|
|
635
752
|
case _:
|
|
636
753
|
return f"{base}: reverse resonance on a shared topic."
|
|
637
754
|
|
|
755
|
+
@staticmethod
|
|
756
|
+
def _normalize_polarity_text(text: str) -> str:
|
|
757
|
+
"""Expand contractions / slang so negation tokens become detectable."""
|
|
758
|
+
t = (text or "").lower()
|
|
759
|
+
for src, dst in _CONTRACTION_MAP:
|
|
760
|
+
t = t.replace(src, dst)
|
|
761
|
+
# Common no-apostrophe typos after apostrophe expansion
|
|
762
|
+
for src, dst in (
|
|
763
|
+
(" dont ", " do not "),
|
|
764
|
+
(" doesnt ", " does not "),
|
|
765
|
+
(" didnt ", " did not "),
|
|
766
|
+
(" isnt ", " is not "),
|
|
767
|
+
(" arent ", " are not "),
|
|
768
|
+
(" wasnt ", " was not "),
|
|
769
|
+
(" werent ", " were not "),
|
|
770
|
+
(" wont ", " will not "),
|
|
771
|
+
(" cant ", " can not "),
|
|
772
|
+
):
|
|
773
|
+
t = t.replace(src, dst)
|
|
774
|
+
# Leading typo without spaces: "i dont love" already spaced by lower()
|
|
775
|
+
if t.startswith("dont "):
|
|
776
|
+
t = "do not " + t[5:]
|
|
777
|
+
return t
|
|
778
|
+
|
|
779
|
+
@staticmethod
|
|
780
|
+
def _has_negation(text: str) -> bool:
|
|
781
|
+
"""True if text contains an English negation cue after normalization."""
|
|
782
|
+
norm = RetrievalEngine._normalize_polarity_text(text)
|
|
783
|
+
tokens = set(WORD_PATTERN.findall(norm))
|
|
784
|
+
# Also catch leftover no-apostrophe forms as whole tokens
|
|
785
|
+
return bool(tokens & _NEGATION_TOKENS)
|
|
786
|
+
|
|
638
787
|
@staticmethod
|
|
639
788
|
def _standalone_numbers(text: str) -> set[str]:
|
|
640
789
|
"""Extract numeric tokens that are not glued to letters (skip Q3, v2)."""
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import uuid
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
|
-
from datetime import datetime
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
14
|
from typing import Any, Dict, List, Optional
|
|
15
15
|
|
|
16
16
|
|
|
@@ -69,9 +69,13 @@ class SignatureRecord:
|
|
|
69
69
|
# Extra metadata
|
|
70
70
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
71
71
|
|
|
72
|
+
# Storage / lifecycle (Tier 3)
|
|
73
|
+
is_deleted: bool = False
|
|
74
|
+
idempotency_key: Optional[str] = None
|
|
75
|
+
|
|
72
76
|
def __post_init__(self) -> None:
|
|
73
77
|
if self.created_at is None:
|
|
74
|
-
self.created_at = datetime.
|
|
78
|
+
self.created_at = datetime.now(tz=timezone.utc)
|
|
75
79
|
if self.effective_spike is None:
|
|
76
80
|
from pdm_memory.core.math import calculate_effective_spike
|
|
77
81
|
self.effective_spike = calculate_effective_spike(
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# © 2026 Westfield Innovations LLC. Patent Pending.
|
|
2
|
+
# U.S. App. No. 19/739,419 | 63/953,563 | 63/953,842
|
|
3
|
+
# MODIFICATION PROHIBITED. USE AS SHIPPED.
|
|
4
|
+
|
|
5
|
+
"""JSON export/import for PDM signatures — backup and cross-backend migration."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, List, Union
|
|
13
|
+
|
|
14
|
+
from pdm_memory.core.signature import SignatureRecord
|
|
15
|
+
from pdm_memory.storage.base import BaseStorage
|
|
16
|
+
from pdm_memory.storage.schema import hash_fact_text
|
|
17
|
+
|
|
18
|
+
_EXPORT_VERSION = "1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _dt_to_iso(value: datetime | None) -> str | None:
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
if value.tzinfo is None:
|
|
25
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
26
|
+
return value.isoformat()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _iso_to_dt(value: str | None) -> datetime | None:
|
|
30
|
+
if not value:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
parsed = datetime.fromisoformat(value)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return None
|
|
36
|
+
if parsed.tzinfo is None:
|
|
37
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
38
|
+
return parsed
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def record_to_dict(rec: SignatureRecord) -> dict[str, Any]:
|
|
42
|
+
"""Serialize a SignatureRecord to a JSON-safe dict."""
|
|
43
|
+
return {
|
|
44
|
+
"id": rec.id,
|
|
45
|
+
"user": rec.user,
|
|
46
|
+
"compressed_fact": rec.compressed_fact,
|
|
47
|
+
"source": rec.source,
|
|
48
|
+
"p_magnitude": rec.p_magnitude,
|
|
49
|
+
"t_persistence": rec.t_persistence,
|
|
50
|
+
"phase_privilege": rec.phase_privilege,
|
|
51
|
+
"effective_spike": rec.effective_spike,
|
|
52
|
+
"intent_tags": list(rec.intent_tags or []),
|
|
53
|
+
"question_regime": rec.question_regime,
|
|
54
|
+
"domain": rec.domain,
|
|
55
|
+
"drawer_domain": rec.drawer_domain,
|
|
56
|
+
"retrieval_count": rec.retrieval_count,
|
|
57
|
+
"last_retrieved": _dt_to_iso(rec.last_retrieved),
|
|
58
|
+
"created_at": _dt_to_iso(rec.created_at),
|
|
59
|
+
"validation_prediction_total": rec.validation_prediction_total,
|
|
60
|
+
"validation_prediction_correct": rec.validation_prediction_correct,
|
|
61
|
+
"decay_rate": rec.decay_rate,
|
|
62
|
+
"t_deadline": _dt_to_iso(rec.t_deadline),
|
|
63
|
+
"urgency_rate": rec.urgency_rate,
|
|
64
|
+
"metadata": dict(rec.metadata or {}),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def dict_to_record(data: dict[str, Any], *, user: str) -> SignatureRecord:
|
|
69
|
+
"""Deserialize a JSON dict into SignatureRecord."""
|
|
70
|
+
text = str(data.get("compressed_fact") or data.get("text") or "").strip()
|
|
71
|
+
tags = data.get("intent_tags") or data.get("tags") or []
|
|
72
|
+
if isinstance(tags, str):
|
|
73
|
+
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
|
74
|
+
|
|
75
|
+
return SignatureRecord(
|
|
76
|
+
id=str(data.get("id") or ""),
|
|
77
|
+
user=user,
|
|
78
|
+
compressed_fact=text,
|
|
79
|
+
source=str(data.get("source") or "import"),
|
|
80
|
+
p_magnitude=float(data.get("p_magnitude", 50.0)),
|
|
81
|
+
t_persistence=float(data.get("t_persistence", 30.0)),
|
|
82
|
+
phase_privilege=float(data.get("phase_privilege", 1.0)),
|
|
83
|
+
effective_spike=data.get("effective_spike"),
|
|
84
|
+
intent_tags=list(tags),
|
|
85
|
+
question_regime=str(data.get("question_regime") or "neutral"),
|
|
86
|
+
domain=str(data.get("domain") or "insight"),
|
|
87
|
+
drawer_domain=str(data.get("drawer_domain") or data.get("drawer") or "general"),
|
|
88
|
+
retrieval_count=int(data.get("retrieval_count") or 0),
|
|
89
|
+
last_retrieved=_iso_to_dt(data.get("last_retrieved")),
|
|
90
|
+
created_at=_iso_to_dt(data.get("created_at")),
|
|
91
|
+
validation_prediction_total=int(data.get("validation_prediction_total") or 0),
|
|
92
|
+
validation_prediction_correct=int(data.get("validation_prediction_correct") or 0),
|
|
93
|
+
decay_rate=float(data.get("decay_rate", 0.9)),
|
|
94
|
+
t_deadline=_iso_to_dt(data.get("t_deadline")),
|
|
95
|
+
urgency_rate=float(data.get("urgency_rate", 2.0)),
|
|
96
|
+
metadata=dict(data.get("metadata") or {}),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def export_signatures_json(
|
|
101
|
+
storage: BaseStorage,
|
|
102
|
+
path: Union[str, Path],
|
|
103
|
+
*,
|
|
104
|
+
user: str = "default",
|
|
105
|
+
limit: int = 100_000,
|
|
106
|
+
) -> int:
|
|
107
|
+
"""
|
|
108
|
+
Export all signatures for ``user`` to a JSON file.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Number of signatures written.
|
|
112
|
+
"""
|
|
113
|
+
records = storage.list(user=user, limit=limit)
|
|
114
|
+
payload = {
|
|
115
|
+
"version": _EXPORT_VERSION,
|
|
116
|
+
"exported_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
117
|
+
"user": user,
|
|
118
|
+
"count": len(records),
|
|
119
|
+
"signatures": [record_to_dict(r) for r in records],
|
|
120
|
+
}
|
|
121
|
+
out = Path(path)
|
|
122
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
out.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
124
|
+
return len(records)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def import_signatures_json(
|
|
128
|
+
storage: BaseStorage,
|
|
129
|
+
path: Union[str, Path],
|
|
130
|
+
*,
|
|
131
|
+
user: str = "default",
|
|
132
|
+
skip_duplicates: bool = True,
|
|
133
|
+
) -> dict[str, int]:
|
|
134
|
+
"""
|
|
135
|
+
Import signatures from a JSON export file.
|
|
136
|
+
|
|
137
|
+
When ``skip_duplicates`` is True, skips rows whose id or fact hash already exist.
|
|
138
|
+
"""
|
|
139
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
140
|
+
items: List[dict[str, Any]] = raw.get("signatures") or raw.get("records") or []
|
|
141
|
+
if not isinstance(items, list):
|
|
142
|
+
raise ValueError("JSON must contain a 'signatures' array")
|
|
143
|
+
|
|
144
|
+
saved = 0
|
|
145
|
+
skipped = 0
|
|
146
|
+
errors = 0
|
|
147
|
+
|
|
148
|
+
txn = getattr(storage, "transaction", None)
|
|
149
|
+
if callable(txn):
|
|
150
|
+
ctx = txn()
|
|
151
|
+
else:
|
|
152
|
+
from contextlib import nullcontext
|
|
153
|
+
|
|
154
|
+
ctx = nullcontext()
|
|
155
|
+
|
|
156
|
+
with ctx:
|
|
157
|
+
for item in items:
|
|
158
|
+
if not isinstance(item, dict):
|
|
159
|
+
errors += 1
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
rec = dict_to_record(item, user=user)
|
|
163
|
+
if not rec.compressed_fact:
|
|
164
|
+
errors += 1
|
|
165
|
+
continue
|
|
166
|
+
if not rec.id:
|
|
167
|
+
import uuid
|
|
168
|
+
|
|
169
|
+
rec.id = str(uuid.uuid4())
|
|
170
|
+
|
|
171
|
+
if skip_duplicates:
|
|
172
|
+
if storage.get(rec.id, user=user) is not None:
|
|
173
|
+
skipped += 1
|
|
174
|
+
continue
|
|
175
|
+
fact = rec.compressed_fact
|
|
176
|
+
if fact.startswith("[HASH:") and fact.endswith("]"):
|
|
177
|
+
text_hash = fact[6:-1]
|
|
178
|
+
else:
|
|
179
|
+
text_hash = hash_fact_text(fact.strip()[:500])
|
|
180
|
+
if storage.find_by_hash(text_hash, user=user) is not None:
|
|
181
|
+
skipped += 1
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
storage.save(rec)
|
|
185
|
+
saved += 1
|
|
186
|
+
except Exception:
|
|
187
|
+
errors += 1
|
|
188
|
+
|
|
189
|
+
return {"saved": saved, "skipped": skipped, "errors": errors}
|