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,317 @@
1
+ """BEAM-10M dataset loader — fetches real conversations from HF.
2
+
3
+ BEAM-10M (Mohammadta/BEAM-10M) is a LongMemEval-style benchmark:
4
+ each row is a long, multi-session conversation with:
5
+ - conversation_id
6
+ - user_profile (user_info + user_relationships) — ground-truth facts
7
+ - narratives — a structured timeline of events
8
+ - chat — 10 plans, each with 10 batches of ~60 turns
9
+ - probing_questions — questions to test memory of the conversation
10
+ - plans — alternate plans structure
11
+
12
+ The HuggingFace datasets API is rate-limited from many sandboxes
13
+ (429 Too Many Requests from CloudFront). The datasets-server endpoint
14
+ (hosted separately) IS reachable. This loader pulls via datasets-server
15
+ so the bench works even when direct HF datasets API is blocked.
16
+
17
+ BULK DOWNLOAD PATHS (added for full-dataset benchmarks):
18
+ - Local cache directory of per-row JSON files (preferred — produced
19
+ by scripts/download_beam_full.sh OR by the .github/workflows/
20
+ beam-cache.yml workflow that runs on a GitHub Actions runner)
21
+ - Local parquet file (if you have downloaded the full parquet via
22
+ `huggingface-cli download Mohammadta/BEAM-10M` on a runner whose
23
+ IP is not rate-limited; the file is ~342MB). Pass the path via
24
+ the BEAM_PARQUET env var or the parquet_path argument.
25
+ - datasets-server /rows endpoint as fallback (works in sandboxes)
26
+
27
+ Usage:
28
+ from cortexm.bench.beam_loader import load_beam_rows
29
+ rows = load_beam_rows(n=10) # fetch all 10 conversations
30
+ for r in rows:
31
+ print(r['conversation_id'])
32
+ print(r['user_profile']['user_info'])
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ import urllib.request
39
+ from pathlib import Path
40
+ from typing import Iterator
41
+
42
+
43
+ DATASETS_SERVER = "https://datasets-server.huggingface.co"
44
+ DATASET_NAME = "Mohammadta/BEAM-10M"
45
+ CONFIG = "default"
46
+ SPLIT = "10M"
47
+ TOTAL_ROWS = 10 # the 10M split has 10 conversations, each ~10M tokens
48
+
49
+
50
+ def _fetch_rows(offset: int = 0, length: int = 1) -> dict:
51
+ """Fetch BEAM-10M rows via the datasets-server endpoint."""
52
+ url = (f"{DATASETS_SERVER}/rows?dataset={DATASET_NAME}"
53
+ f"&config={CONFIG}&split={SPLIT}"
54
+ f"&offset={offset}&length={length}")
55
+ req = urllib.request.Request(
56
+ url, headers={"User-Agent": "context-m-bench/1.0"})
57
+ with urllib.request.urlopen(req, timeout=600) as resp:
58
+ return json.loads(resp.read().decode("utf-8"))
59
+
60
+
61
+ def _load_parquet(parquet_path: str | Path, n: int = TOTAL_ROWS) -> list[dict]:
62
+ """Load rows from a local parquet file (requires pyarrow or pandas).
63
+
64
+ Each parquet row has the same shape as the datasets-server /rows
65
+ endpoint's `row` field. We return just the row dicts (no envelope).
66
+ """
67
+ try:
68
+ import pyarrow.parquet as pq
69
+ except ImportError:
70
+ try:
71
+ import pandas as pd
72
+ except ImportError:
73
+ raise ImportError(
74
+ "loading BEAM-10M from parquet requires pyarrow or "
75
+ "pandas — install with `pip install pyarrow` or "
76
+ "`pip install pandas`")
77
+ df = pd.read_parquet(parquet_path)
78
+ rows = []
79
+ for _, r in df.head(n).iterrows():
80
+ rows.append(r.to_dict() if hasattr(r, "to_dict") else dict(r))
81
+ return rows
82
+ table = pq.read_table(parquet_path)
83
+ n = min(n, table.num_rows)
84
+ rows = []
85
+ # batch-convert to Python dicts (column-at-a-time is faster than row)
86
+ cols = {name: table.column(name).to_pylist() for name in table.column_names}
87
+ for i in range(n):
88
+ rows.append({name: cols[name][i] for name in cols})
89
+ return rows
90
+
91
+
92
+ def load_beam_rows(n: int = 2, *, cache_dir: str | None = None,
93
+ parquet_path: str | None = None) -> list[dict]:
94
+ """Load N BEAM-10M conversations.
95
+
96
+ Resolution order (first available wins):
97
+ 1. parquet_path argument (or BEAM_PARQUET env var) — a single
98
+ parquet file containing all 10 rows
99
+ 2. cache_dir/beam_row_<i>.json — per-row JSON files (the format
100
+ produced by download_beam_full.sh and beam-cache.yml)
101
+ 3. datasets-server /rows endpoint — streamed on demand
102
+
103
+ Each row is a dict with keys: conversation_id, conversation_seed,
104
+ narratives, user_profile, conversation_plan, user_questions, chat,
105
+ probing_questions, plans.
106
+
107
+ The full 10M dataset has 10 conversations totaling ~975MB in
108
+ memory. We fetch one row at a time (each is ~50-110MB).
109
+ """
110
+ # 1. parquet path?
111
+ pq_path = parquet_path or os.environ.get("BEAM_PARQUET")
112
+ if pq_path and Path(pq_path).exists():
113
+ return _load_parquet(pq_path, n=min(n, TOTAL_ROWS))
114
+
115
+ cache = Path(cache_dir) if cache_dir else None
116
+ if cache:
117
+ cache.mkdir(parents=True, exist_ok=True)
118
+
119
+ rows: list[dict] = []
120
+ n = min(n, TOTAL_ROWS)
121
+ for i in range(n):
122
+ # 2. cached row?
123
+ cached_path = cache / f"beam_row_{i}.json" if cache else None
124
+ if cached_path and cached_path.exists():
125
+ try:
126
+ data = json.loads(cached_path.read_text())
127
+ if "rows" in data and data["rows"]:
128
+ rows.append(data["rows"][0]["row"])
129
+ continue
130
+ # some manifests store the row directly without envelope
131
+ if "conversation_id" in data:
132
+ rows.append(data)
133
+ continue
134
+ except (json.JSONDecodeError, KeyError):
135
+ # corrupt cache — re-fetch
136
+ cached_path.unlink(missing_ok=True)
137
+ # 3. fetch from datasets-server
138
+ data = _fetch_rows(offset=i, length=1)
139
+ if cached_path:
140
+ cached_path.write_text(json.dumps(data))
141
+ if "rows" in data and data["rows"]:
142
+ rows.append(data["rows"][0]["row"])
143
+ return rows
144
+
145
+
146
+ def parse_user_facts(row: dict) -> list[dict]:
147
+ """Extract ground-truth facts from a BEAM row's user_profile.
148
+
149
+ The user_profile has:
150
+ user_info: free text "Name: Jennifer Mccall / Age: 59 / ..."
151
+ user_relationships: free text with bullet points
152
+
153
+ We extract structured facts via regex (zero LLM calls).
154
+ """
155
+ import re
156
+ up = row.get("user_profile", {})
157
+ info = up.get("user_info", "") or ""
158
+ rels = up.get("user_relationships", "") or ""
159
+
160
+ facts: list[dict] = []
161
+ conv_id = row.get("conversation_id", "unknown")
162
+ user_id = f"beam_{conv_id}"
163
+
164
+ # name
165
+ m = re.search(r"Name:\s*([^\n]+)", info)
166
+ if m:
167
+ facts.append({"subject": user_id, "relation": "name",
168
+ "value": m.group(1).strip()})
169
+ # age
170
+ m = re.search(r"Age:\s*(\d+)", info)
171
+ if m:
172
+ facts.append({"subject": user_id, "relation": "age",
173
+ "value": m.group(1).strip()})
174
+ # gender
175
+ m = re.search(r"Gender:\s*([^\n]+)", info)
176
+ if m:
177
+ facts.append({"subject": user_id, "relation": "gender",
178
+ "value": m.group(1).strip()})
179
+ # location
180
+ m = re.search(r"Location:\s*([^\n]+)", info)
181
+ if m:
182
+ facts.append({"subject": user_id, "relation": "location",
183
+ "value": m.group(1).strip()})
184
+ # profession
185
+ m = re.search(r"Profession:\s*([^\n]+)", info)
186
+ if m:
187
+ facts.append({"subject": user_id, "relation": "profession",
188
+ "value": m.group(1).strip()})
189
+
190
+ # relationships — extract names + relations
191
+ # relationship sections start with all-caps headers like
192
+ # "PARENTS & GUARDIANS:" "ROMANTIC PARTNER:" "CHILDREN:" etc.
193
+ cur_section = None
194
+ for line in rels.split("\n"):
195
+ line = line.strip()
196
+ if not line:
197
+ continue
198
+ # detect header — line is all caps + colon
199
+ if line.endswith(":") and line[:-1] == line[:-1].upper():
200
+ cur_section = line[:-1]
201
+ continue
202
+ # bullet line — extract name + age
203
+ m = re.match(r"•\s*([^\(]+)\s*\(.*?\)", line)
204
+ if m and cur_section:
205
+ name = m.group(1).strip().rstrip(",")
206
+ # convert section to relation
207
+ rel_map = {
208
+ "PARENTS & GUARDIANS": "parent",
209
+ "ROMANTIC PARTNER": "partner",
210
+ "CHILDREN": "child",
211
+ "SIBLINGS": "sibling",
212
+ "FRIENDS": "friend",
213
+ "COLLEAGUES": "colleague",
214
+ }
215
+ relation = rel_map.get(cur_section, cur_section.lower())
216
+ facts.append({"subject": user_id, "relation": relation,
217
+ "value": name})
218
+ return facts
219
+
220
+
221
+ def parse_chat_turns(row: dict) -> list[dict]:
222
+ """Flatten a row's chat history into a list of user turns.
223
+
224
+ Each turn has: content, id, index, question_type, role, time_anchor.
225
+ We only keep role='user' turns (these are the messages the user
226
+ actually said — what Context-M needs to extract facts from).
227
+ """
228
+ chat = row.get("chat", [])
229
+ if not isinstance(chat, list):
230
+ return []
231
+ turns_out: list[dict] = []
232
+ for plan_obj in chat:
233
+ if not isinstance(plan_obj, dict):
234
+ continue
235
+ # plan_obj has one key like "plan-1"
236
+ for plan_name, batches in plan_obj.items():
237
+ if not isinstance(batches, list):
238
+ continue
239
+ for batch in batches:
240
+ if not isinstance(batch, dict):
241
+ continue
242
+ turns = batch.get("turns", [])
243
+ if not isinstance(turns, list):
244
+ continue
245
+ for turn_list in turns:
246
+ if not isinstance(turn_list, list):
247
+ continue
248
+ for turn in turn_list:
249
+ if not isinstance(turn, dict):
250
+ continue
251
+ if turn.get("role") == "user":
252
+ turns_out.append({
253
+ "content": turn.get("content", ""),
254
+ "time_anchor": turn.get("time_anchor"),
255
+ "question_type": turn.get("question_type"),
256
+ "plan": plan_name,
257
+ "batch": batch.get("batch_number"),
258
+ })
259
+ return turns_out
260
+
261
+
262
+ def beam_rows_to_personas(rows: list[dict], *,
263
+ max_turns_per_persona: int = 100,
264
+ include_profile: bool = True) -> list[dict]:
265
+ """Convert BEAM rows into the persona dict format the bench expects.
266
+
267
+ Returns list of: {user_id, text, facts} where:
268
+ user_id — derived from conversation_id
269
+ text — concatenation of user_profile (if include_profile=True)
270
+ + the first N user turns. The user_profile contains the
271
+ ground-truth facts (Name, Age, Location, etc.) which the
272
+ μ=0 extractor should pull from the explicit "Name: X"
273
+ lines; the chat turns are the conversational context.
274
+ facts — ground-truth structured facts parsed from user_profile
275
+ (separate from text so the bench can check recall)
276
+ """
277
+ personas = []
278
+ for row in rows:
279
+ conv_id = row.get("conversation_id", "unknown")
280
+ user_id = f"beam_{conv_id}"
281
+ facts = parse_user_facts(row)
282
+ turns = parse_chat_turns(row)
283
+ # cap turns to keep ingest tractable
284
+ turns = turns[:max_turns_per_persona]
285
+ # build the ingest text: profile first (so facts are stated
286
+ # explicitly), then the conversation turns
287
+ parts = []
288
+ if include_profile:
289
+ up = row.get("user_profile", {})
290
+ info = up.get("user_info", "")
291
+ rels = up.get("user_relationships", "")
292
+ if info:
293
+ parts.append(info)
294
+ if rels:
295
+ parts.append(rels)
296
+ # append the chat turns
297
+ for t in turns:
298
+ if t.get("content"):
299
+ parts.append(t["content"])
300
+ text = "\n".join(parts)
301
+ personas.append({
302
+ "user_id": user_id,
303
+ "text": text,
304
+ "facts": facts,
305
+ "n_turns": len(turns),
306
+ "conversation_id": conv_id,
307
+ })
308
+ return personas
309
+
310
+
311
+ __all__ = [
312
+ "load_beam_rows",
313
+ "parse_user_facts",
314
+ "parse_chat_turns",
315
+ "beam_rows_to_personas",
316
+ "DATASET_NAME",
317
+ ]
@@ -0,0 +1,376 @@
1
+ """BEAM-style corpus generator — synthetic long-horizon conversations.
2
+
3
+ Mirrors the BEAM methodology (arXiv:2510.27246): auto-generated,
4
+ coherent, topically diverse multi-session conversations with
5
+ probing questions across 10 memory abilities. Deterministic (seeded);
6
+ persona timelines carry ground-truth registries used by the judge.
7
+
8
+ Buckets: 128K / 500K / 1M / 10M estimated tokens. Signal conversations
9
+ (persona sessions) are embedded in distractor noise (smalltalk +
10
+ long-form topical documents with competing capitalized entities) so the
11
+ answers are genuine needles — retrieval must beat brute-force context
12
+ stuffing, exactly the regime BEAM-10M targets.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timedelta, timezone
20
+
21
+ from cortexm.util import month_name, token_estimate
22
+
23
+ FIRST_NAMES = ["Alice", "Maya", "Priya", "Dana", "Elena", "Marcus", "Tom",
24
+ "Nadia", "Omar", "Julia", "Ken", "Sofia", "Ravi", "Chloe"]
25
+ LAST_NAMES = ["Johnson", "Chen", "Sharma", "Kovač", "Rossi", "Williams",
26
+ "Tanaka", "Silva", "Novak", "Brooks", "Garcia", "Osei"]
27
+ ORGS = ["Google", "Anthropic", "Stripe", "Microsoft", "Netflix", "Shopify",
28
+ "Figma", "Databricks", "Vercel", "Ramp", "Notion", "Linear"]
29
+ CITIES = ["Toronto", "Lisbon", "Austin", "Berlin", "Nairobi", "Seattle",
30
+ "Denver", "Prague", "Osaka", "Melbourne", "Zurich", "Austin"]
31
+ ROLES = ["software engineer", "product manager", "data scientist",
32
+ "designer", "engineering manager", "researcher", "DevOps engineer"]
33
+ TECH = ["Rust", "Python", "Go", "TypeScript", "Kotlin", "Swift"]
34
+ TEAMS = ["Platform", "Search", "Payments", "Growth", "Infra", "Mobile"]
35
+ PROJECTS = ["Project Falcon", "Project Atlas", "Project Beacon", "Project Cedar",
36
+ "Project Delta", "Project Echo", "Project Fable", "Project Granite"]
37
+ EVENTS = [("deployed the payment service", 3), ("ran the marathon", 8),
38
+ ("adopted a cat", 2), ("spoke at a conference", 6),
39
+ ("rebuilt the CI pipeline", 4), ("started piano lessons", 5),
40
+ ("organized a hackathon", 9), ("published a blog post", 3)]
41
+ PREF_CATS = [
42
+ ("coffee", ["espresso", "oat milk lattes", "cold brew", "green tea",
43
+ "black coffee", "matcha"]),
44
+ ("music", ["jazz", "techno", "classical", "indie rock", "lo-fi beats"]),
45
+ ("food", ["ramen", "pizza", "thai curry", "sushi", "tacos"]),
46
+ ("editor theme", ["dark mode", "light mode"]),
47
+ ]
48
+ HOBBIES = ["hiking", "rock climbing", "photography", "baking sourdough",
49
+ "kayaking", "chess", "birdwatching", "pottery"]
50
+ DISTRACTOR_TOPICS = [
51
+ "the weather", "a traffic jam", "a new smartphone release", "the stock market",
52
+ "a soccer match", "a Netflix series", "a recipe for lasagna", "airport delays",
53
+ "a coffee shop queue", "a podcast about history", "a piano concert",
54
+ "a neighbor's garden", "a marathon on TV", "a book about sailing",
55
+ ]
56
+ WIKI_ENTITIES = [
57
+ "Mount Kilimanjaro", "the Baltic Sea", "Kafka Museum", "Antarctic Treaty",
58
+ "Hyperloop One", "Voyager Program", "Silk Road", "Lake Baikal",
59
+ "Gutenberg Press", "Mariana Trench", "Aurora Borealis", "Great Barrier Reef",
60
+ "Florence Cathedral", "Sahara Desert", "Yellowstone Park", "Aztec Calendar",
61
+ ]
62
+ WIKI_VERBS = ["was documented", "attracted researchers", "made headlines",
63
+ "was rediscovered", "inspired a documentary", "broke records",
64
+ "was surveyed", "hosted a festival", "was renovated",
65
+ "surprised scientists"]
66
+ SMALLTALK = [
67
+ "Hey! How's it going today?",
68
+ "That sounds interesting, tell me more.",
69
+ "Oh wow, I didn't know that.",
70
+ "Thanks for the reminder!",
71
+ "Ha, that's funny.",
72
+ "Okay, got it.",
73
+ "Sounds like a plan.",
74
+ "Good morning! Ready for another day?",
75
+ "I was just thinking about something similar.",
76
+ "Cool, I'll keep that in mind.",
77
+ ]
78
+
79
+
80
+ @dataclass
81
+ class Persona:
82
+ user_id: str
83
+ full_name: str
84
+ first: str
85
+ nickname: str | None
86
+ employers: list = field(default_factory=list) # (org, start, end|None)
87
+ roles: list = field(default_factory=list)
88
+ cities: list = field(default_factory=list) # (city, start, end|None)
89
+ prefs: list = field(default_factory=list) # (cat, value, start, end|None)
90
+ skills: list = field(default_factory=list)
91
+ family: list = field(default_factory=list) # (name, relation)
92
+ manager: tuple | None = None # (name, team)
93
+ team_tech: tuple | None = None # (team, tech)
94
+ projects: list = field(default_factory=list) # (name, start, end|None)
95
+ events: list = field(default_factory=list) # (date, desc)
96
+ birthday: tuple | None = None
97
+ hobbies: list = field(default_factory=list)
98
+ instruction: tuple | None = None # (text, expected)
99
+
100
+
101
+ def _d(y: int, m: int, day: int = 1) -> str:
102
+ return f"{y:04d}-{m:02d}-{day:02d}"
103
+
104
+
105
+ def make_persona(rng: random.Random, idx: int, t0: datetime) -> Persona:
106
+ first = FIRST_NAMES[(idx * 5 + rng.randrange(3)) % len(FIRST_NAMES)]
107
+ last = LAST_NAMES[(idx * 7 + rng.randrange(3)) % len(LAST_NAMES)]
108
+ full = f"{first} {last}"
109
+ p = Persona(user_id=f"user{idx}", full_name=full, first=first,
110
+ nickname=first[:3].lower() if rng.random() < 0.5 else None)
111
+
112
+ # employment timeline: 2-3 orgs
113
+ n_jobs = rng.choice([2, 3])
114
+ orgs = rng.sample(ORGS, n_jobs + 1)
115
+ y = t0.year - 2
116
+ start = _d(y, rng.randrange(1, 10))
117
+ for i in range(n_jobs):
118
+ end = _d(y + 1 + i, rng.randrange(1, 12)) if i < n_jobs - 1 else None
119
+ p.employers.append((orgs[i], start, end))
120
+ start = end or start
121
+ p.roles.append((rng.choice(ROLES), p.employers[0][1], None))
122
+
123
+ # residence: 2 cities
124
+ cs = rng.sample(CITIES, 2)
125
+ p.cities = [(cs[0], _d(t0.year - 2, rng.randrange(1, 10)),
126
+ _d(t0.year - 1, rng.randrange(1, 12))),
127
+ (cs[1], _d(t0.year - 1, rng.randrange(1, 12)), None)]
128
+
129
+ # preferences with flips
130
+ for cat, vals in PREF_CATS[:rng.choice([2, 3])]:
131
+ v = rng.sample(vals, min(2, len(vals)))
132
+ p.prefs.append((cat, v[0], _d(t0.year - 2, rng.randrange(1, 12)),
133
+ _d(t0.year - 1, rng.randrange(1, 12))))
134
+ p.prefs.append((cat, v[1], _d(t0.year - 1, rng.randrange(1, 12)), None))
135
+
136
+ p.skills = rng.sample(TECH, 3)
137
+ sib = FIRST_NAMES[(idx * 3 + 7) % len(FIRST_NAMES)]
138
+ sib_last = LAST_NAMES[(idx * 11 + 5) % len(LAST_NAMES)]
139
+ p.family = [(f"{sib} {sib_last}", "sister")]
140
+ mgr = FIRST_NAMES[(idx * 9 + 2) % len(FIRST_NAMES)]
141
+ if mgr == first:
142
+ mgr = FIRST_NAMES[(idx * 9 + 3) % len(FIRST_NAMES)]
143
+ team = rng.choice(TEAMS)
144
+ p.manager = (mgr, team)
145
+ p.team_tech = (team, rng.choice(TECH))
146
+ p.projects = [(proj, _d(t0.year - 1 + i % 2, rng.randrange(1, 12)),
147
+ None if i % 2 else _d(t0.year, rng.randrange(1, 6)))
148
+ for i, proj in enumerate(rng.sample(PROJECTS, 3))]
149
+ evs = rng.sample(EVENTS, 4)
150
+ p.events = [(_d(t0.year - 1 + i % 2, rng.randrange(1, 12), rng.randrange(1, 28)),
151
+ ev[0]) for i, ev in enumerate(evs)]
152
+ p.birthday = (rng.randrange(1, 13), rng.randrange(1, 28))
153
+ p.hobbies = rng.sample(HOBBIES, 2)
154
+ p.instruction = ("Please always respond in French.", "French") \
155
+ if rng.random() < 0.6 else ("Always keep my answers short and concise.", "short")
156
+ return p
157
+
158
+
159
+ def _month(m: int) -> str:
160
+ return month_name(m)
161
+
162
+
163
+ def persona_messages(p: Persona, rng: random.Random, session_date: datetime,
164
+ part: int) -> list[tuple[str, str]]:
165
+ """Surface the persona's life in natural, varied messages."""
166
+ msgs: list[tuple[str, str]] = []
167
+ A = lambda t: msgs.append(("user", t)) # noqa: E731
168
+
169
+ if part == 0:
170
+ # introduction session
171
+ A(f"My name is {p.full_name}.")
172
+ A(rng.choice([
173
+ f"I work at {p.employers[0][0]} as a {p.roles[0][0]}.",
174
+ f"I'm a {p.roles[0][0]} at {p.employers[0][0]}.",
175
+ f"I work as a {p.roles[0][0]} at {p.employers[0][0]}.",
176
+ ]))
177
+ A(f"I live in {p.cities[0][0]}.")
178
+ b = p.birthday
179
+ A(f"My birthday is {_month(b[0])} {b[1]}.")
180
+ A(f"My sister {p.family[0][0]} lives nearby.")
181
+ if p.nickname:
182
+ A(f"But call me {p.nickname.capitalize() if len(p.nickname) > 2 else p.nickname}.")
183
+ A(p.instruction[0])
184
+ # state the first employer WITH its start date so the employment
185
+ # interval is recoverable from the text (TR "where did X work in
186
+ # YYYY" probes); otherwise valid_from defaults to the session date
187
+ # and the historical window is unanswerable from the corpus.
188
+ _e0 = p.employers[0]
189
+ A(f"I've been working at {_e0[0]} since "
190
+ f"{_month(int(_e0[1][5:7]))} {_e0[1][:4]}.")
191
+ if part == 1:
192
+ # preferences + skills
193
+ # state EVERY preference category (old -> new), so every PF probe
194
+ # is answerable from the conversation; part 7's "switched to X"
195
+ # then acts as a genuine re-statement / flip of the current value.
196
+ for i in range(0, len(p.prefs), 2):
197
+ cat, v_old = p.prefs[i][0], p.prefs[i][1]
198
+ v_new = p.prefs[i + 1][1] if i + 1 < len(p.prefs) else v_old
199
+ A(f"I prefer {v_new} over {v_old} for {cat}.")
200
+ for s in p.skills:
201
+ A(rng.choice([f"I know {s}.", f"I code in {s}.",
202
+ f"I've been learning {s}."]))
203
+ A(f"In my free time I {p.hobbies[0]}.")
204
+ if part == 2:
205
+ # job change
206
+ old = p.employers[0]
207
+ new = p.employers[1]
208
+ m_end = int(old[2][5:7]) if old[2] else 6
209
+ A(f"I left {old[0]} in {_month(m_end)}.")
210
+ m_new = int(new[1][5:7])
211
+ A(rng.choice([
212
+ f"I joined {new[0]} on {_month(m_new)} 5th, {new[1][:4]}.",
213
+ f"I started working at {new[0]} in {_month(m_new)} {new[1][:4]}.",
214
+ f"I'm now at {new[0]} as a {p.roles[0][0]}.",
215
+ ]))
216
+ if len(p.employers) > 2:
217
+ mid, last = p.employers[1], p.employers[2]
218
+ m_mid = int(mid[2][5:7]) if mid[2] else 6
219
+ A(f"I left {mid[0]} in {_month(m_mid)} {mid[2][:4]}.")
220
+ A(f"These days I work at {last[0]}.")
221
+ if part == 3:
222
+ # move + family third-person
223
+ c_old, c_new = p.cities[0], p.cities[1]
224
+ m_move = int(c_new[1][5:7])
225
+ A(f"We moved to {c_new[0]} in {_month(m_move)} {c_new[1][:4]}.")
226
+ A(f"My sister {p.family[0][0]} works at {rng.choice(ORGS)}.")
227
+ if part == 4:
228
+ # work structure: manager + team + tech (multi-hop chain)
229
+ mgr, team = p.manager
230
+ tname, tech = p.team_tech
231
+ A(f"My manager is {mgr}.")
232
+ A(f"{mgr} manages the {tname} team.")
233
+ A(f"The {tname} team uses {tech} for everything.")
234
+ A(f"I'm on the {tname} team now.")
235
+ if part == 5:
236
+ # projects
237
+ for name, start, end in p.projects:
238
+ if end:
239
+ m = int(end[5:7])
240
+ A(rng.choice([
241
+ f"We shipped {name} in {_month(m)} {end[:4]}.",
242
+ f"I finished {name} last month.",
243
+ ]))
244
+ else:
245
+ A(rng.choice([
246
+ f"I'm working on {name}.",
247
+ f"We're building {name} right now.",
248
+ f"I work on {name} with a few friends.",
249
+ ]))
250
+ if part == 6:
251
+ # dated events
252
+ for date, desc in p.events[:2]:
253
+ m, day = int(date[5:7]), int(date[8:10])
254
+ A(rng.choice([
255
+ f"On {_month(m)} {day}, {date[:4]} I {desc}.",
256
+ f"I {desc} on {_month(m)} {day}, {date[:4]}.",
257
+ ]))
258
+ A(rng.choice(SMALLTALK))
259
+ if part == 7:
260
+ for date, desc in p.events[2:]:
261
+ m, day = int(date[5:7]), int(date[8:10])
262
+ A(rng.choice([
263
+ f"On {_month(m)} {day}, {date[:4]}, I {desc}.",
264
+ f"I {desc} on {_month(m)} {day}, {date[:4]}.",
265
+ ]))
266
+ # preference flip
267
+ cat = p.prefs[2][0] if len(p.prefs) > 2 else "coffee"
268
+ vals = [v for (c, v, s, e) in p.prefs if c == cat]
269
+ if len(vals) >= 2:
270
+ A(rng.choice([
271
+ f"Actually, I've switched to {vals[-1]}.",
272
+ f"These days I prefer {vals[-1]}.",
273
+ f"I'm more of a {vals[-1]} person now.",
274
+ ]))
275
+ # conversational padding
276
+ for _ in range(rng.randrange(1, 3)):
277
+ A(rng.choice(SMALLTALK))
278
+ return msgs
279
+
280
+
281
+ def distractor_paragraph(rng: random.Random) -> str:
282
+ """Long-form topical distractor with competing capitalized entities."""
283
+ e1, e2 = rng.sample(WIKI_ENTITIES, 2)
284
+ verb1, verb2 = rng.sample(WIKI_VERBS, 2)
285
+ year = rng.randrange(1960, 2024)
286
+ topic = rng.choice(DISTRACTOR_TOPICS)
287
+ return (
288
+ f"I read an article about {e1} yesterday. Apparently {e1} {verb1} in {year}, "
289
+ f"and researchers compared it with {e2}, which {verb2} a decade earlier. "
290
+ f"The article also covered {topic}, and mentioned that {e2} remains a popular "
291
+ f"subject among historians. A guidebook author wrote that visiting {e1} takes "
292
+ f"about three days, while {e2} can be explored in an afternoon. "
293
+ f"Local officials say tourism around {e1} doubled since {year + 10}."
294
+ )
295
+
296
+
297
+ def smalltalk_message(rng: random.Random) -> str:
298
+ return rng.choice(SMALLTALK)
299
+
300
+
301
+ @dataclass
302
+ class Corpus:
303
+ bucket: str
304
+ target_tokens: int
305
+ sessions: list = field(default_factory=list) # (user_id, date, messages)
306
+ personas: list = field(default_factory=list)
307
+ total_tokens: int = 0
308
+ generation_seconds: float = 0.0
309
+
310
+
311
+ BUCKETS = {
312
+ "128k": dict(target=128_000, personas=1, sessions=8, docs_factor=0.9),
313
+ "500k": dict(target=500_000, personas=2, sessions=12, docs_factor=1.0),
314
+ "1m": dict(target=1_000_000, personas=3, sessions=16, docs_factor=1.0),
315
+ "10m": dict(target=10_000_000, personas=6, sessions=40, docs_factor=1.0),
316
+ }
317
+
318
+
319
+ def generate(bucket: str, seed: int = 42, t0: datetime | None = None) -> Corpus:
320
+ import time
321
+ t_start = time.time()
322
+ cfg = BUCKETS[bucket]
323
+ rng = random.Random(seed)
324
+ t0 = t0 or datetime(2026, 3, 1, tzinfo=timezone.utc)
325
+ personas = [make_persona(rng, i, t0) for i in range(cfg["personas"])]
326
+ sessions = []
327
+ total = 0
328
+
329
+ n_sessions = cfg["sessions"]
330
+ for p in personas:
331
+ for s in range(n_sessions):
332
+ date = t0 + timedelta(days=s * 21 + rng.randrange(0, 5))
333
+ msgs = persona_messages(p, rng, date, s % 8)
334
+ if s == 0:
335
+ msgs = [("assistant", "Hi! I'm your assistant. Nice to meet you!")] + msgs
336
+ for r, txt in msgs:
337
+ total += token_estimate(txt)
338
+ sessions.append((p.user_id, date, msgs))
339
+
340
+ # distractor volume to reach the bucket target (measured, not estimated)
341
+ if cfg["target"] - total > 0:
342
+ step = max(1, len(sessions) // 24)
343
+ guard = 0
344
+ while total < cfg["target"] and guard < 500_000:
345
+ guard += 1
346
+ made = 0
347
+ for i in range(0, len(sessions), step):
348
+ if total >= cfg["target"]:
349
+ break
350
+ uid, date, msgs = sessions[i]
351
+ k = rng.randrange(0, max(1, len(msgs)))
352
+ if rng.random() < 0.45:
353
+ txt = distractor_paragraph(rng)
354
+ else:
355
+ txt = smalltalk_message(rng) + " " + smalltalk_message(rng)
356
+ msgs.insert(k, ("user", txt))
357
+ total += token_estimate(txt)
358
+ made += 1
359
+ if made == 0:
360
+ step = max(1, step - 1)
361
+ elif guard % 5 == 0 and step > 1:
362
+ step = max(1, step // 2)
363
+ # occasionally append pure-noise sessions
364
+ if rng.random() < 0.15:
365
+ batch = []
366
+ for _ in range(rng.randrange(6, 14)):
367
+ txt = (distractor_paragraph(rng) if rng.random() < 0.5
368
+ else smalltalk_message(rng) + " " + smalltalk_message(rng))
369
+ batch.append(("user", txt))
370
+ total += token_estimate(txt)
371
+ sessions.append((sessions[rng.randrange(len(sessions))][0],
372
+ t0 + timedelta(days=rng.randrange(400)), batch))
373
+
374
+ return Corpus(bucket=bucket, target_tokens=cfg["target"], sessions=sessions,
375
+ personas=personas, total_tokens=total,
376
+ generation_seconds=time.time() - t_start)