boring-agent-memory 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Iterable, Sequence
5
+ from dataclasses import dataclass, replace
6
+ from pathlib import Path
7
+ from typing import Any, Protocol, cast
8
+
9
+ from .privacy import redact_secrets
10
+ from .query import QueryResult
11
+ from .schema import connect, init_db, require_index
12
+
13
+
14
+ class EmbeddingAdapter(Protocol):
15
+ model_id: str
16
+
17
+ def embed_documents(self, texts: Sequence[str]) -> list[list[float]]: ...
18
+
19
+ def embed_query(self, text: str) -> list[float]: ...
20
+
21
+
22
+ class FastEmbedAdapter:
23
+ """Optional FastEmbed adapter with a strict no-download default."""
24
+
25
+ def __init__(
26
+ self,
27
+ model_name: str,
28
+ model_path: Path | str | None = None,
29
+ allow_download: bool = False,
30
+ cache_dir: Path | str | None = None,
31
+ ) -> None:
32
+ if model_path is None and not allow_download:
33
+ raise ValueError(
34
+ "a local embedding model path is required unless allow_download=True"
35
+ )
36
+ resolved_model_path = Path(model_path).expanduser().resolve() if model_path else None
37
+ if resolved_model_path is not None and not resolved_model_path.exists():
38
+ raise FileNotFoundError(f"embedding model path does not exist: {resolved_model_path}")
39
+ try:
40
+ from fastembed import TextEmbedding # type: ignore[import-not-found]
41
+ except ImportError as exc:
42
+ raise RuntimeError(
43
+ "optional embeddings require: pip install 'boring-agent-memory[embeddings]'"
44
+ ) from exc
45
+
46
+ resolved_cache_dir = (
47
+ Path(cache_dir).expanduser().resolve().as_posix()
48
+ if cache_dir is not None
49
+ else None
50
+ )
51
+ self._model = TextEmbedding(
52
+ model_name=model_name,
53
+ cache_dir=resolved_cache_dir,
54
+ local_files_only=not allow_download,
55
+ specific_model_path=(
56
+ resolved_model_path.as_posix()
57
+ if resolved_model_path is not None
58
+ else None
59
+ ),
60
+ )
61
+ self.model_id = model_name
62
+ self.allow_download = allow_download
63
+ self.model_path = resolved_model_path
64
+
65
+ def embed_documents(self, texts: Sequence[str]) -> list[list[float]]:
66
+ redacted_texts = [redact_secrets(text)[0] for text in texts]
67
+ return [_vector(values) for values in self._model.embed(redacted_texts)]
68
+
69
+ def embed_query(self, text: str) -> list[float]:
70
+ text = redact_secrets(text)[0]
71
+ query_embed = getattr(self._model, "query_embed", None)
72
+ values = next(iter(query_embed(text) if query_embed else self._model.embed([text])))
73
+ return _vector(values)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class EmbeddingDocument:
78
+ result: QueryResult
79
+ text: str
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class DenseIndex:
84
+ adapter: EmbeddingAdapter
85
+ documents: tuple[EmbeddingDocument, ...]
86
+ vectors: tuple[tuple[float, ...], ...]
87
+
88
+ @classmethod
89
+ def build(
90
+ cls,
91
+ adapter: EmbeddingAdapter,
92
+ documents: Sequence[EmbeddingDocument],
93
+ ) -> DenseIndex:
94
+ vectors = adapter.embed_documents(
95
+ [redact_secrets(document.text)[0] for document in documents]
96
+ )
97
+ if len(vectors) != len(documents):
98
+ raise ValueError("embedding adapter returned the wrong number of vectors")
99
+ dimensions = {len(vector) for vector in vectors}
100
+ if len(dimensions) > 1 or dimensions == {0}:
101
+ raise ValueError("embedding adapter returned inconsistent vector dimensions")
102
+ return cls(
103
+ adapter=adapter,
104
+ documents=tuple(documents),
105
+ vectors=tuple(tuple(vector) for vector in vectors),
106
+ )
107
+
108
+ def query(self, text: str, limit: int = 5) -> list[QueryResult]:
109
+ query_vector = self.adapter.embed_query(redact_secrets(text)[0])
110
+ if self.vectors and len(query_vector) != len(self.vectors[0]):
111
+ raise ValueError("query vector dimension does not match the dense index")
112
+ scored = [
113
+ (cosine_similarity(query_vector, vector), document.result)
114
+ for document, vector in zip(self.documents, self.vectors)
115
+ ]
116
+ scored.sort(key=lambda item: (-item[0], item[1].chunk_id))
117
+ seen_documents: set[str] = set()
118
+ results: list[QueryResult] = []
119
+ for score, result in scored:
120
+ if result.id in seen_documents:
121
+ continue
122
+ seen_documents.add(result.id)
123
+ results.append(replace(result, score=-score, strategy="dense"))
124
+ if len(results) >= limit:
125
+ break
126
+ return results
127
+
128
+
129
+ def load_embedding_documents(
130
+ db_path: Path | str,
131
+ source_type: str | None = None,
132
+ workspace: Path | str | None = None,
133
+ ) -> list[EmbeddingDocument]:
134
+ conn = connect(require_index(db_path))
135
+ init_db(conn)
136
+ try:
137
+ filters: list[str] = []
138
+ params: list[object] = []
139
+ if source_type:
140
+ filters.append("d.source_type = ?")
141
+ params.append(source_type)
142
+ if workspace:
143
+ filters.append("d.workspace = ?")
144
+ params.append(Path(workspace).expanduser().resolve().as_posix())
145
+ where = "WHERE " + " AND ".join(filters) if filters else ""
146
+ rows = conn.execute(
147
+ f"""
148
+ SELECT
149
+ d.id, c.id AS chunk_id, d.source_type, d.source_path,
150
+ d.workspace, d.title, c.heading, c.start_line, c.end_line,
151
+ c.content
152
+ FROM chunks c
153
+ JOIN documents d ON d.id = c.document_id
154
+ {where}
155
+ ORDER BY d.source_path, c.ordinal
156
+ """,
157
+ params,
158
+ ).fetchall()
159
+ finally:
160
+ conn.close()
161
+
162
+ documents: list[EmbeddingDocument] = []
163
+ for row in rows:
164
+ location = f"L{row['start_line']}-L{row['end_line']}"
165
+ citation = f"{row['source_path']}:{location}"
166
+ if row["heading"]:
167
+ citation = f"{row['source_path']}#{row['heading']}:{location}"
168
+ result = QueryResult(
169
+ id=row["id"],
170
+ chunk_id=row["chunk_id"],
171
+ source_type=row["source_type"],
172
+ source_path=row["source_path"],
173
+ workspace=row["workspace"],
174
+ title=row["title"],
175
+ heading=row["heading"],
176
+ start_line=int(row["start_line"]),
177
+ end_line=int(row["end_line"]),
178
+ citation=citation,
179
+ score=0.0,
180
+ snippet=row["content"],
181
+ strategy="dense",
182
+ )
183
+ embedding_fields = (
184
+ redact_secrets(value)[0]
185
+ for value in (row["title"], row["heading"], row["content"])
186
+ if value
187
+ )
188
+ text = "\n".join(embedding_fields)
189
+ documents.append(EmbeddingDocument(result=result, text=text))
190
+ return documents
191
+
192
+
193
+ def reciprocal_rank_fusion(
194
+ lexical: Sequence[QueryResult],
195
+ dense: Sequence[QueryResult],
196
+ limit: int,
197
+ lexical_weight: float = 0.6,
198
+ dense_weight: float = 0.4,
199
+ rrf_k: int = 60,
200
+ ) -> list[QueryResult]:
201
+ scores: dict[str, float] = {}
202
+ results: dict[str, QueryResult] = {}
203
+ tie_breaks: dict[str, tuple[int, int, str]] = {}
204
+ missing_rank = 10**9
205
+ lexical_ranks = {result.chunk_id: rank for rank, result in enumerate(lexical, start=1)}
206
+ dense_ranks = {result.chunk_id: rank for rank, result in enumerate(dense, start=1)}
207
+ for result in [*lexical, *dense]:
208
+ results.setdefault(result.chunk_id, result)
209
+ for chunk_id, result in results.items():
210
+ lexical_rank = lexical_ranks.get(chunk_id)
211
+ dense_rank = dense_ranks.get(chunk_id)
212
+ score = 0.0
213
+ if lexical_rank is not None:
214
+ score += lexical_weight / (rrf_k + lexical_rank)
215
+ if dense_rank is not None:
216
+ score += dense_weight / (rrf_k + dense_rank)
217
+ scores[chunk_id] = score
218
+ tie_breaks[chunk_id] = (
219
+ lexical_rank or missing_rank,
220
+ dense_rank or missing_rank,
221
+ chunk_id,
222
+ )
223
+ ordered = sorted(results, key=lambda key: (-scores[key], tie_breaks[key]))
224
+ seen_documents: set[str] = set()
225
+ fused: list[QueryResult] = []
226
+ for chunk_id in ordered:
227
+ result = results[chunk_id]
228
+ if result.id in seen_documents:
229
+ continue
230
+ seen_documents.add(result.id)
231
+ fused.append(replace(result, score=-scores[chunk_id], strategy="hybrid_rrf"))
232
+ if len(fused) >= limit:
233
+ break
234
+ return fused
235
+
236
+
237
+ def cosine_similarity(left: Sequence[float], right: Sequence[float]) -> float:
238
+ if len(left) != len(right):
239
+ raise ValueError("vector dimensions do not match")
240
+ numerator = sum(a * b for a, b in zip(left, right))
241
+ left_norm = math.sqrt(sum(value * value for value in left))
242
+ right_norm = math.sqrt(sum(value * value for value in right))
243
+ if left_norm == 0 or right_norm == 0:
244
+ return 0.0
245
+ return numerator / (left_norm * right_norm)
246
+
247
+
248
+ def _vector(values: object) -> list[float]:
249
+ if hasattr(values, "tolist"):
250
+ values = values.tolist()
251
+ if not isinstance(values, Iterable):
252
+ raise TypeError("embedding adapter returned a non-iterable vector")
253
+ return [float(value) for value in cast(Iterable[Any], values)]
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .canonical import verify_canonical_source
12
+ from .index import build_index
13
+ from .privacy import SECRET_PATTERNS
14
+ from .query import QueryResult, query_memory
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class EvalCase:
19
+ id: str
20
+ query: str
21
+ expected_source: str
22
+ expected_terms: tuple[str, ...] = ()
23
+ forbidden_sources: tuple[str, ...] = ()
24
+ forbidden_terms: tuple[str, ...] = ()
25
+ expected_rank_at_most: int = 3
26
+ limit: int = 3
27
+ mutate_after_build: dict[str, str] | None = None
28
+ expect_stale: bool = False
29
+
30
+
31
+ def run_eval(
32
+ fixture_dir: Path | str,
33
+ golden_path: Path | str | None = None,
34
+ limit: int = 3,
35
+ ) -> dict[str, Any]:
36
+ fixture_dir = Path(fixture_dir).expanduser().resolve()
37
+ golden_path = Path(golden_path).expanduser().resolve() if golden_path else fixture_dir.parent / "golden.jsonl"
38
+ cases = load_cases(golden_path)
39
+
40
+ with tempfile.TemporaryDirectory() as tmp:
41
+ work_fixture = Path(tmp) / "fixtures"
42
+ shutil.copytree(fixture_dir, work_fixture)
43
+ eval_db_path = Path(tmp) / "memory.db"
44
+
45
+ build_stats = build_index(
46
+ db_path=eval_db_path,
47
+ includes=[work_fixture.as_posix()],
48
+ workspace=work_fixture,
49
+ source_type="eval_fixture",
50
+ )
51
+ apply_mutations(work_fixture, cases)
52
+
53
+ rows = [
54
+ evaluate_case(
55
+ case=case,
56
+ db_path=eval_db_path,
57
+ workspace=work_fixture,
58
+ limit=max(limit, case.limit, case.expected_rank_at_most),
59
+ )
60
+ for case in cases
61
+ ]
62
+
63
+ metrics = summarize(rows)
64
+ return {
65
+ "fixture_dir": fixture_dir.as_posix(),
66
+ "golden_path": golden_path.as_posix(),
67
+ "build": build_stats,
68
+ "metrics": metrics,
69
+ "cases": rows,
70
+ }
71
+
72
+
73
+ def evaluate_gates(
74
+ report: dict[str, Any],
75
+ min_recall_at_1: float | None = None,
76
+ min_recall_at_3: float | None = None,
77
+ min_source_accuracy: float | None = None,
78
+ min_snippet_term_rate: float | None = None,
79
+ min_stale_detection_rate: float | None = None,
80
+ max_privacy_leaks: int | None = None,
81
+ ) -> list[str]:
82
+ metrics = report["metrics"]
83
+ failures: list[str] = []
84
+ threshold_checks = {
85
+ "recall_at_1": min_recall_at_1,
86
+ "recall_at_3": min_recall_at_3,
87
+ "source_accuracy": min_source_accuracy,
88
+ "snippet_term_rate": min_snippet_term_rate,
89
+ "stale_detection_rate": min_stale_detection_rate,
90
+ }
91
+ for metric, minimum in threshold_checks.items():
92
+ if minimum is not None and float(metrics[metric]) < minimum:
93
+ failures.append(f"{metric}={metrics[metric]} is below required {minimum}")
94
+
95
+ if max_privacy_leaks is not None and int(metrics["privacy_leak_count"]) > max_privacy_leaks:
96
+ failures.append(
97
+ f"privacy_leak_count={metrics['privacy_leak_count']} exceeds allowed {max_privacy_leaks}"
98
+ )
99
+ return failures
100
+
101
+
102
+ def load_cases(golden_path: Path) -> list[EvalCase]:
103
+ cases: list[EvalCase] = []
104
+ for line_number, line in enumerate(golden_path.read_text(encoding="utf-8").splitlines(), start=1):
105
+ if not line.strip():
106
+ continue
107
+ payload = json.loads(line)
108
+ try:
109
+ cases.append(
110
+ EvalCase(
111
+ id=payload["id"],
112
+ query=payload["query"],
113
+ expected_source=payload["expected_source"],
114
+ expected_terms=tuple(payload.get("expected_terms", ())),
115
+ forbidden_sources=tuple(payload.get("forbidden_sources", ())),
116
+ forbidden_terms=tuple(payload.get("forbidden_terms", ())),
117
+ expected_rank_at_most=int(payload.get("expected_rank_at_most", payload.get("limit", 3))),
118
+ limit=int(payload.get("limit", 3)),
119
+ mutate_after_build=payload.get("mutate_after_build"),
120
+ expect_stale=bool(payload.get("expect_stale", False)),
121
+ )
122
+ )
123
+ except KeyError as exc:
124
+ raise ValueError(f"{golden_path}:{line_number} missing required field {exc}") from exc
125
+ if not cases:
126
+ raise ValueError(f"{golden_path} does not contain any eval cases")
127
+ return cases
128
+
129
+
130
+ def apply_mutations(work_fixture: Path, cases: list[EvalCase]) -> None:
131
+ work_fixture = work_fixture.resolve()
132
+ for case in cases:
133
+ if not case.mutate_after_build:
134
+ continue
135
+ source_path = case.mutate_after_build["source_path"]
136
+ content = case.mutate_after_build["content"]
137
+ target = (work_fixture / source_path).resolve()
138
+ if not target.is_relative_to(work_fixture):
139
+ raise ValueError(f"mutation target escapes fixture directory: {source_path}")
140
+ target.write_text(content, encoding="utf-8")
141
+
142
+
143
+ def evaluate_case(
144
+ case: EvalCase,
145
+ db_path: Path,
146
+ workspace: Path,
147
+ limit: int,
148
+ ) -> dict[str, Any]:
149
+ workspace = workspace.resolve()
150
+ results = query_memory(
151
+ db_path=db_path,
152
+ query=case.query,
153
+ limit=limit,
154
+ source_type="eval_fixture",
155
+ workspace=workspace,
156
+ )
157
+ expected_path = (workspace / case.expected_source).resolve().as_posix()
158
+ rank = rank_of_expected(results, expected_path)
159
+ top = results[0] if results else None
160
+ snippet_terms_present = terms_present(top, case.expected_terms) if top else False
161
+ privacy_leaks = count_privacy_leaks(results, case.forbidden_terms)
162
+ forbidden_source_ranks = ranked_forbidden_sources(results, workspace, case.forbidden_sources)
163
+ stale_detected = False
164
+ if case.expect_stale or case.forbidden_sources:
165
+ stale_detected = stale_sources_did_not_outrank_expected(rank, forbidden_source_ranks)
166
+ if case.expect_stale and top:
167
+ verification = verify_canonical_source(db_path, top.source_path)
168
+ stale_detected = stale_detected and verification.get("content_hash_match") is False
169
+
170
+ return {
171
+ "id": case.id,
172
+ "query": case.query,
173
+ "expected_source": case.expected_source,
174
+ "top_source": relative_source(top.source_path, workspace) if top else None,
175
+ "rank": rank,
176
+ "recall_at_1": rank == 1,
177
+ "recall_at_k": rank is not None and rank <= case.expected_rank_at_most,
178
+ "source_accuracy": top is not None and top.source_path == expected_path,
179
+ "snippet_terms_present": snippet_terms_present,
180
+ "privacy_leak_count": privacy_leaks,
181
+ "forbidden_source_ranks": forbidden_source_ranks,
182
+ "stale_expected": case.expect_stale or bool(case.forbidden_sources),
183
+ "stale_detected": stale_detected,
184
+ }
185
+
186
+
187
+ def rank_of_expected(results: list[QueryResult], expected_path: str) -> int | None:
188
+ for index, result in enumerate(results, start=1):
189
+ if result.source_path == expected_path:
190
+ return index
191
+ return None
192
+
193
+
194
+ def terms_present(result: QueryResult, terms: tuple[str, ...]) -> bool:
195
+ if not terms:
196
+ return True
197
+ haystack = normalize_text(f"{result.title}\n{result.snippet}")
198
+ return all(normalize_text(term) in haystack for term in terms)
199
+
200
+
201
+ def count_privacy_leaks(results: list[QueryResult], forbidden_terms: tuple[str, ...] = ()) -> int:
202
+ count = 0
203
+ for result in results:
204
+ for pattern in SECRET_PATTERNS:
205
+ for match in pattern.finditer(result.snippet):
206
+ if "[REDACTED]" not in match.group(0):
207
+ count += 1
208
+ lowered = result.snippet.lower()
209
+ for term in forbidden_terms:
210
+ if term.lower() in lowered:
211
+ count += 1
212
+ return count
213
+
214
+
215
+ def ranked_forbidden_sources(
216
+ results: list[QueryResult],
217
+ workspace: Path,
218
+ sources: tuple[str, ...],
219
+ ) -> dict[str, int | None]:
220
+ forbidden = set(sources)
221
+ ranks: dict[str, int | None] = {source: None for source in sources}
222
+ for result in results:
223
+ relative = relative_source(result.source_path, workspace)
224
+ if relative in forbidden:
225
+ ranks[relative] = rank_of_expected(results, result.source_path)
226
+ return ranks
227
+
228
+
229
+ def stale_sources_did_not_outrank_expected(
230
+ expected_rank: int | None,
231
+ forbidden_source_ranks: dict[str, int | None],
232
+ ) -> bool:
233
+ if expected_rank is None:
234
+ return False
235
+ return all(rank is None or rank > expected_rank for rank in forbidden_source_ranks.values())
236
+
237
+
238
+ def relative_source(source_path: str, workspace: Path) -> str:
239
+ try:
240
+ return Path(source_path).resolve().relative_to(workspace).as_posix()
241
+ except ValueError:
242
+ return source_path
243
+
244
+
245
+ def summarize(rows: list[dict[str, Any]]) -> dict[str, Any]:
246
+ total = len(rows)
247
+ stale_cases = [row for row in rows if row["stale_expected"]]
248
+ mrr_total = sum((1 / row["rank"]) for row in rows if row["rank"])
249
+ return {
250
+ "cases": total,
251
+ "recall_at_1": ratio(sum(1 for row in rows if row["recall_at_1"]), total),
252
+ "recall_at_3": ratio(sum(1 for row in rows if row["recall_at_k"]), total),
253
+ "mrr": ratio(mrr_total, total),
254
+ "source_accuracy": ratio(sum(1 for row in rows if row["source_accuracy"]), total),
255
+ "snippet_term_rate": ratio(sum(1 for row in rows if row["snippet_terms_present"]), total),
256
+ "privacy_leak_count": sum(row["privacy_leak_count"] for row in rows),
257
+ "stale_detection_rate": ratio(
258
+ sum(1 for row in stale_cases if row["stale_detected"]),
259
+ len(stale_cases),
260
+ ),
261
+ }
262
+
263
+
264
+ def ratio(numerator: float, denominator: int) -> float:
265
+ if denominator == 0:
266
+ return 0.0
267
+ return round(numerator / denominator, 4)
268
+
269
+
270
+ def normalize_text(value: str) -> str:
271
+ return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()