209 lines
8.6 KiB
Python
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())
|