170 lines
7.2 KiB
Python
170 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Validate structured real-case outcome replay manifests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
CASE_REQUIRED_FIELDS = {
|
|
"case_id",
|
|
"subject",
|
|
"source",
|
|
"chart_signature",
|
|
"event_outcomes",
|
|
"similarity",
|
|
"replay",
|
|
}
|
|
SOURCE_REQUIRED_FIELDS = {"url", "source_grade", "license_or_quote_boundary"}
|
|
SUBJECT_REQUIRED_FIELDS = {
|
|
"name", "year", "month", "day", "hour", "minute", "lat", "lon", "tz",
|
|
"node_mode", "birth_source",
|
|
}
|
|
BIRTH_SOURCE_REQUIRED_FIELDS = {
|
|
"url", "source_grade", "time_accuracy_rating", "evidence_basis",
|
|
}
|
|
EVENT_REQUIRED_FIELDS = {
|
|
"event_type", "event_date", "domain", "expected_label", "outcome", "source",
|
|
}
|
|
EVENT_SOURCE_REQUIRED_FIELDS = {"url", "source_grade"}
|
|
ALLOWED_BIRTH_TIME_RATINGS = {"A", "AA"}
|
|
|
|
|
|
def _missing(mapping: dict[str, Any], required: set[str]) -> list[str]:
|
|
return sorted(name for name in required if name not in mapping)
|
|
|
|
|
|
def _case_errors(case: Any, index: int) -> list[dict[str, Any]]:
|
|
if not isinstance(case, dict):
|
|
return [{"case_index": index, "field": "case", "error": "not_object"}]
|
|
|
|
errors: list[dict[str, Any]] = []
|
|
for field in _missing(case, CASE_REQUIRED_FIELDS):
|
|
errors.append({"case_id": case.get("case_id"), "field": field, "error": "missing"})
|
|
|
|
source = case.get("source")
|
|
if isinstance(source, dict):
|
|
for field in _missing(source, SOURCE_REQUIRED_FIELDS):
|
|
errors.append({"case_id": case.get("case_id"), "field": f"source.{field}", "error": "missing"})
|
|
elif "source" in case:
|
|
errors.append({"case_id": case.get("case_id"), "field": "source", "error": "not_object"})
|
|
|
|
subject = case.get("subject")
|
|
if isinstance(subject, dict):
|
|
for field in _missing(subject, SUBJECT_REQUIRED_FIELDS):
|
|
errors.append({"case_id": case.get("case_id"), "field": f"subject.{field}", "error": "missing"})
|
|
birth_source = subject.get("birth_source")
|
|
if isinstance(birth_source, dict):
|
|
for field in _missing(birth_source, BIRTH_SOURCE_REQUIRED_FIELDS):
|
|
errors.append({"case_id": case.get("case_id"), "field": f"subject.birth_source.{field}", "error": "missing"})
|
|
rating = birth_source.get("time_accuracy_rating")
|
|
if rating not in ALLOWED_BIRTH_TIME_RATINGS:
|
|
errors.append({
|
|
"case_id": case.get("case_id"),
|
|
"field": "subject.birth_source.time_accuracy_rating",
|
|
"error": "birth_time_rating_below_A",
|
|
})
|
|
elif "birth_source" in subject:
|
|
errors.append({"case_id": case.get("case_id"), "field": "subject.birth_source", "error": "not_object"})
|
|
elif "subject" in case:
|
|
errors.append({"case_id": case.get("case_id"), "field": "subject", "error": "not_object"})
|
|
|
|
events = case.get("event_outcomes")
|
|
if isinstance(events, list):
|
|
if not events:
|
|
errors.append({"case_id": case.get("case_id"), "field": "event_outcomes", "error": "empty"})
|
|
for event_index, event in enumerate(events):
|
|
if not isinstance(event, dict):
|
|
errors.append({"case_id": case.get("case_id"), "field": f"event_outcomes[{event_index}]", "error": "not_object"})
|
|
continue
|
|
for field in _missing(event, EVENT_REQUIRED_FIELDS):
|
|
errors.append({"case_id": case.get("case_id"), "field": f"event_outcomes[{event_index}].{field}", "error": "missing"})
|
|
event_source = event.get("source")
|
|
if isinstance(event_source, dict):
|
|
for field in _missing(event_source, EVENT_SOURCE_REQUIRED_FIELDS):
|
|
errors.append({"case_id": case.get("case_id"), "field": f"event_outcomes[{event_index}].source.{field}", "error": "missing"})
|
|
elif "source" in event:
|
|
errors.append({"case_id": case.get("case_id"), "field": f"event_outcomes[{event_index}].source", "error": "not_object"})
|
|
elif "event_outcomes" in case:
|
|
errors.append({"case_id": case.get("case_id"), "field": "event_outcomes", "error": "not_array"})
|
|
|
|
return errors
|
|
|
|
|
|
def validate_manifest(path: str | Path) -> dict[str, Any]:
|
|
manifest_path = Path(path)
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
cases = manifest.get("cases")
|
|
if not isinstance(cases, list):
|
|
return {
|
|
"status": "blocked",
|
|
"manifest_path": str(manifest_path),
|
|
"case_count": 0,
|
|
"replay_ready_count": 0,
|
|
"blocked_reason": "cases_not_array",
|
|
"errors": [{"field": "cases", "error": "not_array"}],
|
|
}
|
|
|
|
errors: list[dict[str, Any]] = []
|
|
replay_ready_count = 0
|
|
domain_counts: dict[str, int] = {}
|
|
birth_time_ratings: dict[str, int] = {}
|
|
for index, case in enumerate(cases):
|
|
case_errors = _case_errors(case, index)
|
|
errors.extend(case_errors)
|
|
replay = case.get("replay") if isinstance(case, dict) else {}
|
|
if not case_errors and isinstance(replay, dict) and replay.get("outcome_replay_status") == "replayed":
|
|
replay_ready_count += 1
|
|
if isinstance(case, dict):
|
|
subject = case.get("subject") or {}
|
|
birth_source = subject.get("birth_source") if isinstance(subject, dict) else {}
|
|
rating = birth_source.get("time_accuracy_rating") if isinstance(birth_source, dict) else None
|
|
if isinstance(rating, str):
|
|
birth_time_ratings[rating] = birth_time_ratings.get(rating, 0) + 1
|
|
for event in case.get("event_outcomes") or []:
|
|
if isinstance(event, dict) and isinstance(event.get("domain"), str):
|
|
domain = event["domain"]
|
|
domain_counts[domain] = domain_counts.get(domain, 0) + 1
|
|
|
|
if errors:
|
|
status = "invalid"
|
|
blocked_reason = "case_contract_errors"
|
|
elif not cases:
|
|
status = "blocked"
|
|
blocked_reason = manifest.get("blocked_reason") or "no_structured_outcome_replay_cases_imported"
|
|
elif replay_ready_count == len(cases):
|
|
status = "pass"
|
|
blocked_reason = None
|
|
else:
|
|
status = "partial"
|
|
blocked_reason = "some_cases_not_replayed"
|
|
|
|
return {
|
|
"status": status,
|
|
"manifest_path": str(manifest_path),
|
|
"schema_version": manifest.get("schema_version"),
|
|
"case_schema": manifest.get("case_schema"),
|
|
"case_count": len(cases),
|
|
"replay_ready_count": replay_ready_count,
|
|
"domain_counts": dict(sorted(domain_counts.items())),
|
|
"birth_time_ratings": dict(sorted(birth_time_ratings.items())),
|
|
"blocked_reason": blocked_reason,
|
|
"errors": errors,
|
|
"runtime_boundary": manifest.get("runtime_boundary", ""),
|
|
}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("manifest", nargs="?", default="references/real_case_calibration/replay_manifest.json")
|
|
args = parser.parse_args(argv)
|
|
print(json.dumps(validate_manifest(args.manifest), ensure_ascii=False, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|