316 lines
14 KiB
Python
316 lines
14 KiB
Python
|
|
"""Validate Ghostlight training-loop scene receipt bundles.
|
||
|
|
|
||
|
|
This validator covers the stateful receipts that sit between sandboxed
|
||
|
|
responder turns and eventual training examples: event records, participant
|
||
|
|
appraisals, reviewed mutations, and scene-loop bundle manifests.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
DEFAULT_ROOT = ROOT / "examples" / "training-loops"
|
||
|
|
|
||
|
|
REVIEW_STATUSES = {"draft", "accepted", "accepted_as_draft", "useful_needs_revision", "rejected", "superseded"}
|
||
|
|
TRAINING_USABILITY = {"training_ready", "reference_only", "not_training_data"}
|
||
|
|
VISIBILITY_SCOPES = {"public_room", "participant_local", "camera_mediated", "partial_sightline", "coordinator_only"}
|
||
|
|
INTERPRETATION_LABELS = {"misread", "correct_read", "ambiguous", "strategic_read", "unknown"}
|
||
|
|
DELTA_STATUSES = {"accepted", "rejected", "deferred", "blocked"}
|
||
|
|
DELTA_AUTHORITIES = {"manual_review", "coordinator_review", "deterministic_gate", "blocked"}
|
||
|
|
|
||
|
|
|
||
|
|
class ValidationError(Exception):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def load_json(path: Path) -> Any:
|
||
|
|
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||
|
|
|
||
|
|
|
||
|
|
def require(condition: bool, message: str) -> None:
|
||
|
|
if not condition:
|
||
|
|
raise ValidationError(message)
|
||
|
|
|
||
|
|
|
||
|
|
def require_keys(obj: dict[str, Any], keys: list[str], path: str) -> None:
|
||
|
|
missing = [key for key in keys if key not in obj]
|
||
|
|
require(not missing, f"{path} missing required keys: {', '.join(missing)}")
|
||
|
|
|
||
|
|
|
||
|
|
def require_string(value: Any, path: str) -> None:
|
||
|
|
require(isinstance(value, str) and value.strip(), f"{path} must be a non-empty string")
|
||
|
|
|
||
|
|
|
||
|
|
def require_string_array(value: Any, path: str, *, nonempty: bool = False) -> None:
|
||
|
|
require(isinstance(value, list), f"{path} must be an array")
|
||
|
|
require(not nonempty or bool(value), f"{path} must not be empty")
|
||
|
|
for index, item in enumerate(value):
|
||
|
|
require_string(item, f"{path}[{index}]")
|
||
|
|
|
||
|
|
|
||
|
|
def require_ref(path_value: str, path: str) -> None:
|
||
|
|
require_string(path_value, path)
|
||
|
|
require((ROOT / path_value).exists(), f"{path} does not exist: {path_value}")
|
||
|
|
|
||
|
|
|
||
|
|
def validate_text_ref(value: Any, path: str) -> None:
|
||
|
|
require(isinstance(value, dict), f"{path} must be an object")
|
||
|
|
require_keys(value, ["text", "refs"], path)
|
||
|
|
require_string(value["text"], f"{path}.text")
|
||
|
|
require_string_array(value["refs"], f"{path}.refs", nonempty=True)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_review(value: Any, path: str, *, training_flag: bool = False) -> None:
|
||
|
|
require(isinstance(value, dict), f"{path} must be an object")
|
||
|
|
keys = ["reviewer", "rationale", "failure_labels"]
|
||
|
|
if training_flag:
|
||
|
|
keys.append("accepted_for_training")
|
||
|
|
require_keys(value, keys, path)
|
||
|
|
require_string(value["reviewer"], f"{path}.reviewer")
|
||
|
|
require_string(value["rationale"], f"{path}.rationale")
|
||
|
|
require_string_array(value["failure_labels"], f"{path}.failure_labels")
|
||
|
|
if training_flag:
|
||
|
|
require(isinstance(value["accepted_for_training"], bool), f"{path}.accepted_for_training must be boolean")
|
||
|
|
|
||
|
|
|
||
|
|
def validate_event(document: dict[str, Any], source: Path) -> None:
|
||
|
|
base = str(source)
|
||
|
|
require(document.get("schema_version") == "ghostlight.event_record.v0", f"{base}.schema_version is wrong")
|
||
|
|
require_keys(
|
||
|
|
document,
|
||
|
|
[
|
||
|
|
"event_id",
|
||
|
|
"review_status",
|
||
|
|
"scene_loop_id",
|
||
|
|
"turn_id",
|
||
|
|
"source_responder_output_ref",
|
||
|
|
"actor_agent_id",
|
||
|
|
"observable_action",
|
||
|
|
"spoken_text",
|
||
|
|
"visible_object_refs",
|
||
|
|
"visibility_scope",
|
||
|
|
"affected_participant_ids",
|
||
|
|
"public_state_changes",
|
||
|
|
"hidden_intent_handling",
|
||
|
|
"review",
|
||
|
|
],
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
require(document["review_status"] in REVIEW_STATUSES, f"{base}.review_status is invalid")
|
||
|
|
for key in ["event_id", "scene_loop_id", "turn_id", "actor_agent_id", "observable_action", "hidden_intent_handling"]:
|
||
|
|
require_string(document[key], f"{base}.{key}")
|
||
|
|
require_ref(document["source_responder_output_ref"], f"{base}.source_responder_output_ref")
|
||
|
|
require(isinstance(document["spoken_text"], str), f"{base}.spoken_text must be a string")
|
||
|
|
require_string_array(document["visible_object_refs"], f"{base}.visible_object_refs")
|
||
|
|
require_string_array(document["affected_participant_ids"], f"{base}.affected_participant_ids", nonempty=True)
|
||
|
|
require(document["visibility_scope"] in VISIBILITY_SCOPES, f"{base}.visibility_scope is invalid")
|
||
|
|
require(isinstance(document["public_state_changes"], list), f"{base}.public_state_changes must be an array")
|
||
|
|
for index, item in enumerate(document["public_state_changes"]):
|
||
|
|
validate_text_ref(item, f"{base}.public_state_changes[{index}]")
|
||
|
|
validate_review(document["review"], f"{base}.review", training_flag=True)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_appraisal(document: dict[str, Any], source: Path) -> None:
|
||
|
|
base = str(source)
|
||
|
|
require(document.get("schema_version") == "ghostlight.participant_appraisal.v0", f"{base}.schema_version is wrong")
|
||
|
|
require_keys(
|
||
|
|
document,
|
||
|
|
[
|
||
|
|
"appraisal_id",
|
||
|
|
"review_status",
|
||
|
|
"scene_loop_id",
|
||
|
|
"turn_id",
|
||
|
|
"participant_agent_id",
|
||
|
|
"event_ref",
|
||
|
|
"current_character_state_ref",
|
||
|
|
"participant_local_context",
|
||
|
|
"observable_event_summary",
|
||
|
|
"known_facts",
|
||
|
|
"active_pressures",
|
||
|
|
"perceived_relationships",
|
||
|
|
"interpretation",
|
||
|
|
"emotional_appraisal",
|
||
|
|
"interpretation_label",
|
||
|
|
"confidence_notes",
|
||
|
|
"candidate_implications",
|
||
|
|
"review",
|
||
|
|
],
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
require(document["review_status"] in REVIEW_STATUSES, f"{base}.review_status is invalid")
|
||
|
|
for key in ["appraisal_id", "scene_loop_id", "turn_id", "participant_agent_id", "observable_event_summary", "interpretation", "emotional_appraisal", "confidence_notes"]:
|
||
|
|
require_string(document[key], f"{base}.{key}")
|
||
|
|
require_ref(document["event_ref"], f"{base}.event_ref")
|
||
|
|
require_ref(document["current_character_state_ref"], f"{base}.current_character_state_ref")
|
||
|
|
require_string_array(document["participant_local_context"], f"{base}.participant_local_context", nonempty=True)
|
||
|
|
require_string_array(document["known_facts"], f"{base}.known_facts", nonempty=True)
|
||
|
|
require_string_array(document["active_pressures"], f"{base}.active_pressures", nonempty=True)
|
||
|
|
require_string_array(document["perceived_relationships"], f"{base}.perceived_relationships", nonempty=True)
|
||
|
|
require(document["interpretation_label"] in INTERPRETATION_LABELS, f"{base}.interpretation_label is invalid")
|
||
|
|
implications = document["candidate_implications"]
|
||
|
|
require(isinstance(implications, dict), f"{base}.candidate_implications must be an object")
|
||
|
|
for key in ["memory", "relationship", "state", "world"]:
|
||
|
|
require_string_array(implications.get(key), f"{base}.candidate_implications.{key}")
|
||
|
|
validate_review(document["review"], f"{base}.review", training_flag=True)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_delta(value: Any, path: str) -> None:
|
||
|
|
require(isinstance(value, dict), f"{path} must be an object")
|
||
|
|
require_keys(value, ["delta_id", "target_ref", "delta_type", "summary", "authority", "status", "rationale"], path)
|
||
|
|
for key in ["delta_id", "target_ref", "delta_type", "summary", "rationale"]:
|
||
|
|
require_string(value[key], f"{path}.{key}")
|
||
|
|
require(value["authority"] in DELTA_AUTHORITIES, f"{path}.authority is invalid")
|
||
|
|
require(value["status"] in DELTA_STATUSES, f"{path}.status is invalid")
|
||
|
|
|
||
|
|
|
||
|
|
def validate_mutation(document: dict[str, Any], source: Path) -> None:
|
||
|
|
base = str(source)
|
||
|
|
require(document.get("schema_version") == "ghostlight.reviewed_mutation.v0", f"{base}.schema_version is wrong")
|
||
|
|
require_keys(
|
||
|
|
document,
|
||
|
|
[
|
||
|
|
"mutation_id",
|
||
|
|
"review_status",
|
||
|
|
"scene_loop_id",
|
||
|
|
"turn_id",
|
||
|
|
"prior_state_refs",
|
||
|
|
"event_ref",
|
||
|
|
"responder_output_ref",
|
||
|
|
"appraisal_refs",
|
||
|
|
"accepted_deltas",
|
||
|
|
"rejected_deltas",
|
||
|
|
"deferred_deltas",
|
||
|
|
"authority_notes",
|
||
|
|
"post_state_summary",
|
||
|
|
"review",
|
||
|
|
],
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
require(document["review_status"] in REVIEW_STATUSES, f"{base}.review_status is invalid")
|
||
|
|
for key in ["mutation_id", "scene_loop_id", "turn_id", "authority_notes", "post_state_summary"]:
|
||
|
|
require_string(document[key], f"{base}.{key}")
|
||
|
|
require_string_array(document["prior_state_refs"], f"{base}.prior_state_refs", nonempty=True)
|
||
|
|
for index, ref in enumerate(document["prior_state_refs"]):
|
||
|
|
require_ref(ref, f"{base}.prior_state_refs[{index}]")
|
||
|
|
require_ref(document["event_ref"], f"{base}.event_ref")
|
||
|
|
require_ref(document["responder_output_ref"], f"{base}.responder_output_ref")
|
||
|
|
require_string_array(document["appraisal_refs"], f"{base}.appraisal_refs", nonempty=True)
|
||
|
|
for index, ref in enumerate(document["appraisal_refs"]):
|
||
|
|
require_ref(ref, f"{base}.appraisal_refs[{index}]")
|
||
|
|
for key in ["accepted_deltas", "rejected_deltas", "deferred_deltas"]:
|
||
|
|
require(isinstance(document[key], list), f"{base}.{key} must be an array")
|
||
|
|
for index, delta in enumerate(document[key]):
|
||
|
|
validate_delta(delta, f"{base}.{key}[{index}]")
|
||
|
|
require(document["accepted_deltas"], f"{base}.accepted_deltas must not be empty")
|
||
|
|
validate_review(document["review"], f"{base}.review", training_flag=True)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_bundle(document: dict[str, Any], source: Path) -> None:
|
||
|
|
base = str(source)
|
||
|
|
require(document.get("schema_version") == "ghostlight.scene_loop_bundle.v0", f"{base}.schema_version is wrong")
|
||
|
|
require_keys(
|
||
|
|
document,
|
||
|
|
[
|
||
|
|
"bundle_id",
|
||
|
|
"review_status",
|
||
|
|
"training_usability",
|
||
|
|
"reference_fixture_refs",
|
||
|
|
"scene_source",
|
||
|
|
"initial_state_ref",
|
||
|
|
"turns",
|
||
|
|
"review_summary",
|
||
|
|
],
|
||
|
|
base,
|
||
|
|
)
|
||
|
|
require(document["review_status"] in REVIEW_STATUSES, f"{base}.review_status is invalid")
|
||
|
|
usability = document["training_usability"]
|
||
|
|
require(isinstance(usability, dict), f"{base}.training_usability must be an object")
|
||
|
|
require("overall" in usability, f"{base}.training_usability.overall is required")
|
||
|
|
for key, value in usability.items():
|
||
|
|
require_string(key, f"{base}.training_usability key")
|
||
|
|
require(value in TRAINING_USABILITY, f"{base}.training_usability.{key} is invalid")
|
||
|
|
require(isinstance(document["reference_fixture_refs"], list) and document["reference_fixture_refs"], f"{base}.reference_fixture_refs must be non-empty")
|
||
|
|
for index, ref in enumerate(document["reference_fixture_refs"]):
|
||
|
|
require_ref(ref, f"{base}.reference_fixture_refs[{index}]")
|
||
|
|
scene = document["scene_source"]
|
||
|
|
require_keys(scene, ["source_fixture_id", "source_scene_ids", "immutability_note"], f"{base}.scene_source")
|
||
|
|
require_string_array(scene["source_scene_ids"], f"{base}.scene_source.source_scene_ids", nonempty=True)
|
||
|
|
require_string(scene["immutability_note"], f"{base}.scene_source.immutability_note")
|
||
|
|
require_ref(document["initial_state_ref"], f"{base}.initial_state_ref")
|
||
|
|
turns = document["turns"]
|
||
|
|
require(isinstance(turns, list) and len(turns) >= 3, f"{base}.turns must include at least three turns")
|
||
|
|
for index, turn in enumerate(turns):
|
||
|
|
turn_path = f"{base}.turns[{index}]"
|
||
|
|
require_keys(
|
||
|
|
turn,
|
||
|
|
[
|
||
|
|
"turn_id",
|
||
|
|
"acting_agent_id",
|
||
|
|
"coordinator_artifact_ref",
|
||
|
|
"projected_context_ref",
|
||
|
|
"responder_packet_ref",
|
||
|
|
"raw_responder_output_ref",
|
||
|
|
"event_ref",
|
||
|
|
"appraisal_refs",
|
||
|
|
"mutation_ref",
|
||
|
|
"training_usability",
|
||
|
|
],
|
||
|
|
turn_path,
|
||
|
|
)
|
||
|
|
for key in ["turn_id", "acting_agent_id"]:
|
||
|
|
require_string(turn[key], f"{turn_path}.{key}")
|
||
|
|
for key in ["coordinator_artifact_ref", "projected_context_ref", "responder_packet_ref", "raw_responder_output_ref", "event_ref", "mutation_ref"]:
|
||
|
|
require_ref(turn[key], f"{turn_path}.{key}")
|
||
|
|
require_string_array(turn["appraisal_refs"], f"{turn_path}.appraisal_refs", nonempty=True)
|
||
|
|
for ref_index, ref in enumerate(turn["appraisal_refs"]):
|
||
|
|
require_ref(ref, f"{turn_path}.appraisal_refs[{ref_index}]")
|
||
|
|
require(turn["training_usability"] in TRAINING_USABILITY, f"{turn_path}.training_usability is invalid")
|
||
|
|
summary = document["review_summary"]
|
||
|
|
require_keys(summary, ["quality_bar_ref", "training_ready_organs", "not_training_data_organs", "failure_labels", "review_notes"], f"{base}.review_summary")
|
||
|
|
require_string(summary["quality_bar_ref"], f"{base}.review_summary.quality_bar_ref")
|
||
|
|
require_string_array(summary["training_ready_organs"], f"{base}.review_summary.training_ready_organs")
|
||
|
|
require_string_array(summary["not_training_data_organs"], f"{base}.review_summary.not_training_data_organs")
|
||
|
|
require_string_array(summary["failure_labels"], f"{base}.review_summary.failure_labels")
|
||
|
|
require_string_array(summary["review_notes"], f"{base}.review_summary.review_notes", nonempty=True)
|
||
|
|
|
||
|
|
|
||
|
|
VALIDATORS = {
|
||
|
|
"ghostlight.event_record.v0": validate_event,
|
||
|
|
"ghostlight.participant_appraisal.v0": validate_appraisal,
|
||
|
|
"ghostlight.reviewed_mutation.v0": validate_mutation,
|
||
|
|
"ghostlight.scene_loop_bundle.v0": validate_bundle,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(description="Validate Ghostlight training-loop receipt artifacts.")
|
||
|
|
parser.add_argument("paths", nargs="*", type=Path)
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
paths = args.paths or sorted(DEFAULT_ROOT.glob("**/*.json"))
|
||
|
|
if not paths:
|
||
|
|
print("ok: no training-loop artifacts found")
|
||
|
|
return 0
|
||
|
|
for path in paths:
|
||
|
|
document = load_json(path)
|
||
|
|
if not isinstance(document, dict):
|
||
|
|
raise ValidationError(f"{path} must be an object")
|
||
|
|
schema_version = document.get("schema_version")
|
||
|
|
validator = VALIDATORS.get(schema_version)
|
||
|
|
if validator is None:
|
||
|
|
# Initial state and readable review artifacts are intentionally plain
|
||
|
|
# support documents; they only need to be JSON.
|
||
|
|
print(f"ok: {path} (support artifact)")
|
||
|
|
continue
|
||
|
|
validator(document, path)
|
||
|
|
print(f"ok: {path}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|