File size: 14,300 Bytes
e8f2c80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
#!/usr/bin/env python3
"""Validate synthetic signature-to-background dataset JSONL files."""

from __future__ import annotations

import json
import re
from collections import Counter
from pathlib import Path


ROOT = Path(__file__).resolve().parents[2]
PROCESSED = ROOT / "dataset" / "processed"
CATALOG_PATH = ROOT / "dataset" / "config" / "process_catalog.v1.json"

TASK_TYPE = "signature_to_backgrounds"
SPLITS = {"train", "val", "test"}
SFT_TARGET_TYPES = {
    "dominant_irreducible": "irreducible",
    "dominant_reducible": "reducible",
}
SFT_HEADERS = ["dominant:", "irreducible:", "reducible:"]
TRAINABLE_EVIDENCE_TRACE_RE = re.compile(
    r"(?im)(evidence trace|^\s*evidence\s*:|^\s*citations?\s*:|^\s*sources?\s*:|^\s*references?\s*:|source_file|claim_supported)"
)
THINK_BLOCK_RE = re.compile(r"(?is)<think>(.*?)</think>")
ANSWER_BLOCK_RE = re.compile(r"(?is)<answer>\s*(\{.*?\})\s*</answer>")
ANSWER_TEXT_BLOCK_RE = re.compile(r"(?is)<answer>\s*(.*?)\s*</answer>")


def load_catalog() -> tuple[str, set[str]]:
    with CATALOG_PATH.open() as handle:
        catalog = json.load(handle)
    version = str(catalog["version"])
    process_ids = {str(process["id"]) for process in catalog["processes"]}
    if len(process_ids) != len(catalog["processes"]):
        raise ValueError(f"{CATALOG_PATH}: duplicate process ids")
    return version, process_ids


PROCESS_CATALOG_VERSION, PROCESS_IDS = load_catalog()


def read_jsonl(path: Path) -> list[dict]:
    rows: list[dict] = []
    with path.open() as handle:
        for line_no, line in enumerate(handle, 1):
            if not line.strip():
                continue
            try:
                rows.append(json.loads(line))
            except json.JSONDecodeError as exc:
                raise ValueError(f"{path}:{line_no}: invalid JSON: {exc}") from exc
    return rows


def section_items(text: str) -> dict[str, list[str]]:
    sections = {header: [] for header in SFT_HEADERS}
    current: str | None = None
    for raw_line in text.splitlines():
        line = raw_line.strip()
        lowered = line.lower()
        if lowered in sections:
            current = lowered
            continue
        if line.startswith("- "):
            if current is None:
                continue
            sections[current].append(line[2:].strip())
    return sections


def parse_answer_block(text: str) -> dict | None:
    match = ANSWER_BLOCK_RE.search(text)
    if match is None:
        return None
    try:
        parsed = json.loads(match.group(1))
    except json.JSONDecodeError:
        return None
    return parsed if isinstance(parsed, dict) else None


def parse_answer_text_block(text: str) -> str | None:
    match = ANSWER_TEXT_BLOCK_RE.search(text)
    if match is None:
        return None
    lines = [line.strip() for line in match.group(1).splitlines() if line.strip()]
    if len(lines) != 1:
        return None
    return lines[0]


def validate_expected_answer(answer: object, row_id: str, path: str) -> list[str]:
    errors: list[str] = []
    if not isinstance(answer, dict):
        return [f"{row_id}: {path} must be an object"]
    if set(answer) != {"dominant", "irreducible", "reducible"}:
        errors.append(f"{row_id}: {path} must contain exactly dominant, irreducible, reducible")
        return errors
    dominant = answer.get("dominant")
    if not isinstance(dominant, str) or not dominant:
        errors.append(f"{row_id}: {path}.dominant must be a non-empty string")
    elif dominant not in PROCESS_IDS:
        errors.append(f"{row_id}: unknown dominant process id {dominant!r}")
    for key in ["irreducible", "reducible"]:
        values = answer.get(key)
        if not isinstance(values, list):
            errors.append(f"{row_id}: {path}.{key} must be a list")
            continue
        for value in values:
            if not isinstance(value, str):
                errors.append(f"{row_id}: {path}.{key} contains a non-string id")
            elif value not in PROCESS_IDS:
                errors.append(f"{row_id}: unknown {key} process id {value!r}")
    return errors


