Files
shd.build/tools/game-data-capture/src/game_data_capture/domain/validation.py
T
jb f48cc878aa
Validate and deploy production / validate (push) Successful in 24s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy production / deploy (push) Failing after 29s
Validate and deploy staging / deploy (push) Failing after 6s
feat: classify gear and weapon mods
2026-07-31 21:54:47 -04:00

391 lines
18 KiB
Python

"""Semantic checks that portable JSON Schema cannot express."""
from collections.abc import Iterable, Mapping
from datetime import datetime
from . import taxonomy
SUPPORTED_ARCHIVE_SCHEMA_VERSION = 1
SUPPORTED_HANDOFF_SCHEMA_VERSION = 1
class ContractValidationError(ValueError):
"""Raised when a document violates a version or referential contract."""
def ensure_supported_version(document: Mapping[str, object], expected: int) -> None:
version = document.get("schema_version")
if type(version) is not int or version != expected:
raise ContractValidationError(
f"Unsupported schema_version {version!r}; expected {expected}"
)
def _objects(value: object, field: str) -> Iterable[Mapping[str, object]]:
if not isinstance(value, list):
raise ContractValidationError(f"{field} must be an array")
for index, item in enumerate(value):
if not isinstance(item, Mapping):
raise ContractValidationError(f"{field}[{index}] must be an object")
yield item
def _ids(items: Iterable[Mapping[str, object]], field: str) -> set[str]:
result: set[str] = set()
for item in items:
value = item.get(field)
if not isinstance(value, str):
raise ContractValidationError(f"{field} must be a string")
if value in result:
raise ContractValidationError(f"Duplicate {field}: {value}")
result.add(value)
return result
def _references(item: Mapping[str, object], field: str) -> set[str]:
raw = item.get(field, [])
if not isinstance(raw, list) or any(not isinstance(value, str) for value in raw):
raise ContractValidationError(f"{field} must contain string IDs")
return set(raw)
def _require_subset(values: set[str], allowed: set[str], context: str) -> None:
dangling = values - allowed
if dangling:
raise ContractValidationError(f"{context} contains dangling references: {sorted(dangling)}")
def validate_classification(classification: Mapping[str, object]) -> None:
family_raw = classification.get("family")
if not isinstance(family_raw, str):
raise ContractValidationError("Unknown classification family")
try:
family = taxonomy.Family(family_raw)
except ValueError as error:
raise ContractValidationError("Unknown classification family") from error
view = classification.get("view")
if not isinstance(view, str) or view not in taxonomy.SCREEN_VIEWS[family]:
raise ContractValidationError(f"Unknown {family.value} view: {view!r}")
confidence = classification.get("confidence")
if (
isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or not 0 <= confidence <= 1
):
raise ContractValidationError("Classification confidence must be between 0 and 1")
gear_slot = classification.get("gear_slot")
if family is taxonomy.Family.GEAR:
if gear_slot not in taxonomy.GEAR_SLOTS:
raise ContractValidationError(f"Unknown Gear slot: {gear_slot!r}")
elif gear_slot is not None:
raise ContractValidationError("Only Gear classifications may set gear_slot")
record_types = classification.get("record_types")
if not isinstance(record_types, list) or any(
not isinstance(record_type, str) for record_type in record_types
):
raise ContractValidationError("record_types must contain strings")
if len(record_types) != len(set(record_types)):
raise ContractValidationError("record_types must be unique")
if len(record_types) > 8:
raise ContractValidationError("record_types cannot contain more than 8 values")
try:
normalized_record_types = {taxonomy.RecordType(record_type) for record_type in record_types}
except ValueError as error:
raise ContractValidationError("Unknown canonical record type") from error
allowed = set(taxonomy.RECORD_TYPES_BY_VIEW[(family, view)])
if family is taxonomy.Family.UNKNOWN:
if record_types:
raise ContractValidationError("Unknown screens cannot claim canonical record types")
reason = classification.get("abstention_reason")
if reason not in taxonomy.ABSTENTION_REASONS:
raise ContractValidationError("Unknown screens require an abstention reason")
else:
if not record_types or not normalized_record_types.issubset(allowed):
raise ContractValidationError(
f"record_types are incompatible with {family.value}/{view}"
)
if classification.get("abstention_reason") is not None:
raise ContractValidationError("Known screens cannot set abstention_reason")
def _validate_evidence(value: Mapping[str, object]) -> None:
_validate_value_state(value, "evidence")
confidence = value.get("confidence")
if (
isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or not 0 <= confidence <= 1
):
raise ContractValidationError("Evidence confidence must be between 0 and 1")
region = value.get("region")
if not isinstance(region, Mapping):
raise ContractValidationError("Evidence region must be an object")
coordinate_space = region.get("coordinate_space")
numeric_coordinates: list[int | float] = []
for name in ("x", "y", "width", "height"):
number = region.get(name)
if isinstance(number, bool) or not isinstance(number, int | float):
raise ContractValidationError("Evidence bounds must be numeric")
numeric_coordinates.append(number)
x, y, width, height = numeric_coordinates
if coordinate_space == "normalized":
if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > 1 or y + height > 1:
raise ContractValidationError("Normalized evidence bounds must fit within the image")
elif coordinate_space == "pixels":
if any(not isinstance(number, int) for number in numeric_coordinates):
raise ContractValidationError("Pixel evidence bounds must be integers")
if x < 0 or y < 0 or width <= 0 or height <= 0:
raise ContractValidationError("Pixel evidence bounds must be positive and non-negative")
else:
raise ContractValidationError(f"Unknown coordinate space: {coordinate_space!r}")
def _validate_value_state(value: Mapping[str, object], context: str) -> None:
raw_text = value.get("raw_text")
normalized = value.get("normalized_value")
missing_reason = value.get("missing_reason")
if raw_text is not None and not isinstance(raw_text, str):
raise ContractValidationError(f"{context} raw_text must be a string or null")
if normalized is None and not isinstance(missing_reason, str):
raise ContractValidationError(f"Null {context} requires missing_reason")
if normalized is not None and missing_reason is not None:
raise ContractValidationError(f"Present {context} cannot set missing_reason")
def _value_state(value: Mapping[str, object], context: str) -> tuple[object, object, object]:
_validate_value_state(value, context)
return value.get("raw_text"), value.get("normalized_value"), value.get("missing_reason")
def _parse_correction_time(value: object) -> datetime:
if not isinstance(value, str):
raise ContractValidationError("Correction corrected_at must be a timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ContractValidationError("Correction corrected_at must be a timestamp") from error
if parsed.tzinfo is None:
raise ContractValidationError("Correction corrected_at must include a UTC offset")
return parsed
def _validate_correction_history(
evidence: Mapping[str, object], seen_correction_ids: set[str]
) -> None:
history = evidence.get("correction_history")
if not isinstance(history, list):
raise ContractValidationError("correction_history must be an array")
previous_after: tuple[object, object, object] | None = None
previous_time: datetime | None = None
for index, correction in enumerate(history):
if not isinstance(correction, Mapping):
raise ContractValidationError(f"correction_history[{index}] must be an object")
correction_id = correction.get("correction_id")
if not isinstance(correction_id, str):
raise ContractValidationError("correction_id must be a string")
if correction_id in seen_correction_ids:
raise ContractValidationError(f"Duplicate correction_id: {correction_id}")
seen_correction_ids.add(correction_id)
corrected_at = _parse_correction_time(correction.get("corrected_at"))
if previous_time is not None and corrected_at <= previous_time:
raise ContractValidationError("Correction history timestamps must be strictly ordered")
before = correction.get("before")
after = correction.get("after")
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
raise ContractValidationError("Correction before and after must be value states")
before_state = _value_state(before, "correction before")
after_state = _value_state(after, "correction after")
if previous_after is not None and before_state != previous_after:
raise ContractValidationError("Correction history contains a disconnected value state")
previous_after = after_state
previous_time = corrected_at
if previous_after is not None and previous_after != _value_state(evidence, "evidence"):
raise ContractValidationError("Correction history final state does not match evidence")
def validate_archive_document(document: Mapping[str, object]) -> None:
validate_archive_references(document)
for screen in _objects(document.get("screens"), "screens"):
classification = screen.get("classification")
if not isinstance(classification, Mapping):
raise ContractValidationError("screen.classification must be an object")
validate_classification(classification)
seen_correction_ids: set[str] = set()
for value in _objects(document.get("evidence_values"), "evidence_values"):
_validate_evidence(value)
_validate_correction_history(value, seen_correction_ids)
def validate_handoff_document(document: Mapping[str, object]) -> None:
validate_handoff_references(document)
expected_boundary: dict[str, object] = {
"intended_destination": "authenticated_isac_intake",
"mutation_authority": "none",
"requires_authenticated_action": True,
"preserves_moderation": True,
"creates_audit_events": False,
}
if document.get("trust_boundary") != expected_boundary:
raise ContractValidationError("The handoff trust boundary is invalid")
seen_correction_ids: set[str] = set()
for value in _objects(document.get("evidence_values"), "evidence_values"):
_validate_evidence(value)
_validate_correction_history(value, seen_correction_ids)
def validate_archive_references(document: Mapping[str, object]) -> None:
ensure_supported_version(document, SUPPORTED_ARCHIVE_SCHEMA_VERSION)
artifacts = list(_objects(document.get("artifacts"), "artifacts"))
captures = list(_objects(document.get("captures"), "captures"))
screens = list(_objects(document.get("screens"), "screens"))
jobs = list(_objects(document.get("jobs"), "jobs"))
extractions = list(_objects(document.get("extractions"), "extractions"))
groups = list(_objects(document.get("groups"), "groups"))
reviews = list(_objects(document.get("reviews"), "reviews"))
exports = list(_objects(document.get("exports"), "exports"))
errors = list(_objects(document.get("errors"), "errors"))
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
instances = list(_objects(document.get("observed_instances"), "observed_instances"))
candidates = list(_objects(document.get("canonical_candidates"), "canonical_candidates"))
artifact_ids = _ids(artifacts, "artifact_id")
_ids(captures, "capture_id")
screen_ids = _ids(screens, "screen_id")
_ids(jobs, "job_id")
_ids(extractions, "extraction_id")
group_ids = _ids(groups, "group_id")
_ids(reviews, "review_id")
_ids(exports, "export_id")
error_ids = _ids(errors, "error_id")
evidence_ids = _ids(evidence, "evidence_id")
instance_ids = _ids(instances, "instance_id")
candidate_ids = _ids(candidates, "candidate_id")
artifact_hashes = {
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
}
for capture in captures:
_require_subset({str(capture.get("artifact_id"))}, artifact_ids, "capture.artifact_id")
for artifact in artifacts:
parent_id = artifact.get("parent_artifact_id")
if parent_id is not None:
if str(parent_id) == str(artifact.get("artifact_id")):
raise ContractValidationError("artifact.parent_artifact_id cannot reference itself")
_require_subset({str(parent_id)}, artifact_ids, "artifact.parent_artifact_id")
for screen in screens:
_require_subset({str(screen.get("artifact_id"))}, artifact_ids, "screen.artifact_id")
for value in evidence:
artifact_id = str(value.get("image_artifact_id"))
_require_subset(
{artifact_id},
artifact_ids,
"evidence.image_artifact_id",
)
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
for job in jobs:
last_error_id = job.get("last_error_id")
if last_error_id is not None:
_require_subset({str(last_error_id)}, error_ids, "job.last_error_id")
for extraction in extractions:
_require_subset({str(extraction.get("screen_id"))}, screen_ids, "extraction.screen_id")
_require_subset(
_references(extraction, "evidence_refs"),
evidence_ids,
"extraction.evidence_refs",
)
for group in groups:
_require_subset(_references(group, "screen_ids"), screen_ids, "group.screen_ids")
supersedes_group_ids = _references(group, "supersedes_group_ids")
if str(group.get("group_id")) in supersedes_group_ids:
raise ContractValidationError("group.supersedes_group_ids cannot reference itself")
_require_subset(
supersedes_group_ids,
group_ids,
"group.supersedes_group_ids",
)
for instance in instances:
_require_subset(
_references(instance, "source_screen_ids"),
screen_ids,
"observed_instance.source_screen_ids",
)
for field in _objects(instance.get("fields"), "observed_instance.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"observed_instance.fields.evidence_refs",
)
context = instance.get("observed_context")
if not isinstance(context, Mapping):
raise ContractValidationError("observed_instance.observed_context must be an object")
for context_name in ("level", "quality", "tier", "upgrade_state"):
context_field = context.get(context_name)
if not isinstance(context_field, Mapping):
raise ContractValidationError(f"observed_context.{context_name} must be an object")
_require_subset(
_references(context_field, "evidence_refs"),
evidence_ids,
f"observed_context.{context_name}.evidence_refs",
)
for candidate in candidates:
_require_subset(
_references(candidate, "source_instance_ids"),
instance_ids,
"canonical_candidate.source_instance_ids",
)
_require_subset(
_references(candidate, "evidence_refs"),
evidence_ids,
"canonical_candidate.evidence_refs",
)
for field in _objects(candidate.get("fields"), "canonical_candidate.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"canonical_candidate.fields.evidence_refs",
)
for review in reviews:
_require_subset({str(review.get("candidate_id"))}, candidate_ids, "review.candidate_id")
for export in exports:
export_artifact_id = export.get("artifact_id")
if export_artifact_id is not None:
_require_subset({str(export_artifact_id)}, artifact_ids, "export.artifact_id")
def validate_handoff_references(document: Mapping[str, object]) -> None:
ensure_supported_version(document, SUPPORTED_HANDOFF_SCHEMA_VERSION)
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
records = list(_objects(document.get("records"), "records"))
evidence_ids = _ids(evidence, "evidence_id")
artifacts = list(_objects(document.get("source_artifacts"), "source_artifacts"))
artifact_ids = _ids(artifacts, "artifact_id")
artifact_hashes = {
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
}
_ids(records, "handoff_record_id")
for value in evidence:
artifact_id = str(value.get("image_artifact_id"))
_require_subset(
{artifact_id},
artifact_ids,
"evidence.image_artifact_id",
)
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
for record in records:
for field in _objects(record.get("fields"), "record.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"record.fields.evidence_refs",
)
for section_name in ("relationships", "mechanics"):
section = record.get(section_name)
if not isinstance(section, Mapping):
raise ContractValidationError(f"record.{section_name} must be an object")
_require_subset(
_references(section, "evidence_refs"),
evidence_ids,
f"record.{section_name}.evidence_refs",
)