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