Ghostlight/tools/validate_training_loop_artifacts.py

316 lines
14 KiB
Python
Raw Normal View History

2026-05-04 14:59:42 +01:00
"""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())