cortexm 0.3.0__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.
Files changed (120) hide show
  1. context_m.py +17 -0
  2. cortexm/__init__.py +45 -0
  3. cortexm/accel.py +403 -0
  4. cortexm/api/__init__.py +0 -0
  5. cortexm/api/chaos.py +118 -0
  6. cortexm/api/memory.py +635 -0
  7. cortexm/bench/__init__.py +0 -0
  8. cortexm/bench/abilities.py +311 -0
  9. cortexm/bench/baselines.py +89 -0
  10. cortexm/bench/beam_loader.py +317 -0
  11. cortexm/bench/generator.py +376 -0
  12. cortexm/bench/harness.py +211 -0
  13. cortexm/bench/messy.py +218 -0
  14. cortexm/bench/micro.py +251 -0
  15. cortexm/bench/ood.py +443 -0
  16. cortexm/bench/run.py +137 -0
  17. cortexm/bridge/__init__.py +0 -0
  18. cortexm/bridge/dates.py +178 -0
  19. cortexm/bridge/decoders.py +204 -0
  20. cortexm/bridge/enrich.py +255 -0
  21. cortexm/bridge/extractor.py +316 -0
  22. cortexm/bridge/fallback.py +332 -0
  23. cortexm/bridge/onnx_runtime.py +158 -0
  24. cortexm/bridge/patterns.py +760 -0
  25. cortexm/bridge/ppr.py +104 -0
  26. cortexm/bridge/prefilter.py +188 -0
  27. cortexm/bridge/query_extract.py +420 -0
  28. cortexm/bridge/reader.py +1174 -0
  29. cortexm/bridge/rerank.py +204 -0
  30. cortexm/bridge/writer.py +492 -0
  31. cortexm/cli.py +295 -0
  32. cortexm/cognition/__init__.py +53 -0
  33. cortexm/cognition/abstraction.py +192 -0
  34. cortexm/cognition/analogy.py +159 -0
  35. cortexm/cognition/engine.py +204 -0
  36. cortexm/cognition/gaps.py +365 -0
  37. cortexm/cognition/scanner.py +204 -0
  38. cortexm/config.py +375 -0
  39. cortexm/cortexm.py +8 -0
  40. cortexm/enterprise/__init__.py +0 -0
  41. cortexm/enterprise/audit.py +178 -0
  42. cortexm/enterprise/governance.py +239 -0
  43. cortexm/errors.py +35 -0
  44. cortexm/features/__init__.py +0 -0
  45. cortexm/features/git.py +204 -0
  46. cortexm/features/prefetch.py +88 -0
  47. cortexm/features/zk.py +105 -0
  48. cortexm/federation/__init__.py +39 -0
  49. cortexm/federation/crdt.py +275 -0
  50. cortexm/federation/fabric.py +109 -0
  51. cortexm/federation/hlc.py +80 -0
  52. cortexm/federation/node.py +145 -0
  53. cortexm/federation/schema_report.py +73 -0
  54. cortexm/federation/transport.py +164 -0
  55. cortexm/index/__init__.py +19 -0
  56. cortexm/index/nsg.py +386 -0
  57. cortexm/mcp/__init__.py +0 -0
  58. cortexm/mcp/server.py +985 -0
  59. cortexm/metrics.py +62 -0
  60. cortexm/migrate/__init__.py +0 -0
  61. cortexm/migrate/importers.py +192 -0
  62. cortexm/provenance/__init__.py +78 -0
  63. cortexm/provenance/agent.py +214 -0
  64. cortexm/provenance/cose.py +201 -0
  65. cortexm/provenance/scitt.py +258 -0
  66. cortexm/provenance/vc.py +250 -0
  67. cortexm/security/__init__.py +0 -0
  68. cortexm/security/crypto.py +162 -0
  69. cortexm/security/hashes.py +140 -0
  70. cortexm/security/injection.py +149 -0
  71. cortexm/security/mind.py +154 -0
  72. cortexm/security/pii.py +265 -0
  73. cortexm/security/rbac.py +169 -0
  74. cortexm/security/sandbox.py +131 -0
  75. cortexm/security/zk_hamming.py +142 -0
  76. cortexm/security/zk_sql.py +485 -0
  77. cortexm/server/__init__.py +0 -0
  78. cortexm/server/metrics.py +88 -0
  79. cortexm/server/rest.py +936 -0
  80. cortexm/server/sparql.py +984 -0
  81. cortexm/text/__init__.py +0 -0
  82. cortexm/text/dissim.py +252 -0
  83. cortexm/text/embedder.py +155 -0
  84. cortexm/text/fuzzy.py +218 -0
  85. cortexm/text/idiolect.py +253 -0
  86. cortexm/text/labse.py +374 -0
  87. cortexm/text/tokenizer.py +79 -0
  88. cortexm/trace/__init__.py +0 -0
  89. cortexm/trace/blob_arena.py +277 -0
  90. cortexm/trace/consolidate.py +337 -0
  91. cortexm/trace/contradictions.py +69 -0
  92. cortexm/trace/dedup.py +114 -0
  93. cortexm/trace/edges.py +214 -0
  94. cortexm/trace/fact.py +121 -0
  95. cortexm/trace/fade.py +245 -0
  96. cortexm/trace/lifecycle.py +112 -0
  97. cortexm/trace/rebuild.py +173 -0
  98. cortexm/trace/rules.py +171 -0
  99. cortexm/trace/store.py +680 -0
  100. cortexm/trace/structural.py +183 -0
  101. cortexm/trace/tmt.py +335 -0
  102. cortexm/util.py +148 -0
  103. cortexm/vsa/__init__.py +0 -0
  104. cortexm/vsa/attribution.py +149 -0
  105. cortexm/vsa/cleanup.py +161 -0
  106. cortexm/vsa/codecs.py +397 -0
  107. cortexm/vsa/hologram_overlay.py +139 -0
  108. cortexm/vsa/index.py +163 -0
  109. cortexm/vsa/ops.py +149 -0
  110. cortexm/vsa/palace.py +446 -0
  111. cortexm/vsa/role_vectors.py +236 -0
  112. cortexm/vsa/slb.py +78 -0
  113. cortexm/vsa/tlsh_trie.py +137 -0
  114. cortexm/vsa/working_memory.py +249 -0
  115. cortexm-0.3.0.dist-info/METADATA +482 -0
  116. cortexm-0.3.0.dist-info/RECORD +120 -0
  117. cortexm-0.3.0.dist-info/WHEEL +5 -0
  118. cortexm-0.3.0.dist-info/entry_points.txt +2 -0
  119. cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
  120. cortexm-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,311 @@
