Instructions to use Falln87/clerk-memory with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use Falln87/clerk-memory with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
CLERK generator: correct single-pass timeline builder, ledger-aware question targets, eviction-safe gold ops
Browse files- 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 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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]]
|
| 136 |
-
"""Render one session
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
"""
|
| 142 |
-
name = persona["name"]
|
| 143 |
turns: List[Dict[str, str]] = []
|
| 144 |
-
|
| 145 |
|
| 146 |
def say(user: str, assistant: Optional[str] = None) -> None:
|
| 147 |
turns.append({"role": "user", "content": user})
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
else:
|
| 151 |
-
turns.append({"role": "assistant", "content": assistant})
|
| 152 |
|
| 153 |
-
# ----
|
| 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"] <
|
| 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 |
-
# ----
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 204 |
-
qas.append({"q":
|
| 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": "
|
| 214 |
"pred": spec["p"], "value": f["value"]})
|
| 215 |
|
| 216 |
-
# ---- render: statements + chatter, then Q&A block
|
| 217 |
-
|
| 218 |
for ev in events:
|
| 219 |
-
|
| 220 |
-
statement_turns.append({"role": "assistant", "content": rng.choice(ACKS)})
|
| 221 |
if rng.random() < 0.35 and n_chatter > 0:
|
| 222 |
-
|
| 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 |
-
|
| 229 |
-
qa_turns.append({"role": "assistant", "content": qa["a"]})
|
| 230 |
|
| 231 |
-
turns
|
| 232 |
-
|
|
|
|
|
|
|
| 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 |
-
# -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
def gold_ops_for_events(
|
| 253 |
-
ledger: List[Slot],
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
ops: List[Dict[str, Any]] = []
|
| 260 |
-
|
| 261 |
for ev in events:
|
| 262 |
if ev["kind"] == "ADD":
|
| 263 |
-
|
| 264 |
-
"p": ev["pred"], "o": ev["value"]})
|
| 265 |
elif ev["kind"] == "UPDATE":
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
| 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 |
-
|
| 304 |
-
|
| 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 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
turns.append({"role": "user", "content": ev["text"]})
|
| 319 |
turns.append({"role": "assistant", "content": rng.choice(ACKS)})
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
|
| 328 |
sessions.append({
|
| 329 |
"session_idx": s_idx,
|
| 330 |
"turns": turns,
|
| 331 |
"events": events,
|
| 332 |
-
"qas": qas
|
| 333 |
"gold_ops": ops,
|
| 334 |
-
"ledger_before":
|
|
|
|
| 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=
|
| 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 |
-
|
| 370 |
with open(args.out, "w") as f:
|
| 371 |
for i in range(args.personas):
|
| 372 |
-
|
| 373 |
-
|
| 374 |
tl = generate_persona_timeline(rng, name, args.sessions, args.budget)
|
| 375 |
f.write(json.dumps(tl, ensure_ascii=False) + "\n")
|
| 376 |
-
|
| 377 |
-
print(f"wrote {
|
| 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__":
|