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

CLERK generator: correct single-pass timeline builder, ledger-aware question targets, eviction-safe gold ops

Browse files
Files changed (1) hide show
  1. clerk/generator.py +147 -126
clerk/generator.py CHANGED
@@ -6,8 +6,16 @@ programmatically, the gold ledger state after every session is known by
6
  construction, which yields exact supervision for the consolidation policy —
7
  no LLM judge anywhere in the pipeline.
8
 
9
- Everything is seeded: two runs with the same --seed produce byte-identical
10
- data.
 
 
 
 
 
 
 
 
11
 
12
  Author: Justin Wolcott (fallnai-research.org)
13
  License: Apache-2.0
@@ -26,10 +34,13 @@ from clerk.common import (
26
  apply_ops,
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"],
@@ -97,10 +108,14 @@ Q_TEMPLATES = [
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]:
@@ -130,27 +145,27 @@ def make_persona(rng: random.Random, name: str, n_init: int = 8) -> Dict[str, An
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):
@@ -164,7 +179,7 @@ def render_session(
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)
@@ -184,178 +199,184 @@ def render_session(
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)
@@ -366,15 +387,15 @@ def main() -> None:
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__":
 
6
  construction, which yields exact supervision for the consolidation policy —
7
  no LLM judge anywhere in the pipeline.
8
 
9
+ Invariants the code maintains:
10
+ * Questions are only asked about predicates the ledger can actually
11
+ support: never-stated (unknown), currently-valid (stale/superseded),
12
+ or currently-tombstoned (negated/temporal). Active-but-EVICTED
13
+ predicates are never queried, so supervision never asks the model to
14
+ recall what the oracle dropped.
15
+ * n_uses (the salience signal) is tracked incrementally at the moment a
16
+ question is asked, so the eviction oracle at session t only sees
17
+ evidence available up to t.
18
+ * Everything is seeded: the same --seed yields byte-identical data.
19
 
20
  Author: Justin Wolcott (fallnai-research.org)
21
  License: Apache-2.0
 
34
  apply_ops,
35
  empty_ledger,
36
  enforce_budget,
37
+ parse_ledger,
38
  serialize_ledger,
39
  )
40
 
41
  # ---------------------------------------------------------------- predicates
42
+ # p: ledger key | h: human phrase | v: value pool
43
+ # add/upd/tmb: statement templates. {v} = new value. {v_old} = previous value.
44
 
