Onur Solmaz commited on
Commit
613d417
·
1 Parent(s): 978418e

feat(llm): test repetition-structured supervision

Browse files
llm/build_repetition_training_mix.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build a fixed-user training arm with shared reply supervision."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ from collections import Counter, defaultdict
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ try:
15
+ from .training_data import load_emotion_ids, load_examples, sha256_file
16
+ except ImportError:
17
+ from training_data import load_emotion_ids, load_examples, sha256_file
18
+
19
+ HERE = Path(__file__).resolve().parent
20
+ ROOT = HERE.parent
21
+ DEFAULT_EMOTIONS = ROOT / "schema" / "emotions.json"
22
+ TOKEN_RE = re.compile(r"[a-z0-9]+(?:'[a-z0-9]+)?", re.IGNORECASE)
23
+ REQUIRED_CONTRACT_FIELDS = (
24
+ "id",
25
+ "intent",
26
+ "subtype",
27
+ "user",
28
+ "emotion",
29
+ "reply",
30
+ "safety",
31
+ )
32
+
33
+
34
+ def stable_json(value: Any) -> str:
35
+ return json.dumps(value, separators=(",", ":"), sort_keys=True)
36
+
37
+
38
+ def atomic_json(path: Path, value: Any) -> None:
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ temporary = path.with_suffix(path.suffix + ".tmp")
41
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
42
+ temporary.replace(path)
43
+
44
+
45
+ def atomic_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ temporary = path.with_suffix(path.suffix + ".tmp")
48
+ with temporary.open("w") as output:
49
+ for row in rows:
50
+ output.write(stable_json(row) + "\n")
51
+ temporary.replace(path)
52
+
53
+
54
+ def normalized_text(text: str) -> str:
55
+ return " ".join(text.casefold().split())
56
+
57
+
58
+ def reply_tokens(text: str) -> frozenset[str]:
59
+ return frozenset(token.casefold() for token in TOKEN_RE.findall(text))
60
+
61
+
62
+ def token_distance(first: frozenset[str], second: frozenset[str]) -> float:
63
+ union = first | second
64
+ if not union:
65
+ return 0.0
66
+ return 1.0 - len(first & second) / len(union)
67
+
68
+
69
+ def load_emotion_profiles(path: Path) -> dict[str, dict[str, Any]]:
70
+ payload = json.loads(path.read_text())
71
+ profiles = {}
72
+ if not isinstance(payload, list):
73
+ raise ValueError(f"{path}: expected a JSON list")
74
+ for item in payload:
75
+ if not isinstance(item, dict):
76
+ raise ValueError(f"{path}: every emotion profile must be an object")
77
+ emotion = item.get("id")
78
+ family = item.get("family")
79
+ if not isinstance(emotion, str) or not emotion:
80
+ raise ValueError(f"{path}: every emotion profile needs an id")
81
+ if not isinstance(family, str) or not family:
82
+ raise ValueError(f"{path}: {emotion} needs a family")
83
+ profiles[emotion] = item
84
+ if not profiles:
85
+ raise ValueError(f"{path}: no emotion profiles")
86
+ return profiles
87
+
88
+
89
+ def load_contract_rows(path: Path, emotion_ids: set[str]) -> list[dict[str, Any]]:
90
+ rows = []
91
+ seen_ids = set()
92
+ with path.open() as source:
93
+ for line_number, line in enumerate(source, 1):
94
+ if not line.strip():
95
+ continue
96
+ try:
97
+ row = json.loads(line)
98
+ except json.JSONDecodeError as error:
99
+ raise ValueError(f"{path}:{line_number}: invalid JSON: {error}") from error
100
+ if not isinstance(row, dict):
101
+ raise ValueError(f"{path}:{line_number}: expected an object")
102
+ missing = [field for field in REQUIRED_CONTRACT_FIELDS if field not in row]
103
+ if missing:
104
+ raise ValueError(f"{path}:{line_number}: missing fields {missing}")
105
+ for field in REQUIRED_CONTRACT_FIELDS[:-1]:
106
+ if not isinstance(row[field], str) or not row[field].strip():
107
+ raise ValueError(
108
+ f"{path}:{line_number}: {field} must be a non-empty string"
109
+ )
110
+ if not isinstance(row["safety"], bool):
111
+ raise ValueError(f"{path}:{line_number}: safety must be boolean")
112
+ if row["emotion"] not in emotion_ids:
113
+ raise ValueError(
114
+ f"{path}:{line_number}: unknown emotion {row['emotion']!r}"
115
+ )
116
+ if row["id"] in seen_ids:
117
+ raise ValueError(f"{path}:{line_number}: repeated id {row['id']!r}")
118
+ seen_ids.add(row["id"])
119
+ rows.append({**row, "source_index": len(rows)})
120
+ if not rows:
121
+ raise ValueError(f"{path}: no rows")
122
+ return rows
123
+
124
+
125
+ def select_patterns(rows: list[dict[str, Any]], bank_size: int) -> list[dict[str, Any]]:
126
+ if bank_size < 1:
127
+ raise ValueError("bank_size must be positive")
128
+ if len(rows) < bank_size:
129
+ raise ValueError(
130
+ f"condition has {len(rows)} rows, fewer than bank size {bank_size}"
131
+ )
132
+ ordered = sorted(rows, key=lambda row: row["id"])
133
+ tokens = {row["id"]: reply_tokens(row["reply"]) for row in ordered}
134
+ distances: dict[tuple[str, str], float] = {}
135
+
136
+ def distance(first: dict[str, Any], second: dict[str, Any]) -> float:
137
+ key = tuple(sorted((first["id"], second["id"])))
138
+ if key not in distances:
139
+ distances[key] = token_distance(tokens[first["id"]], tokens[second["id"]])
140
+ return distances[key]
141
+
142
+ first = min(
143
+ ordered,
144
+ key=lambda candidate: (
145
+ sum(distance(candidate, other) for other in ordered),
146
+ candidate["id"],
147
+ ),
148
+ )
149
+ selected = [first]
150
+ selected_ids = {first["id"]}
151
+ while len(selected) < bank_size:
152
+ scores = [
153
+ (
154
+ min(distance(candidate, pattern) for pattern in selected),
155
+ candidate,
156
+ )
157
+ for candidate in ordered
158
+ if candidate["id"] not in selected_ids
159
+ ]
160
+ maximum = max(score for score, _ in scores)
161
+ chosen = min(
162
+ (candidate for score, candidate in scores if score == maximum),
163
+ key=lambda row: row["id"],
164
+ )
165
+ selected.append(chosen)
166
+ selected_ids.add(chosen["id"])
167
+ return sorted(selected, key=lambda row: row["id"])
168
+
169
+
170
+ def assign_patterns(
171
+ rows: list[dict[str, Any]], patterns: list[dict[str, Any]]
172
+ ) -> dict[str, dict[str, Any]]:
173
+ ordered_rows = sorted(rows, key=lambda row: row["id"])
174
+ ordered_patterns = sorted(patterns, key=lambda row: row["id"])
175
+ quotient, remainder = divmod(len(ordered_rows), len(ordered_patterns))
176
+ capacity = {
177
+ pattern["id"]: quotient + (index < remainder)
178
+ for index, pattern in enumerate(ordered_patterns)
179
+ }
180
+ assigned: dict[str, dict[str, Any]] = {}
181
+ for pattern in ordered_patterns:
182
+ assigned[pattern["id"]] = pattern
183
+ capacity[pattern["id"]] -= 1
184
+ pairs = []
185
+ for row in ordered_rows:
186
+ if row["id"] in assigned:
187
+ continue
188
+ row_tokens = reply_tokens(row["reply"])
189
+ for pattern in ordered_patterns:
190
+ pairs.append(
191
+ (
192
+ token_distance(row_tokens, reply_tokens(pattern["reply"])),
193
+ row["id"],
194
+ pattern["id"],
195
+ row,
196
+ pattern,
197
+ )
198
+ )
199
+ for _, row_id, pattern_id, row, pattern in sorted(
200
+ pairs, key=lambda item: item[:3]
201
+ ):
202
+ if row_id in assigned or capacity[pattern_id] == 0:
203
+ continue
204
+ assigned[row_id] = pattern
205
+ capacity[pattern_id] -= 1
206
+ if len(assigned) != len(rows) or any(capacity.values()):
207
+ raise RuntimeError("balanced reply-pattern assignment did not complete")
208
+ return assigned
209
+
210
+
211
+ def build_repetition_mix(
212
+ base_path: Path,
213
+ contract_path: Path,
214
+ audit_path: Path,
215
+ output_path: Path,
216
+ transformed_path: Path,
217
+ patterns_path: Path,
218
+ manifest_path: Path,
219
+ emotions_path: Path,
220
+ bank_size: int,
221
+ ) -> dict[str, Any]:
222
+ for path in (output_path, transformed_path, patterns_path, manifest_path):
223
+ if path.exists():
224
+ raise RuntimeError(f"output already exists: {path}")
225
+ emotion_ids = set(load_emotion_ids(emotions_path))
226
+ profiles = load_emotion_profiles(emotions_path)
227
+ base = load_examples(base_path, emotion_ids)
228
+ contract = load_contract_rows(contract_path, emotion_ids)
229
+ audit = json.loads(audit_path.read_text())
230
+ excluded_indices = {int(match["row_index"]) for match in audit.get("near_matches", [])}
231
+ if any(index < 0 or index >= len(contract) for index in excluded_indices):
232
+ raise RuntimeError("similarity audit contains an invalid contract row index")
233
+ selected = [
234
+ row for row in contract if row["source_index"] not in excluded_indices
235
+ ]
236
+ groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
237
+ for row in selected:
238
+ groups[(row["intent"], row["subtype"])].append(row)
239
+
240
+ transformed_by_index: dict[int, dict[str, Any]] = {}
241
+ pattern_rows = []
242
+ condition_rows = []
243
+ changed_emotions = 0
244
+ for condition in sorted(groups):
245
+ rows = groups[condition]
246
+ safety_values = {row["safety"] for row in rows}
247
+ if len(safety_values) != 1:
248
+ raise RuntimeError(f"condition {condition} mixes safety roles")
249
+ patterns = select_patterns(rows, bank_size)
250
+ assignments = assign_patterns(rows, patterns)
251
+ uses = Counter(pattern["id"] for pattern in assignments.values())
252
+ for pattern in patterns:
253
+ pattern_rows.append(
254
+ {
255
+ "pattern_id": pattern["id"],
256
+ "intent": pattern["intent"],
257
+ "subtype": pattern["subtype"],
258
+ "emotion": pattern["emotion"],
259
+ "emotion_family": profiles[pattern["emotion"]]["family"],
260
+ "reply": pattern["reply"],
261
+ "source_user": pattern["user"],
262
+ "uses": uses[pattern["id"]],
263
+ }
264
+ )
265
+ condition_rows.append(
266
+ {
267
+ "intent": condition[0],
268
+ "subtype": condition[1],
269
+ "rows": len(rows),
270
+ "patterns": len(patterns),
271
+ "minimum_pattern_uses": min(uses.values()),
272
+ "maximum_pattern_uses": max(uses.values()),
273
+ "emotion_count": len({pattern["emotion"] for pattern in patterns}),
274
+ "emotion_family_count": len(
275
+ {profiles[pattern["emotion"]]["family"] for pattern in patterns}
276
+ ),
277
+ }
278
+ )
279
+ for row in rows:
280
+ pattern = assignments[row["id"]]
281
+ changed_emotions += row["emotion"] != pattern["emotion"]
282
+ transformed_by_index[row["source_index"]] = {
283
+ "id": row["id"],
284
+ "user": row["user"],
285
+ "emotion": pattern["emotion"],
286
+ "reply": pattern["reply"],
287
+ "intent": row["intent"],
288
+ "subtype": row["subtype"],
289
+ "safety": row["safety"],
290
+ "pattern_id": pattern["id"],
291
+ "source_emotion": row["emotion"],
292
+ }
293
+
294
+ transformed = [
295
+ transformed_by_index[index]
296
+ for index in sorted(transformed_by_index)
297
+ ]
298
+ if [normalized_text(row["user"]) for row in selected] != [
299
+ normalized_text(row["user"]) for row in transformed
300
+ ]:
301
+ raise RuntimeError("transformation changed user messages or row order")
302
+ pattern_pairs = {
303
+ (row["pattern_id"], row["emotion"], normalized_text(row["reply"]))
304
+ for row in pattern_rows
305
+ }
306
+ if any(
307
+ (row["pattern_id"], row["emotion"], normalized_text(row["reply"]))
308
+ not in pattern_pairs
309
+ for row in transformed
310
+ ):
311
+ raise RuntimeError("transformed row does not use a registered reply pattern")
312
+
313
+ training_rows = [
314
+ *base,
315
+ *(
316
+ {
317
+ "user": row["user"],
318
+ "emotion": row["emotion"],
319
+ "reply": row["reply"],
320
+ }
321
+ for row in transformed
322
+ ),
323
+ ]
324
+ users = [normalized_text(row["user"]) for row in training_rows]
325
+ if len(users) != len(set(users)):
326
+ raise RuntimeError("training mix contains repeated user messages")
327
+
328
+ atomic_jsonl(transformed_path, transformed)
329
+ atomic_jsonl(patterns_path, pattern_rows)
330
+ atomic_jsonl(output_path, training_rows)
331
+ use_counts = Counter(row["pattern_id"] for row in transformed)
332
+ manifest = {
333
+ "schema": "microquack.repetition-training-mix.v1",
334
+ "dataset_role": "development_training",
335
+ "bank_size": bank_size,
336
+ "base": {
337
+ "path": str(base_path.resolve()),
338
+ "sha256": sha256_file(base_path),
339
+ "rows": len(base),
340
+ },
341
+ "contract": {
342
+ "path": str(contract_path.resolve()),
343
+ "sha256": sha256_file(contract_path),
344
+ "rows": len(contract),
345
+ "selected_rows": len(selected),
346
+ },
347
+ "held_out_similarity_audit": {
348
+ "path": str(audit_path.resolve()),
349
+ "sha256": sha256_file(audit_path),
350
+ "excluded_rows": len(excluded_indices),
351
+ "excluded_indices": sorted(excluded_indices),
352
+ },
353
+ "transformation": {
354
+ "reply_assignment": "balanced nearest selected pattern within intent/subtype",
355
+ "emotion_assignment": "selected reply pattern source label",
356
+ "conditions": len(groups),
357
+ "patterns": len(pattern_rows),
358
+ "unique_replies": len(
359
+ {normalized_text(row["reply"]) for row in transformed}
360
+ ),
361
+ "changed_emotion_rows": changed_emotions,
362
+ "minimum_pattern_uses": min(use_counts.values()),
363
+ "maximum_pattern_uses": max(use_counts.values()),
364
+ "pattern_use_counts": dict(sorted(Counter(use_counts.values()).items())),
365
+ "condition_summary_sha256": hashlib.sha256(
366
+ stable_json(condition_rows).encode()
367
+ ).hexdigest(),
368
+ "conditions_detail": condition_rows,
369
+ },
370
+ "outputs": {
371
+ "training": {
372
+ "path": str(output_path.resolve()),
373
+ "sha256": sha256_file(output_path),
374
+ "rows": len(training_rows),
375
+ "unique_users": len(set(users)),
376
+ "unique_replies": len(
377
+ {normalized_text(row["reply"]) for row in training_rows}
378
+ ),
379
+ "emotion_counts": dict(
380
+ sorted(Counter(row["emotion"] for row in training_rows).items())
381
+ ),
382
+ },
383
+ "transformed_contract": {
384
+ "path": str(transformed_path.resolve()),
385
+ "sha256": sha256_file(transformed_path),
386
+ "rows": len(transformed),
387
+ },
388
+ "patterns": {
389
+ "path": str(patterns_path.resolve()),
390
+ "sha256": sha256_file(patterns_path),
391
+ "rows": len(pattern_rows),
392
+ },
393
+ },
394
+ "emotions": {
395
+ "path": str(emotions_path.resolve()),
396
+ "sha256": sha256_file(emotions_path),
397
+ "count": len(emotion_ids),
398
+ },
399
+ "boundaries": {
400
+ "same_selected_user_messages": True,
401
+ "runtime_reply_table": False,
402
+ "new_teacher_generation": False,
403
+ "validation_in_training": False,
404
+ "sealed_test_used": False,
405
+ "chat_suites_used_for_training": False,
406
+ },
407
+ }
408
+ atomic_json(manifest_path, manifest)
409
+ return manifest
410
+
411
+
412
+ def parse_args() -> argparse.Namespace:
413
+ parser = argparse.ArgumentParser(description=__doc__)
414
+ parser.add_argument("--base", type=Path, required=True)
415
+ parser.add_argument("--contract", type=Path, required=True)
416
+ parser.add_argument("--similarity-audit", type=Path, required=True)
417
+ parser.add_argument("--output", type=Path, required=True)
418
+ parser.add_argument("--transformed-output", type=Path, required=True)
419
+ parser.add_argument("--patterns-output", type=Path, required=True)
420
+ parser.add_argument("--manifest", type=Path, required=True)
421
+ parser.add_argument("--emotions", type=Path, default=DEFAULT_EMOTIONS)
422
+ parser.add_argument("--bank-size", type=int, default=16)
423
+ args = parser.parse_args()
424
+ if args.bank_size < 1:
425
+ parser.error("--bank-size must be positive")
426
+ return args
427
+
428
+
429
+ def main() -> None:
430
+ args = parse_args()
431
+ manifest = build_repetition_mix(
432
+ args.base.resolve(),
433
+ args.contract.resolve(),
434
+ args.similarity_audit.resolve(),
435
+ args.output.resolve(),
436
+ args.transformed_output.resolve(),
437
+ args.patterns_output.resolve(),
438
+ args.manifest.resolve(),
439
+ args.emotions.resolve(),
440
+ args.bank_size,
441
+ )
442
+ print(json.dumps(manifest["outputs"]["training"], sort_keys=True))
443
+
444
+
445
+ if __name__ == "__main__":
446
+ main()
llm/compare_chat_evals.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Compare two symmetric chat evaluations with paired case uncertainty."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import random
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ try:
14
+ from .training_data import sha256_file
15
+ except ImportError:
16
+ from training_data import sha256_file
17
+
18
+ SCHEMA = "microquack.chat-eval-comparison.v1"
19
+ GENERATION_FIELDS = (
20
+ "decode_mode",
21
+ "seeds",
22
+ "reply_temperature",
23
+ "reply_top_k",
24
+ "input_token_bias",
25
+ "max_reply_tokens",
26
+ "no_repeat_bigram",
27
+ )
28
+
29
+
30
+ def load_report(path: Path) -> dict[str, Any]:
31
+ payload = json.loads(path.read_text())
32
+ if payload.get("schema") != "microquack.chat-eval-result.v1":
33
+ raise ValueError(f"{path}: unsupported chat evaluation schema")
34
+ return payload
35
+
36
+
37
+ def case_outcomes(report: dict[str, Any]) -> dict[str, bool]:
38
+ grouped: dict[str, list[bool]] = {}
39
+ for row in report["predictions"]:
40
+ grouped.setdefault(str(row["case_id"]), []).append(bool(row["sensible"]))
41
+ expected_seeds = len(report["generation"]["seeds"])
42
+ if not grouped:
43
+ raise ValueError("evaluation has no predictions")
44
+ for case_id, outcomes in grouped.items():
45
+ if len(outcomes) != expected_seeds:
46
+ raise ValueError(
47
+ f"{case_id}: expected {expected_seeds} predictions, got {len(outcomes)}"
48
+ )
49
+ return {case_id: all(outcomes) for case_id, outcomes in grouped.items()}
50
+
51
+
52
+ def percentile(values: list[float], probability: float) -> float:
53
+ if not values:
54
+ raise ValueError("cannot find a percentile of an empty sample")
55
+ position = probability * (len(values) - 1)
56
+ lower = math.floor(position)
57
+ upper = math.ceil(position)
58
+ if lower == upper:
59
+ return values[lower]
60
+ weight = position - lower
61
+ return values[lower] * (1 - weight) + values[upper] * weight
62
+
63
+
64
+ def paired_summary(
65
+ baseline: dict[str, bool],
66
+ candidate: dict[str, bool],
67
+ case_ids: list[str],
68
+ *,
69
+ bootstrap_samples: int,
70
+ bootstrap_seed: int,
71
+ minimum_worthwhile_effect: float,
72
+ ) -> dict[str, Any]:
73
+ if not case_ids:
74
+ raise ValueError("comparison subset has no cases")
75
+ missing = [
76
+ case_id
77
+ for case_id in case_ids
78
+ if case_id not in baseline or case_id not in candidate
79
+ ]
80
+ if missing:
81
+ raise ValueError(f"missing case outcomes: {missing[:5]}")
82
+ baseline_values = [int(baseline[case_id]) for case_id in case_ids]
83
+ candidate_values = [int(candidate[case_id]) for case_id in case_ids]
84
+ deltas = [
85
+ candidate_value - baseline_value
86
+ for baseline_value, candidate_value in zip(
87
+ baseline_values, candidate_values, strict=True
88
+ )
89
+ ]
90
+ count = len(case_ids)
91
+ baseline_count = sum(baseline_values)
92
+ candidate_count = sum(candidate_values)
93
+ effect = sum(deltas) / count
94
+ generator = random.Random(bootstrap_seed)
95
+ bootstrap_effects = sorted(
96
+ sum(deltas[generator.randrange(count)] for _ in range(count)) / count
97
+ for _ in range(bootstrap_samples)
98
+ )
99
+ lower = percentile(bootstrap_effects, 0.025)
100
+ upper = percentile(bootstrap_effects, 0.975)
101
+ if effect >= minimum_worthwhile_effect and lower > 0:
102
+ decision = "clear_improvement"
103
+ elif upper < minimum_worthwhile_effect:
104
+ decision = "no_worthwhile_improvement"
105
+ else:
106
+ decision = "inconclusive"
107
+ return {
108
+ "cases": count,
109
+ "baseline": {
110
+ "robust_case_count": baseline_count,
111
+ "robust_case_rate": baseline_count / count,
112
+ },
113
+ "candidate": {
114
+ "robust_case_count": candidate_count,
115
+ "robust_case_rate": candidate_count / count,
116
+ },
117
+ "absolute_effect": effect,
118
+ "candidate_only_passes": sum(delta == 1 for delta in deltas),
119
+ "baseline_only_passes": sum(delta == -1 for delta in deltas),
120
+ "both_pass": sum(
121
+ baseline_value == candidate_value == 1
122
+ for baseline_value, candidate_value in zip(
123
+ baseline_values, candidate_values, strict=True
124
+ )
125
+ ),
126
+ "both_fail": sum(
127
+ baseline_value == candidate_value == 0
128
+ for baseline_value, candidate_value in zip(
129
+ baseline_values, candidate_values, strict=True
130
+ )
131
+ ),
132
+ "paired_bootstrap_95_interval": [lower, upper],
133
+ "minimum_worthwhile_effect": minimum_worthwhile_effect,
134
+ "decision": decision,
135
+ }
136
+
137
+
138
+ def overlap_case_ids(report: dict[str, Any]) -> dict[str, list[str]] | None:
139
+ overlap = report.get("training_overlap")
140
+ if overlap is None:
141
+ return None
142
+ return {
143
+ name: [str(case_id) for case_id in subset["case_ids"]]
144
+ for name, subset in overlap["subsets"].items()
145
+ }
146
+
147
+
148
+ def compare_reports(
149
+ baseline_path: Path,
150
+ candidate_path: Path,
151
+ *,
152
+ bootstrap_samples: int = 20_000,
153
+ bootstrap_seed: int = 73,
154
+ minimum_worthwhile_effect: float = 0.10,
155
+ ) -> dict[str, Any]:
156
+ baseline_report = load_report(baseline_path)
157
+ candidate_report = load_report(candidate_path)
158
+ if baseline_report["suite"] != candidate_report["suite"]:
159
+ raise ValueError("evaluation suites do not match")
160
+ baseline_generation = {
161
+ field: baseline_report["generation"][field] for field in GENERATION_FIELDS
162
+ }
163
+ candidate_generation = {
164
+ field: candidate_report["generation"][field] for field in GENERATION_FIELDS
165
+ }
166
+ if baseline_generation != candidate_generation:
167
+ raise ValueError("generation settings are not symmetric")
168
+
169
+ baseline = case_outcomes(baseline_report)
170
+ candidate = case_outcomes(candidate_report)
171
+ if set(baseline) != set(candidate):
172
+ raise ValueError("evaluation case sets do not match")
173
+ case_ids = sorted(baseline)
174
+ overall = paired_summary(
175
+ baseline,
176
+ candidate,
177
+ case_ids,
178
+ bootstrap_samples=bootstrap_samples,
179
+ bootstrap_seed=bootstrap_seed,
180
+ minimum_worthwhile_effect=minimum_worthwhile_effect,
181
+ )
182
+
183
+ baseline_overlap = overlap_case_ids(baseline_report)
184
+ candidate_overlap = overlap_case_ids(candidate_report)
185
+ paired_subsets = None
186
+ if baseline_overlap is not None and candidate_overlap is not None:
187
+ baseline_seen = set(baseline_overlap["seen"])
188
+ candidate_seen = set(candidate_overlap["seen"])
189
+ partitions = {
190
+ "both_seen": sorted(baseline_seen & candidate_seen),
191
+ "both_unseen": sorted(
192
+ set(case_ids) - baseline_seen - candidate_seen
193
+ ),
194
+ "baseline_seen_candidate_unseen": sorted(
195
+ baseline_seen - candidate_seen
196
+ ),
197
+ "baseline_unseen_candidate_seen": sorted(
198
+ candidate_seen - baseline_seen
199
+ ),
200
+ }
201
+ paired_subsets = {
202
+ name: paired_summary(
203
+ baseline,
204
+ candidate,
205
+ selected,
206
+ bootstrap_samples=bootstrap_samples,
207
+ bootstrap_seed=bootstrap_seed,
208
+ minimum_worthwhile_effect=minimum_worthwhile_effect,
209
+ )
210
+ for name, selected in partitions.items()
211
+ if selected
212
+ }
213
+
214
+ return {
215
+ "schema": SCHEMA,
216
+ "baseline": {
217
+ "report": str(baseline_path.resolve()),
218
+ "report_sha256": sha256_file(baseline_path),
219
+ "checkpoint": baseline_report["checkpoint"],
220
+ },
221
+ "candidate": {
222
+ "report": str(candidate_path.resolve()),
223
+ "report_sha256": sha256_file(candidate_path),
224
+ "checkpoint": candidate_report["checkpoint"],
225
+ },
226
+ "suite": baseline_report["suite"],
227
+ "generation": baseline_generation,
228
+ "metric": "robust_case_rate",
229
+ "uncertainty": {
230
+ "method": "paired case bootstrap percentile interval",
231
+ "confidence": 0.95,
232
+ "samples": bootstrap_samples,
233
+ "seed": bootstrap_seed,
234
+ },
235
+ "overall": overall,
236
+ "training_overlap": {
237
+ "baseline": baseline_report.get("training_overlap"),
238
+ "candidate": candidate_report.get("training_overlap"),
239
+ "paired_subsets": paired_subsets,
240
+ },
241
+ }
242
+
243
+
244
+ def atomic_json(path: Path, payload: dict[str, Any]) -> None:
245
+ path.parent.mkdir(parents=True, exist_ok=True)
246
+ temporary = path.with_suffix(path.suffix + ".tmp")
247
+ temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
248
+ temporary.replace(path)
249
+
250
+
251
+ def parse_args() -> argparse.Namespace:
252
+ parser = argparse.ArgumentParser(description=__doc__)
253
+ parser.add_argument("--baseline", type=Path, required=True)
254
+ parser.add_argument("--candidate", type=Path, required=True)
255
+ parser.add_argument("--output", type=Path, required=True)
256
+ parser.add_argument("--bootstrap-samples", type=int, default=20_000)
257
+ parser.add_argument("--bootstrap-seed", type=int, default=73)
258
+ parser.add_argument("--minimum-worthwhile-effect", type=float, default=0.10)
259
+ args = parser.parse_args()
260
+ if args.bootstrap_samples < 1:
261
+ parser.error("--bootstrap-samples must be positive")
262
+ if not 0 <= args.minimum_worthwhile_effect <= 1:
263
+ parser.error("--minimum-worthwhile-effect must be between zero and one")
264
+ return args
265
+
266
+
267
+ def main() -> None:
268
+ args = parse_args()
269
+ result = compare_reports(
270
+ args.baseline.resolve(),
271
+ args.candidate.resolve(),
272
+ bootstrap_samples=args.bootstrap_samples,
273
+ bootstrap_seed=args.bootstrap_seed,
274
+ minimum_worthwhile_effect=args.minimum_worthwhile_effect,
275
+ )
276
+ atomic_json(args.output.resolve(), result)
277
+ print(json.dumps(result["overall"], indent=2, sort_keys=True))
278
+
279
+
280
+ if __name__ == "__main__":
281
+ main()
llm/evaluate_chat.py CHANGED
@@ -47,12 +47,25 @@ def parse_args() -> argparse.Namespace:
47
  parser.add_argument("--output", type=Path)
