Add the Scene Validator per MASTER_SPEC §14 and a central message-code registry.
- compiler/common/message_codes.py: single source of truth for message codes
(Appendix B SC0001-SC0014 plus documented extensions SC0015 Unexpected Field
Removed, SC0016 Empty Concept Removed, SC0017 Interaction Dropped) with a
message() factory that pins each code's severity and title.
- compiler/validator/scene_validator.py: validate_scene(scene_json, config,
logger=None) -> CompilerResult.
- Strict schema validation classifies failures: missing required -> SC0009,
wrong type/malformed -> SC0002 (Errors that stop the compilation).
- Recoverable normalization: unexpected fields -> SC0015 warning + removed
(suppressed when config.validator.allow_unknown_fields is true); empty/
whitespace concepts -> SC0016 warning + removed after trimming; interactions
whose participants reference a non-existent character -> SC0017 warning +
dropped.
- Never creates concepts, generates tags, or does KB lookup. Output is a
Scene model wrapped in a CompilerResult; order is deterministic.
New warning codes (SC0015-SC0017) are extensions to be folded into Appendix B.
Tests: valid input, hard errors (SC0009/SC0002) that stop, recoverable warnings
(SC0015/SC0016/SC0017), allow_unknown_fields suppression, determinism, and the
code registry.
Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
6.6 KiB
Python
189 lines
6.6 KiB
Python
"""The Scene Validator (MASTER_SPEC §14).
|
|
|
|
Verifies and normalizes Scene JSON before resolution, guaranteeing that only
|
|
structurally valid, normalized Scene JSON reaches the Resolver.
|
|
|
|
- Hard errors (stop compilation): a missing required field (SC0009) or a wrong
|
|
type / malformed structure (SC0002).
|
|
- Recoverable issues (warn and continue): an unexpected field is removed
|
|
(SC0015), an empty/whitespace-only concept is removed after trimming (SC0016),
|
|
and an interaction whose participants reference a non-existent character is
|
|
dropped (SC0017).
|
|
|
|
The Validator never creates or guesses concepts, generates tags, or performs
|
|
Knowledge Base lookup. Validation order is stable: identical input yields
|
|
identical output.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from typing import Any
|
|
|
|
from compiler.common.config import Config
|
|
from compiler.common.log import StructuredLogger
|
|
from compiler.common.message_codes import message
|
|
from compiler.common.result import CompilerResult, Message
|
|
from schemas.models import Scene
|
|
from schemas.validation import validate_document
|
|
|
|
# The seven concept-bearing fields on a Character, plus the scene-level arrays.
|
|
_CHARACTER_CONCEPT_FIELDS = (
|
|
"identity",
|
|
"appearance",
|
|
"clothing",
|
|
"accessories",
|
|
"pose",
|
|
"expression",
|
|
"actions",
|
|
)
|
|
_SCENE_CONCEPT_FIELDS = ("objects", "environment", "camera", "lighting")
|
|
|
|
|
|
def validate_scene(
|
|
scene_json: dict[str, Any],
|
|
config: Config,
|
|
logger: StructuredLogger | None = None,
|
|
) -> CompilerResult:
|
|
"""Validate and normalize a Scene JSON document.
|
|
|
|
Returns:
|
|
A CompilerResult whose ``data`` is the validated :class:`Scene` on
|
|
success, or ``None`` with error messages when a hard error stops the
|
|
compilation.
|
|
"""
|
|
errors, field_warnings = _classify_schema_issues(scene_json)
|
|
if errors:
|
|
result = CompilerResult()
|
|
for error in errors:
|
|
result = result.add_error(error)
|
|
if logger is not None:
|
|
logger.basic("scene_validation_failed", errors=len(errors))
|
|
return result
|
|
|
|
warnings: list[Message] = []
|
|
if not config.validator.allow_unknown_fields:
|
|
warnings.extend(field_warnings)
|
|
|
|
cleaned, cleanup_warnings = _clean_scene(scene_json)
|
|
warnings.extend(cleanup_warnings)
|
|
|
|
scene = Scene.from_json(cleaned)
|
|
result = CompilerResult(data=scene)
|
|
for warning in warnings:
|
|
result = result.add_warning(warning)
|
|
if logger is not None:
|
|
logger.basic("scene_validated", warnings=len(warnings))
|
|
return result
|
|
|
|
|
|
def _classify_schema_issues(scene_json: Any) -> tuple[list[Message], list[Message]]:
|
|
"""Split strict schema issues into hard errors and unexpected-field warnings."""
|
|
errors: list[Message] = []
|
|
field_warnings: list[Message] = []
|
|
for issue in validate_document(scene_json, "scene"):
|
|
location = issue.path or "<root>"
|
|
if issue.validator == "additionalProperties":
|
|
field_warnings.append(
|
|
message(
|
|
"SC0015",
|
|
f"Unexpected field at '{location}' was removed: {issue.message}",
|
|
path=issue.path,
|
|
)
|
|
)
|
|
elif issue.validator == "required":
|
|
errors.append(
|
|
message(
|
|
"SC0009",
|
|
f"Missing required field at '{location}': {issue.message}",
|
|
path=issue.path,
|
|
)
|
|
)
|
|
else:
|
|
errors.append(
|
|
message(
|
|
"SC0002",
|
|
f"Invalid Scene JSON at '{location}': {issue.message}",
|
|
path=issue.path,
|
|
)
|
|
)
|
|
return errors, field_warnings
|
|
|
|
|
|
def _clean_scene(scene_json: dict[str, Any]) -> tuple[dict[str, Any], list[Message]]:
|
|
"""Trim/remove empty concepts and drop interactions with dangling participants."""
|
|
warnings: list[Message] = []
|
|
cleaned = dict(scene_json)
|
|
|
|
character_ids: set[int] = set()
|
|
cleaned_characters = []
|
|
for character in scene_json.get("characters", []):
|
|
character = dict(character)
|
|
for field in _CHARACTER_CONCEPT_FIELDS:
|
|
character[field] = _clean_concepts(
|
|
character.get(field, []), f"characters/{character.get('id')}/{field}", warnings
|
|
)
|
|
cleaned_characters.append(character)
|
|
if isinstance(character.get("id"), int):
|
|
character_ids.add(character["id"])
|
|
cleaned["characters"] = cleaned_characters
|
|
|
|
for field in _SCENE_CONCEPT_FIELDS:
|
|
cleaned[field] = _clean_concepts(scene_json.get(field, []), field, warnings)
|
|
|
|
cleaned["interactions"] = _clean_interactions(
|
|
scene_json.get("interactions", []), character_ids, warnings
|
|
)
|
|
return cleaned, warnings
|
|
|
|
|
|
def _clean_concepts(items: Sequence[Any], location: str, warnings: list[Message]) -> list[Any]:
|
|
"""Trim concept names and drop empty ones (SC0016), preserving order."""
|
|
cleaned: list[Any] = []
|
|
for item in items:
|
|
if isinstance(item, str):
|
|
trimmed = item.strip()
|
|
if trimmed:
|
|
cleaned.append(trimmed)
|
|
else:
|
|
warnings.append(
|
|
message("SC0016", f"Empty concept removed at '{location}'.", path=location)
|
|
)
|
|
elif isinstance(item, dict):
|
|
entry = dict(item)
|
|
name = entry.get("name", "")
|
|
entry["name"] = name.strip() if isinstance(name, str) else name
|
|
if entry["name"]:
|
|
cleaned.append(entry)
|
|
else:
|
|
warnings.append(
|
|
message("SC0016", f"Empty concept removed at '{location}'.", path=location)
|
|
)
|
|
else:
|
|
cleaned.append(item)
|
|
return cleaned
|
|
|
|
|
|
def _clean_interactions(
|
|
interactions: Sequence[Any], character_ids: set[int], warnings: list[Message]
|
|
) -> list[Any]:
|
|
"""Drop interactions whose participants reference a non-existent character (SC0017)."""
|
|
cleaned: list[Any] = []
|
|
for interaction in interactions:
|
|
participants = interaction.get("participants", [])
|
|
missing = [p for p in participants if p not in character_ids]
|
|
if missing:
|
|
warnings.append(
|
|
message(
|
|
"SC0017",
|
|
(
|
|
f"Interaction '{interaction.get('concept')}' dropped: participant(s) "
|
|
f"{missing} reference no existing character."
|
|
),
|
|
participants=participants,
|
|
)
|
|
)
|
|
else:
|
|
cleaned.append(interaction)
|
|
return cleaned
|