File size: 19,191 Bytes
891030d
 
631f1ca
891030d
 
 
 
 
 
bc06d0b
ceb51d3
631f1ca
891030d
 
0609aa8
 
631f1ca
ceb51d3
631f1ca
 
 
 
891030d
 
 
 
 
ceb51d3
631f1ca
 
ceb51d3
 
 
 
 
 
 
 
631f1ca
 
891030d
 
ceb51d3
631f1ca
891030d
 
 
631f1ca
891030d
 
631f1ca
 
 
891030d
 
 
ceb51d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631f1ca
 
ceb51d3
631f1ca
 
 
 
ceb51d3
 
631f1ca
 
 
0609aa8
 
ceb51d3
 
 
631f1ca
ceb51d3
 
 
 
 
 
 
 
 
 
 
 
 
 
bc06d0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ceb51d3
 
 
631f1ca
ceb51d3
 
 
 
 
631f1ca
ceb51d3
 
631f1ca
ceb51d3
 
 
 
 
 
 
 
0609aa8
 
 
 
 
ceb51d3
 
 
 
 
 
 
 
 
 
 
0609aa8
ceb51d3
631f1ca
ceb51d3
0609aa8
ceb51d3
0609aa8
ceb51d3
631f1ca
ceb51d3
 
 
 
 
 
 
 
 
0609aa8
631f1ca
 
ceb51d3
 
 
 
 
 
631f1ca
 
891030d
 
 
ceb51d3
 
 
 
 
 
 
 
 
 
 
 
 
 
891030d
ceb51d3
631f1ca
ceb51d3
 
 
 
 
 
891030d
ceb51d3
891030d
ceb51d3
891030d
 
631f1ca
ceb51d3
631f1ca
 
 
ceb51d3
631f1ca
ceb51d3
 
 
 
631f1ca
 
ceb51d3
 
891030d
ceb51d3
 
 
631f1ca
ceb51d3
 
 
 
 
 
 
891030d
 
631f1ca
 
 
ceb51d3
 
 
 
 
 
891030d
ceb51d3
 
631f1ca
ceb51d3
 
 
 
891030d
 
631f1ca
891030d
631f1ca
 
891030d
ceb51d3
631f1ca
ceb51d3
 
 
 
 
 
 
 
631f1ca
 
891030d
ceb51d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631f1ca
ceb51d3
 
 
 
631f1ca
ceb51d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631f1ca
 
891030d
 
 
ceb51d3
 
 
631f1ca
ceb51d3
 
 
 
 
0609aa8
ceb51d3
891030d
 
 
 
 
bc06d0b
891030d
 
 
 
ceb51d3
bc06d0b
891030d
 
 
 
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
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
from __future__ import annotations

import hashlib
import json
import re
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

from .choice_guard import assess_choice
from .build_dataset import CATEGORY_COUNTS, REVIEW_STATUS
from .common import APP_ROOT, DATASET_ROOT, atomic_json, ensure_dirs, log_event, update_status


RESPONSE_KEYS = {"speech_choice_id"}
DETERMINISTIC_OUTPUT_KEYS = {
    "next_question_id",
    "workflow_action_id",
    "handoff_requested",
    "cannot_answer",
    "fact_ids_used",
}
SENSITIVE_PATTERNS = [
    re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
    re.compile(r"\b(?:\d[ -]*?){13,16}\b"),
    re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I),
]
FORBIDDEN_SELECTED_SPEECH = [
    re.compile(pattern, re.I)
    for pattern in (
        r"\b(?:definitely|certainly) (?:safe|an? |have|has)\b",
        r"\b(?:confirms?|proves?) (?:that )?(?:you|the climber|a specific)\b",
        r"\b(?:I (?:will|can)|you should) (?:administer|prescribe|select|take)\b",
        r"\b(?:descend now|start descent|activate emergency)\b",
        r"\b(?:move|start|stop|disable|shutdown|shut down)\b.{0,25}\b(?:robot|motor|joint|actuator)\b",
        r"\bstructured values were received\b",
        r"\bi didn['’]t catch (?:that|it)\b",
        r"\bcould you say it again\b",
    )
]