48
  parser.add_argument("--device", default="auto")
49
  parser.add_argument("--seeds", type=int, nargs="+", default=DEFAULT_SEEDS)
 
 
 
 
 
 
50
  parser.add_argument("--reply-temperature", type=float, default=0.3)
51
  parser.add_argument("--reply-top-k", type=int, default=5)
52
- parser.add_argument("--input-token-bias", type=float, default=0.5)
53
  parser.add_argument("--max-reply-tokens", type=int, default=16)
54
  parser.add_argument(
55
- "--no-repeat-bigram", action=argparse.BooleanOptionalAction, default=True
 
 
 
 
 
 
 
56
  )
57
  parser.add_argument("--require", action="store_true")
58
  args = parser.parse_args()
@@ -148,8 +161,33 @@ def has_repeated_bigram(reply: str) -> bool:
148
  return len(bigrams) != len(set(bigrams))
149
 
150
 
 
 
 
 
151
  def normalize_reply(reply: str) -> str:
152
- return " ".join(reply.casefold().split())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
 
155
  def load_checkpoint(
@@ -186,17 +224,28 @@ def generate(
186
  device: Any,
187
  args: argparse.Namespace,
188
  ) -> list[list[tuple[str, str]]]:
 
 
189
  if kind == "character":
190
- greedy = model.greedy_replies(prompts, device=device, batch_size=128)
191
- return [greedy for _ in seeds]
 
 
 
 
 
 
 
 
 
192
  return [
193
  model.replies(
194
  prompts,
195
  tokenizer,
196
  device=device,
197
  batch_size=128,
198
- reply_temperature=args.reply_temperature,
199
- reply_top_k=args.reply_top_k,
200
  seed=seed,
201
  no_repeat_bigram=args.no_repeat_bigram,
202
  max_reply_tokens=args.max_reply_tokens,
@@ -290,6 +339,47 @@ def summarize(
290
  }
291
 
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  def main() -> None:
294
  import torch
295
 
@@ -386,6 +476,18 @@ def main() -> None:
386
  )
387
 
388
  metrics = summarize(rows, suite, args.seeds, len(emotion_ids))
 
 
 
 
 
 
 
 
 
 
 
 
389
  thresholds = suite["thresholds"]
390
  gates = {
391
  metric: metrics[metric] is not None and metrics[metric] >= threshold
@@ -415,13 +517,27 @@ def main() -> None:
415
  "generation": {
416
  "seeds": args.seeds,
417
  "device": str(device),
418
- "reply_temperature": args.reply_temperature,
419
- "reply_top_k": args.reply_top_k,
 
 
 
420
  "input_token_bias": args.input_token_bias,
421
  "max_reply_tokens": args.max_reply_tokens,
422
  "no_repeat_bigram": args.no_repeat_bigram,
423
  "elapsed_seconds": elapsed,
424
  },
 
 
 
 
 
 
 
 
 
 
 
425
  "thresholds": thresholds,
426
  "metrics": metrics,
427
  "gates": gates,
 
47
  parser.add_argument("--output", type=Path)
48
  parser.add_argument("--device", default="auto")
49
  parser.add_argument("--seeds", type=int, nargs="+", default=DEFAULT_SEEDS)
50
+ parser.add_argument(
51
+ "--decode-mode",
52
+ choices=("greedy", "sampled"),
53
+ default="sampled",
54
+ help="apply the same greedy or temperature/top-k policy to every model",
55
+ )
56
  parser.add_argument("--reply-temperature", type=float, default=0.3)
57
  parser.add_argument("--reply-top-k", type=int, default=5)
58
+ parser.add_argument("--input-token-bias", type=float, default=0.0)
59
  parser.add_argument("--max-reply-tokens", type=int, default=16)
60
  parser.add_argument(
61
+ "--no-repeat-bigram", action=argparse.BooleanOptionalAction, default=False
62
+ )
63
+ parser.add_argument(
64
+ "--training-data",
65
+ type=Path,
66
+ action="append",
67
+ default=[],
68
+ help="training JSONL used to split evaluation cases into seen and unseen prompts",
69
  )
70
  parser.add_argument("--require", action="store_true")
71
  args = parser.parse_args()
 
161
  return len(bigrams) != len(set(bigrams))
162
 
163
 
164
+ def normalized_text(text: str) -> str:
165
+ return " ".join(text.casefold().split())
166
+
167
+
168
  def normalize_reply(reply: str) -> str:
169
+ return normalized_text(reply)
170
+
171
+
172
+ def load_training_users(paths: list[Path]) -> set[str]:
173
+ users = set()
174
+ for path in paths:
175
+ with path.open() as source:
176
+ for line_number, line in enumerate(source, 1):
177
+ if not line.strip():
178
+ continue
179
+ try:
180
+ row = json.loads(line)
181
+ except json.JSONDecodeError as error:
182
+ raise ValueError(
183
+ f"{path}:{line_number}: invalid JSON: {error}"
184
+ ) from error
185
+ if not isinstance(row, dict) or not isinstance(row.get("user"), str):
186
+ raise ValueError(f"{path}:{line_number}: user must be a string")
187
+ user = normalized_text(row["user"])
188
+ if user:
189
+ users.add(user)
190
+ return users
191
 
192
 
193
  def load_checkpoint(
 
224
  device: Any,
225
  args: argparse.Namespace,
226
  ) -> list[list[tuple[str, str]]]:
227
+ temperature = 0.0 if args.decode_mode == "greedy" else args.reply_temperature
228
+ top_k = 0 if args.decode_mode == "greedy" else args.reply_top_k
229
  if kind == "character":
230
+ return [
231
+ model.replies(
232
+ prompts,
233
+ device=device,
234
+ batch_size=128,
235
+ reply_temperature=temperature,
236
+ reply_top_k=top_k,
237
+ seed=seed,
238
+ )
239
+ for seed in seeds
240
+ ]
241
  return [
242
  model.replies(
243
  prompts,
244
  tokenizer,
245
  device=device,
246
  batch_size=128,
247
+ reply_temperature=temperature,
248
+ reply_top_k=top_k,
249
  seed=seed,
250
  no_repeat_bigram=args.no_repeat_bigram,
251
  max_reply_tokens=args.max_reply_tokens,
 
339
  }
340
 
341
 
342
+ def summarize_training_overlap(
343
+ rows: list[dict[str, Any]],
344
+ suite: dict[str, Any],
345
+ seeds: list[int],
346
+ emotion_count: int,
347
+ training_users: set[str],
348
+ ) -> dict[str, Any]:
349
+ seen_ids = {
350
+ case["id"]
351
+ for case in suite["cases"]
352
+ if normalized_text(case["user"]) in training_users
353
+ }
354
+ subsets = {}
355
+ for name, selected_ids in (
356
+ ("seen", seen_ids),
357
+ (
358
+ "unseen",
359
+ {case["id"] for case in suite["cases"]} - seen_ids,
360
+ ),
361
+ ):
362
+ selected_cases = [
363
+ case for case in suite["cases"] if case["id"] in selected_ids
364
+ ]
365
+ if selected_cases:
366
+ selected_rows = [row for row in rows if row["case_id"] in selected_ids]
367
+ metrics = summarize(
368
+ selected_rows,
369
+ {**suite, "cases": selected_cases},
370
+ seeds,
371
+ emotion_count,
372
+ )
373
+ else:
374
+ metrics = None
375
+ subsets[name] = {
376
+ "cases": len(selected_cases),
377
+ "case_ids": [case["id"] for case in selected_cases],
378
+ "metrics": metrics,
379
+ }
380
+ return subsets
381
+
382
+
383
  def main() -> None:
384
  import torch
385
 
 
476
  )
477
 
478
  metrics = summarize(rows, suite, args.seeds, len(emotion_ids))
479
+ training_paths = [path.resolve() for path in args.training_data]
480
+ training_overlap = (
481
+ summarize_training_overlap(
482
+ rows,
483
+ suite,
484
+ args.seeds,
485
+ len(emotion_ids),
486
+ load_training_users(training_paths),
487
+ )
488
+ if training_paths
489
+ else None
490
+ )
491
  thresholds = suite["thresholds"]
492
  gates = {
493
  metric: metrics[metric] is not None and metrics[metric] >= threshold
 
517
  "generation": {
518
  "seeds": args.seeds,
519
  "device": str(device),
520
+ "decode_mode": args.decode_mode,
521
+ "reply_temperature": (
522
+ 0.0 if args.decode_mode == "greedy" else args.reply_temperature
523
+ ),
524
+ "reply_top_k": 0 if args.decode_mode == "greedy" else args.reply_top_k,
525
  "input_token_bias": args.input_token_bias,
526
  "max_reply_tokens": args.max_reply_tokens,
527
  "no_repeat_bigram": args.no_repeat_bigram,
528
  "elapsed_seconds": elapsed,
529
  },
530
+ "training_overlap": (
531
+ {
532
+ "data": [
533
+ {"path": str(path), "sha256": sha256_file(path)}
534
+ for path in training_paths
535
+ ],
536
+ "subsets": training_overlap,
537
+ }
538
+ if training_overlap is not None
539
+ else None
540
+ ),
541
  "thresholds": thresholds,
542
  "metrics": metrics,
543
  "gates": gates,
llm/test_build_repetition_training_mix.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from llm.build_repetition_training_mix import build_repetition_mix
7
+
8
+
9
+ class RepetitionTrainingMixTests(unittest.TestCase):
10
+ def write_inputs(self, root: Path) -> tuple[Path, Path, Path, Path]:
11
+ emotions = root / "emotions.json"
12
+ base = root / "base.jsonl"
13
+ contract = root / "contract.jsonl"
14
+ audit = root / "audit.json"
15
+ emotions.write_text(
16
+ json.dumps(
17
+ [
18
+ {"id": "calm", "family": "positive"},
19
+ {"id": "happy", "family": "positive"},
20
+ {"id": "worried", "family": "fear"},
21
+ ]
22
+ )
23
+ )
24
+ base.write_text(
25
+ json.dumps(
26
+ {"user": "Base user", "emotion": "calm", "reply": "Base reply"}
27
+ )
28
+ + "\n"
29
+ )
30
+ rows = []
31
+ for index, (emotion, reply) in enumerate(
32
+ (
33
+ ("happy", "Hello friend"),
34
+ ("calm", "Hello there"),
35
+ ("happy", "Greetings friend"),
36
+ ("calm", "Greetings there"),
37
+ ("worried", "Please stay safe"),
38
+ ("worried", "Move somewhere safe"),
39
+ )
40
+ ):
41
+ rows.append(
42
+ {
43
+ "id": f"row-{index}",
44
+ "intent": "chat",
45
+ "subtype": "greeting" if index < 3 else "safety",
46
+ "user": f"User message {index}",
47
+ "emotion": emotion,
48
+ "reply": reply,
49
+ "safety": index >= 3,
50
+ }
51
+ )
52
+ contract.write_text("".join(json.dumps(row) + "\n" for row in rows))
53
+ audit.write_text(json.dumps({"near_matches": [{"row_index": 2}]}))
54
+ return base, contract, audit, emotions
55
+
56
+ def build(self, root: Path) -> tuple[dict, list[dict], list[dict], list[dict]]:
57
+ base, contract, audit, emotions = self.write_inputs(root)
58
+ output = root / "train.jsonl"
59
+ transformed = root / "transformed.jsonl"
60
+ patterns = root / "patterns.jsonl"
61
+ manifest_path = root / "manifest.json"
62
+ manifest = build_repetition_mix(
63
+ base,
64
+ contract,
65
+ audit,
66
+ output,
67
+ transformed,
68
+ patterns,
69
+ manifest_path,
70
+ emotions,
71
+ bank_size=2,
72
+ )
73
+ load = lambda path: [json.loads(line) for line in path.read_text().splitlines()]
74
+ return manifest, load(output), load(transformed), load(patterns)
75
+
76
+ def test_builds_balanced_reply_patterns_without_changing_users(self) -> None:
77
+ with tempfile.TemporaryDirectory() as directory:
78
+ manifest, training, transformed, patterns = self.build(Path(directory))
79
+
80
+ self.assertEqual(len(training), 6)
81
+ self.assertEqual(
82
+ [row["user"] for row in transformed],
83
+ ["User message 0", "User message 1", "User message 3", "User message 4", "User message 5"],
84
+ )
85
+ self.assertEqual(len(patterns), 4)
86
+ self.assertEqual(sorted(row["uses"] for row in patterns), [1, 1, 1, 2])
87
+ registered = {
88
+ (row["pattern_id"], row["emotion"], row["reply"]) for row in patterns
89
+ }
90
+ self.assertTrue(
91
+ all(
92
+ (row["pattern_id"], row["emotion"], row["reply"]) in registered
93
+ for row in transformed
94
+ )
95
+ )
96
+ self.assertEqual(manifest["held_out_similarity_audit"]["excluded_indices"], [2])
97
+ self.assertTrue(manifest["boundaries"]["same_selected_user_messages"])
98
+ self.assertFalse(manifest["boundaries"]["runtime_reply_table"])
99
+ self.assertLess(manifest["outputs"]["training"]["unique_replies"], 6)
100
+
101
+ def test_output_is_deterministic(self) -> None:
102
+ with tempfile.TemporaryDirectory() as first_directory, tempfile.TemporaryDirectory() as second_directory:
103
+ first = self.build(Path(first_directory))
104
+ second = self.build(Path(second_directory))
105
+ self.assertEqual(first[1:], second[1:])
106
+ self.assertEqual(
107
+ first[0]["transformation"]["condition_summary_sha256"],
108
+ second[0]["transformation"]["condition_summary_sha256"],
109
+ )
110
+
111
+ def test_rejects_a_bank_larger_than_a_condition(self) -> None:
112
+ with tempfile.TemporaryDirectory() as directory:
113
+ root = Path(directory)
114
+ base, contract, audit, emotions = self.write_inputs(root)
115
+ with self.assertRaisesRegex(ValueError, "fewer than bank size"):
116
+ build_repetition_mix(
117
+ base,
118
+ contract,
119
+ audit,
120
+ root / "train.jsonl",
121
+ root / "transformed.jsonl",
122
+ root / "patterns.jsonl",
123
+ root / "manifest.json",
124
+ emotions,
125
+ bank_size=4,
126
+ )
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()
llm/test_compare_chat_evals.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from llm.compare_chat_evals import compare_reports
7
+
8
+
9
+ class CompareChatEvalsTests(unittest.TestCase):
10
+ def write_report(
11
+ self,
12
+ path: Path,
13
+ passes: list[bool],
14
+ *,
15
+ decode_mode: str = "greedy",
16
+ ) -> None:
17
+ case_ids = [f"case-{index}" for index in range(len(passes))]
18
+ report = {
19
+ "schema": "microquack.chat-eval-result.v1",
20
+ "checkpoint": {
21
+ "path": str(path.with_suffix(".pt")),
22
+ "sha256": "checkpoint",
23
+ "kind": "word",
24
+ },
25
+ "suite": {"path": "suite.json", "sha256": "suite", "role": "held_out"},
26
+ "generation": {
27
+ "decode_mode": decode_mode,
28
+ "seeds": [7],
29
+ "reply_temperature": 0.0,
30
+ "reply_top_k": 0,
31
+ "input_token_bias": 0.0,
32
+ "max_reply_tokens": 16,
33
+ "no_repeat_bigram": False,
34
+ },
35
+ "training_overlap": {
36
+ "data": [],
37
+ "subsets": {
38
+ "seen": {"case_ids": case_ids[:2]},
39
+ "unseen": {"case_ids": case_ids[2:]},
40
+ },
41
+ },
42
+ "predictions": [
43
+ {"case_id": case_id, "seed": 7, "sensible": passed}
44
+ for case_id, passed in zip(case_ids, passes, strict=True)
45
+ ],
46
+ }
47
+ path.write_text(json.dumps(report))
48
+
49
+ def test_reports_paired_counts_effect_and_subsets(self) -> None:
50
+ with tempfile.TemporaryDirectory() as directory:
51
+ root = Path(directory)
52
+ baseline = root / "baseline.json"
53
+ candidate = root / "candidate.json"
54
+ self.write_report(baseline, [False] * 20)
55
+ self.write_report(candidate, [True] * 20)
56
+ result = compare_reports(
57
+ baseline,
58
+ candidate,
59
+ bootstrap_samples=1_000,
60
+ bootstrap_seed=7,
61
+ )
62
+
63
+ overall = result["overall"]
64
+ self.assertEqual(overall["baseline"]["robust_case_count"], 0)
65
+ self.assertEqual(overall["candidate"]["robust_case_count"], 20)
66
+ self.assertEqual(overall["candidate_only_passes"], 20)
67
+ self.assertEqual(overall["absolute_effect"], 1.0)
68
+ self.assertEqual(overall["decision"], "clear_improvement")
69
+ paired = result["training_overlap"]["paired_subsets"]
70
+ self.assertEqual(paired["both_seen"]["cases"], 2)
71
+ self.assertEqual(paired["both_unseen"]["cases"], 18)
72
+
73
+ def test_equal_reports_do_not_claim_worthwhile_improvement(self) -> None:
74
+ with tempfile.TemporaryDirectory() as directory:
75
+ root = Path(directory)
76
+ baseline = root / "baseline.json"
77
+ candidate = root / "candidate.json"
78
+ passes = [True, False] * 10
79
+ self.write_report(baseline, passes)
80
+ self.write_report(candidate, passes)
81
+ result = compare_reports(
82
+ baseline,
83
+ candidate,
84
+ bootstrap_samples=100,
85
+ minimum_worthwhile_effect=0.10,
86
+ )
87
+ self.assertEqual(result["overall"]["decision"], "no_worthwhile_improvement")
88
+
89
+ def test_compares_different_training_overlap_splits(self) -> None:
90
+ with tempfile.TemporaryDirectory() as directory:
91
+ root = Path(directory)
92
+ baseline = root / "baseline.json"
93
+ candidate = root / "candidate.json"
94
+ self.write_report(baseline, [True, False, True, False])
95
+ self.write_report(candidate, [True, True, False, False])
96
+ payload = json.loads(candidate.read_text())
97
+ payload["training_overlap"]["subsets"] = {
98
+ "seen": {"case_ids": ["case-1", "case-2"]},
99
+ "unseen": {"case_ids": ["case-0", "case-3"]},
100
+ }
101
+ candidate.write_text(json.dumps(payload))
102
+ result = compare_reports(baseline, candidate, bootstrap_samples=10)
103
+ paired = result["training_overlap"]["paired_subsets"]
104
+ self.assertEqual(paired["both_seen"]["cases"], 1)
105
+ self.assertEqual(paired["both_unseen"]["cases"], 1)
106
+ self.assertEqual(paired["baseline_seen_candidate_unseen"]["cases"], 1)
107
+ self.assertEqual(paired["baseline_unseen_candidate_seen"]["cases"], 1)
108
+
109
+ def test_rejects_asymmetric_generation(self) -> None:
110
+ with tempfile.TemporaryDirectory() as directory:
111
+ root = Path(directory)
112
+ baseline = root / "baseline.json"
113
+ candidate = root / "candidate.json"
114
+ self.write_report(baseline, [True, False])
115
+ self.write_report(candidate, [True, False], decode_mode="sampled")
116
+ with self.assertRaisesRegex(ValueError, "not symmetric"):
117
+ compare_reports(baseline, candidate, bootstrap_samples=10)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()
llm/test_evaluate_chat.py CHANGED
@@ -2,9 +2,11 @@
2
 
3
  import json
4
  import sys
 
5
  import unittest
6
  from collections import Counter
7
  from pathlib import Path
 
8
 
9
  HERE = Path(__file__).resolve().parent
10
  ROOT = HERE.parent
@@ -12,10 +14,13 @@ sys.path.insert(0, str(HERE))
12
 
13
  from evaluate_chat import ( # noqa: E402
14
  contains_cue,
 
15
  has_repeated_bigram,
16
  has_repeated_word_run,
17
  load_cases,
 
18
  summarize,
 
19
  )
20
  from generate_common_anchors import make_examples # noqa: E402
21
 
@@ -95,6 +100,86 @@ class ChatEvalTests(unittest.TestCase):
95
  self.assertFalse(has_repeated_word_run("I can help you."))
96
  self.assertFalse(has_repeated_bigram("I can help you."))
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def test_summary_reports_cross_case_and_seed_diversity(self) -> None:
99
  suite = {
100
  "cases": [
 
2
 
3
  import json
4
  import sys
5
+ import tempfile
6
  import unittest
7
  from collections import Counter
8
  from pathlib import Path
9
+ from types import SimpleNamespace
10
 
11
  HERE = Path(__file__).resolve().parent
12
  ROOT = HERE.parent
 
14
 
15
  from evaluate_chat import ( # noqa: E402
16
  contains_cue,
17
+ generate,
18
  has_repeated_bigram,
19
  has_repeated_word_run,
20
  load_cases,
21
+ load_training_users,
22
  summarize,
23
+ summarize_training_overlap,
24
  )
25
  from generate_common_anchors import make_examples # noqa: E402
26
 
 
100
  self.assertFalse(has_repeated_word_run("I can help you."))
101
  self.assertFalse(has_repeated_bigram("I can help you."))
102
 
103
+ def test_generate_applies_the_same_sample_policy_to_character_models(self) -> None:
104
+ class FakeCharacterModel:
105
+ def __init__(self) -> None:
106
+ self.calls = []
107
+
108
+ def replies(self, prompts, **kwargs):
109
+ self.calls.append(kwargs)
110
+ return [("happy", f"seed {kwargs['seed']}") for _ in prompts]
111
+
112
+ model = FakeCharacterModel()
113
+ args = SimpleNamespace(
114
+ decode_mode="sampled",
115
+ reply_temperature=0.3,
116
+ reply_top_k=5,
117
+ input_token_bias=0.0,
118
+ no_repeat_bigram=False,
119
+ max_reply_tokens=16,
120
+ )
121
+ batches = generate(
122
+ "character", model, None, ["hello"], [7, 11], "cpu", args
123
+ )
124
+ self.assertEqual([batch[0][1] for batch in batches], ["seed 7", "seed 11"])
125
+ self.assertEqual([call["reply_temperature"] for call in model.calls], [0.3, 0.3])
126
+ self.assertEqual([call["reply_top_k"] for call in model.calls], [5, 5])
127
+
128
+ args.decode_mode = "greedy"
129
+ model.calls.clear()
130
+ generate("character", model, None, ["hello"], [7], "cpu", args)
131
+ self.assertEqual(model.calls[0]["reply_temperature"], 0.0)
132
+ self.assertEqual(model.calls[0]["reply_top_k"], 0)
133
+
134
+ def test_training_overlap_splits_seen_and_unseen_cases(self) -> None:
135
+ with tempfile.TemporaryDirectory() as directory:
136
+ path = Path(directory) / "train.jsonl"
137
+ path.write_text(
138
+ json.dumps(
139
+ {"user": " Hello There ", "emotion": "happy", "reply": "Hi."}
140
+ )
141
+ + "\n"
142
+ + json.dumps({"user": " ", "emotion": "happy", "reply": "Hi."})
143
+ + "\n"
144
+ )
145
+ training_users = load_training_users([path])
146
+ self.assertEqual(training_users, {"hello there"})
147
+
148
+ suite = {
149
+ "cases": [
150
+ {"id": "seen", "user": "hello there", "category": "chat"},
151
+ {"id": "unseen", "user": "new prompt", "category": "chat"},
152
+ ]
153
+ }
154
+ rows = []
155
+ for case_id, user in (("seen", "hello there"), ("unseen", "new prompt")):
156
+ rows.append(
157
+ {
158
+ "case_id": case_id,
159
+ "category": "chat",
160
+ "user": user,
161
+ "seed": 7,
162
+ "emotion": "happy",
163
+ "reply": "Hello.",
164
+ "valid_emotion": True,
165
+ "well_formed_reply": True,
166
+ "emotion_ok": True,
167
+ "target_emotion_ok": None,
168
+ "reply_cue_ok": True,
169
+ "reply_forbidden_ok": True,
170
+ "reply_ok": True,
171
+ "sensible": True,
172
+ "repeated_word_run": False,
173
+ "repeated_bigram": False,
174
+ }
175
+ )
176
+ subsets = summarize_training_overlap(rows, suite, [7], 73, training_users)
177
+ self.assertEqual(subsets["seen"]["cases"], 1)
178
+ self.assertEqual(subsets["seen"]["case_ids"], ["seen"])
179
+ self.assertEqual(subsets["unseen"]["cases"], 1)
180
+ self.assertEqual(subsets["unseen"]["case_ids"], ["unseen"])
181
+ self.assertEqual(subsets["seen"]["metrics"]["robust_case_count"], 1)
182
+
183
  def test_summary_reports_cross_case_and_seed_diversity(self) -> None:
184
  suite = {
185
  "cases": [
package.json CHANGED
@@ -9,7 +9,7 @@
9
  "lint": "eslint .",
10
  "typecheck": "tsc --noEmit -p apps/web/tsconfig.json",
11
  "test": "vitest run && pnpm run test:teacher-data",
12
- "test:teacher-data": "python3 -m unittest llm/test_generate_teacher_dataset.py llm/test_generate_teacher_corpus.py llm/test_generate_intent_pilot.py llm/test_generate_contract_corpus.py llm/test_audit_eval_similarity.py llm/test_build_intent_training_mix.py llm/test_training_data.py llm/test_evaluate_chat.py",
13
  "coverage": "vitest run --coverage",
14
  "dry": "slophammer-ts dry .",
15
  "slophammer": "slophammer-ts check .",
 
9
  "lint": "eslint .",
10
  "typecheck": "tsc --noEmit -p apps/web/tsconfig.json",
11
  "test": "vitest run && pnpm run test:teacher-data",
12
+ "test:teacher-data": "python3 -m unittest llm/test_generate_teacher_dataset.py llm/test_generate_teacher_corpus.py llm/test_generate_intent_pilot.py llm/test_generate_contract_corpus.py llm/test_audit_eval_similarity.py llm/test_build_intent_training_mix.py llm/test_build_repetition_training_mix.py llm/test_training_data.py llm/test_evaluate_chat.py llm/test_compare_chat_evals.py",
13
  "coverage": "vitest run --coverage",
14
  "dry": "slophammer-ts dry .",
15
  "slophammer": "slophammer-ts check .",