Falln87 commited on
Commit
193e1ae
·
verified ·
1 Parent(s): 6380390

CLERK synthetic eval: clerk rollout + prompted-ledger/full-context/sliding-window/RAG baselines, per-type accuracy, memory-state metrics, token costs

Browse files
Files changed (1) hide show
  1. clerk/eval_synthetic.py +293 -0
clerk/eval_synthetic.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLERK synthetic-benchmark evaluation.
2
+
3
+ Runs the held-out evolving-session benchmark and reports, per system:
4
+
5
+ * answer accuracy by question type (stale / superseded / negated /
6
+ temporal / unknown)
7
+ * supersession error rate: on superseded questions, how often the system
8
+ answers with the STALE value (the old fact still present in history)
9
+ * token cost per question (memory amortization)
10
+ * for write-time systems: JSON validity rate and ledger fact
11
+ precision/recall against the gold memory state
12
+
13
+ Systems:
14
+ clerk -- trained adapter; ledger-only read context
15
+ prompted_ledger -- SAME base model, same prompts, no training
16
+ (isolates the effect of training the policy)
17
+ full -- base model, entire transcript as context
18
+ window -- base model, last --window sessions
19
+ rag -- base model, top-k retrieved transcript turns
20
+
21
+ Usage:
22
+ python -m clerk.eval_synthetic --timelines test.jsonl --adapter \
23
+ Falln87/clerk-qwen2.5-0.5b-lora --systems clerk,prompted_ledger,full,window,rag \
24
+ --out results_synthetic.json
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import time
32
+ from typing import Any, Dict, List, Optional, Tuple
33
+
34
+ import torch
35
+ from peft import PeftModel
36
+ from transformers import AutoModelForCausalLM, AutoTokenizer
37
+
38
+ from clerk.common import empty_ledger, parse_ledger, parse_ops, apply_ops
39
+ from clerk.prompts import (
40
+ ANSWER_SYSTEM,
41
+ ANSWER_USER_TMPL,
42
+ CONSOLIDATE_SYSTEM,
43
+ CONSOLIDATE_USER_TMPL,
44
+ render_transcript,
45
+ )
46
+
47
+ BASE_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
48
+
49
+ NEG_MARKERS = ["no longer", "not anymore", "don't have", "do not have",
50
+ "passed away", "single again", "sold", "broke up",
51
+ "cancelled", "canceled", "withdrew", "stopped", "left"]
52
+ UNK_MARKERS = ["don't know", "do not know", "haven't told", "have not told",
53
+ "not sure", "no record", "haven't mentioned", "have not"]
54
+
55
+ WINDOW_SESSIONS = 2
56
+ RAG_K = 5
57
+
58
+
59
+ def norm(s: str) -> str:
60
+ import re
61
+ s = s.lower().strip()
62
+ s = re.sub(r"^(a|an|the)\s+", "", s)
63
+ s = re.sub(r"[^a-z0-9 ]", "", s)
64
+ return re.sub(r"\s+", " ", s).strip()
65
+
66
+
67
+ # ------------------------------------------------------------------ model
68
+
69
+ def load_model(adapter: Optional[str], base: str):
70
+ tok = AutoTokenizer.from_pretrained(base)
71
+ model = AutoModelForCausalLM.from_pretrained(
72
+ base, dtype=torch.bfloat16, device_map="auto",
73
+ attn_implementation="sdpa")
74
+ if adapter:
75
+ model = PeftModel.from_pretrained(model, adapter)
76
+ model.eval()
77
+ return model, tok
78
+
79
+
80
+ @torch.no_grad()
81
+ def generate(model, tok, system: str, user: str, max_new_tokens: int) -> Tuple[str, int]:
82
+ msgs = [{"role": "system", "content": system},
83
+ {"role": "user", "content": user}]
84
+ text = tok.apply_chat_template(msgs, add_generation_prompt=True,
85
+ tokenize=False)
86
+ inputs = tok([text], return_tensors="pt").to(model.device)
87
+ n_prompt = inputs.input_ids.shape[1]
88
+ out = model.generate(**inputs, max_new_tokens=max_new_tokens,
89
+ do_sample=False, pad_token_id=tok.eos_token_id)
90
+ completion = tok.decode(out[0][n_prompt_tokens:], skip_special_tokens=True)
91
+ return completion_text, n_prompt_tokens + (out.shape[1] - n_prompt_tokens)
92
+
93
+
94
+ # ------------------------------------------------------------ answering
95
+
96
+ def check_answer(qtype: str, gold: Dict[str, Any], response: str) -> bool:
97
+ r = response.lower()
98
+ if qtype in ("stale", "superseded"):
99
+ return norm(gold["value"]) in norm(response)
100
+ if qtype == "temporal":
101
+ return norm(gold["value"]) in norm(response)
102
+ if qtype == "negated":
103
+ return any(m in r for m in NEG_MARKERS)
104
+ if qtype == "unknown":
105
+ return any(m in r for m in UNK_MARKERS)
106
+ raise ValueError(qtype)
107
+
108
+
109
+ def answer_with_ledger(model, tok, ledger_json: str, question: str) -> Tuple[str, int]:
110
+ user = ANSWER_USER_TMPL.format(ledger=ledger_json, question=question)
111
+ resp, n = generate(model, tok, ANSWER_SYSTEM, user, max_new_tokens=48)
112
+ return resp, n
113
+
114
+
115
+ def answer_with_transcript(model, tok, transcript: str, question: str) -> Tuple[str, int]:
116
+ user = (f"TRANSCRIPT SO FAR:\n{transcript}\n\nQuestion: {question}\n\n"
117
+ "Answer in one short sentence using only the transcript.")
118
+ resp, n = generate(model, tok, ANSWER_SYSTEM, user, max_new_tokens=48)
119
+ return resp, n
120
+
121
+
122
+ # ------------------------------------------------------- memory builders
123
+
124
+ def consolidate(model, tok, ledger_json: str, idx: int, transcript: str):
125
+ user = CONSOLIDATE_USER_TMPL.format(ledger=ledger_json, idx=idx,
126
+ transcript=transcript)
127
+ resp, n = generate(model, tok, CONSOLIDATE_SYSTEM, user,
128
+ max_new_tokens=256)
129
+ ops, err = parse_ops(resp)
130
+ return ops, err, resp, n
131
+
132
+
133
+ # ------------------------------------------------------------- systems
134
+
135
+ class Runner:
136
+ """Runs one system over a set of personas, accumulating metrics."""
137
+
138
+ def __init__(self, system: str, model, tok, budget: int,
139
+ window: int = WINDOW_SESSIONS, rag_k: int = RAG_K):
140
+ self.system = system
141
+ self.model = model
142
+ self.tok = tok
143
+ self.budget = budget
144
+ self.window = window
145
+ self.rag_k = rag_k
146
+ self.metrics: Dict[str, Any] = {}
147
+ self.per_type: Dict[str, Dict[str, int]] = {}
148
+ self.stale_errors = {"superseded": 0, "asked": 0}
149
+ self.tok_prompt = 0
150
+ self.tok_completion = 0
151
+ self.n_answers = 0
152
+ self.consolidation = {"n": 0, "parse_ok": 0, "dropped": 0,
153
+ "ops": 0}
154
+ self.fact_tp = self.fact_pred = self.fact_gold = 0
155
+
156
+ def _record(self, qtype: str, correct: bool, stale: bool) -> None:
157
+ d = self.per_type.setdefault(qtype, {"n": 0, "correct": 0})
158
+ d["n"] += 1
159
+ if correct:
160
+ d["correct"] += 1
161
+ if qtype == "superseded":
162
+ self.stale_errors["asked"] += 1
163
+ if stale:
164
+ self.stale_errors["superseded"] += 1
165
+
166
+ def fact_scores(self, rollout_ledger_json: str, gold_ledger_json: str) -> None:
167
+ rl = {(s.subject, s.predicate): s.object
168
+ for s in parse_ledger(rollout_ledger_json, self.budget)
169
+ if s.occupied and s.valid}
170
+ gl = {(s.subject, s.predicate): s.object
171
+ for s in parse_ledger(gold_ledger_json, self.budget)
172
+ if s.occupied and s.valid}
173
+ self.fact_tp += len(set(rl.items()) & set(gl.items()))
174
+ self.fact_pred += len(rl)
175
+ self.fact_gold += len(gl)
176
+
177
+ def run(self, persona: Dict[str, Any]) -> None:
178
+ name = persona["name"]
179
+ sessions = persona["sessions"]
180
+ if self.system in ("clerk", "prompted_ledger"):
181
+ ledger = empty_ledger(self.budget)
182
+ ledger_json = None
183
+ history_parts: List[str] = []
184
+
185
+ for sess in sessions:
186
+ idx = sess["session_idx"]
187
+ sess_transcript = render_transcript(sess["turns"])
188
+
189
+ # ---------- answering (against memory state BEFORE consolidation)
190
+ for qa in sess["qas"]:
191
+ if self.system in ("clerk", "prompted_ledger"):
192
+ resp, n_tok = answer_with_ledger(
193
+ self.model, self.tok, ledger_json, qa["q"])
194
+ elif self.system == "full":
195
+ ctx = "\n\n".join(history_parts + [render_transcript(sess["turns"])])
196
+ resp, n_tok = answer_with_transcript(
197
+ self.model, self.tok, ctx, qa["q"])
198
+ elif self.system == "window":
199
+ ctx = "\n\n".join(history_parts[-self.window:] +
200
+ [render_transcript(sess["turns"])])
201
+ resp, n_tok = answer_with_transcript(
202
+ self.model, self.tok, ctx, qa["q"])
203
+ elif self.system == "rag":
204
+ ctx = self._rag_context(history_parts, qa["q"], name)
205
+ resp, n_tok = answer_with_transcript(
206
+ self.model, self.tok, ctx, qa["q"])
207
+ else:
208
+ raise ValueError(self.system)
209
+
210
+ correct = check_answer(qa["type"], qa, resp)
211
+ self._record(qa["type"], correct,
212
+ stale=self._is_stale(qa, resp, persona, idx))
213
+ self.tok_prompt += n_tok[0]
214
+ self.tok_completion += n_tok[1]
215
+ self.n_answers += 1
216
+
217
+ # ---- memory update phase
218
+ rendered = render_transcript(sess["turns"])
219
+ if self.system == "clerk":
220
+ ledger_json_new, ops, err, n_tok, dropped = self._clerk_write(
221
+ ledger_json, idx, rendered)
222
+ elif self.system == "prompted_ledger":
223
+ ledger_json_new, ops, err, n_tok, dropped = self._clerk_write(
224
+ ledger_json, idx, rendered)
225
+ else:
226
+ ledger_json_new, ops, err, n_tok, dropped = None, [], "", 0, 0
227
+
228
+ if self.system in ("clerk", "prompted_ledger"):
229
+ self.consolidation["n"] += 1
230
+ self.consolidation["ops"] += len(ops)
231
+ self.consolidation["dropped"] += dropped
232
+ if err == "":
233
+ self.consolidation["parse_ok"] += 1
234
+ self.fact_scores(self._ledger_json_after(ledger_json_new),
235
+ sess["ledger_after"])
236
+ ledger_json = ledger_json_new
237
+
238
+ history_parts.append(rendered)
239
+
240
+ # ---- helpers --------------------------------------------------
241
+
242
+ def _clerk_write(self, ledger_json: Optional[str], idx: int,
243
+ rendered: str) -> Tuple[str, List[Dict], str, int, int]:
244
+ if ledger_json is None:
245
+ ledger_json = json.dumps([], separators=(",", ":"))
246
+ user = CONSOLIDATE_USER_TMPL.format(ledger=ledger_json, idx=idx,
247
+ transcript=rendered)
248
+ resp, n_tok = generate(self.model, self.tok, CONSOLIDATE_SYSTEM,
249
+ user, max_new_tokens=320)
250
+ ops, err = parse_ops(resp)
251
+ if err != "":
252
+ ops = []
253
+ new_ledger, stats = apply_ops(parse_ledger(ledger_json, self.budget),
254
+ ops, idx)
255
+ return (json.dumps([r for r in _serialize(new_ledger)],
256
+ separators=(",", ":"), ensure_ascii=False),
257
+ ops, err, n_tok, stats["dropped"])
258
+
259
+ def _ledger_json_after(self, ledger_json: str) -> str:
260
+ return ledger_json
261
+
262
+ def _is_stale_answer(self, qa: Dict[str, Any], resp: str,
263
+ gold_ledger_before: str, name: str) -> bool:
264
+ if qa["type"] != "superseded":
265
+ return False
266
+ gl = parse_ledger(gold_ledger_before, self.budget)
267
+ old = next((s.object for s in gl if s.occupied
268
+ and s.predicate == qa["pred"]), None)
269
+ return old is not None and norm(old) in norm(resp) \
270
+ and norm(qa["value"]) not in norm(resp)
271
+
272
+ def _rag_context(self, history_parts: List[str], question: str,
273
+ name: str) -> str:
274
+ global _RAG_MODEL
275
+ if _RAG_MODEL is None:
276
+ from sentence_transformers import SentenceTransformer
277
+ _RAG_MODEL = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
278
+ chunks: List[str] = []
279
+ for part in history_parts:
280
+ for line in part.split("\n"):
281
+ if line.startswith("USER:"):
282
+ chunks.append(line)
283
+ if not chunks:
284
+ return ""
285
+ q_emb = _RAG_MODEL.encode([question])
286
+ c_emb = _RAG_MODEL.encode(chunks)
287
+ import numpy as np
288
+ sims = (q_emb @ c_emb.T)[0]
289
+ top = np.argsort(-sims)[: self.rag_k]
290
+ return "\n".join(chunks[i] for i in sorted(top))
291
+
292
+
293
+ _RAG_MODEL = None