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.
Files changed (71) hide show
  1. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/PKG-INFO +9 -3
  2. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/README.md +4 -0
  3. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/__init__.py +15 -4
  4. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/retrieval.py +158 -9
  5. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/signature.py +6 -2
  6. pdm_memory-0.2.0/pdm_memory/io/__init__.py +6 -0
  7. pdm_memory-0.2.0/pdm_memory/io/json_transfer.py +189 -0
  8. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/memory.py +477 -49
  9. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/models.py +40 -0
  10. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/__init__.py +7 -1
  11. pdm_memory-0.2.0/pdm_memory/storage/base.py +134 -0
  12. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/cloud_driver.py +76 -5
  13. pdm_memory-0.2.0/pdm_memory/storage/factory.py +178 -0
  14. pdm_memory-0.2.0/pdm_memory/storage/postgres_driver.py +353 -0
  15. pdm_memory-0.2.0/pdm_memory/storage/schema.py +302 -0
  16. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/sqlite_driver.py +110 -120
  17. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/sync.py +5 -4
  18. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/cli.py +88 -1
  19. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/explorer_actions.py +2 -2
  20. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/server.py +33 -12
  21. pdm_memory-0.2.0/pdm_memory/types.py +19 -0
  22. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/PKG-INFO +9 -3
  23. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/SOURCES.txt +11 -0
  24. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/requires.txt +3 -0
  25. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pyproject.toml +6 -3
  26. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_cli.py +25 -0
  27. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_explorer_console.py +10 -0
  28. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_memory.py +101 -3
  29. pdm_memory-0.2.0/tests/test_pdm_concurrency.py +124 -0
  30. pdm_memory-0.2.0/tests/test_pdm_physics.py +197 -0
  31. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_sqlite_driver.py +21 -0
  32. pdm_memory-0.2.0/tests/test_storage_factory.py +90 -0
  33. pdm_memory-0.2.0/tests/test_tier2.py +171 -0
  34. pdm_memory-0.2.0/tests/test_tier3.py +121 -0
  35. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_torsion.py +45 -0
  36. pdm_memory-0.1.8/pdm_memory/storage/base.py +0 -137
  37. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/LICENSE +0 -0
  38. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/auth/__init__.py +0 -0
  39. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/auth/jwt_handler.py +0 -0
  40. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/bench.py +0 -0
  41. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/__init__.py +0 -0
  42. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/alignment.py +0 -0
  43. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/core/math.py +0 -0
  44. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/__init__.py +0 -0
  45. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/auto_signature.py +0 -0
  46. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/batch.py +0 -0
  47. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/ingest/ingester.py +0 -0
  48. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/__init__.py +0 -0
  49. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/anthropic_adapter.py +0 -0
  50. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/context_manager.py +0 -0
  51. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/gemini_adapter.py +0 -0
  52. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/groq_adapter.py +0 -0
  53. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/ollama_adapter.py +0 -0
  54. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/integrations/openai_adapter.py +0 -0
  55. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/storage/errors.py +0 -0
  56. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/__init__.py +0 -0
  57. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/bench.py +0 -0
  58. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory/tools/static/index.html +0 -0
  59. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/dependency_links.txt +0 -0
  60. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/entry_points.txt +0 -0
  61. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/pdm_memory.egg-info/top_level.txt +0 -0
  62. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/setup.cfg +0 -0
  63. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_alignment.py +0 -0
  64. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_bench.py +0 -0
  65. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_cloud_driver.py +0 -0
  66. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_explorer_ui.py +0 -0
  67. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_gemini_adapter.py +0 -0
  68. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_groq_adapter.py +0 -0
  69. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_jwt_auth.py +0 -0
  70. {pdm_memory-0.1.8 → pdm_memory-0.2.0}/tests/test_math.py +0 -0
  71. {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.1.8
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://github.com/westfield-innovations/pdm-memory/issues
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
  [![Python](https://img.shields.io/pypi/pyversions/pdm-memory)](https://pypi.org/project/pdm-memory/)
176
178
  [![CI](https://github.com/westfield-innovations/pdm-memory/actions/workflows/ci.yml/badge.svg)](https://github.com/westfield-innovations/pdm-memory/actions)
177
179
  [![License: Proprietary](https://img.shields.io/badge/License-Proprietary%20ELv2-blue)](LICENSE)
180
+ [![Documentation](https://img.shields.io/badge/Docs-azus.ai%2Fsupport-blue)](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
  [![Python](https://img.shields.io/pypi/pyversions/pdm-memory)](https://pypi.org/project/pdm-memory/)
5
5
  [![CI](https://github.com/westfield-innovations/pdm-memory/actions/workflows/ci.yml/badge.svg)](https://github.com/westfield-innovations/pdm-memory/actions)
6
6
  [![License: Proprietary](https://img.shields.io/badge/License-Proprietary%20ELv2-blue)](LICENSE)
7
+ [![Documentation](https://img.shields.io/badge/Docs-azus.ai%2Fsupport-blue)](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
- __version__ = "0.1.8"
27
- __all__ = ["Memory", "MemoryHit", "DrawerInfo", "AlignmentReport", "TorsionReport", "__version__"]
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
- "not", "never", "no", "false", "isn't", "aren't", "wasn't", "weren't",
51
- "cannot", "can't", "won't", "without", "none", "neither",
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
- tok_a = set(self._tokenize_query(a.compressed_fact or ""))
554
- tok_b = set(self._tokenize_query(b.compressed_fact or ""))
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 = bool(_NEGATION_TOKENS & set(WORD_PATTERN.findall((a.compressed_fact or "").lower())))
563
- neg_b = bool(_NEGATION_TOKENS & set(WORD_PATTERN.findall((b.compressed_fact or "").lower())))
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 [])} {(a.compressed_fact or '')}".lower()
574
- text_blob_b = f"{' '.join(b.intent_tags or [])} {(b.compressed_fact or '')}".lower()
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.utcnow()
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,6 @@
1
+ # © 2026 Westfield Innovations LLC. Patent Pending.
2
+ """PDM import/export helpers."""
3
+
4
+ from pdm_memory.io.json_transfer import export_signatures_json, import_signatures_json
5
+
6
+ __all__ = ["export_signatures_json", "import_signatures_json"]
@@ -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}