Falln87 commited on
Commit
3cd7b4e
·
verified ·
1 Parent(s): fdcd0a4

CLERK generator: clean full rewrite of render_session and persona timeline builder

Browse files
Files changed (1) hide show
  1. clerk/generator.py +261 -58
clerk/generator.py CHANGED
@@ -27,20 +27,17 @@ from clerk.common import (
27
  empty_ledger,
28
  enforce_budget,
29
  serialize_ledger,
30
- salience,
31
  )
32
 
33
  # ---------------------------------------------------------------- predicates
34
 
35
- # (predicate key, human phrase, value pool, statement template, update
36
- # template, tombstone template)
37
  PREDICATES: List[Dict[str, Any]] = [
38
  {"p": "job", "h": "job", "v": ["barista", "chef", "nurse", "high school teacher", "librarian", "electrician", "accountant", "software engineer", "pharmacist", "bus driver"],
39
  "add": "Big news — I started a new job as a {v}.", "upd": "I actually switched jobs again. I'm a {v} now.", "tmb": "I left my job — between things right now."},
40
  {"p": "city", "h": "city", "v": ["Denver", "Portland", "Nashville", "Austin", "Madison", "Boise", "Tucson", "Salem", "Reno", "Spokane"],
41
  "add": "I just moved to {v}!", "upd": "We relocated again — I'm in {v} now.", "tmb": "I'm between cities at the moment, staying with family."},
42
  {"p": "pet_kind", "h": "pet", "v": ["cat", "dog", "rabbit", "parakeet", "hamster", "iguana"],
43
- "add": "I adopted a {v}.", "upd": "Long story, but I rehomed the {v_old} and got a {v} instead.", "tmb": "My pet passed away last month. It's been rough."},
44
  {"p": "partner", "h": "partner", "v": ["Sam", "Jordan", "Priya", "Diego", "Nina", "Marcus", "Ellie", "Omar"],
45
  "add": "I've been seeing someone — their name is {v}.", "upd": "That ended, and I'm dating {v} now.", "tmb": "We broke up, so I'm single again."},
46
  {"p": "car", "h": "car", "v": ["a red Civic", "a blue Outback", "a gray Silverado", "a used Miata", "an old Corolla", "a white Jetta"],
@@ -54,13 +51,13 @@ PREDICATES: List[Dict[str, Any]] = [
54
  {"p": "boss", "h": "boss", "v": ["Dana", "Victor", "Renee", "Kofi", "Beatrice", "Hank"],
55
  "add": "My new boss's name is {v}.", "upd": "There's a new boss over me now: {v}.", "tmb": "My boss left and they haven't replaced them."},
56
  {"p": "coworker", "h": "closest coworker", "v": ["Lena", "Theo", "Yusuf", "Marge", "Pete", "Iris"],
57
- "add": "I've become friends with a coworker named {v}.", "upd": "On a new team now — my closest coworker there is {v}.", "tmb": "That coworker transferred away."},
58
  {"p": "project", "h": "big work project", "v": ["the website redesign", "the database migration", "the product launch", "the security audit", "the onboarding revamp"],
59
  "add": "I'm leading {v} at work.", "upd": "I got moved off that and onto {v}.", "tmb": "The project got cancelled, honestly."},
60
  {"p": "deadline", "h": "big deadline", "v": ["March 14", "April 2", "May 9", "June 21", "July 3", "August 30"],
61
  "add": "My big deadline is {v}.", "upd": "The deadline moved — it's {v} now.", "tmb": "The deadline got pushed indefinitely."},
62
  {"p": "instrument", "h": "instrument", "v": ["guitar", "violin", "accordion", "cello", "drums", "trumpet"],
63
- "add": "I've been learning the {v}.", "upd": "I switched instruments — playing the {v} now.", "tmb": "I gave up the instrument, wrists couldn't take it."},
64
  {"p": "team", "h": "sports team I follow", "v": ["the Larks", "the Comets", "the Harbors", "the Northside Pistons", "the Rovers"],
65
  "add": "I've started following {v}.", "upd": "I switched allegiances — it's {v} now.", "tmb": "I stopped following sports entirely."},
66
  {"p": "allergy", "h": "allergy", "v": ["shellfish", "peanuts", "latex", "penicillin", "kiwi"],
@@ -75,7 +72,7 @@ PREDICATES: List[Dict[str, Any]] = [
75
 
76
  TRAIN_NAMES = ["Alex", "Bailey", "Casey", "Drew", "Emery", "Frankie", "Gray", "Harper",
77
  "Indigo", "Jesse", "Kai", "Logan", "Marley", "Noel", "Oakley", "Parker",
78
- "Quinn", "Reese", "Sage", "Tatum", "Undra", "Val", "Wren", "Xander"]
79
  TEST_NAMES = ["Ari", "Bellamy", "Crew", "Dallas", "East", "Flynn", "Greer", "Hollis",
80
  "Ira", "Jules", "Kit", "Lennon", "Morgan", "Nico", "Onyx", "Perry",
81
  "Quince", "Rory", "Sailor", "Tao", "Uri", "Vesper", "Wilder", "Yael"]
@@ -94,35 +91,28 @@ ACKS = ["Got it — noted.", "That's good to hear.", "Thanks for the update!",
94
  "Ah, interesting.", "Makes sense.", "Oh wow, okay.", "Sounds like a plan.",
95
  "Noted, thanks for telling me."]
96
 
97
- Q_TEMPLATES = {
98
- "recent": ["Do you remember what my {h} is?", "Quick quiz: what's my {h} again?"],
99
- "stale": ["Do you still remember my {h} from before?", "You remember what my {h} is, right?"],
100
- "superseded": ["Wait — what is my {h} these days?", "Remind me: what's my current {h}?"],
101
- "negated": ["Do I still have a {h}?", "What about my {h} — still a thing?"],
102
- "unknown": ["What's my {h}?", "Do you happen to know my {h}?"],
103
- "temporal": ["What was my {h} back then, before it changed?", "What did my {h} used to be?"],
104
- }
105
 
106
- ANSWER_OK = {
107
- "value": "{v}",
108
- "negated": "You don't have that anymore — you mentioned it's no longer the case.",
109
- "unknown": "I don't know — you haven't told me about that yet.",
110
- "temporal": "{v}",
111
- }
112
 
113
 
114
- def _fill(t: str, v: Optional[str] = None, h: Optional[str] = None, s: Optional[str] = None) -> str:
115
- out = t
116
- if v is not None:
117
- out = out.replace("{v}", v)
118
- if h is not None:
119
- out = out.replace("{h}", h)
120
- if s is not None:
121
- out = out.replace("{s}", s)
122
- return out
123
 
124
 
125
- # ---------------------------------------------------------------- generation
 
 
 
 
 
126
 
127
  def make_persona(rng: random.Random, name: str, n_init: int = 8) -> Dict[str, Any]:
128
  preds = rng.sample(PREDICATES, n_init)
@@ -130,49 +120,262 @@ def make_persona(rng: random.Random, name: str, n_init: int = 8) -> Dict[str, An
130
  for spec in preds:
131
  facts.append({
132
  "pred": spec["p"], "value": rng.choice(spec["v"]),
133
- "born_session": 0, "state": "active", # active | updated | tombstoned
134
- "history": [],
135
  })
136
  return {"name": name, "facts": facts}
137
 
138
 
139
- def find_fact(persona: Dict[str, Any], pred: str) -> Optional[Dict[str, Any]]:
140
- for f in persona["facts"]:
141
- if f["pred"] == pred:
142
- return f
143
- return None
144
-
145
-
146
- def spec_of(pred: str) -> Dict[str, Any]:
147
- return next(p for p in PREDICATES if p["p"] == pred)
148
-
149
 
150
  def render_session(
151
  rng: random.Random,
152
  persona: Dict[str, Any],
153
  session_idx: int,
154
  n_add: int, n_upd: int, n_tmb: int, n_q: int,
155
- ) -> Tuple[List[Dict[str, str]], List[Dict[str, Any]], List[Dict[str, Any]]]:
156
- """Render one session. Returns (turns, write_events, qas).
157
 
158
- write_events: [{"kind": ADD/UPDATE/TOMBSTONE, "pred":..., "value":...}]
159
- qas: [{"q":..., "a":..., "type":..., "pred":...}]
 
160
  """
161
  name = persona["name"]
162
- turns: List[Dict[str, Any]] = []
163
  events: List[Dict[str, Any]] = []
164
 
165
- # --- pick events
166
- updatable = [f for f in persona["facts"] if f["state"] == "active"
167
- and f["born_session"] < session_idx]
168
- spec_pool = [p for p in PREDICATES if find_fact(persona, p["p"]) is None]
 
 
169
 
170
- for _ in range(n_add):
171
- if not spec_pool_left(spec_pool_left_spec := spec_pool_left := None) and False:
172
- pass # placeholder removed
173
- del spec_pool_left
174
 
175
  for _ in range(n_add):
176
- if not spec_pool_left_check(spec_pool_left_spec := None) and False:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  pass
178
- return [], [], []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  empty_ledger,
28
  enforce_budget,
29
  serialize_ledger,
 
30
  )
31
 
32
  # ---------------------------------------------------------------- predicates
33
 
 
 
34
  PREDICATES: List[Dict[str, Any]] = [
35
  {"p": "job", "h": "job", "v": ["barista", "chef", "nurse", "high school teacher", "librarian", "electrician", "accountant", "software engineer", "pharmacist", "bus driver"],
36
  "add": "Big news — I started a new job as a {v}.", "upd": "I actually switched jobs again. I'm a {v} now.", "tmb": "I left my job — between things right now."},
37
  {"p": "city", "h": "city", "v": ["Denver", "Portland", "Nashville", "Austin", "Madison", "Boise", "Tucson", "Salem", "Reno", "Spokane"],
38
  "add": "I just moved to {v}!", "upd": "We relocated again — I'm in {v} now.", "tmb": "I'm between cities at the moment, staying with family."},
39
  {"p": "pet_kind", "h": "pet", "v": ["cat", "dog", "rabbit", "parakeet", "hamster", "iguana"],
40
+ "add": "I adopted a {v}.", "upd": "Long story, but I rehomed the old one and got a {v} instead.", "tmb": "My pet passed away last month. It's been rough."},
41
  {"p": "partner", "h": "partner", "v": ["Sam", "Jordan", "Priya", "Diego", "Nina", "Marcus", "Ellie", "Omar"],
42
  "add": "I've been seeing someone — their name is {v}.", "upd": "That ended, and I'm dating {v} now.", "tmb": "We broke up, so I'm single again."},
43
  {"p": "car", "h": "car", "v": ["a red Civic", "a blue Outback", "a gray Silverado", "a used Miata", "an old Corolla", "a white Jetta"],
 
51
  {"p": "boss", "h": "boss", "v": ["Dana", "Victor", "Renee", "Kofi", "Beatrice", "Hank"],
52
  "add": "My new boss's name is {v}.", "upd": "There's a new boss over me now: {v}.", "tmb": "My boss left and they haven't replaced them."},
53
  {"p": "coworker", "h": "closest coworker", "v": ["Lena", "Theo", "Yusuf", "Marge", "Pete", "Iris"],
54
+ "add": "I've become friends with a coworker named {v}.", "upd": "I'm on a new team now — my closest coworker there is {v}.", "tmb": "That coworker transferred away."},
55
  {"p": "project", "h": "big work project", "v": ["the website redesign", "the database migration", "the product launch", "the security audit", "the onboarding revamp"],
56
  "add": "I'm leading {v} at work.", "upd": "I got moved off that and onto {v}.", "tmb": "The project got cancelled, honestly."},
57
  {"p": "deadline", "h": "big deadline", "v": ["March 14", "April 2", "May 9", "June 21", "July 3", "August 30"],
58
  "add": "My big deadline is {v}.", "upd": "The deadline moved — it's {v} now.", "tmb": "The deadline got pushed indefinitely."},
59
  {"p": "instrument", "h": "instrument", "v": ["guitar", "violin", "accordion", "cello", "drums", "trumpet"],
60
+ "add": "I've been learning the {v}.", "upd": "I switched instruments — playing the {v} now.", "tmb": "I gave up the instrument, my wrists couldn't take it."},
61
  {"p": "team", "h": "sports team I follow", "v": ["the Larks", "the Comets", "the Harbors", "the Northside Pistons", "the Rovers"],
62
  "add": "I've started following {v}.", "upd": "I switched allegiances — it's {v} now.", "tmb": "I stopped following sports entirely."},
63
  {"p": "allergy", "h": "allergy", "v": ["shellfish", "peanuts", "latex", "penicillin", "kiwi"],
 
72
 
73
  TRAIN_NAMES = ["Alex", "Bailey", "Casey", "Drew", "Emery", "Frankie", "Gray", "Harper",
74
  "Indigo", "Jesse", "Kai", "Logan", "Marley", "Noel", "Oakley", "Parker",
75
+ "Quinn", "Reese", "Sage", "Tatum", "Val", "Wren", "Xander", "Yosef"]
76
  TEST_NAMES = ["Ari", "Bellamy", "Crew", "Dallas", "East", "Flynn", "Greer", "Hollis",
77
  "Ira", "Jules", "Kit", "Lennon", "Morgan", "Nico", "Onyx", "Perry",
78
  "Quince", "Rory", "Sailor", "Tao", "Uri", "Vesper", "Wilder", "Yael"]
 
91
  "Ah, interesting.", "Makes sense.", "Oh wow, okay.", "Sounds like a plan.",
92
  "Noted, thanks for telling me."]
93
 
94
+ Q_TEMPLATES = [
95
+ "Do you remember what my {h} is?",
96
+ "Quick quiz: what's my {h} again?",
97
+ "Remind me — what's my current {h}?",
98
+ "What about my {h}? Do you have that on record?",
99
+ ]
 
 
100
 
101
+ ANSWER_NEGATED = "Not anymore — you mentioned that's no longer the case."
102
+ ANSWER_UNKNOWN = "I don't know — you haven't told me about that yet."
103
+ ANSWER_TEMPORAL = "Back then it was {v}, though it's changed since."
 
 
 
104
 
105
 
106
+ def spec_of(pred: str) -> Dict[str, Any]:
107
+ return next(p for p in PREDICATES if p["p"] == pred)
 
 
 
 
 
 
 
108
 
109
 
110
+ def find_fact(persona: Dict[str, Any], pred: str) -> Optional[Dict[str, Any]]:
111
+ for f in persona["facts"]:
112
+ if f["pred"] == pred:
113
+ return f
114
+ return None
115
+
116
 
117
  def make_persona(rng: random.Random, name: str, n_init: int = 8) -> Dict[str, Any]:
118
  preds = rng.sample(PREDICATES, n_init)
 
120
  for spec in preds:
121
  facts.append({
122
  "pred": spec["p"], "value": rng.choice(spec["v"]),
123
+ "born_session": 0, "state": "active", "history": [],
 
124
  })
125
  return {"name": name, "facts": facts}
126
 
127
 
128
+ # ---------------------------------------------------------------- sessions
 
 
 
 
 
 
 
 
 
129
 
130
  def render_session(
131
  rng: random.Random,
132
  persona: Dict[str, Any],
133
  session_idx: int,
134
  n_add: int, n_upd: int, n_tmb: int, n_q: int,
135
+ ) -> Tuple[List[Dict[str, str]], List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, str]]]:
136
+ """Render one session of the evolving dialogue.
137
 
138
+ Returns (turns, write_events, qas, chatter_turns_before_qa).
139
+ write_events: [{"kind": "ADD"|"UPDATE"|"TOMBSTONE", "pred":..., "value":...}]
140
+ qas: [{"q":..., "a":..., "type":..., "pred":..., "value":...}]
141
  """
142
  name = persona["name"]
143
+ turns: List[Dict[str, str]] = []
144
  events: List[Dict[str, Any]] = []
145
 
146
+ def say(user: str, assistant: Optional[str] = None) -> None:
147
+ turns.append({"role": "user", "content": user})
148
+ if assistant is None:
149
+ turns.append({"role": "assistant", "content": rng.choice(ACKS)})
150
+ else:
151
+ turns.append({"role": "assistant", "content": assistant})
152
 
153
+ # ---- write events, in randomized order
154
+ events: List[Dict[str, Any]] = []
 
 
155
 
156
  for _ in range(n_add):
157
+ open_specs = [p for p in PREDICATES if find_fact(persona, p["p"]) is None]
158
+ if not open_specs:
159
+ break
160
+ spec = rng.choice(open_specs)
161
+ value = rng.choice(spec["v"])
162
+ events.append({"kind": "ADD", "pred": spec["p"], "value": value,
163
+ "text": spec["add"].replace("{v}", value)})
164
+
165
+ for _ in range(n_upd):
166
+ updatable = [f for f in persona["facts"]
167
+ if f["state"] == "active" and f["born_session"] < session_idx]
168
+ if not updatable:
169
+ break
170
+ f = rng.choice(updatable)
171
+ spec = spec_of(f["pred"])
172
+ value = rng.choice([v for v in spec["v"] if v != f["value"]])
173
+ events.append({"kind": "UPDATE", "pred": f["pred"], "value": value,
174
+ "text": spec["upd"].replace("{v}", value)})
175
+
176
+ for _ in range(n_tmb):
177
+ tmbable = [f for f in persona["facts"] if f["state"] == "active"]
178
+ if not tmbable:
179
+ break
180
+ f = rng.choice(tmbable)
181
+ spec = spec_of(f["pred"])
182
+ events.append({"kind": "TOMBSTONE", "pred": f["pred"], "value": None,
183
+ "text": spec["tmb"]})
184
+
185
+ rng.shuffle(events)
186
+
187
+ # ---- interleave chatter
188
+ n_chatter = rng.randint(1, 3)
189
+
190
+ # ---- questions (asked BEFORE consolidation, answered from ledger@t-1)
191
+ qas: List[Dict[str, Any]] = []
192
+ q_specs: List[str] = rng.sample(PREDICATES, min(n_q + 2, len(PREDICATES)))
193
+ for spec in q_specs[:n_q]:
194
+ f = find_fact(persona, spec["p"])
195
+ q = rng.choice(Q_TEMPLATES).replace("{h}", spec["h"])
196
+ if f is None:
197
+ qas.append({"q": q, "a": ANSWER_UNKNOWN, "type": "unknown",
198
+ "pred": spec["p"], "value": None})
199
+ elif f["state"] == "tombstoned":
200
+ # temporal question if old value is known, else negated
201
+ old = f["history"][-1]["value"] if f["history"] else None
202
+ if old and rng.random() < 0.5:
203
+ q = "What was my " + spec["h"] + " back then, before it changed?"
204
+ qas.append({"q": q, "a": ANSWER_TEMPORAL.replace("{v}", old),
205
+ "type": "temporal", "pred": spec["p"], "value": old})
206
+ else:
207
+ qas.append({"q": q, "a": ANSWER_NEGATED, "type": "negated",
208
+ "pred": spec["p"], "value": None})
209
+ elif f["state"] == "updated":
210
+ qas.append({"q": q, "a": f["value"], "type": "superseded",
211
+ "pred": spec["p"], "value": f["value"]})
212
+ else:
213
+ qas.append({"q": q, "a": f["value"], "type": "stale",
214
+ "pred": spec["p"], "value": f["value"]})
215
+
216
+ # ---- render: statements + chatter, then Q&A block at the end
217
+ statement_turns: List[Dict[str, str]] = []
218
+ for ev in events:
219
+ statement_turns.append({"role": "user", "content": ev["text"]})
220
+ statement_turns.append({"role": "assistant", "content": rng.choice(ACKS)})
221
+ if rng.random() < 0.35 and n_chatter > 0:
222
+ statement_turns.append({"role": "user", "content": rng.choice(CHATTER)})
223
+ statement_turns.append({"role": "assistant", "content": rng.choice(ACKS)})
224
+ n_chatter -= 1
225
+
226
+ qa_turns: List[Dict[str, str]] = []
227
+ for qa in qas:
228
+ qa_turns.append({"role": "user", "content": qa["q"]})
229
+ qa_turns.append({"role": "assistant", "content": qa["a"]})
230
+
231
+ turns = statement_turns + qa_turns
232
+ return turns, events, qas, statement_turns
233
+
234
+
235
+ def apply_event_to_persona(persona: Dict[str, Any], ev: Dict[str, Any],
236
+ session_idx: int) -> None:
237
+ f = find_fact(persona, ev["pred"])
238
+ if ev["kind"] == "ADD":
239
+ persona["facts"].append({"pred": ev["pred"], "value": ev["value"],
240
+ "born_session": session_idx, "state": "active",
241
+ "history": []})
242
+ elif ev["kind"] == "UPDATE":
243
+ f["history"].append({"value": f["value"], "until_session": session_idx})
244
+ f["value"] = ev["value"]
245
+ f["state"] = "updated"
246
+ elif ev["kind"] == "TOMBSTONE":
247
+ f["state"] = "tombstoned"
248
+
249
+
250
+ # ---------------------------------------------------------------- timeline
251
+
252
+ def gold_ops_for_events(
253
+ ledger: List[Slot], events: List[Dict[str, Any]],
254
+ session_idx: int, budget: int, persona: Dict[str, Any],
255
+ ) -> List[Dict[str, Any]]:
256
+ """Translate the session's write events into a gold edit program, then
257
+ apply budget pressure with the salience oracle. Slot ids are resolved by
258
+ looking at the ledger the reducer will actually see."""
259
+ ops: List[Dict[str, Any]] = []
260
+ ledger, _ = apply_ops(ledger, [], session_idx) # no-op copy
261
+ for ev in events:
262
+ if ev["kind"] == "ADD":
263
+ ops.append({"op": "ADD", "s": persona["name"],
264
+ "p": ev["pred"], "o": ev["value"]})
265
+ elif ev["kind"] == "UPDATE":
266
+ slot_id = _slot_id_for(ledger, persona["name"], ev["pred"],
267
+ ev["value"], ops)
268
+ ops.append({"op": "UPDATE", "i": slot_id, "s": persona["name"],
269
+ "p": ev["pred"], "o": ev["value"]})
270
+ elif ev["kind"] == "TOMBSTONE":
271
+ slot_id = _slot_id_for(ledger, persona["name"], ev["pred"], None, ops)
272
+ ops.append({"op": "TOMBSTONE", "i": slot_id})
273
+ ledger, _ = apply_ops(ledger, [ops[-1]], session_idx)
274
+ ledger, evict_ops = enforce_budget(ledger, [], session_idx)
275
+ ops.extend(evict_ops)
276
+ return ops
277
+
278
+
279
+ def _slot_id_for(ledger: List[Slot], subject: str, pred: str,
280
+ new_value: Optional[str], pending_ops: List[Dict[str, Any]]) -> int:
281
+ """Find the slot id that (after pending ops so far) holds (subject, pred)."""
282
+ sim = [Slot(**vars(s)) for s in ledger]
283
+ sim, _ = apply_ops(sim, pending_ops, -1)
284
+ for s in sim:
285
+ if s.occupied and s.subject == subject and s.predicate == pred:
286
+ return s.id
287
+ raise KeyError(f"no slot for ({subject}, {pred})")
288
+
289
+
290
+ def generate_persona_timeline(
291
+ rng: random.Random, name: str, n_sessions: int, budget: int,
292
+ ) -> Dict[str, Any]:
293
+ persona = make_persona(rng, name)
294
+ ledger = empty_ledger(budget)
295
+ sessions = []
296
+
297
+ # session 0 introduces the initial facts as ADD events
298
+ intro_events = [{"kind": "ADD", "pred": f["pred"], "value": f["value"],
299
+ "text": spec_of(f["pred"])["add"].replace("{v}", f["value"])}
300
+ for f in persona["facts"]]
301
+
302
+ for s_idx in range(n_sessions):
303
+ if s_idx == 0:
304
+ events, n_add, n_upd, n_tmb = intro_events, 0, 0, 0
305
+ else:
306
+ n_add = rng.randint(1, 2)
307
+ n_upd = rng.randint(0, 2)
308
+ n_tmb = rng.randint(0, 1)
309
+ turns, events, qas, _ = render_session(
310
+ rng, persona, s_idx, n_add, n_upd, n_tmb, rng.randint(1, 3))
311
+ for ev in events:
312
+ apply_event_to_persona(persona, ev, s_idx)
313
+
314
+ # gold consolidation program + resulting ledger
315
+ if s_idx == 0:
316
+ turns = []
317
+ for ev in intro_events:
318
+ turns.append({"role": "user", "content": ev["text"]})
319
+ turns.append({"role": "assistant", "content": rng.choice(ACKS)})
320
+ ops = gold_ops_for_events(ledger, events, s_idx, budget, persona)
321
+ ledger, _ = apply_ops(ledger, ops, s_idx)
322
+ ledger, _ = enforce_budget(ledger, [], s_idx)
323
+
324
+ # increment n_uses on slots that were queried this session
325
+ for qa in (sessions[-1]["qas"] if s_idx > 0 and sessions else []):
326
  pass
327
+
328
+ sessions.append({
329
+ "session_idx": s_idx,
330
+ "turns": turns,
331
+ "events": events,
332
+ "qas": qas if s_idx > 0 else [],
333
+ "gold_ops": ops,
334
+ "ledger_before": serialize_ledger(ledger) if False else None,
335
+ })
336
+
337
+ # second pass to store ledger states cleanly
338
+ ledger = empty_ledger(budget)
339
+ for sess in sessions:
340
+ sess["ledger_before"] = serialize_ledger(ledger)
341
+ ledger, _ = apply_ops(ledger, sess["gold_ops"], sess["session_idx"])
342
+ ledger, _ = enforce_budget(ledger, [], sess["session_idx"])
343
+ sess["ledger_after"] = serialize_ledger(ledger)
344
+
345
+ # count question hits for salience bookkeeping (documented oracle signal)
346
+ for sess in sessions:
347
+ for qa in sess["qas"]:
348
+ pred = qa["pred"]
349
+ for sl in ledger:
350
+ if sl.occupied and sl.predicate == pred:
351
+ sl.n_uses += 1
352
+
353
+ return {"name": name, "sessions": sessions,
354
+ "final_ledger": serialize_ledger(ledger)}
355
+
356
+
357
+ def main() -> None:
358
+ ap = argparse.ArgumentParser(description=__doc__)
359
+ ap.add_argument("--personas", type=int, default=100)
360
+ ap.add_argument("--sessions", type=int, default=8)
361
+ ap.add_argument("--budget", type=int, default=LEDGER_BUDGET_DEFAULT)
362
+ ap.add_argument("--seed", type=int, default=137)
363
+ ap.add_argument("--split", choices=["train", "test"], default="train")
364
+ ap.add_argument("--out", type=str, required=True)
365
+ args = ap.parse_args()
366
+
367
+ rng = random.Random(args.seed)
368
+ names = TRAIN_NAMES if args.split == "train" else TEST_NAMES
369
+ written = 0
370
+ with open(args.out, "w") as f:
371
+ for i in range(args.personas):
372
+ name = names[i % len(names)] + ("" if i < len(names)
373
+ else f" {i // len(names)}")
374
+ tl = generate_persona_timeline(rng, name, args.sessions, args.budget)
375
+ f.write(json.dumps(tl, ensure_ascii=False) + "\n")
376
+ written += 1
377
+ print(f"wrote {written} personas to {args.out}")
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()