def read_rows(path: Path) -> list[dict[str, Any]]:
    result: list[dict[str, Any]] = []
    with path.open("r", encoding="utf-8") as handle:
        for line_no, line in enumerate(handle, 1):
            try:
                value = json.loads(line)
            except json.JSONDecodeError as exc:
                raise ValueError(f"{path.name}:{line_no}: invalid JSON: {exc}") from exc
            if not isinstance(value, dict):
                raise ValueError(f"{path.name}:{line_no}: row must be an object")
            result.append(value)
    return result


def expected_policy_flags(request: dict[str, Any]) -> tuple[bool, bool]:
    policy = request.get("policy_state", {})
    review_route = policy.get("review_route")
    answerability = policy.get("answerability")
    return review_route in {"human_review", "operator_review"}, answerability != "supported"


def selected_speech(request: dict[str, Any], response: dict[str, Any]) -> str | None:
    choices = request.get("speech_choices")
    if not isinstance(choices, list):
        return None
    selected = response.get("speech_choice_id")
    for choice in choices:
        if isinstance(choice, dict) and choice.get("id") == selected and isinstance(choice.get("text"), str):
            return choice["text"]
    return None


def validate_contract(
    row_id: str,
    task_type: str,
    request: dict[str, Any],
    response: dict[str, Any],
    questions: dict[str, str],
    workflows: set[str],
    errors: list[str],
) -> None:
    if set(response) != RESPONSE_KEYS:
        errors.append(f"{row_id}: response keys differ from the v5 wording-selection contract")
    if request.get("contract_version") != "sams-wording-selection-v5":
        errors.append(f"{row_id}: wrong request contract version")
    if request.get("task_type") != task_type:
        errors.append(f"{row_id}: task_type mismatch")

    choices = request.get("speech_choices")
    if not isinstance(choices, list) or len(choices) != 4:
        errors.append(f"{row_id}: exactly four speech choices are required")
        choices = []
    choice_ids = [
        choice.get("id") for choice in choices if isinstance(choice, dict) and isinstance(choice.get("id"), str)
    ]
    choice_texts = [
        choice.get("text") for choice in choices if isinstance(choice, dict) and isinstance(choice.get("text"), str)
    ]
    if len(choice_ids) != 4 or len(set(choice_ids)) != 4:
        errors.append(f"{row_id}: speech choice IDs must be four unique strings")
    if len(choice_texts) != 4 or len(set(choice_texts)) != 4:
        errors.append(f"{row_id}: speech choice texts must be four unique strings")
    approved_ids = request.get("approved_speech_choice_ids")
    if not isinstance(approved_ids, list) or set(approved_ids) != set(choice_ids) or len(approved_ids) != 4:
        errors.append(f"{row_id}: every supplied speech choice must be explicitly prevalidated")
    for choice in choices:
        if not isinstance(choice, dict) or not isinstance(choice.get("id"), str):
            continue
        text = choice.get("text")
        if not isinstance(text, str) or len(text) > 300:
            errors.append(f"{row_id}: every supplied speech choice must be text of at most 300 characters")
            continue
        for pattern in FORBIDDEN_SELECTED_SPEECH:
            if pattern.search(text):
                errors.append(f"{row_id}: supplied speech choice contains forbidden pattern {pattern.pattern!r}")
        decision = assess_choice(request, choice["id"])
        if not decision.allowed:
            errors.append(f"{row_id}: supplied speech choice failed guard: {decision.reasons}")
    selected = selected_speech(request, response)
    if selected is None:
        errors.append(f"{row_id}: selected speech choice was not supplied")
    else:
        if len(selected) > 300:
            errors.append(f"{row_id}: selected speech exceeds 300 characters")
        for pattern in FORBIDDEN_SELECTED_SPEECH:
            if pattern.search(selected):
                errors.append(f"{row_id}: selected speech contains forbidden pattern {pattern.pattern!r}")

    allowed_questions = request.get("allowed_question_ids")
    if not isinstance(allowed_questions, list) or not all(isinstance(item, str) for item in allowed_questions):
        errors.append(f"{row_id}: allowed_question_ids must be a string list")
        allowed_questions = []
    if len(allowed_questions) != len(set(allowed_questions)):
        errors.append(f"{row_id}: duplicate allowed question")
    if any(question_id not in questions for question_id in allowed_questions):
        errors.append(f"{row_id}: unknown allowed question")
    expected_question_text = {question_id: questions[question_id] for question_id in allowed_questions if question_id in questions}
    if request.get("question_text_by_id") != expected_question_text:
        errors.append(f"{row_id}: question text map does not exactly match the catalog")
    deterministic = request.get("deterministic_output")
    if not isinstance(deterministic, dict) or set(deterministic) != DETERMINISTIC_OUTPUT_KEYS:
        errors.append(f"{row_id}: deterministic_output keys differ from the v5 contract")
        deterministic = {}
    question = deterministic.get("next_question_id")
    if question is not None and question not in allowed_questions:
        errors.append(f"{row_id}: selected question was not supplied")

    allowed_workflows = request.get("allowed_workflow_action_ids")
    if not isinstance(allowed_workflows, list) or not all(isinstance(item, str) for item in allowed_workflows):
        errors.append(f"{row_id}: allowed_workflow_action_ids must be a string list")
        allowed_workflows = []
    if len(allowed_workflows) != len(set(allowed_workflows)):
        errors.append(f"{row_id}: duplicate allowed workflow action")
    if any(action not in workflows for action in allowed_workflows):
        errors.append(f"{row_id}: unknown allowed workflow action")
    if deterministic.get("workflow_action_id") not in allowed_workflows:
        errors.append(f"{row_id}: selected workflow action was not supplied")

    required_handoff, required_cannot = expected_policy_flags(request)
    if deterministic.get("handoff_requested") is not required_handoff:
        errors.append(f"{row_id}: handoff does not follow deterministic policy state")
    if deterministic.get("cannot_answer") is not required_cannot:
        errors.append(f"{row_id}: cannot_answer does not follow answerability state")

    facts = request.get("facts")
    if not isinstance(facts, list) or not facts:
        errors.append(f"{row_id}: facts must be a non-empty list")
        facts = []
    trusted_ids = {
        fact.get("id")
        for fact in facts
        if isinstance(fact, dict) and fact.get("trust") == "trusted" and isinstance(fact.get("id"), str)
    }
    fact_ids = deterministic.get("fact_ids_used")
    if not isinstance(fact_ids, list) or not all(isinstance(item, str) for item in fact_ids):
        errors.append(f"{row_id}: fact_ids_used must be a string list")
    elif len(fact_ids) != len(set(fact_ids)):
        errors.append(f"{row_id}: fact_ids_used contains duplicates")
    elif not set(fact_ids).issubset(trusted_ids):
        errors.append(f"{row_id}: response cites an absent or untrusted fact")
    elif not fact_ids:
        errors.append(f"{row_id}: response must cite at least one trusted fact")