45
  PREDICATES: List[Dict[str, Any]] = [
46
  {"p": "job", "h": "job", "v": ["barista", "chef", "nurse", "high school teacher", "librarian", "electrician", "accountant", "software engineer", "pharmacist", "bus driver"],
 
108
  "Remind me — what's my current {h}?",
109
  "What about my {h}? Do you have that on record?",
110
  ]
111
+ Q_TEMPLATES_TEMPORAL = [
112
+ "What was my {h} back then, before it changed?",
113
+ "What did my {h} used to be?",
114
+ ]
115
 
116
  ANSWER_NEGATED = "Not anymore — you mentioned that's no longer the case."
117
  ANSWER_UNKNOWN = "I don't know — you haven't told me about that yet."
118
+ ANSWER_TEMPORAL = "Back then it was {v}, though it has changed since."
119
 
120
 
121
  def spec_of(pred: str) -> Dict[str, Any]:
 
145
  def render_session(
146
  rng: random.Random,
147
  persona: Dict[str, Any],
148
+ ledger_before: List[Slot],
149
  n_add: int, n_upd: int, n_tmb: int, n_q: int,
150
+ ) -> Tuple[List[Dict[str, str]], List[Dict[str, Any]], List[Dict[str, Any]]]:
151
+ """Render one session. Returns (turns, write_events, qas).
152
+
153
+ Question targets are constrained by the ledger the assistant will read
154
+ from (ledger_before, i.e. end of the previous session):
155
+ predicate absent from persona facts ....... unknown
156
+ valid slot in ledger ....................... stale / superseded
157
+ tombstoned slot in ledger .................. negated / temporal
158
+ active in persona but EVICTED from ledger .. never asked
159
  """
 
160
  turns: List[Dict[str, str]] = []
161
+ qas: List[Dict[str, Any]] = []
162
 
163
  def say(user: str, assistant: Optional[str] = None) -> None:
164
  turns.append({"role": "user", "content": user})
165
+ turns.append({"role": "assistant",
166
+ "content": rng.choice(ACKS) if assistant is None else assistant})
 
 
167
 
168
+ # ---- events (chosen against persona state at session start)
169
  events: List[Dict[str, Any]] = []
170
 
171
  for _ in range(n_add):
 
179
 
180
  for _ in range(n_upd):
181
  updatable = [f for f in persona["facts"]
182
+ if f["state"] == "active" and f["born_session"] < _session_being_rendered]
183
  if not updatable:
184
  break
185
  f = rng.choice(updatable)
 
199
 
200
  rng.shuffle(events)
201
 
202
+ # ---- questions, asked before consolidation
203
+ led_state: Dict[str, Tuple[bool, bool]] = {} # pred -> (in_persona, in_ledger_valid)
204
+ for s in ledger_before:
205
+ if s.occupied:
206
+ led_state[s.predicate] = (True, s.valid)
207
+ for f in persona["facts"]:
208
+ if f["pred"] not in led_state:
209
+ # active but never consolidated (session 0) or evicted
210
+ led_state[f["pred"]] = (True, f["state"] != "tombstoned")
211
+
212
+ askable = [p for p in PREDICATES
213
+ if p["p"] not in led_state # unknown questions
214
+ or led_state[p["p"]][1] is True # stale/superseded
215
+ or not led_state[p["p"]][1]] # negated/temporal
216
+ rng.shuffle(askable)
217
+ for spec in askable[:n_q]:
218
  f = find_fact(persona, spec["p"])
219
  q = rng.choice(Q_TEMPLATES).replace("{h}", spec["h"])
220
  if f is None:
221
  qas.append({"q": q, "a": ANSWER_UNKNOWN, "type": "unknown",
222
  "pred": spec["p"], "value": None})
223
  elif f["state"] == "tombstoned":
 
224
  old = f["history"][-1]["value"] if f["history"] else None
225
  if old and rng.random() < 0.5:
226
+ q2 = rng.choice(Q_TEMPLATES_TEMPORAL).replace("{h}", spec["h"])
227
+ qas.append({"q": q2, "a": ANSWER_TEMPORAL.replace("{v}", old),
228
  "type": "temporal", "pred": spec["p"], "value": old})
229
  else:
230
  qas.append({"q": q, "a": ANSWER_NEGATED, "type": "negated",
231
  "pred": spec["p"], "value": None})
 
 
 
232
  else:
233
+ qas.append({"q": q, "a": f["value"], "type": "superseded",
234
  "pred": spec["p"], "value": f["value"]})
235
 
236
+ # ---- render turns: statements (+ chatter), then the Q&A block
237
+ n_chatter = rng.randint(1, 3)
238
  for ev in events:
239
+ say(ev["text"])
 
240
  if rng.random() < 0.35 and n_chatter > 0:
241
+ say(rng.choice(CHATTER))
 
242
  n_chatter -= 1
 
 
243
  for qa in qas:
244
+ say(qa["q"], qa["a"])
 
245
 
246
+ return turns, events, qas
247
+
248
+
249
+ _session_being_rendered: int = 0
250
 
251
 
252
  def apply_event_to_persona(persona: Dict[str, Any], ev: Dict[str, Any],
253
  session_idx: int) -> None:
 
254
  if ev["kind"] == "ADD":
255
  persona["facts"].append({"pred": ev["pred"], "value": ev["value"],
256
  "born_session": session_idx, "state": "active",
257
  "history": []})
258
  elif ev["kind"] == "UPDATE":
259
+ f = find_fact(persona, ev["pred"])
260
  f["history"].append({"value": f["value"], "until_session": session_idx})
261
  f["value"] = ev["value"]
262
  f["state"] = "updated"
263
  elif ev["kind"] == "TOMBSTONE":
264
+ f = find_fact(persona, ev["pred"])
265
  f["state"] = "tombstoned"
266
 
267
 
268
+ # ------------------------------------------------------- gold op programs
269
+
270
+ def _slot_id_for(ledger: List[Slot], pending: List[Dict[str, Any]],
271
+ subject: str, pred: str) -> Optional[int]:
272
+ """Slot id holding (subject, pred) after `ledger` + `pending` ops."""
273
+ sim = [Slot(**vars(s)) for s in ledger]
274
+ sim, _ = apply_ops(sim, pending, -1)
275
+ for s in sim:
276
+ if s.occupied and s.subject == subject and s.predicate == pred:
277
+ return s.id
278
+ return None
279
+
280
 
281
  def gold_ops_for_events(
282
+ ledger: List[Slot],
283
+ persona_name: str,
284
+ events: List[Dict[str, Any]],
285
+ session_idx: int,
286
+ ) -> Tuple[List[Dict[str, Any]], List[Slot]]:
287
+ """Build the gold edit program for one session's events, in transcript
288
+ order, applying budget pressure with the salience oracle afterwards.
289
+
290
+ Eviction-safe: if a predicate's slot was already evicted, an UPDATE
291
+ degrades to an ADD (the fact must be re-remembered) and a TOMBSTONE of
292
+ an absent slot is skipped."""
293
  ops: List[Dict[str, Any]] = []
294
+ ledger_now = [Slot(**vars(s)) for s in ledger]
295
  for ev in events:
296
  if ev["kind"] == "ADD":
297
+ op = {"op": "ADD", "s": persona_name, "p": ev["pred"], "o": ev["value"]}
 
298
  elif ev["kind"] == "UPDATE":
299
+ sid = _slot_id_for(ledger_now, ops, persona_name, ev["pred"])
300
+ if sid is None:
301
+ op = {"op": "ADD", "s": persona_name, "p": ev["pred"], "o": ev["value"]}
302
+ else:
303
+ op = {"op": "UPDATE", "i": sid, "s": persona_name,
304
+ "p": ev["pred"], "o": ev["value"]}
305
+ else: # TOMBSTONE
306
+ sid = _slot_id_for(ledger_now, ops, persona_name, ev["pred"])
307
+ if sid is None:
308
+ continue
309
+ op = {"op": "TOMBSTONE", "i": sid}
310
+ ops.append(op)
311
+ ledger_now, _ = apply_ops(ledger_now, [op], session_idx)
312
+
313
+ # budget pressure: evict lowest-salience occupied slots until it fits
314
+ occupied = [s for s in ledger_now if s.occupied]
315
+ for _ in range(max(0, len(occupied) - len(ledger_now))):
316
+ from clerk.common import salience
317
+ victim = min((s for s in ledger_now if s.occupied),
318
+ key=lambda s: salience(s, session_idx))
319
+ ops.append({"op": "EVICT", "i": victim.id})
320
+ ledger_now[victim.id] = Slot(id=victim.id)
321
+ return ops, ledger_now
322
 
323
 
324
+ # ---------------------------------------------------------------- timeline
325
+
326
  def generate_persona_timeline(
327
  rng: random.Random, name: str, n_sessions: int, budget: int,
328
  ) -> Dict[str, Any]:
329
  persona = make_persona(rng, name)
330
  ledger = empty_ledger(budget)
331
+ sessions: List[Dict[str, Any]] = []
 
 
 
 
 
332
 
333
  for s_idx in range(n_sessions):
334
+ global _session_being_rendered
335
+ _session_being_rendered = s_idx
 
 
 
 
 
 
 
 
336
 
 
337
  if s_idx == 0:
338
+ # intro session: persona states the initial facts
339
+ events = [{"kind": "ADD", "pred": f["pred"], "value": f["value"],
340
+ "text": spec_of(f["pred"])["add"].replace("{v}", f["value"])}
341
+ for f in persona["facts"]]
342
+ rng.shuffle(events)
343
+ turns: List[Dict[str, str]] = []
344
+ for ev in events:
345
  turns.append({"role": "user", "content": ev["text"]})
346
  turns.append({"role": "assistant", "content": rng.choice(ACKS)})
347
+ qas: List[Dict[str, Any]] = []
348
+ else:
349
+ turns, events, qas = render_session(
350
+ rng, persona, ledger,
351
+ rng.randint(1, 2), rng.randint(0, 2), rng.randint(0, 1),
352
+ rng.randint(1, 3))
353
+ for ev in events:
354
+ apply_event_to_persona(persona, ev, s_idx)
355
+ for qa in qas:
356
+ for sl in ledger:
357
+ if sl.occupied and sl.predicate == qa["pred"]:
358
+ sl.n_uses += 1
359
 
360
+ ledger_before = serialize_ledger(ledger)
361
+ ops, ledger_after = gold_ops_for_events(ledger, name, events, s_idx)
362
+ ledger = ledger_after
363
 
364
  sessions.append({
365
  "session_idx": s_idx,
366
  "turns": turns,
367
  "events": events,
368
+ "qas": qas,
369
  "gold_ops": ops,
370
+ "ledger_before": ledger_before,
371
+ "ledger_after": serialize_ledger(ledger),
372
  })
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  return {"name": name, "sessions": sessions,
375
  "final_ledger": serialize_ledger(ledger)}
376
 
377
 
378
  def main() -> None:
379
+ ap = argparse.ArgumentParser(description="CLERK synthetic session generator")
380
  ap.add_argument("--personas", type=int, default=100)
381
  ap.add_argument("--sessions", type=int, default=8)
382
  ap.add_argument("--budget", type=int, default=LEDGER_BUDGET_DEFAULT)
 
387
 
388
  rng = random.Random(args.seed)
389
  names = TRAIN_NAMES if args.split == "train" else TEST_NAMES
390
+ n = 0
391
  with open(args.out, "w") as f:
392
  for i in range(args.personas):
393
+ suffix = "" if i < len(names) else f" {i // len(names)}"
394
+ name = names[i % len(names)] + suffix
395
  tl = generate_persona_timeline(rng, name, args.sessions, args.budget)
396
  f.write(json.dumps(tl, ensure_ascii=False) + "\n")
397
+ n += 1
398
+ print(f"wrote {n} personas ({args.split}) to {args.out}")
399
 
400
 
401
  if __name__ == "__main__":