Brain4Agent 0.0.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,320 @@
1
+ """Evidence for the caller to judge, instead of a verdict computed here.
2
+
3
+ Every cheap abstention signal was measured and all of them failed (see
4
+ docs/decisions.md D-008, D-012). At 95% of answerable queries kept, refusal
5
+ tops out around 10% for BM25 score, IDF coverage or unseen-word ratio, and 21%
6
+ for a logistic regression over thirteen features. A model reaches 89-100%, but
7
+ the caller is an agent that already has one, and paying for a second inference
8
+ to answer "is this relevant" is duplicate spend on the critical path.
9
+
10
+ So this module computes no verdict. It retrieves and reports what it can check
11
+ for free, including the one signal that correlates with an unanswerable
12
+ question: **a term the question treats as central that the corpus never
13
+ contains.** The caller decides what to do with that.
14
+
15
+ ``missing_terms`` is the useful field, and it is a list of words rather than a
16
+ score on purpose: a caller can show it, log it, or ignore it, and a human can
17
+ read it without knowing what a BM25 score of 13.4 means.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from dataclasses import dataclass, field
24
+ from typing import Protocol
25
+
26
+ from brain4agent.tokenize import load_jieba, word_tokens
27
+
28
+ # Question terms that are structural rather than about the answer. Reporting
29
+ # "什么" as missing would be true of every question and useless.
30
+ _INTERROGATIVE = frozenset(
31
+ {
32
+ "什么",
33
+ "怎么",
34
+ "怎样",
35
+ "哪个",
36
+ "哪些",
37
+ "哪里",
38
+ "多少",
39
+ "几次",
40
+ "多久",
41
+ "为什么",
42
+ "是否",
43
+ "有没有",
44
+ "如何",
45
+ }
46
+ )
47
+
48
+ _FILLER = frozenset("的了是在和与就都而及或吧呢吗啊呀哦嗯我你他她它这那一个们")
49
+
50
+ # Part-of-speech tags worth reporting as missing.
51
+ #
52
+ # Without this filter the top-1 keep rate was 0.325 -- 27 false refusals out of
53
+ # 40 answerable questions -- because "直接", "应该", "不是", "哪几个" and "最终"
54
+ # are in plenty of *questions* and in no *corpus*. They are the grammar of
55
+ # asking, not the content of an answer. Only nominals and things that look like
56
+ # names can plausibly be the thing a question is missing.
57
+ _CONTENT_POS = frozenset(
58
+ {
59
+ "n", # 名词
60
+ "nr", # 人名
61
+ "ns", # 地名
62
+ "nt", # 机构团体
63
+ "nz", # 其他专名
64
+ "ng",
65
+ "nl",
66
+ "eng", # 英文
67
+ "vn", # 动词性名词:测试、查询
68
+ "an", # 形容词性名词
69
+ }
70
+ )
71
+
72
+
73
+ class _MemoryLike(Protocol):
74
+ id: str
75
+ session_id: str
76
+ content: str
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Evidence:
81
+ """One retrieved session, with the checks a caller can act on.
82
+
83
+ ``coverage`` is the share of the question's IDF-weighted terms present in
84
+ this session. ``missing_terms`` is what *this session* does not contain --
85
+ which explains why it is ranked where it is, but is not by itself evidence
86
+ that the memory is absent. A session missing 用户 is normal; the corpus
87
+ knowing 用户 somewhere else means the concept is not unknown.
88
+ """
89
+
90
+ memory: _MemoryLike
91
+ score: float
92
+ coverage: float
93
+ missing_terms: list[str] = field(default_factory=list)
94
+ # Memories whose text is close enough to this one that they may be saying the
95
+ # same thing. Reported, not resolved: the caller has a model and can read both
96
+ # and decide. Silently merging would be irreversible and this store has no
97
+ # standing to make that call.
98
+ possible_duplicates: list[str] = field(default_factory=list)
99
+
100
+ @property
101
+ def session_id(self) -> str:
102
+ return self.memory.session_id
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class SearchResult:
107
+ """What the memory system hands back.
108
+
109
+ ``corpus_missing`` is the strong signal: terms the question treats as
110
+ central that appear nowhere in the entire stored corpus. A session
111
+ missing them is irrelevant; the brain missing them is an answer.
112
+
113
+ Keeping this separate from ``Evidence.missing_terms`` matters. Conflating
114
+ the two produced 16 false refusals out of 40, because 用户 and 记忆 are
115
+ absent from most individual sessions and present in the corpus.
116
+ """
117
+
118
+ query: str
119
+ evidence: list[Evidence]
120
+ corpus_terms: int
121
+ sessions_searched: int
122
+ corpus_missing: list[str] = field(default_factory=list)
123
+
124
+ @property
125
+ def empty(self) -> bool:
126
+ return not self.evidence
127
+
128
+ def best(self) -> Evidence | None:
129
+ return self.evidence[0] if self.evidence else None
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class JudgedResult:
134
+ """A search plus a hosted judge's opinion, with the distinction kept.
135
+
136
+ ``verdict`` is one of:
137
+
138
+ - ``found`` -- the judge accepted a session; ``found`` returns it
139
+ - ``not_found`` -- every checked session was rejected
140
+ - ``uncertain`` -- the judge failed for at least one; the answer is unknown
141
+
142
+ The third state exists because a judge that cannot be reached is not the
143
+ same as a memory that does not exist. A caller that treats them the same
144
+ will tell a user "I never recorded this" during an outage.
145
+ """
146
+
147
+ result: SearchResult
148
+ kept: list[Evidence]
149
+ verdict: str
150
+ checks: int
151
+
152
+ @property
153
+ def found(self) -> Evidence | None:
154
+ return self.kept[0] if self.kept else None
155
+
156
+ @property
157
+ def certainty(self) -> str:
158
+ return self.verdict
159
+
160
+
161
+ def _is_entity_like(term: str) -> bool:
162
+ """Fallback heuristic when jieba is absent.
163
+
164
+ No dictionary means no POS tags, so length and filler content are all there
165
+ is to go on. Noisier than the jieba path, and documented as such rather than
166
+ quietly degraded.
167
+ """
168
+ return len(term) > 1 and term not in _INTERROGATIVE and not all(ch in _FILLER for ch in term)
169
+
170
+
171
+ def content_words(text: str) -> list[str]:
172
+ """Nominals and names from the question, in order.
173
+
174
+ Uses jieba's POS tagger when available. A long digit-bearing token is kept
175
+ even though it tags as ``m``: an IP address or a version number is exactly
176
+ the kind of thing whose absence is worth reporting.
177
+ """
178
+ jieba = load_jieba()
179
+ if jieba is None:
180
+ return [t for t in word_tokens(text) if _is_entity_like(t)]
181
+
182
+ import jieba.posseg as pseg
183
+
184
+ out: list[str] = []
185
+ for word, flag in pseg.cut(text):
186
+ w = word.strip().lower()
187
+ if not w or w.isspace():
188
+ continue
189
+ long_number = flag == "m" and len(w) >= 3 and any(ch.isdigit() for ch in w)
190
+ if flag in _CONTENT_POS or long_number:
191
+ out.append(w)
192
+ return out
193
+
194
+
195
+ # Backwards-compatible alias: the function was private before the graph
196
+ # needed it from another module.
197
+ _content_words = content_words
198
+
199
+
200
+ def question_terms(query: str) -> list[str]:
201
+ """Content terms of the question, longest first.
202
+
203
+ Longest first because the more specific term is the more informative
204
+ absence: for 数据库 what matters is that no session contains it, not that
205
+ 数据 appears somewhere.
206
+ """
207
+ terms = [t for t in content_words(query) if t not in _INTERROGATIVE and t not in _FILLER]
208
+ seen: set[str] = set()
209
+ unique = [t for t in terms if not (t in seen or seen.add(t))]
210
+ return sorted(unique, key=len, reverse=True)
211
+
212
+
213
+ def build_evidence(
214
+ *,
215
+ query: str,
216
+ hits: list[tuple[str, float]],
217
+ by_session: dict[str, list[_MemoryLike]],
218
+ session_text: dict[str, str],
219
+ id_to_session: dict[str, str],
220
+ sessions_searched: int,
221
+ top_k: int,
222
+ ) -> SearchResult:
223
+ """Turn ranked memory ids into ranked session evidence.
224
+
225
+ Presence is checked by substring, not by token set. For the question this
226
+ answers -- does the corpus contain the word 数据库 -- substring matching is
227
+ both exact and cheap, and it avoids tokenising the whole corpus on every
228
+ search. Tokenising for presence would also be wrong in the other direction:
229
+ a session containing 数据 and 存量 separately is not a session about
230
+ 数据库, but a bag of bigrams says it is.
231
+ """
232
+ q_terms = question_terms(query)
233
+
234
+ # Computed before the empty-hits early return. "Nothing matched" and "the
235
+ # corpus has never contained 数据库" are different answers, and the second is
236
+ # what explains the first -- dropping it exactly when nothing matched threw
237
+ # away the only useful part of an empty result.
238
+ #
239
+ # Checked once against the joined corpus, not per session: 用户 missing from
240
+ # one session says nothing, 用户 missing from every session says everything.
241
+ texts = list(session_text.values())
242
+ joined = "\n".join(texts)
243
+ corpus_missing = [t for t in q_terms if t not in joined]
244
+
245
+ if not hits:
246
+ return SearchResult(
247
+ query=query,
248
+ evidence=[],
249
+ corpus_terms=0,
250
+ sessions_searched=sessions_searched,
251
+ corpus_missing=corpus_missing,
252
+ )
253
+
254
+ n_sessions = len(by_session) or 1
255
+
256
+ # Memoised. idf(term) is called once per query term for the weight total and
257
+ # again for every evidence row's coverage, so the same term was rescanned up
258
+ # to top_k+1 times over the whole corpus. Measured: build_evidence was 10.9 ms
259
+ # of a 13.8 ms query, and almost all of it was re-walking texts looking for
260
+ # words it had already looked for.
261
+ _df_cache: dict[str, int] = {}
262
+
263
+ def idf(term: str) -> float:
264
+ d = _df_cache.get(term)
265
+ if d is None:
266
+ d = sum(1 for t in texts if term in t)
267
+ _df_cache[term] = d
268
+ return math.log(1.0 + (n_sessions - d + 0.5) / (d + 0.5))
269
+
270
+ total_weight = sum(idf(t) for t in q_terms) or 1.0
271
+
272
+ evidence: list[Evidence] = []
273
+ seen: set[str] = set()
274
+ for memory_id, score in hits:
275
+ sid = id_to_session.get(memory_id)
276
+ if sid is None or sid in seen:
277
+ continue
278
+ seen.add(sid)
279
+
280
+ text = session_text.get(sid, "")
281
+ present = [t for t in q_terms if t in text]
282
+ missing = [t for t in q_terms if t not in text]
283
+ covered = sum(idf(t) for t in present)
284
+
285
+ first = by_session[sid][0]
286
+ evidence.append(
287
+ Evidence(
288
+ memory=first,
289
+ score=score,
290
+ coverage=round(covered / total_weight, 4),
291
+ missing_terms=missing,
292
+ )
293
+ )
294
+ if len(evidence) >= top_k:
295
+ break
296
+
297
+ corpus_terms = sum(1 for t in q_terms if any(t in x for x in texts))
298
+ return SearchResult(
299
+ query=query,
300
+ evidence=evidence,
301
+ corpus_terms=corpus_terms,
302
+ sessions_searched=sessions_searched,
303
+ corpus_missing=corpus_missing,
304
+ )
305
+
306
+
307
+ def describe(result: SearchResult) -> str:
308
+ """Human-readable summary of a search."""
309
+ if result.empty:
310
+ line = f"no evidence for {result.query!r}"
311
+ if result.corpus_missing:
312
+ line += f"\n corpus has no: {', '.join(result.corpus_missing)}"
313
+ return line
314
+ lines = [f"{len(result.evidence)} sessions for {result.query!r}"]
315
+ if result.corpus_missing:
316
+ lines.append(f" corpus has no: {', '.join(result.corpus_missing)}")
317
+ for e in result.evidence:
318
+ missing = f" session lacks: {', '.join(e.missing_terms)}" if e.missing_terms else ""
319
+ lines.append(f" [{e.score:5.2f} cov={e.coverage:.2f}] {e.session_id}{missing}")
320
+ return "\n".join(lines)
brain4agent/extract.py ADDED
@@ -0,0 +1,230 @@
1
+ """Turn a conversation turn into durable memories.
2
+
3
+ This is the piece that makes the store a memory system rather than a log.
4
+ Storing every turn verbatim is what produced the noise in D-020: a hundred
5
+ exchanges about the same subsystem, none of which can be told apart. Extracting
6
+ one self-contained fact per thing worth keeping is how a real memory layer
7
+ avoids that — and it is exactly what neural-memory does with fibers.
8
+
9
+ Optional by design. Without a key, ``Extractor.from_env()`` returns None and the
10
+ store falls back to keeping raw turns, which still works.
11
+
12
+ What "durable" means here, because it is the whole judgement call:
13
+
14
+ - a fact about how the system is
15
+ - a decision and its reason
16
+ - an error and its cause
17
+ - a preference about how things should be done
18
+
19
+ Not durable: acknowledgements, restatements of the question, plans that were
20
+ never executed, and anything a reader would find obvious from the code.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ from dataclasses import dataclass
28
+
29
+ from brain4agent.llm import LLM
30
+
31
+ KINDS = frozenset({"fact", "decision", "error", "preference", "context"})
32
+
33
+ MAX_INPUT_CHARS = 6000
34
+ MAX_CONTENT_CHARS = 500
35
+ MAX_ENTITIES = 10
36
+ DEFAULT_MAX_MEMORIES = 5
37
+
38
+ SYSTEM = """You extract durable memories from one exchange in a working conversation.
39
+
40
+ A durable memory is something a colleague would need weeks later: a fact about
41
+ how the system is, a decision and its reason, an error and its cause, or a
42
+ stated preference.
43
+
44
+ Rules:
45
+ - One memory per entry. Each must be self-contained: a reader with no context
46
+ must understand it. Replace pronouns and deictic references ("it", "that file",
47
+ "上次那个") with what they actually name.
48
+ - **Keep the question's vocabulary.** A future search will use the words of the
49
+ person asking, not yours. If the question asks "哪个文件哪一行", the memory
50
+ must contain 文件 and 行. Rewriting it as only "extraction/keywords.py 第 443 行"
51
+ makes it unfindable by the very question it answers.
52
+ - Keep specifics verbatim: file names, line numbers, addresses, versions, commit
53
+ hashes, error messages.
54
+ - Drop acknowledgements, pure pleasantries, and plans never executed.
55
+ - If the exchange has nothing durable, return an empty list. Returning nothing
56
+ is a correct and common answer.
57
+
58
+ Output strict JSON and nothing else:
59
+ {"memories":[{"kind":"fact","content":"...","entities":["..."],"importance":0.7}]}
60
+
61
+ kind is one of: fact, decision, error, preference, context.
62
+ importance is 0.0-1.0: how likely a future question depends on this entry."""
63
+
64
+ USER = """Exchange:
65
+ ---
66
+ {exchange}
67
+ ---"""
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class ExtractedMemory:
72
+ """One durable item distilled from a turn."""
73
+
74
+ kind: str
75
+ content: str
76
+ entities: tuple[str, ...] = ()
77
+ importance: float = 0.5
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class ExtractionResult:
82
+ """Outcome of one extraction.
83
+
84
+ The distinction between an empty result and a failed one decides what the
85
+ store does: empty means "nothing durable here, drop the turn", failed means
86
+ "the model was unavailable, keep the raw text". Collapsing them would
87
+ either lose data on an outage or keep chit-chat when the model says it is
88
+ chit-chat.
89
+ """
90
+
91
+ memories: tuple[ExtractedMemory, ...] = ()
92
+ ok: bool = True
93
+
94
+
95
+ class Extractor:
96
+ """Distils a turn into zero or more :class:`ExtractedMemory`.
97
+
98
+ Every public method returns a list, never raises for model problems. A
99
+ failed extraction must degrade to "store the raw turn", which the caller
100
+ decides; it must not take the write down with it.
101
+ """
102
+
103
+ def __init__(self, llm: LLM, *, max_memories: int = DEFAULT_MAX_MEMORIES) -> None:
104
+ self.llm = llm
105
+ self.max_memories = max_memories
106
+ self.calls = 0
107
+ self.failures = 0
108
+ self.empty = 0
109
+
110
+ @classmethod
111
+ def from_env(cls) -> Extractor | None:
112
+ llm = LLM.from_env()
113
+ return cls(llm) if llm is not None else None
114
+
115
+ def extract(
116
+ self,
117
+ text: str,
118
+ *,
119
+ question: str | None = None,
120
+ max_memories: int | None = None,
121
+ ) -> ExtractionResult:
122
+ """Distil one turn, or one question/answer pair.
123
+
124
+ Passing ``question`` is strongly preferred when the text is an answer:
125
+ the question carries the words a future search will use, and dropping
126
+ them was measured to halve hit@1 (D-023).
127
+ """
128
+ body = text.strip()
129
+ if not body:
130
+ return ExtractionResult((), ok=True)
131
+
132
+ if question and question.strip():
133
+ exchange = f"question: {question.strip()}\nanswer: {body}"
134
+ else:
135
+ exchange = body
136
+
137
+ limit = max_memories or self.max_memories
138
+ self.calls += 1
139
+ raw = self.llm.chat(
140
+ SYSTEM, USER.format(exchange=exchange[:MAX_INPUT_CHARS]), max_tokens=1024
141
+ )
142
+ if raw is None:
143
+ self.failures += 1
144
+ return ExtractionResult((), ok=False)
145
+
146
+ memories = parse(raw, limit=limit)
147
+ if not memories:
148
+ # "Nothing durable here" is a legitimate answer, not a failure, and
149
+ # must not count against health.
150
+ self.empty += 1
151
+ return ExtractionResult(tuple(memories), ok=True)
152
+
153
+ def stats(self) -> dict[str, object]:
154
+ return {
155
+ "calls": self.calls,
156
+ "failures": self.failures,
157
+ "empty": self.empty,
158
+ **self.llm.stats(),
159
+ }
160
+
161
+
162
+ def parse(raw: str, *, limit: int = DEFAULT_MAX_MEMORIES) -> list[ExtractedMemory]:
163
+ """Parse a model response into memories, dropping anything malformed.
164
+
165
+ Models wrap JSON in prose or fences often enough that a strict
166
+ ``json.loads`` on the whole string is not enough. Anything that does not
167
+ survive validation is dropped rather than repaired: a guessed memory is
168
+ worse than a missing one.
169
+ """
170
+ blob = _find_json(raw)
171
+ if blob is None:
172
+ return []
173
+
174
+ items = blob.get("memories")
175
+ if not isinstance(items, list):
176
+ return []
177
+
178
+ out: list[ExtractedMemory] = []
179
+ for item in items[:limit]:
180
+ if not isinstance(item, dict):
181
+ continue
182
+
183
+ kind = str(item.get("kind", "")).strip().lower()
184
+ if kind not in KINDS:
185
+ continue
186
+
187
+ content = str(item.get("content", "")).strip()
188
+ if not content:
189
+ continue
190
+
191
+ raw_entities = item.get("entities")
192
+ entities: list[str] = []
193
+ if isinstance(raw_entities, list):
194
+ for value in raw_entities:
195
+ text = str(value).strip()
196
+ if text and text not in entities:
197
+ entities.append(text)
198
+ entities = entities[:MAX_ENTITIES]
199
+
200
+ try:
201
+ importance = float(item.get("importance", 0.5))
202
+ except (TypeError, ValueError):
203
+ importance = 0.5
204
+ importance = min(1.0, max(0.0, importance))
205
+
206
+ out.append(
207
+ ExtractedMemory(
208
+ kind=kind,
209
+ content=content[:MAX_CONTENT_CHARS],
210
+ entities=tuple(entities),
211
+ importance=importance,
212
+ )
213
+ )
214
+ return out
215
+
216
+
217
+ _FENCE = re.compile(r"^```[a-zA-Z]*\s*|\s*```$")
218
+
219
+
220
+ def _find_json(raw: str) -> dict | None:
221
+ text = _FENCE.sub("", raw.strip())
222
+ start = text.find("{")
223
+ end = text.rfind("}")
224
+ if start < 0 or end <= start:
225
+ return None
226
+ try:
227
+ value = json.loads(text[start : end + 1])
228
+ except json.JSONDecodeError:
229
+ return None
230
+ return value if isinstance(value, dict) else None
brain4agent/graph.py ADDED
@@ -0,0 +1,100 @@
1
+ """Entity co-occurrence graph.
2
+
3
+ D-023 left a specific gap. Distilled memories recall well (hit@10 1.0) but rank
4
+ poorly, because every fact is a clean, topic-focused sentence and facts about
5
+ the same subject share most of their vocabulary. IDF falls, and the top result
6
+ becomes a coin flip between memories that are all "about" the question.
7
+
8
+ Word matching cannot fix that, because the information is not in the words — it
9
+ is in which memory is *connected* to the question through what it is about.
10
+
11
+ This module supplies that connection cheaply and without a model:
12
+
13
+ - each memory records its entities (nouns and names, extracted at write time)
14
+ - an entity shared by two memories is an edge between them
15
+ - retrieval expands from the best word-matches along those edges, with decay,
16
+ so a memory that shares a specific entity with a strong hit gets a boost even
17
+ if it does not share the query's exact wording
18
+
19
+ Deliberately not Personalized PageRank. PPR needs a converged power iteration
20
+ over the whole graph per query, and the measured problem here is one hop deep:
21
+ "the fact I want shares an entity with a fact that already matched". One hop
22
+ plus decay is measurable, explainable, and fast. If a two-hop gain ever shows up
23
+ in a benchmark, this is the place to extend.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections import Counter
29
+ from dataclasses import dataclass, field
30
+
31
+ # An entity appearing in more than this share of memories carries no signal --
32
+ # it is the corpus's background, not a distinction. Keeping it would let every
33
+ # memory reach every other and turn the boost into a constant.
34
+ COMMON_ENTITY_RATIO = 0.25
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class EntityGraph:
39
+ """Memory <-> entity index with one-hop expansion."""
40
+
41
+ _by_entity: dict[str, set[str]] = field(default_factory=dict)
42
+ _by_memory: dict[str, set[str]] = field(default_factory=dict)
43
+ _n: int = 0
44
+
45
+ def add(self, memory_id: str, entities: list[str] | tuple[str, ...]) -> None:
46
+ if not entities:
47
+ return
48
+ unique = {e for e in entities if e}
49
+ if not unique:
50
+ return
51
+ self._by_memory[memory_id] = unique
52
+ for entity in unique:
53
+ self._by_entity.setdefault(entity, set()).add(memory_id)
54
+
55
+ def finish(self) -> None:
56
+ """Record corpus size so the common-entity cut-off can be applied."""
57
+ self._n = len(self._by_memory)
58
+
59
+ def _too_common(self, entity: str) -> bool:
60
+ if self._n < 8: # too small to have a meaningful background
61
+ return False
62
+ return len(self._by_entity.get(entity, ())) > max(8, self._n * COMMON_ENTITY_RATIO)
63
+
64
+ def expand(
65
+ self,
66
+ seeds: list[tuple[str, float]],
67
+ *,
68
+ decay: float = 0.35,
69
+ max_seeds: int = 5,
70
+ ) -> dict[str, float]:
71
+ """Boost memories that share a specific entity with a strong hit.
72
+
73
+ ``seeds`` is the ranked BM25 output. Only the head of it is expanded:
74
+ propagating from a weak match would spread noise rather than signal.
75
+
76
+ Returns memory_id -> boost, excluding the seeds themselves.
77
+ """
78
+ boosts: dict[str, float] = {}
79
+ seed_ids = {mid for mid, _ in seeds}
80
+
81
+ for memory_id, score in seeds[:max_seeds]:
82
+ for entity in self._by_memory.get(memory_id, ()):
83
+ if self._too_common(entity):
84
+ continue
85
+ for other in self._by_entity.get(entity, ()):
86
+ if other in seed_ids:
87
+ continue
88
+ candidate = score * decay
89
+ if candidate > boosts.get(other, 0.0):
90
+ boosts[other] = candidate
91
+ return boosts
92
+
93
+ def stats(self) -> dict[str, int]:
94
+ sizes = Counter(len(v) for v in self._by_memory.values())
95
+ return {
96
+ "memories": len(self._by_memory),
97
+ "entities": len(self._by_entity),
98
+ "edges": sum(len(v) - 1 for v in self._by_entity.values() if len(v) > 1),
99
+ "isolated": sizes.get(0, 0) + sum(1 for v in self._by_memory.values() if len(v) == 1),
100
+ }