def validate_common(row: dict, row_id: str) -> list[str]:
    errors: list[str] = []
    required = {
        "id",
        "source_id",
        "split",
        "task_type",
        "title",
        "year",
        "physics_target",
        "broad_physics_area",
        "final_state",
        "process_catalog_version",
        "expected_answer",
        "metadata",
        "target_type",
        "target_category",
        "target_process_id",
        "target_background",
    }
    missing = required - set(row)
    if missing:
        errors.append(f"{row_id}: missing fields {sorted(missing)}")
    if row.get("task_type") != TASK_TYPE:
        errors.append(f"{row_id}: invalid task_type {row.get('task_type')!r}")
    if row.get("split") not in SPLITS:
        errors.append(f"{row_id}: invalid split {row.get('split')!r}")
    if not isinstance(row.get("final_state"), dict):
        errors.append(f"{row_id}: final_state must be an object")
    if row.get("process_catalog_version") != PROCESS_CATALOG_VERSION:
        errors.append(f"{row_id}: process_catalog_version must be {PROCESS_CATALOG_VERSION!r}")
    errors.extend(validate_expected_answer(row.get("expected_answer"), row_id, "expected_answer"))
    target_type = row.get("target_type")
    target_category = row.get("target_category")
    target_process_id = row.get("target_process_id")
    target_background = row.get("target_background")
    if target_type not in SFT_TARGET_TYPES:
        errors.append(f"{row_id}: invalid target_type {target_type!r}")
    elif target_category != SFT_TARGET_TYPES[target_type]:
        errors.append(f"{row_id}: target_category {target_category!r} does not match target_type {target_type!r}")
    if not isinstance(target_process_id, str) or target_process_id not in PROCESS_IDS:
        errors.append(f"{row_id}: target_process_id must be a known process id")
    if not isinstance(target_background, str) or not target_background:
        errors.append(f"{row_id}: target_background must be a non-empty string")
    row_expected = row.get("expected_answer")
    if (
        isinstance(row_expected, dict)
        and isinstance(target_category, str)
        and isinstance(target_process_id, str)
        and target_process_id not in row_expected.get(target_category, [])
    ):
        errors.append(f"{row_id}: target_process_id must appear in expected_answer.{target_category}")
    metadata = row.get("metadata")
    if not isinstance(metadata, dict):
        errors.append(f"{row_id}: metadata must be an object")
        return errors
    if metadata.get("process_catalog_version") != PROCESS_CATALOG_VERSION:
        errors.append(f"{row_id}: metadata.process_catalog_version must be {PROCESS_CATALOG_VERSION!r}")
    if metadata.get("expected_answer") != row.get("expected_answer"):
        errors.append(f"{row_id}: metadata.expected_answer must match row expected_answer")
    for key in ["target_type", "target_category", "target_process_id", "target_background"]:
        if metadata.get(key) != row.get(key):
            errors.append(f"{row_id}: metadata.{key} must match row {key}")
    for key in ["dominant_backgrounds", "irreducible_backgrounds", "reducible_backgrounds", "ranked_processes"]:
        if not isinstance(metadata.get(key), list) or not metadata.get(key):
            errors.append(f"{row_id}: metadata.{key} must be a non-empty list")
    if isinstance(metadata.get("dominant_backgrounds"), list) and len(metadata["dominant_backgrounds"]) != 1:
        errors.append(f"{row_id}: metadata.dominant_backgrounds must contain exactly one process")
    row_dominant = row_expected.get("dominant") if isinstance(row_expected, dict) else None
    if metadata.get("dominant_process_id") != row_dominant:
        errors.append(f"{row_id}: metadata.dominant_process_id must match expected_answer.dominant")
    if isinstance(target_category, str) and isinstance(target_background, str):
        category_labels = metadata.get(f"{target_category}_backgrounds")
        if isinstance(category_labels, list) and target_background not in category_labels:
            errors.append(f"{row_id}: target_background must appear in metadata.{target_category}_backgrounds")
    for key in ["irreducible_process_ids", "reducible_process_ids", "ranked_process_ids"]:
        values = metadata.get(key)
        if not isinstance(values, list):
            errors.append(f"{row_id}: metadata.{key} must be a list")
            continue
        unknown = [value for value in values if value not in PROCESS_IDS]
        if unknown:
            errors.append(f"{row_id}: metadata.{key} contains unknown ids {unknown[:5]}")
    return errors


def validate_messages(row: dict, row_id: str) -> list[str]:
    errors: list[str] = []
    messages = row.get("messages")
    if not isinstance(messages, list) or len(messages) != 3:
        return [f"{row_id}: messages must have exactly 3 entries"]
    roles = [message.get("role") for message in messages if isinstance(message, dict)]
    if roles != ["system", "user", "assistant"]:
        errors.append(f"{row_id}: wrong message roles {roles}")
    for idx, label in [(1, "user message"), (2, "assistant message")]:
        content = str(messages[idx].get("content", "")) if isinstance(messages[idx], dict) else ""
        match = TRAINABLE_EVIDENCE_TRACE_RE.search(content)
        if match:
            errors.append(f"{row_id}: {label} contains evidence trace text {match.group(0)!r}")
    return errors