1
+ """The 10 BEAM memory abilities — probe builders + deterministic judge.
2
+
3
+ Ability taxonomy follows BEAM (arXiv:2510.27246, Table 1):
4
+ Abstention, Contradiction Resolution, Event Ordering, Information
5
+ Extraction, Instruction Following, Knowledge Update, Multi-Hop
6
+ Reasoning, Preference Following, Summarization, Temporal Reasoning.
7
+
8
+ Scoring is context-sufficiency ("nugget") based: a probe is answered
9
+ iff the retrieved memory block contains the ground-truth nuggets an
10
+ LLM reader would need to answer correctly — the same philosophy as
11
+ BEAM's nugget design, with a deterministic judge so the whole harness
12
+ runs offline under the μ=0 protocol (zero LLM calls, including the
13
+ judge). A pluggable LLM judge/reader slot is documented in
14
+ bench/harness.py for canonical-protocol replication.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+
22
+ from cortexm.util import month_name, normalize
23
+
24
+ ABILITIES = ["AB", "CR", "EO", "IE", "IF", "KU", "MH", "PF", "SZ", "TR"]
25
+ ABILITY_NAMES = {
26
+ "AB": "Abstention", "CR": "Contradiction Resolution",
27
+ "EO": "Event Ordering", "IE": "Information Extraction",
28
+ "IF": "Instruction Following", "KU": "Knowledge Update",
29
+ "MH": "Multi-Hop Reasoning", "PF": "Preference Following",
30
+ "SZ": "Summarization", "TR": "Temporal Reasoning",
31
+ }
32
+
33
+ FACT_LINE = re.compile(r"^- \((.+?), ([\w]+), (.+?)\) \[valid ([^;→]+)→([^;]*?);", re.M)
34
+
35
+
36
+ @dataclass
37
+ class Probe:
38
+ ability: str
39
+ question: str
40
+ user_id: str
41
+ entity: str
42
+ expected: dict = field(default_factory=dict)
43
+
44
+
45
+ def _current(items):
46
+ """Latest still-valid item from a (value, start, end) timeline."""
47
+ live = [it for it in items if it[2] is None]
48
+ return live[-1] if live else items[-1]
49
+
50
+
51
+ def _prior(items, current):
52
+ vals = [it for it in items if it[0] != current[0]]
53
+ return vals[-1] if vals else None
54
+
55
+
56
+ # ---------------------------------------------------------------- builders
57
+ def build_probes(personas, rng) -> list[Probe]:
58
+ probes: list[Probe] = []
59
+ for p in personas:
60
+ name = p.first
61
+ cur_emp = _current(p.employers)
62
+ prev_emp = _prior(p.employers, cur_emp)
63
+ cur_city = _current(p.cities)
64
+ prev_city = _prior(p.cities, cur_city)
65
+
66
+ # --- IE: single-fact recall ------------------------------------
67
+ m, d = p.birthday
68
+ probes += [
69
+ Probe("IE", q, p.user_id, p.full_name,
70
+ {"contains_any": [f"{month_name(m).lower()} {d}"]})
71
+ for q in (f"What is {name}'s birthday?",
72
+ f"When was {name} born?")
73
+ ]
74
+ probes.append(Probe("IE", f"Who is {name}'s sister?", p.user_id,
75
+ p.full_name,
76
+ {"contains_any": [p.family[0][0].split()[0].lower()]}))
77
+ probes.append(Probe("IE", f"What does {name} do for a living?",
78
+ p.user_id, p.full_name,
79
+ {"contains_any": [p.roles[0][0].lower()]}))
80
+ probes.append(Probe("IE", f"What is {name}'s full name?",
81
+ p.user_id, p.full_name,
82
+ {"contains_any": [p.full_name.lower()]}))
83
+ probes.append(Probe("IE", f"What does {name} do in their free time?",
84
+ p.user_id, p.full_name,
85
+ {"contains_any": [p.hobbies[0].lower()]}))
86
+
87
+ # --- CR: contradiction resolution -------------------------------
88
+ probes += [
89
+ Probe("CR", q, p.user_id, p.full_name,
90
+ {"contains_any": [cur_emp[0].lower()]})
91
+ for q in (f"Where does {name} work now?",
92
+ f"What is {name}'s current employer?",
93
+ f"Which company does {name} currently work at?")
94
+ ]
95
+ if prev_emp:
96
+ probes.append(Probe("CR", f"Has {name} always worked at {prev_emp[0]}?",
97
+ p.user_id, p.full_name,
98
+ {"contains_any": [cur_emp[0].lower()],
99
+ "also_any": [prev_emp[0].lower()]}))
100
+
101
+ # --- EO: event ordering ------------------------------------------
102
+ evs = sorted(p.events, key=lambda e: e[0])
103
+ pairs = [(evs[i], evs[j]) for i in range(len(evs))
104
+ for j in range(i + 1, len(evs))][:6]
105
+ for a, b in pairs:
106
+ probes.append(Probe(
107
+ "EO", f"Which happened first: {a[1]} or {b[1]}?",
108
+ p.user_id, p.full_name,
109
+ {"events": [(a[1], a[0]), (b[1], b[0])]}))
110
+
111
+ # --- KU: knowledge update / supersession --------------------------
112
+ if prev_emp:
113
+ probes += [
114
+ Probe("KU", f"Is {name} still working at {prev_emp[0]}?",
115
+ p.user_id, p.full_name,
116
+ {"current": cur_emp[0].lower(), "old": prev_emp[0].lower()})
117
+ for _ in range(2)
118
+ ]
119
+ probes.append(Probe("KU", f"Where does {name} live these days?",
120
+ p.user_id, p.full_name,
121
+ {"contains_any": [cur_city[0].lower()]}))
122
+
123
+ # --- MH: multi-hop ------------------------------------------------
124
+ mgr, team = p.manager
125
+ tname, tech = p.team_tech
126
+ probes += [
127
+ Probe("MH", q, p.user_id, p.full_name,
128
+ {"contains_any": [tech.lower()], "also_any": [mgr.lower()]})
129
+ for q in (
130
+ f"What programming language does the team of {name}'s manager use?",
131
+ f"{name}'s manager leads a team — which language does that team use?",
132
+ )
133
+ ]
134
+
135
+ # --- PF: preference following ------------------------------------
136
+ by_cat: dict[str, list] = {}
137
+ for cat, v, s, e in p.prefs:
138
+ by_cat.setdefault(cat, []).append((v, s, e))
139
+ for cat, items in by_cat.items():
140
+ cur = _current(items)
141
+ probes += [
142
+ Probe("PF", q, p.user_id, p.full_name,
143
+ {"contains_any": [cur[0].lower()]})
144
+ for q in (f"What {cat} does {name} prefer now?",
145
+ f"If you were picking {cat} for {name}, what would you choose?")
146
+ ]
147
+
148
+ # --- SZ: summarization (set F1) ----------------------------------
149
+ probes.append(Probe(
150
+ "SZ", f"List all the projects {name} has worked on.",
151
+ p.user_id, p.full_name,
152
+ {"set": [pr[0].lower() for pr in p.projects]}))
153
+
154
+ # --- TR: temporal reasoning ----------------------------------------
155
+ if prev_city:
156
+ probes += [
157
+ Probe("TR", f"Where did {name} live before {cur_city[0]}?",
158
+ p.user_id, p.full_name,
159
+ {"contains_any": [prev_city[0].lower()]})
160
+ ]
161
+ old_job = p.employers[0]
162
+ if old_job[2]:
163
+ probes.append(Probe(
164
+ "TR", f"Where did {name} work in {old_job[1][:4]}?",
165
+ p.user_id, p.full_name,
166
+ {"contains_any": [old_job[0].lower()]}))
167
+ ev = evs[0]
168
+ probes.append(Probe(
169
+ "TR", f"What did {name} do in {month_name(int(ev[0][5:7]))} {ev[0][:4]}?",
170
+ p.user_id, p.full_name,
171
+ {"contains_any": [ev[1].lower()]}))
172
+
173
+ # --- IF: instruction following -------------------------------------
174
+ probes += [
175
+ Probe("IF", q, p.user_id, p.full_name,
176
+ {"contains_any": [p.instruction[1].lower()]})
177
+ for q in (f"In what language should you respond to {name}?"
178
+ if "french" in p.instruction[1].lower()
179
+ else f"How should answers to {name} be formatted?",
180
+ f"What standing instruction did {name} give you?")
181
+ ]
182
+
183
+ # --- AB: abstention (never-mentioned attributes) --------------------
184
+ probes += [
185
+ Probe("AB", q, p.user_id, p.full_name,
186
+ {"forbidden": [kw], "entity": p.full_name.lower()})
187
+ for q, kw in (
188
+ (f"What is {name}'s favorite podcast?", "podcast"),
189
+ (f"What is {name}'s middle name?", "middle name"),
190
+ (f"What is {name}'s blood type?", "blood"),
191
+ (f"What is {name}'s zodiac sign?", "zodiac"),
192
+ )
193
+ ]
194
+ return probes
195
+
196
+
197
+ # ---------------------------------------------------------------- judge
198
+ def _norm(s: str) -> str:
199
+ return normalize(s)
200
+
201
+
202
+ def _contains(context: str, key: str) -> bool:
203
+ k = _norm(key)
204
+ return k in _norm(context) if k else False
205
+
206
+
207
+ def _content_words(s: str) -> set[str]:
208
+ from cortexm.text.tokenizer import content_words
209
+ return set(content_words(s))
210
+
211
+
212
+ def _fuzzy_event_present(context: str, desc: str) -> bool:
213
+ """Event description findable in context (word-overlap based)."""
214
+ target = _content_words(desc)
215
+ if not target:
216
+ return False
217
+ ctx_norm = _norm(context)
218
+ if _norm(desc) in ctx_norm:
219
+ return True
220
+ # any context segment sharing >= half the content words
221
+ for line in context.split("\n"):
222
+ words = _content_words(line)
223
+ if words and len(words & target) >= max(1, len(target) // 2):
224
+ return True
225
+ return False
226
+
227
+
228
+ def parse_fact_lines(context: str) -> list[dict]:
229
+ out = []
230
+ for m in FACT_LINE.finditer(context):
231
+ out.append({"subject": m.group(1), "relation": m.group(2),
232
+ "value": m.group(3), "valid_from": m.group(4),
233
+ "valid_to": m.group(5).strip() or None})
234
+ return out
235
+
236
+
237
+ def judge(probe: Probe, context: str) -> tuple[float, dict]:
238
+ """Deterministic context-sufficiency judge. Returns (score, detail)."""
239
+ exp = probe.expected
240
+ facts = parse_fact_lines(context)
241
+
242
+ if probe.ability == "AB":
243
+ forbidden = exp["forbidden"]
244
+ if any(kw in context.lower() for kw in forbidden):
245
+ return 0.0, {"reason": f"fabricated/irrelevant '{forbidden}'"}
246
+ # a 'likes/prefers' fact presented as an answer to the persona also fails
247
+ ent_first = probe.entity.split()[0].lower()
248
+ for f in facts:
249
+ if (f["relation"] in ("likes", "prefers", "name", "alias")
250
+ and ent_first in f["subject"].lower()
251
+ and any(kw in f["value"].lower() for kw in forbidden)):
252
+ return 0.0, {"reason": "offered unrelated fact as answer"}
253
+ return 1.0, {"reason": "correct abstention"}
254
+
255
+ if probe.ability == "EO":
256
+ evs = exp["events"]
257
+ ok = all(_fuzzy_event_present(context, d) for d, _ in evs)
258
+ if ok:
259
+ # verify the ORDERING note, if present, is correct
260
+ note = re.search(r"ORDERING: (.+?) \((\d{4}-\d{2}-\d{2})\) happened before (.+?) \((\d{4}-\d{2}-\d{2})\)", context)
261
+ if note:
262
+ got = (note.group(2), note.group(4))
263
+ want = (evs[0][1], evs[1][1])
264
+ if got != want:
265
+ return 0.0, {"reason": "ordering note inverted"}
266
+ return 1.0, {"reason": "both events with dates present"}
267
+ return 0.0, {"reason": "event(s) missing from context"}
268
+
269
+ if probe.ability == "KU" and "current" in exp:
270
+ cur, old = exp["current"], exp["old"]
271
+ has_cur = _contains(context, cur)
272
+ has_left = any(f["relation"] == "left" and _contains(f["value"], old)
273
+ for f in facts)
274
+ has_expired = any(
275
+ f["relation"] in ("works_at", "lives_in") and _contains(f["value"], old)
276
+ and f["valid_to"] and f["valid_to"] not in ("∞", "")
277
+ for f in facts)
278
+ if has_cur and (has_left or has_expired):
279
+ return 1.0, {"reason": "current value + supersession evidence"}
280
+ if has_cur:
281
+ return 0.5, {"reason": "current value only"}
282
+ return 0.0, {"reason": "missing current value"}
283
+
284
+ if probe.ability == "SZ":
285
+ want = set(exp["set"])
286
+ got = set()
287
+ for f in facts:
288
+ for w in want:
289
+ if _contains(f["value"], w) or _contains(f["subject"], w):
290
+ got.add(w)
291
+ # also allow raw mentions anywhere in the context block
292
+ for w in want:
293
+ if _contains(context, w):
294
+ got.add(w)
295
+ if not want:
296
+ return 1.0, {"reason": "empty set"}
297
+ inter = len(got & want)
298
+ prec = inter / len(got) if got else 0.0
299
+ rec = inter / len(want)
300
+ f1 = 2 * prec * rec / (prec + rec) if (prec + rec) else 0.0
301
+ return (1.0 if f1 >= 0.5 else 0.0), {"f1": round(f1, 3),
302
+ "retrieved": sorted(got)}
303
+
304
+ # containment-based abilities: IE / CR / MH / PF / TR / IF
305
+ keys = exp.get("contains_any", [])
306
+ also = exp.get("also_any", [])
307
+ if not keys or any(_contains(context, k) for k in keys):
308
+ if also and not any(_contains(context, k) for k in also):
309
+ return 0.5, {"reason": "primary nugget only"}
310
+ return 1.0, {"reason": "nugget(s) present"}
311
+ return 0.0, {"reason": "nugget missing"}
@@ -0,0 +1,89 @@
1
+ """Baseline retrievers for the neuro-symbolic delta table.
2
+
3
+ * ``bm25_rag`` — lexical RAG over raw chunks (the "context stuffer"
4
+ proxy: BEAM's Vanilla/RAG baselines score 12-25%).
5
+ * ``vector_only`` — our VSA palace WITHOUT the symbolic read path:
6
+ pure neural fact-level RAG. Isolates exactly what
7
+ the symbolic Trace contributes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import re
14
+ from collections import Counter
15
+
16
+ from cortexm.text.tokenizer import STOPWORDS, words
17
+
18
+
19
+ # ---------------------------------------------------------------- BM25
20
+ class BM25Index:
21
+ def __init__(self, docs: list[dict], k1: float = 1.5, b: float = 0.75) -> None:
22
+ self.k1, self.b = k1, b
23
+ self.docs = docs
24
+ self.doc_ids = [d["id"] for d in docs]
25
+ self.doc_len = []
26
+ self.tf: list[Counter] = []
27
+ self.postings: dict[str, list[tuple[int, int]]] = {}
28
+ for i, d in enumerate(docs):
29
+ toks = [t for t in words(d["text"]) if t not in STOPWORDS]
30
+ self.doc_len.append(len(toks))
31
+ counts = Counter(toks)
32
+ self.tf.append(counts)
33
+ for t, c in counts.items():
34
+ self.postings.setdefault(t, []).append((i, c))
35
+ self.N = len(docs) or 1
36
+ self.avgdl = (sum(self.doc_len) / self.N) if self.N else 1.0
37
+ self.df = {t: len(p) for t, p in self.postings.items()}
38
+
39
+ def search(self, query: str, k: int = 8) -> list[tuple[str, float]]:
40
+ qtf = Counter(t for t in words(query) if t not in STOPWORDS)
41
+ scores: dict[int, float] = {}
42
+ for t, _ in qtf.items():
43
+ postings = self.postings.get(t)
44
+ if not postings:
45
+ continue
46
+ idf = math.log(1 + (self.N - self.df[t] + 0.5) / (self.df[t] + 0.5))
47
+ for i, c in postings:
48
+ dl = self.doc_len[i] or 1
49
+ s = idf * (c * (self.k1 + 1)) / (
50
+ c + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
51
+ scores[i] = scores.get(i, 0.0) + s
52
+ top = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
53
+ return [(self.doc_ids[i], s) for i, s in top]
54
+
55
+ def doc_text(self, doc_id: str) -> str:
56
+ for d in self.docs:
57
+ if d["id"] == doc_id:
58
+ return d["text"]
59
+ return ""
60
+
61
+
62
+ def bm25_context(index: BM25Index, query: str, k: int = 8) -> str:
63
+ hits = index.search(query, k)
64
+ parts = [index.doc_text(did)[:220] for did, _ in hits]
65
+ return "\n".join(f"- {p}" for p in parts if p)
66
+
67
+
68
+ # ------------------------------------------------------- vector-only
69
+ def vector_only_context(memory, query: str, user_id: str, k: int = 8) -> str:
70
+ """VSA palace search → source chunks. No temporal logic, no
71
+ contradiction chains, no symbolic expansion — neural retrieval only."""
72
+ q_vec = memory.palace.embedder.embed(query)
73
+ scope = {f.id for f in memory.store.query_facts(user_id=user_id,
74
+ active=True)}
75
+ hits = memory.palace.search(q_vec, max(k * 3, 24),
76
+ candidate_ids=scope or None)
77
+ seen_chunks: list[str] = []
78
+ seen_ids = set()
79
+ for fid, score in hits:
80
+ f = memory.store.get_fact(fid)
81
+ if not f or not f.source_id:
82
+ continue
83
+ chunk = memory.store.get_chunk(f.source_id)
84
+ if chunk and chunk["id"] not in seen_ids:
85
+ seen_ids.add(chunk["id"])
86
+ seen_chunks.append(chunk["text"][:240])
87
+ if len(seen_chunks) >= k:
88
+ break
89
+ return "\n".join(f"- {c}" for c in seen_chunks)