Files
kunail0804-ComfyUI-Scene-Co…/compiler/validator/scene_validator.py
T
3f0358bfa9 feat(validator): implement the Scene Validator (#11) (#55)
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>
2026-07-07 19:52:24 +02:00

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