patternmem-rag 0.1.1__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.
- patternmem/__init__.py +40 -0
- patternmem/_utils.py +25 -0
- patternmem/augmenter.py +155 -0
- patternmem/backend.py +138 -0
- patternmem/backends/__init__.py +1 -0
- patternmem/backends/chroma_backend.py +215 -0
- patternmem/backends/faiss_backend.py +302 -0
- patternmem/backends/json_backend.py +173 -0
- patternmem/backends/neo4j_backend.py +236 -0
- patternmem/backends/networkx_backend.py +157 -0
- patternmem/backends/sqlite_backend.py +197 -0
- patternmem/decay.py +90 -0
- patternmem/eval_router.py +316 -0
- patternmem/middleware.py +336 -0
- patternmem/observability.py +101 -0
- patternmem/reflector.py +211 -0
- patternmem/resolver.py +138 -0
- patternmem/types.py +212 -0
- patternmem_rag-0.1.1.dist-info/METADATA +238 -0
- patternmem_rag-0.1.1.dist-info/RECORD +23 -0
- patternmem_rag-0.1.1.dist-info/WHEEL +5 -0
- patternmem_rag-0.1.1.dist-info/licenses/LICENSE +21 -0
- patternmem_rag-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.backends.neo4j_backend
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Neo4j / AuraDB backend for PatternMem. Requires the ``[neo4j]`` optional extra::
|
|
5
|
+
|
|
6
|
+
pip install patternmem-rag[neo4j]
|
|
7
|
+
|
|
8
|
+
Graph schema (implement exactly — do not deviate)
|
|
9
|
+
--------------------------------------------------
|
|
10
|
+
Nodes
|
|
11
|
+
~~~~~
|
|
12
|
+
- ``FailurePattern {id, query_embedding, failure_type, root_cause, hint_text,
|
|
13
|
+
score, created_at, decay_weight}``
|
|
14
|
+
- ``QueryType {label}`` — values: temporal | entity | multi-hop | factoid
|
|
15
|
+
- ``RetrievalHint {text}``
|
|
16
|
+
|
|
17
|
+
Edges
|
|
18
|
+
~~~~~
|
|
19
|
+
- ``(:FailurePattern)-[:TRIGGERED_BY]->(:QueryType)``
|
|
20
|
+
- ``(:FailurePattern)-[:SUGGESTS]->(:RetrievalHint)``
|
|
21
|
+
- ``(:RetrievalHint)-[:SIMILAR_TO {sim_score}]->(:RetrievalHint)``
|
|
22
|
+
|
|
23
|
+
Vector index
|
|
24
|
+
~~~~~~~~~~~~
|
|
25
|
+
Named ``'failure_pattern_embeddings'`` on the ``query_embedding`` property of
|
|
26
|
+
``FailurePattern`` nodes. Created automatically on first use.
|
|
27
|
+
|
|
28
|
+
Lookup Cypher (from spec — do not modify)
|
|
29
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
30
|
+
::
|
|
31
|
+
|
|
32
|
+
CALL db.index.vector.queryNodes('failure_pattern_embeddings', 5, $query_embedding)
|
|
33
|
+
YIELD node, score
|
|
34
|
+
WHERE score > 0.82
|
|
35
|
+
RETURN node.hint_text, node.failure_type
|
|
36
|
+
ORDER BY score DESC LIMIT 3
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from neo4j import AsyncGraphDatabase # type: ignore[import]
|
|
46
|
+
from neo4j.exceptions import ClientError # type: ignore[import]
|
|
47
|
+
|
|
48
|
+
_NEO4J_AVAILABLE = True
|
|
49
|
+
except ImportError:
|
|
50
|
+
_NEO4J_AVAILABLE = False
|
|
51
|
+
|
|
52
|
+
from patternmem.backend import MemoryBackend
|
|
53
|
+
from patternmem.types import FailurePattern, FailureType
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Cypher statements
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_CREATE_VECTOR_INDEX = """
|
|
60
|
+
CREATE VECTOR INDEX failure_pattern_embeddings IF NOT EXISTS
|
|
61
|
+
FOR (n:FailurePattern)
|
|
62
|
+
ON n.query_embedding
|
|
63
|
+
OPTIONS {indexConfig: {`vector.dimensions`: 384, `vector.similarity_function`: 'cosine'}}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_UPSERT_PATTERN = """
|
|
67
|
+
MERGE (p:FailurePattern {id: $id})
|
|
68
|
+
SET p.query_embedding = $query_embedding,
|
|
69
|
+
p.failure_type = $failure_type,
|
|
70
|
+
p.root_cause = $root_cause,
|
|
71
|
+
p.hint_text = $hint_text,
|
|
72
|
+
p.score = $score,
|
|
73
|
+
p.created_at = $created_at,
|
|
74
|
+
p.decay_weight = $decay_weight
|
|
75
|
+
WITH p
|
|
76
|
+
MERGE (h:RetrievalHint {text: $hint_text})
|
|
77
|
+
MERGE (p)-[:SUGGESTS]->(h)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
_LOOKUP_PATTERNS = """
|
|
81
|
+
CALL db.index.vector.queryNodes('failure_pattern_embeddings', 5, $query_embedding)
|
|
82
|
+
YIELD node, score
|
|
83
|
+
WHERE score > $threshold
|
|
84
|
+
RETURN node.id AS id,
|
|
85
|
+
node.query_embedding AS query_embedding,
|
|
86
|
+
node.failure_type AS failure_type,
|
|
87
|
+
node.root_cause AS root_cause,
|
|
88
|
+
node.hint_text AS hint_text,
|
|
89
|
+
node.score AS score,
|
|
90
|
+
node.created_at AS created_at,
|
|
91
|
+
node.decay_weight AS decay_weight,
|
|
92
|
+
score AS sim_score
|
|
93
|
+
ORDER BY score DESC LIMIT 3
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
_COUNT_PATTERNS = "MATCH (p:FailurePattern) RETURN count(p) AS cnt"
|
|
97
|
+
|
|
98
|
+
_UPDATE_DECAY = """
|
|
99
|
+
MATCH (p:FailurePattern {id: $id})
|
|
100
|
+
SET p.decay_weight = $decay_weight
|
|
101
|
+
RETURN p.id AS id
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
_DELETE_PATTERN = """
|
|
105
|
+
MATCH (p:FailurePattern {id: $id})
|
|
106
|
+
DETACH DELETE p
|
|
107
|
+
RETURN $id AS id
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Neo4jBackend(MemoryBackend):
|
|
112
|
+
"""Neo4j / AuraDB backend with native vector index lookup.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
uri:
|
|
117
|
+
Bolt or neo4j+s URI (e.g. ``"bolt://localhost:7687"`` or
|
|
118
|
+
``"neo4j+s://<aura-id>.databases.neo4j.io"``).
|
|
119
|
+
auth:
|
|
120
|
+
``(username, password)`` tuple.
|
|
121
|
+
database:
|
|
122
|
+
Target database name. ``"neo4j"`` for most single-DB setups.
|
|
123
|
+
similarity_threshold:
|
|
124
|
+
Cosine similarity floor for the vector index lookup. Passed as
|
|
125
|
+
``$threshold`` to the lookup Cypher.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
uri: str,
|
|
131
|
+
auth: tuple[str, str],
|
|
132
|
+
database: str = "neo4j",
|
|
133
|
+
similarity_threshold: float = 0.82,
|
|
134
|
+
) -> None:
|
|
135
|
+
if not _NEO4J_AVAILABLE:
|
|
136
|
+
raise ImportError(
|
|
137
|
+
"Neo4jBackend requires the 'neo4j' driver. "
|
|
138
|
+
"Install it with: pip install patternmem-rag[neo4j]"
|
|
139
|
+
)
|
|
140
|
+
self._driver = AsyncGraphDatabase.driver(uri, auth=auth)
|
|
141
|
+
self._database = database
|
|
142
|
+
self._threshold = similarity_threshold
|
|
143
|
+
self._index_ready = False
|
|
144
|
+
|
|
145
|
+
async def _ensure_index(self) -> None:
|
|
146
|
+
"""Create the vector index if it does not already exist."""
|
|
147
|
+
if self._index_ready:
|
|
148
|
+
return
|
|
149
|
+
async with self._driver.session(database=self._database) as session:
|
|
150
|
+
try:
|
|
151
|
+
await session.run(_CREATE_VECTOR_INDEX)
|
|
152
|
+
except ClientError:
|
|
153
|
+
# Index may already exist under a different schema — ignore
|
|
154
|
+
pass
|
|
155
|
+
self._index_ready = True
|
|
156
|
+
|
|
157
|
+
async def write_pattern(self, pattern: FailurePattern) -> None:
|
|
158
|
+
await self._ensure_index()
|
|
159
|
+
async with self._driver.session(database=self._database) as session:
|
|
160
|
+
await session.run(
|
|
161
|
+
_UPSERT_PATTERN,
|
|
162
|
+
id=pattern.id,
|
|
163
|
+
query_embedding=pattern.query_embedding,
|
|
164
|
+
failure_type=pattern.failure_type.name,
|
|
165
|
+
root_cause=pattern.root_cause,
|
|
166
|
+
hint_text=pattern.hint_text,
|
|
167
|
+
score=pattern.score,
|
|
168
|
+
created_at=pattern.created_at.isoformat(),
|
|
169
|
+
decay_weight=pattern.decay_weight,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def lookup_patterns(
|
|
173
|
+
self,
|
|
174
|
+
query_embedding: list[float],
|
|
175
|
+
top_k: int = 3,
|
|
176
|
+
) -> list[FailurePattern]:
|
|
177
|
+
await self._ensure_index()
|
|
178
|
+
async with self._driver.session(database=self._database) as session:
|
|
179
|
+
result = await session.run(
|
|
180
|
+
_LOOKUP_PATTERNS,
|
|
181
|
+
query_embedding=query_embedding,
|
|
182
|
+
threshold=self._threshold,
|
|
183
|
+
)
|
|
184
|
+
records = await result.data()
|
|
185
|
+
|
|
186
|
+
patterns: list[FailurePattern] = []
|
|
187
|
+
for rec in records:
|
|
188
|
+
patterns.append(
|
|
189
|
+
FailurePattern(
|
|
190
|
+
id=rec["id"],
|
|
191
|
+
query_embedding=list(rec["query_embedding"]),
|
|
192
|
+
failure_type=FailureType[rec["failure_type"]],
|
|
193
|
+
root_cause=rec["root_cause"],
|
|
194
|
+
hint_text=rec["hint_text"],
|
|
195
|
+
score=rec["score"],
|
|
196
|
+
created_at=datetime.fromisoformat(rec["created_at"]).replace(
|
|
197
|
+
tzinfo=timezone.utc
|
|
198
|
+
),
|
|
199
|
+
decay_weight=rec["decay_weight"],
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return patterns[:top_k]
|
|
203
|
+
|
|
204
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
205
|
+
async with self._driver.session(database=self._database) as session:
|
|
206
|
+
result = await session.run(_COUNT_PATTERNS)
|
|
207
|
+
record = await result.single()
|
|
208
|
+
return {
|
|
209
|
+
"count": int(record["cnt"]) if record else 0,
|
|
210
|
+
"database": self._database,
|
|
211
|
+
"backend": "neo4j",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async def update_pattern(self, pattern_id: str, decay_weight: float) -> None:
|
|
215
|
+
async with self._driver.session(database=self._database) as session:
|
|
216
|
+
result = await session.run(
|
|
217
|
+
_UPDATE_DECAY, id=pattern_id, decay_weight=decay_weight
|
|
218
|
+
)
|
|
219
|
+
record = await result.single()
|
|
220
|
+
if record is None:
|
|
221
|
+
raise KeyError(
|
|
222
|
+
f"Pattern {pattern_id!r} not found in Neo4j backend"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def delete_pattern(self, pattern_id: str) -> None:
|
|
226
|
+
async with self._driver.session(database=self._database) as session:
|
|
227
|
+
result = await session.run(_DELETE_PATTERN, id=pattern_id)
|
|
228
|
+
summary = await result.consume()
|
|
229
|
+
if summary.counters.nodes_deleted == 0:
|
|
230
|
+
raise KeyError(
|
|
231
|
+
f"Pattern {pattern_id!r} not found in Neo4j backend"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def close(self) -> None:
|
|
235
|
+
"""Close the driver connection pool. Call on teardown."""
|
|
236
|
+
await self._driver.close()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.backends.networkx_backend
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
In-memory NetworkX DiGraph backend for PatternMem.
|
|
5
|
+
|
|
6
|
+
Ideal for experimentation, notebooks, and unit tests that need a
|
|
7
|
+
zero-file-I/O graph-native store. Optionally serialises to GraphML
|
|
8
|
+
via ``persist_path`` for lightweight persistence across restarts.
|
|
9
|
+
|
|
10
|
+
Similarity
|
|
11
|
+
----------
|
|
12
|
+
Cosine similarity via numpy — identical algorithm to ``JSONBackend``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import networkx as nx
|
|
26
|
+
|
|
27
|
+
_NX_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
_NX_AVAILABLE = False
|
|
30
|
+
|
|
31
|
+
from patternmem._utils import cosine_similarity as _cosine_similarity
|
|
32
|
+
from patternmem.backend import MemoryBackend
|
|
33
|
+
from patternmem.types import FailurePattern, FailureType
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pattern_to_attrs(p: FailurePattern) -> dict[str, Any]:
|
|
38
|
+
"""Flatten a FailurePattern into a NetworkX node-attribute dict."""
|
|
39
|
+
return {
|
|
40
|
+
"query_embedding": p.query_embedding,
|
|
41
|
+
"failure_type": p.failure_type.name,
|
|
42
|
+
"root_cause": p.root_cause,
|
|
43
|
+
"hint_text": p.hint_text,
|
|
44
|
+
"score": p.score,
|
|
45
|
+
"created_at": p.created_at.isoformat(),
|
|
46
|
+
"decay_weight": p.decay_weight,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _attrs_to_pattern(node_id: str, attrs: dict[str, Any]) -> FailurePattern:
|
|
51
|
+
return FailurePattern(
|
|
52
|
+
id=node_id,
|
|
53
|
+
query_embedding=attrs["query_embedding"],
|
|
54
|
+
failure_type=FailureType[attrs["failure_type"]],
|
|
55
|
+
root_cause=attrs["root_cause"],
|
|
56
|
+
hint_text=attrs["hint_text"],
|
|
57
|
+
score=attrs["score"],
|
|
58
|
+
created_at=datetime.fromisoformat(attrs["created_at"]).replace(
|
|
59
|
+
tzinfo=timezone.utc
|
|
60
|
+
),
|
|
61
|
+
decay_weight=attrs["decay_weight"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NetworkXBackend(MemoryBackend):
|
|
66
|
+
"""In-memory graph backend backed by a ``networkx.DiGraph``.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
persist_path:
|
|
71
|
+
Optional path to a GraphML file. If provided the graph is loaded on
|
|
72
|
+
construction and saved on every write/update/delete. ``None`` means
|
|
73
|
+
pure in-memory (no persistence).
|
|
74
|
+
similarity_threshold:
|
|
75
|
+
Minimum cosine similarity for lookup.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
persist_path: str | Path | None = None,
|
|
81
|
+
similarity_threshold: float = 0.82,
|
|
82
|
+
) -> None:
|
|
83
|
+
if not _NX_AVAILABLE:
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"NetworkXBackend requires 'networkx'. "
|
|
86
|
+
"Install it with: pip install patternmem-rag[networkx]"
|
|
87
|
+
)
|
|
88
|
+
self._threshold = similarity_threshold
|
|
89
|
+
self._persist_path = Path(persist_path) if persist_path else None
|
|
90
|
+
self._lock = asyncio.Lock()
|
|
91
|
+
|
|
92
|
+
self._graph: Any = nx.DiGraph()
|
|
93
|
+
if self._persist_path and self._persist_path.exists():
|
|
94
|
+
self._graph = nx.read_graphml(str(self._persist_path))
|
|
95
|
+
# GraphML deserialises embeddings as strings; re-parse them
|
|
96
|
+
for nid in list(self._graph.nodes):
|
|
97
|
+
raw_emb = self._graph.nodes[nid].get("query_embedding", "[]")
|
|
98
|
+
if isinstance(raw_emb, str):
|
|
99
|
+
import json
|
|
100
|
+
self._graph.nodes[nid]["query_embedding"] = json.loads(raw_emb)
|
|
101
|
+
|
|
102
|
+
def _persist(self) -> None:
|
|
103
|
+
if self._persist_path:
|
|
104
|
+
import json
|
|
105
|
+
# GraphML can't store lists natively — serialise embeddings to JSON str
|
|
106
|
+
tmp_graph: Any = self._graph.copy()
|
|
107
|
+
for nid in tmp_graph.nodes:
|
|
108
|
+
emb = tmp_graph.nodes[nid].get("query_embedding", [])
|
|
109
|
+
tmp_graph.nodes[nid]["query_embedding"] = json.dumps(emb)
|
|
110
|
+
self._persist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
nx.write_graphml(tmp_graph, str(self._persist_path))
|
|
112
|
+
|
|
113
|
+
async def write_pattern(self, pattern: FailurePattern) -> None:
|
|
114
|
+
async with self._lock:
|
|
115
|
+
self._graph.add_node(pattern.id, **_pattern_to_attrs(pattern))
|
|
116
|
+
self._persist()
|
|
117
|
+
|
|
118
|
+
async def lookup_patterns(
|
|
119
|
+
self,
|
|
120
|
+
query_embedding: list[float],
|
|
121
|
+
top_k: int = 3,
|
|
122
|
+
) -> list[FailurePattern]:
|
|
123
|
+
async with self._lock:
|
|
124
|
+
scored: list[tuple[float, FailurePattern]] = []
|
|
125
|
+
for nid, attrs in self._graph.nodes(data=True):
|
|
126
|
+
stored_emb: list[float] = attrs.get("query_embedding", [])
|
|
127
|
+
if not stored_emb:
|
|
128
|
+
continue
|
|
129
|
+
sim = _cosine_similarity(query_embedding, stored_emb)
|
|
130
|
+
if sim >= self._threshold:
|
|
131
|
+
scored.append((sim, _attrs_to_pattern(nid, attrs)))
|
|
132
|
+
|
|
133
|
+
scored.sort(key=lambda t: t[0], reverse=True)
|
|
134
|
+
return [p for _, p in scored[:top_k]]
|
|
135
|
+
|
|
136
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
137
|
+
async with self._lock:
|
|
138
|
+
count = self._graph.number_of_nodes()
|
|
139
|
+
return {
|
|
140
|
+
"count": count,
|
|
141
|
+
"persist_path": str(self._persist_path) if self._persist_path else None,
|
|
142
|
+
"backend": "networkx",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async def update_pattern(self, pattern_id: str, decay_weight: float) -> None:
|
|
146
|
+
async with self._lock:
|
|
147
|
+
if pattern_id not in self._graph:
|
|
148
|
+
raise KeyError(f"Pattern {pattern_id!r} not found in NetworkX backend")
|
|
149
|
+
self._graph.nodes[pattern_id]["decay_weight"] = decay_weight
|
|
150
|
+
self._persist()
|
|
151
|
+
|
|
152
|
+
async def delete_pattern(self, pattern_id: str) -> None:
|
|
153
|
+
async with self._lock:
|
|
154
|
+
if pattern_id not in self._graph:
|
|
155
|
+
raise KeyError(f"Pattern {pattern_id!r} not found in NetworkX backend")
|
|
156
|
+
self._graph.remove_node(pattern_id)
|
|
157
|
+
self._persist()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.backends.sqlite_backend
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
SQLite backend for PatternMem using aiosqlite for async I/O.
|
|
5
|
+
|
|
6
|
+
Storage layout
|
|
7
|
+
--------------
|
|
8
|
+
A single SQLite database file (WAL mode) with a ``failure_patterns`` table.
|
|
9
|
+
The query embedding is stored as a JSON text column — no extension required.
|
|
10
|
+
|
|
11
|
+
Similarity
|
|
12
|
+
----------
|
|
13
|
+
All embeddings are loaded into numpy for brute-force cosine similarity.
|
|
14
|
+
This is acceptable for typical pattern-memory sizes (< 10 000 patterns).
|
|
15
|
+
For larger workloads, use Neo4j with its native vector index.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import numpy as np
|
|
26
|
+
import aiosqlite
|
|
27
|
+
|
|
28
|
+
from patternmem._utils import cosine_similarity as _cosine_similarity
|
|
29
|
+
from patternmem.backend import MemoryBackend
|
|
30
|
+
from patternmem.types import FailurePattern, FailureType
|
|
31
|
+
|
|
32
|
+
_DEFAULT_PATH = Path.home() / ".patternmem" / "patterns.db"
|
|
33
|
+
|
|
34
|
+
_CREATE_TABLE = """
|
|
35
|
+
CREATE TABLE IF NOT EXISTS failure_patterns (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
query_embedding TEXT NOT NULL, -- JSON array of floats
|
|
38
|
+
failure_type TEXT NOT NULL,
|
|
39
|
+
root_cause TEXT NOT NULL DEFAULT '',
|
|
40
|
+
hint_text TEXT NOT NULL DEFAULT '',
|
|
41
|
+
score REAL NOT NULL DEFAULT 0.0,
|
|
42
|
+
created_at TEXT NOT NULL, -- ISO-8601 UTC
|
|
43
|
+
decay_weight REAL NOT NULL DEFAULT 1.0
|
|
44
|
+
)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _row_to_pattern(row: aiosqlite.Row) -> FailurePattern:
|
|
50
|
+
return FailurePattern(
|
|
51
|
+
id=row["id"],
|
|
52
|
+
query_embedding=json.loads(row["query_embedding"]),
|
|
53
|
+
failure_type=FailureType[row["failure_type"]],
|
|
54
|
+
root_cause=row["root_cause"],
|
|
55
|
+
hint_text=row["hint_text"],
|
|
56
|
+
score=row["score"],
|
|
57
|
+
created_at=datetime.fromisoformat(row["created_at"]).replace(tzinfo=timezone.utc),
|
|
58
|
+
decay_weight=row["decay_weight"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SQLiteBackend(MemoryBackend):
|
|
63
|
+
"""Async SQLite backend via ``aiosqlite``.
|
|
64
|
+
|
|
65
|
+
Each public method opens a fresh ``aiosqlite`` connection using
|
|
66
|
+
``aiosqlite.connect()`` as an async context manager, performs its
|
|
67
|
+
operation, and lets the context manager close it cleanly. This is the
|
|
68
|
+
correct aiosqlite usage pattern and avoids the "thread can only be started
|
|
69
|
+
once" error that arises from reusing a connection object.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
path:
|
|
74
|
+
Path to the SQLite file. Created automatically if absent.
|
|
75
|
+
similarity_threshold:
|
|
76
|
+
Minimum cosine similarity for lookup.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
path: str | Path = _DEFAULT_PATH,
|
|
82
|
+
similarity_threshold: float = 0.82,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._path = Path(path)
|
|
85
|
+
self._threshold = similarity_threshold
|
|
86
|
+
|
|
87
|
+
def _ensure_dir(self) -> None:
|
|
88
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
async def _init_schema(self, conn: aiosqlite.Connection) -> None:
|
|
91
|
+
"""Enable WAL and create the table if needed."""
|
|
92
|
+
await conn.execute("PRAGMA journal_mode=WAL")
|
|
93
|
+
await conn.execute(_CREATE_TABLE)
|
|
94
|
+
await conn.commit()
|
|
95
|
+
|
|
96
|
+
async def write_pattern(self, pattern: FailurePattern) -> None:
|
|
97
|
+
self._ensure_dir()
|
|
98
|
+
async with aiosqlite.connect(str(self._path)) as conn:
|
|
99
|
+
conn.row_factory = aiosqlite.Row
|
|
100
|
+
await self._init_schema(conn)
|
|
101
|
+
await conn.execute(
|
|
102
|
+
"""
|
|
103
|
+
INSERT INTO failure_patterns
|
|
104
|
+
(id, query_embedding, failure_type, root_cause,
|
|
105
|
+
hint_text, score, created_at, decay_weight)
|
|
106
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
107
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
108
|
+
query_embedding = excluded.query_embedding,
|
|
109
|
+
failure_type = excluded.failure_type,
|
|
110
|
+
root_cause = excluded.root_cause,
|
|
111
|
+
hint_text = excluded.hint_text,
|
|
112
|
+
score = excluded.score,
|
|
113
|
+
created_at = excluded.created_at,
|
|
114
|
+
decay_weight = excluded.decay_weight
|
|
115
|
+
""",
|
|
116
|
+
(
|
|
117
|
+
pattern.id,
|
|
118
|
+
json.dumps(pattern.query_embedding),
|
|
119
|
+
pattern.failure_type.name,
|
|
120
|
+
pattern.root_cause,
|
|
121
|
+
pattern.hint_text,
|
|
122
|
+
pattern.score,
|
|
123
|
+
pattern.created_at.isoformat(),
|
|
124
|
+
pattern.decay_weight,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
await conn.commit()
|
|
128
|
+
|
|
129
|
+
async def lookup_patterns(
|
|
130
|
+
self,
|
|
131
|
+
query_embedding: list[float],
|
|
132
|
+
top_k: int = 3,
|
|
133
|
+
) -> list[FailurePattern]:
|
|
134
|
+
self._ensure_dir()
|
|
135
|
+
async with aiosqlite.connect(str(self._path)) as conn:
|
|
136
|
+
conn.row_factory = aiosqlite.Row
|
|
137
|
+
await self._init_schema(conn)
|
|
138
|
+
async with conn.execute(
|
|
139
|
+
"SELECT * FROM failure_patterns"
|
|
140
|
+
) as cursor:
|
|
141
|
+
rows = await cursor.fetchall()
|
|
142
|
+
|
|
143
|
+
if not rows:
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
scored: list[tuple[float, FailurePattern]] = []
|
|
147
|
+
for row in rows:
|
|
148
|
+
stored_emb: list[float] = json.loads(row["query_embedding"])
|
|
149
|
+
if not stored_emb:
|
|
150
|
+
continue
|
|
151
|
+
sim = _cosine_similarity(query_embedding, stored_emb)
|
|
152
|
+
if sim >= self._threshold:
|
|
153
|
+
scored.append((sim, _row_to_pattern(row)))
|
|
154
|
+
|
|
155
|
+
scored.sort(key=lambda t: t[0], reverse=True)
|
|
156
|
+
return [p for _, p in scored[:top_k]]
|
|
157
|
+
|
|
158
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
159
|
+
self._ensure_dir()
|
|
160
|
+
async with aiosqlite.connect(str(self._path)) as conn:
|
|
161
|
+
conn.row_factory = aiosqlite.Row
|
|
162
|
+
await self._init_schema(conn)
|
|
163
|
+
async with conn.execute(
|
|
164
|
+
"SELECT COUNT(*) as cnt FROM failure_patterns"
|
|
165
|
+
) as cursor:
|
|
166
|
+
row = await cursor.fetchone()
|
|
167
|
+
return {
|
|
168
|
+
"count": int(row["cnt"]) if row else 0,
|
|
169
|
+
"path": str(self._path),
|
|
170
|
+
"backend": "sqlite",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async def update_pattern(self, pattern_id: str, decay_weight: float) -> None:
|
|
174
|
+
self._ensure_dir()
|
|
175
|
+
async with aiosqlite.connect(str(self._path)) as conn:
|
|
176
|
+
conn.row_factory = aiosqlite.Row
|
|
177
|
+
await self._init_schema(conn)
|
|
178
|
+
result = await conn.execute(
|
|
179
|
+
"UPDATE failure_patterns SET decay_weight = ? WHERE id = ?",
|
|
180
|
+
(decay_weight, pattern_id),
|
|
181
|
+
)
|
|
182
|
+
await conn.commit()
|
|
183
|
+
if result.rowcount == 0:
|
|
184
|
+
raise KeyError(f"Pattern {pattern_id!r} not found in SQLite backend")
|
|
185
|
+
|
|
186
|
+
async def delete_pattern(self, pattern_id: str) -> None:
|
|
187
|
+
self._ensure_dir()
|
|
188
|
+
async with aiosqlite.connect(str(self._path)) as conn:
|
|
189
|
+
conn.row_factory = aiosqlite.Row
|
|
190
|
+
await self._init_schema(conn)
|
|
191
|
+
result = await conn.execute(
|
|
192
|
+
"DELETE FROM failure_patterns WHERE id = ?",
|
|
193
|
+
(pattern_id,),
|
|
194
|
+
)
|
|
195
|
+
await conn.commit()
|
|
196
|
+
if result.rowcount == 0:
|
|
197
|
+
raise KeyError(f"Pattern {pattern_id!r} not found in SQLite backend")
|
patternmem/decay.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patternmem.decay
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
Pure decay/eviction functions for PatternMem pattern memory.
|
|
5
|
+
|
|
6
|
+
These are *pure functions* — no I/O, no async, no side effects. They are
|
|
7
|
+
the only place in the codebase that contains the numeric constants that govern
|
|
8
|
+
pattern reinforcement and eviction. Tests may monkey-patch these constants.
|
|
9
|
+
|
|
10
|
+
Constants
|
|
11
|
+
---------
|
|
12
|
+
REINFORCE_DELTA : float
|
|
13
|
+
Amount added to ``decay_weight`` when a hint-augmented run improves the
|
|
14
|
+
score by more than ``IMPROVEMENT_GAP``.
|
|
15
|
+
DECAY_DELTA : float
|
|
16
|
+
Amount subtracted from ``decay_weight`` when a hint-augmented run does *not*
|
|
17
|
+
improve the score by the required gap.
|
|
18
|
+
EVICTION_FLOOR : float
|
|
19
|
+
Patterns whose ``decay_weight`` falls below this value are deleted.
|
|
20
|
+
IMPROVEMENT_GAP : float
|
|
21
|
+
Minimum score improvement required to be counted as a reinforcement event.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Module-level constants (single source of truth for decay numerics)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
REINFORCE_DELTA: float = 0.1
|
|
31
|
+
DECAY_DELTA: float = 0.2
|
|
32
|
+
EVICTION_FLOOR: float = 0.1
|
|
33
|
+
IMPROVEMENT_GAP: float = 0.15
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Public functions
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compute_new_decay_weight(
|
|
42
|
+
stored: float,
|
|
43
|
+
old_score: float,
|
|
44
|
+
new_score: float,
|
|
45
|
+
) -> float:
|
|
46
|
+
"""Return the updated ``decay_weight`` after comparing *old_score* to *new_score*.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
stored:
|
|
51
|
+
Current ``decay_weight`` of the pattern in the backend.
|
|
52
|
+
old_score:
|
|
53
|
+
The evaluation score stored in the pattern at write time.
|
|
54
|
+
new_score:
|
|
55
|
+
The evaluation score from the most recent Phase 2 run (after hint injection).
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
float
|
|
60
|
+
New decay weight. May be below ``EVICTION_FLOOR`` — the caller is
|
|
61
|
+
responsible for checking ``should_evict`` and triggering deletion.
|
|
62
|
+
|
|
63
|
+
Notes
|
|
64
|
+
-----
|
|
65
|
+
The formula mirrors the spec exactly::
|
|
66
|
+
|
|
67
|
+
if new_score > stored_pattern.score + 0.15:
|
|
68
|
+
decay_weight += 0.1 # reinforce
|
|
69
|
+
else:
|
|
70
|
+
decay_weight -= 0.2 # decay
|
|
71
|
+
"""
|
|
72
|
+
if new_score > old_score + IMPROVEMENT_GAP:
|
|
73
|
+
return stored + REINFORCE_DELTA
|
|
74
|
+
return stored - DECAY_DELTA
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def should_evict(decay_weight: float) -> bool:
|
|
78
|
+
"""Return ``True`` if the pattern should be permanently evicted.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
decay_weight:
|
|
83
|
+
The *updated* decay weight (output of ``compute_new_decay_weight``).
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
bool
|
|
88
|
+
``True`` iff ``decay_weight < EVICTION_FLOOR``.
|
|
89
|
+
"""
|
|
90
|
+
return decay_weight < EVICTION_FLOOR
|