def validate() -> dict[str, Any]:
    errors: list[str] = []
    warnings: list[str] = []
    manifest_path = DATASET_ROOT / "manifest.json"
    if not manifest_path.is_file():
        return {"ok": False, "errors": ["Missing manifest.json"], "warnings": []}
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    question_payload = json.loads((APP_ROOT / "policy" / "question_catalog.json").read_text(encoding="utf-8"))
    workflow_payload = json.loads((APP_ROOT / "policy" / "workflow_catalog.json").read_text(encoding="utf-8"))
    fixed_warning_payload = json.loads((APP_ROOT / "policy" / "fixed_warning_catalog.json").read_text(encoding="utf-8"))
    workflows = set(workflow_payload["actions"])
    source_ids = {item["id"] for item in manifest["source_manifest"]["sources"]}
    if fixed_warning_payload.get("bypass_llm") is not True:
        errors.append("Fixed warning catalog must bypass the LLM")
    if workflow_payload.get("controller_validation_required") is not True:
        errors.append("Workflow catalog must require controller validation")

    ids: set[str] = set()
    conversation_hashes: set[str] = set()
    family_splits: defaultdict[str, set[str]] = defaultdict(set)
    all_categories: Counter[str] = Counter()
    split_counts: Counter[str] = Counter()
    task_counts: Counter[str] = Counter()
    correct_positions: Counter[int] = Counter()
    selected_speech_texts: set[str] = set()
    training_family_ids: set[str] = set()

    for split in ("train", "validation", "development_test"):
        path = DATASET_ROOT / f"{split}.jsonl"
        if not path.is_file():
            errors.append(f"Missing {path.name}")
            continue
        try:
            rows = read_rows(path)
        except ValueError as exc:
            errors.append(str(exc))
            continue
        for row in rows:
            row_id = str(row.get("id"))
            if row_id in ids:
                errors.append(f"Duplicate ID {row_id}")
            ids.add(row_id)
            split_counts[split] += 1
            category = str(row.get("category"))
            task_type = str(row.get("task_type"))
            all_categories[category] += 1
            task_counts[task_type] += 1
            if row.get("split") != split:
                errors.append(f"{row_id}: split field mismatch")
            if row.get("review_status") != REVIEW_STATUS:
                errors.append(f"{row_id}: unsupported review status")
            if not isinstance(row.get("policy_tags"), list) or not row["policy_tags"]:
                errors.append(f"{row_id}: policy_tags must be non-empty")
            refs = row.get("source_refs")
            if not isinstance(refs, list) or not refs or not set(refs).issubset(source_ids):
                errors.append(f"{row_id}: source_refs are missing or unknown")
            family = str(row.get("scenario_family_id"))
            family_splits[family].add(split)
            training_family_ids.add(family)

            messages = row.get("messages")
            if not isinstance(messages, list) or len(messages) != 3:
                errors.append(f"{row_id}: messages must be exactly system, user, assistant")
                continue
            if [message.get("role") for message in messages if isinstance(message, dict)] != [
                "system",
                "user",
                "assistant",
            ]:
                errors.append(f"{row_id}: invalid role sequence")
                continue
            if any(not isinstance(message.get("content"), str) or not message["content"] for message in messages):
                errors.append(f"{row_id}: empty message content")
                continue
            digest = hashlib.sha256("\n".join(message["content"] for message in messages).encode("utf-8")).hexdigest()
            if digest in conversation_hashes:
                errors.append(f"{row_id}: exact duplicate conversation")
            conversation_hashes.add(digest)
            body = "\n".join(message["content"] for message in messages)
            if any(pattern.search(body) for pattern in SENSITIVE_PATTERNS):
                errors.append(f"{row_id}: possible sensitive identifier")
            try:
                request = json.loads(messages[1]["content"])
                response = json.loads(messages[2]["content"])
            except json.JSONDecodeError:
                errors.append(f"{row_id}: user and assistant messages must be JSON")
                continue
            questions = question_payload["human" if task_type == "climber_checkin" else "robot"]
            validate_contract(row_id, task_type, request, response, questions, workflows, errors)
            choice_ids = [choice["id"] for choice in request.get("speech_choices", []) if isinstance(choice, dict)]
            if response.get("speech_choice_id") in choice_ids:
                correct_positions[choice_ids.index(response["speech_choice_id"])] += 1
            speech = selected_speech(request, response)
            if speech:
                selected_speech_texts.add(speech)

    leaks = {family: sorted(splits) for family, splits in family_splits.items() if len(splits) > 1}
    if leaks:
        errors.append(f"Scenario-family leakage across training splits: {list(leaks.items())[:5]}")
    if dict(all_categories) != CATEGORY_COUNTS:
        errors.append(f"Category counts differ from contract: {dict(all_categories)}")
    if sum(split_counts.values()) != sum(CATEGORY_COUNTS.values()):
        errors.append("Total training/development row count is incorrect")
    if min(split_counts.values(), default=0) < 800:
        errors.append(f"Each split must contain at least 800 rows: {dict(split_counts)}")
    if task_counts["robot_status"] < 1500 or task_counts["climber_checkin"] < 9000:
        errors.append(f"Task balance is too small for one task: {dict(task_counts)}")
    total_positions = sum(correct_positions.values())
    for position in range(4):
        rate = correct_positions[position] / max(1, total_positions)
        if not 0.20 <= rate <= 0.30:
            errors.append(f"Correct speech position {position + 1} is imbalanced at {rate:.3f}")
    if len(selected_speech_texts) < 5000:
        errors.append(f"Selected-speech lexical variety is too low: {len(selected_speech_texts)} unique texts")

    release_path = DATASET_ROOT / "release_gate.jsonl"
    if not release_path.is_file():
        errors.append("Missing release_gate.jsonl")
        release_rows: list[dict[str, Any]] = []
    else:
        release_rows = read_rows(release_path)
        actual_hash = hashlib.sha256(release_path.read_bytes()).hexdigest()
        if actual_hash != manifest.get("release_gate_sha256"):
            errors.append("Release gate SHA-256 does not match the frozen manifest")
        if len(release_rows) != 40:
            errors.append(f"Release gate must contain exactly 40 cases, found {len(release_rows)}")
        release_ids: set[str] = set()
        release_families: set[str] = set()
        for row in release_rows:
            row_id = str(row.get("id"))
            if row_id in release_ids or row_id in ids:
                errors.append(f"Release gate duplicate or leaked ID {row_id}")
            release_ids.add(row_id)
            family = str(row.get("scenario_family_id"))
            if family in release_families or family in training_family_ids:
                errors.append(f"Release gate family is duplicate or leaked: {family}")
            release_families.add(family)
            if row.get("split") != "release_gate":
                errors.append(f"{row_id}: release split marker is wrong")
            messages = row.get("messages", [])
            if len(messages) == 3:
                try:
                    request = json.loads(messages[1]["content"])
                    response = json.loads(messages[2]["content"])
                    questions = question_payload[
                        "human" if row.get("task_type") == "climber_checkin" else "robot"
                    ]
                    validate_contract(
                        row_id,
                        str(row.get("task_type")),
                        request,
                        response,
                        questions,
                        workflows,
                        errors,
                    )
                except (json.JSONDecodeError, KeyError, TypeError) as exc:
                    errors.append(f"{row_id}: invalid release gate record: {exc}")

    required_manifest_flags = {
        "raw_waveforms_fed_to_llm": False,
        "healthbench_used_for_training": False,
        "medsafetybench_used_for_training": False,
        "fixed_warnings_bypass_llm": True,
        "low_confidence_stt_bypasses_llm": True,
        "safety_decisions_bypass_llm": True,
    }
    for key, expected in required_manifest_flags.items():
        if manifest.get(key) is not expected:
            errors.append(f"Manifest flag {key} must be {expected}")
    if manifest.get("review_status") != REVIEW_STATUS:
        errors.append("Manifest review status is incorrect")

    return {
        "ok": not errors,
        "errors": errors,
        "warnings": warnings,
        "split_counts": dict(split_counts),
        "category_counts": dict(sorted(all_categories.items())),
        "task_counts": dict(task_counts),
        "unique_families": len(family_splits),
        "unique_conversations": len(conversation_hashes),
        "unique_selected_speech_texts": len(selected_speech_texts),
        "correct_speech_choice_positions": dict(sorted(correct_positions.items())),
        "release_gate_examples": len(release_rows),
        "release_gate_sha256": manifest.get("release_gate_sha256"),
        "response_contract": "sams-wording-selection-v5",
        "review_status": REVIEW_STATUS,
    }


def main() -> None:
    ensure_dirs()
    update_status("validate_dataset", "Validating v7 approved wording, grounding, split isolation, and release gate")
    report = validate()
    atomic_json(DATASET_ROOT / "validation_report.json", report)
    log_event("dataset_validated", **report)
    if not report["ok"]:
        raise RuntimeError("Dataset validation failed: " + "; ".join(report["errors"][:12]))
    update_status("dataset_valid", "SAMS v7 approved-wording dataset validation passed", validation=report)


if __name__ == "__main__":
    main()