Ghostlight/tools/validate_lore_grounding.py
2026-05-01 21:51:13 +01:00

209 lines
8.6 KiB
Python

"""Validate Ghostlight lore grounding digest examples.
This focused validator checks the structural invariants needed before lore
digests can feed grounded projection fixtures.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_EXAMPLES = sorted((ROOT / "examples" / "lore-grounding").glob("*.json"))
class ValidationError(Exception):
pass
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
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_nonempty_string(value: Any, path: str) -> None:
require(isinstance(value, str) and value.strip(), f"{path} must be a non-empty string")
def require_string_list(value: Any, path: str) -> None:
require(isinstance(value, list), f"{path} must be an array")
for index, item in enumerate(value):
require_nonempty_string(item, f"{path}[{index}]")
def validate_sourced_text(obj: Any, path: str) -> None:
require(isinstance(obj, dict), f"{path} must be an object")
require_keys(obj, ["text", "source_refs"], path)
require_nonempty_string(obj["text"], f"{path}.text")
require_string_list(obj["source_refs"], f"{path}.source_refs")
def validate_sourced_text_list(value: Any, path: str) -> None:
require(isinstance(value, list), f"{path} must be an array")
for index, item in enumerate(value):
validate_sourced_text(item, f"{path}[{index}]")
def validate_digest(document: dict[str, Any], source: Path) -> None:
require(document.get("schema_version") == "ghostlight.lore_grounding_digest.v0", f"{source} has wrong schema_version")
require_keys(
document,
[
"digest_id",
"source_status",
"fixture_status",
"source_refs",
"historical_context",
"groups",
"cultural_pressure_fields",
"factional_misread_matrix",
"role_pressure_fields",
"speaker_lore_boundaries",
"author_only_boundaries",
"open_questions",
],
str(source),
)
require(document["source_status"] in {"template", "draft", "reviewed", "superseded"}, f"{source}.source_status is invalid")
require(document["fixture_status"] in {"historical_grounded", "transition_grounded", "procedural_branch", "promoted_branch"}, f"{source}.fixture_status is invalid")
source_ids: set[str] = set()
for index, ref in enumerate(document["source_refs"]):
path = f"{source}.source_refs[{index}]"
require_keys(ref, ["source_id", "path", "relevance"], path)
require_nonempty_string(ref["source_id"], f"{path}.source_id")
require_nonempty_string(ref["path"], f"{path}.path")
require_nonempty_string(ref["relevance"], f"{path}.relevance")
source_ids.add(ref["source_id"])
require(source_ids, f"{source}.source_refs must not be empty")
context = document["historical_context"]
require_keys(context, ["time_period", "location", "summary", "public_facts", "ambient_pressures"], f"{source}.historical_context")
for key in ["time_period", "location", "summary"]:
require_nonempty_string(context[key], f"{source}.historical_context.{key}")
validate_sourced_text_list(context["public_facts"], f"{source}.historical_context.public_facts")
validate_sourced_text_list(context["ambient_pressures"], f"{source}.historical_context.ambient_pressures")
group_ids: set[str] = set()
for index, group in enumerate(document["groups"]):
path = f"{source}.groups[{index}]"
require_keys(
group,
[
"group_id",
"label",
"group_type",
"institutional_logic",
"material_incentives",
"internal_virtues",
"internal_shames",
"prestige_markers",
"taboo_triggers",
"emotional_display_norms",
"trust_defaults",
"speech_register_cues",
"source_refs",
],
path,
)
require_nonempty_string(group["group_id"], f"{path}.group_id")
group_ids.add(group["group_id"])
for key in [
"institutional_logic",
"material_incentives",
"internal_virtues",
"internal_shames",
"prestige_markers",
"taboo_triggers",
"emotional_display_norms",
"trust_defaults",
"speech_register_cues",
"source_refs",
]:
require_string_list(group[key], f"{path}.{key}")
require(group_ids, f"{source}.groups must not be empty")
for index, field in enumerate(document["cultural_pressure_fields"]):
path = f"{source}.cultural_pressure_fields[{index}]"
require_keys(field, ["field_id", "applies_to", "pressure_type", "summary", "projection_use", "source_refs"], path)
require_string_list(field["applies_to"], f"{path}.applies_to")
for group_id in field["applies_to"]:
require(group_id in group_ids, f"{path}.applies_to references unknown group: {group_id}")
require_nonempty_string(field["summary"], f"{path}.summary")
require_nonempty_string(field["projection_use"], f"{path}.projection_use")
require_string_list(field["source_refs"], f"{path}.source_refs")
for index, misread in enumerate(document["factional_misread_matrix"]):
path = f"{source}.factional_misread_matrix[{index}]"
require_keys(
misread,
[
"observer_group_id",
"target_group_id",
"observer_read",
"target_self_read",
"misread_patterns",
"likely_insults",
"projection_use",
"source_refs",
],
path,
)
for key in ["observer_group_id", "target_group_id"]:
require(misread[key] in group_ids, f"{path}.{key} references unknown group: {misread[key]}")
for key in ["observer_read", "target_self_read", "misread_patterns", "likely_insults", "source_refs"]:
require_string_list(misread[key], f"{path}.{key}")
require_nonempty_string(misread["projection_use"], f"{path}.projection_use")
for index, role in enumerate(document["role_pressure_fields"]):
path = f"{source}.role_pressure_fields[{index}]"
require_keys(role, ["role_id", "summary", "obligations", "available_masks", "failure_modes", "source_refs"], path)
require_nonempty_string(role["role_id"], f"{path}.role_id")
require_nonempty_string(role["summary"], f"{path}.summary")
for key in ["obligations", "available_masks", "failure_modes", "source_refs"]:
require_string_list(role[key], f"{path}.{key}")
for index, boundary in enumerate(document["speaker_lore_boundaries"]):
path = f"{source}.speaker_lore_boundaries[{index}]"
require_keys(boundary, ["speaker_id", "known_context", "beliefs", "misreads", "must_not_know"], path)
require_nonempty_string(boundary["speaker_id"], f"{path}.speaker_id")
for key in ["known_context", "beliefs", "misreads", "must_not_know"]:
validate_sourced_text_list(boundary[key], f"{path}.{key}")
validate_sourced_text_list(document["author_only_boundaries"], f"{source}.author_only_boundaries")
for index, question in enumerate(document["open_questions"]):
path = f"{source}.open_questions[{index}]"
require_keys(question, ["question", "blocks_training", "notes"], path)
require_nonempty_string(question["question"], f"{path}.question")
require(isinstance(question["blocks_training"], bool), f"{path}.blocks_training must be boolean")
require_nonempty_string(question["notes"], f"{path}.notes")
def main() -> int:
parser = argparse.ArgumentParser(description="Validate Ghostlight lore grounding digest examples.")
parser.add_argument("paths", nargs="*", type=Path, help="Lore grounding digest JSON files to validate.")
args = parser.parse_args()
paths = args.paths or DEFAULT_EXAMPLES
for path in paths:
validate_digest(load_json(path), path)
print(f"ok: {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())