def validate_sft(row: dict) -> list[str]:
    row_id = str(row.get("id", "<missing id>"))
    errors = validate_common(row, row_id)
    errors.extend(validate_messages(row, row_id))
    if errors:
        return errors

    answer = str(row["messages"][2]["content"])
    lowered = answer.lower()
    answer_matches = ANSWER_TEXT_BLOCK_RE.findall(answer)
    if THINK_BLOCK_RE.search(answer):
        errors.append(f"{row_id}: SFT answer must not contain a <think> block")
    if len(answer_matches) != 1:
        errors.append(f"{row_id}: SFT answer must contain exactly one <answer>...</answer> block")
    if "<think" in lowered or "</think>" in lowered:
        errors.append(f"{row_id}: SFT answer must not contain think tags")
    if len(answer.split()) > 40:
        errors.append(f"{row_id}: SFT answer is too long")

    answer_text = parse_answer_text_block(answer)
    if answer_text is None:
        errors.append(f"{row_id}: SFT answer block must contain exactly one non-empty line")
    elif answer_text.startswith("- "):
        errors.append(f"{row_id}: SFT answer block must not use bullets")
    else:
        expected_label = row.get("target_background")
        if answer_text != expected_label:
            errors.append(f"{row_id}: SFT answer {answer_text!r} must match target background {expected_label!r}")
    return errors


def validate_rl(row: dict) -> list[str]:
    row_id = str(row.get("id", "<missing id>"))
    errors = validate_common(row, row_id)
    required = {"prompt", "chosen_answer", "rejected_answer", "quality_note"}
    missing = required - set(row)
    if missing:
        errors.append(f"{row_id}: missing RL fields {sorted(missing)}")
        return errors
    for field in ["prompt", "chosen_answer", "rejected_answer"]:
        match = TRAINABLE_EVIDENCE_TRACE_RE.search(str(row.get(field, "")))
        if match:
            errors.append(f"{row_id}: {field} contains evidence trace text {match.group(0)!r}")
    chosen = str(row.get("chosen_answer", ""))
    lowered = chosen.lower()
    if "<think>" not in lowered or "</think>" not in lowered:
        errors.append(f"{row_id}: RL chosen_answer must contain a <think>...</think> block")
    else:
        think_match = THINK_BLOCK_RE.search(chosen)
        think_text = think_match.group(1).lower() if think_match else ""
        for required_phrase in [
            "same reconstructed final-state particles",
            "irreducible backgrounds",
            "fakes",
            "reducible backgrounds",
        ]:
            if required_phrase not in think_text:
                errors.append(f"{row_id}: RL think block must mention {required_phrase!r}")
    answer = parse_answer_block(chosen)
    if answer is None:
        errors.append(f"{row_id}: RL chosen_answer must contain parseable <answer> JSON")
    else:
        errors.extend(validate_expected_answer(answer, row_id, "chosen_answer answer"))
        if answer != row.get("expected_answer"):
            errors.append(f"{row_id}: chosen_answer JSON must exactly match expected_answer")
    if str(row.get("chosen_answer", "")).strip() == str(row.get("rejected_answer", "")).strip():
        errors.append(f"{row_id}: chosen_answer and rejected_answer are identical")
    return errors


def main() -> int:
    errors: list[str] = []
    sft = read_jsonl(PROCESSED / "sft.jsonl")
    rl = read_jsonl(PROCESSED / "rl.jsonl")

    if not sft:
        errors.append("sft.jsonl is empty")
    if not rl:
        errors.append("rl.jsonl is empty")

    for name, rows in [("sft", sft), ("rl", rl)]:
        ids = [row.get("id") for row in rows if row.get("id")]
        dupes = [item for item, count in Counter(ids).items() if count > 1]
        if dupes:
            errors.append(f"{name}: duplicate ids: {dupes[:10]}")

    sft_by_id = {row.get("id"): row for row in sft}
    rl_by_id = {row.get("id"): row for row in rl}
    if set(sft_by_id) != set(rl_by_id):
        errors.append("SFT and RL ids do not match")

    for row in sft:
        errors.extend(validate_sft(row))
    for row in rl:
        errors.extend(validate_rl(row))

    split_counts = Counter(row.get("split") for row in sft)
    if "train" not in split_counts:
        errors.append("No train examples found")
    if not ({"val", "test"} & set(split_counts)):
        errors.append("No validation/test examples found")

    if errors:
        print("Validation failed:")
        for error in errors:
            print(f"- {error}")
        return 1

    print("Validation passed")
    print(f"SFT examples: {len(sft)}")
    print(f"RL examples: {len(rl)}")
    print(f"SFT split counts: {dict(split_counts)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())