772 lines
35 KiB
Python
772 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
ReportDocument v1 contract validator (stdlib only).
|
|
|
|
Mirrors `contracts/personal-report/report-document.v1.schema.json` and
|
|
`frontend/src/lib/personal-report-contract.ts` (Zod). This module deliberately
|
|
uses only the Python standard library: the project does not vendor `jsonschema`,
|
|
so the validator below implements the schema semantics explicitly so that the
|
|
three sides (JSON Schema / Zod / Python) stay aligned and testable.
|
|
|
|
Public surface:
|
|
SCHEMA_VERSION, REPORT_CONTRACT_VERSION, MAX_SERIALIZED_BYTES
|
|
CLAIM_STATUSES, REPORT_TYPES, PRESENTATION_MODES, BIRTH_TIME_STATUSES
|
|
TECHNIQUE_STATUSES, CONFLICT_STATUSES, CHART_IDS
|
|
FAILURE_CODES (shared stable enum used by the persistence layer)
|
|
compute_evidence_hash(document) -> str
|
|
validate_report_document(document) -> ValidationResult
|
|
is_valid_report_document(document) -> bool
|
|
load_report_document(path) -> dict
|
|
parse_report_document_json(text) -> ValidationResult
|
|
CLI: python3 scripts/personal_report_contract.py <path-to-json>
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
SCHEMA_VERSION = "report_document.v1"
|
|
REPORT_CONTRACT_VERSION = "1"
|
|
MAX_SERIALIZED_BYTES = 1_572_864 # 1.5 MiB hard cap on UTF-8 JSON serialization.
|
|
|
|
CLAIM_STATUSES = (
|
|
"multi_system_consensus",
|
|
"single_system_inference",
|
|
"parameter_sensitive",
|
|
"unclosed_divisional_chart",
|
|
"user_history_verification_required",
|
|
"blocked",
|
|
)
|
|
REPORT_TYPES = ("personal_full", "personal_thematic")
|
|
PRESENTATION_MODES = ("default", "research")
|
|
BIRTH_TIME_STATUSES = ("reported", "candidate", "accepted", "confirmed")
|
|
TECHNIQUE_STATUSES = ("verified", "partial", "blocked")
|
|
CONFLICT_STATUSES = ("unresolved", "partial", "resolved")
|
|
CHART_IDS = ("D1", "D9", "D10")
|
|
HOUSE_NUMBERS = tuple(range(1, 13))
|
|
|
|
# Stable failure-code enum shared with the personal_reports table check
|
|
# constraint and frontend/src/lib/personal-report-service.ts.
|
|
FAILURE_CODES = (
|
|
"profile_incomplete",
|
|
"birth_time_not_usable",
|
|
"report_generation_in_progress",
|
|
"report_rate_limited",
|
|
"calculation_unavailable",
|
|
"model_unavailable",
|
|
"report_schema_invalid",
|
|
"report_guard_rejected",
|
|
"report_not_found",
|
|
)
|
|
|
|
UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
SHA1_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
ISO8601_PATTERN = re.compile(
|
|
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$"
|
|
)
|
|
EVIDENCE_ID_PATTERN = re.compile(r"^ev-[a-z0-9_-]{1,63}$")
|
|
SECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
|
TECHNIQUE_ID_PATTERN = re.compile(r"^[a-z0-9_.-]{1,80}$")
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Semantic guard patterns. These must stay byte-for-byte equivalent to the
|
|
# FORBIDDEN_PATTERNS list in frontend/src/lib/personal-report-contract.ts.
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = (
|
|
("html_tag_open", r"<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b"),
|
|
("html_tag_close", r"</\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>"),
|
|
("event_handler", r"\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*="),
|
|
("executable_url", r"\b(?:javascript|vbscript|data:text/html|data:text/javascript|file):"),
|
|
("processing_instruction", r"<\?"),
|
|
("template_literal", r"\$\{"),
|
|
("stack_trace", r"(?:Traceback \(most recent call last\)|node:internal/| at (?:Object|async|node)\.)"),
|
|
("dunder_path", r"__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__"),
|
|
("process_env", r"\bprocess\.env\b"),
|
|
("unix_home_path", r"(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]"),
|
|
("windows_drive_path", r"^[a-zA-Z]:[\\/]"),
|
|
("jwt_token", r"\beyJ[A-Za-z0-9_-]{20,}\b"),
|
|
("secret_marker", r"\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b"),
|
|
("tool_trace", r"\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b"),
|
|
("chain_of_thought", r"\bchain[\s_-]?of[\s_-]?thought\b"),
|
|
)
|
|
|
|
# Deterministic-prediction phrases that must never appear inside a section whose
|
|
# claimStatus is "blocked". Must match the TS side exactly.
|
|
DETERMINISTIC_PHRASES: Tuple[str, ...] = (
|
|
"必然",
|
|
"必定",
|
|
"一定会",
|
|
"肯定会",
|
|
"绝对会",
|
|
"保证会",
|
|
"无疑将",
|
|
"百分之百",
|
|
"确定无疑",
|
|
"guaranteed",
|
|
"definitely will",
|
|
"certainly will",
|
|
"will certainly",
|
|
"is certain to",
|
|
)
|
|
|
|
# Bounded text limits shared with the schema.
|
|
TEXT_LIMITS: Dict[str, int] = {
|
|
"displayName": 120,
|
|
"birthPlaceLabel": 200,
|
|
"headline": 200,
|
|
"summary": 2000,
|
|
"priority": 200,
|
|
"chartTitle": 120,
|
|
"sign": 40,
|
|
"occupant": 40,
|
|
"planetName": 40,
|
|
"sectionTitle": 160,
|
|
"narrative": 4000,
|
|
"action": 400,
|
|
"caveat": 400,
|
|
"techniqueName": 160,
|
|
"notes": 500,
|
|
"conflictDescription": 1000,
|
|
"conflictImpact": 500,
|
|
"evidenceLabel": 160,
|
|
"evidenceValue": 500,
|
|
"evidenceSource": 200,
|
|
"blockedTechnique": 120,
|
|
"disclaimer": 2000,
|
|
}
|
|
|
|
ARRAY_LIMITS: Dict[str, int] = {
|
|
"priorities": 8,
|
|
"charts": 3,
|
|
"houses": 12,
|
|
"planets": 12,
|
|
"occupants": 12,
|
|
"thematicNarrative": 12,
|
|
"actions": 12,
|
|
"caveats": 12,
|
|
"evidenceRefs": 24,
|
|
"techniqueAudit": 100,
|
|
"conflicts": 50,
|
|
"calculationEvidence": 100,
|
|
"blockedTechniques": 100,
|
|
}
|
|
|
|
_RE = re.compile
|
|
|
|
|
|
def _compile(patterns: Tuple[Tuple[str, str], ...]) -> List[Tuple[str, "re.Pattern[str]"]]:
|
|
return [(name, _RE(expression, re.IGNORECASE)) for name, expression in patterns]
|
|
|
|
|
|
_FORBIDDEN_COMPILED = _compile(FORBIDDEN_PATTERNS)
|
|
_DETERMINISTIC_COMPILED = [
|
|
(_RE(phrase, re.IGNORECASE), phrase) for phrase in DETERMINISTIC_PHRASES
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
valid: bool
|
|
errors: List[str] = field(default_factory=list)
|
|
|
|
def add(self, path: str, message: str) -> None:
|
|
self.errors.append(f"{path}: {message}")
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Evidence hash (deterministic, cross-language).
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
def _canonical_evidence(document: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Canonical evidence object. Defensive: malformed rows are reduced to
|
|
empty placeholders so validation never raises on arbitrary input; the
|
|
canonical hash only matches the TS side for structurally valid documents."""
|
|
appendix = document.get("evidenceAppendix")
|
|
appendix = appendix if isinstance(appendix, dict) else {}
|
|
|
|
def rows(key: str) -> List[Any]:
|
|
value = appendix.get(key)
|
|
return value if isinstance(value, list) else []
|
|
|
|
def safe(row: Any, key: str) -> Any:
|
|
return row.get(key) if isinstance(row, dict) else None
|
|
|
|
canonical = {
|
|
"techniqueAudit": [],
|
|
"conflicts": [],
|
|
"calculationEvidence": [],
|
|
}
|
|
for row in rows("techniqueAudit"):
|
|
entry = {
|
|
"id": safe(row, "id"),
|
|
"techniqueId": safe(row, "techniqueId"),
|
|
"techniqueName": safe(row, "techniqueName"),
|
|
"status": safe(row, "status"),
|
|
"used": safe(row, "used"),
|
|
}
|
|
if isinstance(row, dict) and "notes" in row:
|
|
entry["notes"] = row["notes"]
|
|
canonical["techniqueAudit"].append(entry)
|
|
for row in rows("conflicts"):
|
|
canonical["conflicts"].append({
|
|
"id": safe(row, "id"),
|
|
"description": safe(row, "description"),
|
|
"impact": safe(row, "impact"),
|
|
"status": safe(row, "status"),
|
|
})
|
|
for row in rows("calculationEvidence"):
|
|
canonical["calculationEvidence"].append({
|
|
"id": safe(row, "id"),
|
|
"label": safe(row, "label"),
|
|
"value": safe(row, "value"),
|
|
"source": safe(row, "source"),
|
|
})
|
|
return canonical
|
|
|
|
|
|
def _canonical_json(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
|
|
|
|
|
|
def compute_evidence_hash(document: Dict[str, Any]) -> str:
|
|
"""Deterministic SHA-256 over the evidence appendix, matching the TS side."""
|
|
canonical = _canonical_evidence(document)
|
|
return hashlib.sha256(_canonical_json(canonical).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def serialized_bytes(document: Dict[str, Any]) -> int:
|
|
return len(_canonical_json(document).encode("utf-8"))
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Semantic guards.
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
def forbidden_content_hits(value: str) -> List[str]:
|
|
hits: List[str] = []
|
|
for name, pattern in _FORBIDDEN_COMPILED:
|
|
if pattern.search(value):
|
|
hits.append(name)
|
|
return hits
|
|
|
|
|
|
def _text_guard(result: ValidationResult, path: str, value: Any) -> None:
|
|
if value is None:
|
|
return
|
|
if not isinstance(value, str):
|
|
result.add(path, f"expected string, got {type(value).__name__}")
|
|
return
|
|
hits = forbidden_content_hits(value)
|
|
if hits:
|
|
result.add(path, "forbidden content: " + ", ".join(sorted(set(hits))))
|
|
|
|
|
|
def _blocked_determinism(result: ValidationResult, path: str, claim_status: str, texts: List[Tuple[str, str]]) -> None:
|
|
if claim_status != "blocked":
|
|
return
|
|
for label, text in texts:
|
|
if not isinstance(text, str):
|
|
continue
|
|
for pattern, phrase in _DETERMINISTIC_COMPILED:
|
|
if pattern.search(text):
|
|
result.add(path, f"blocked section contains deterministic prediction ({label!r} matches {phrase!r})")
|
|
|
|
|
|
def _evidence_refs(result: ValidationResult, document: Dict[str, Any]) -> None:
|
|
appendix = document["evidenceAppendix"]
|
|
|
|
def ids(key: str) -> List[Any]:
|
|
value = appendix.get(key)
|
|
return value if isinstance(value, list) else []
|
|
|
|
known_ids: List[str] = []
|
|
seen: Dict[str, str] = {}
|
|
for key in ("techniqueAudit", "conflicts", "calculationEvidence"):
|
|
for index, row in enumerate(ids(key)):
|
|
if not isinstance(row, dict) or not isinstance(row.get("id"), str):
|
|
continue
|
|
evidence_id = row["id"]
|
|
if evidence_id in seen:
|
|
result.add(
|
|
f"evidenceAppendix.{key}[{index}].id",
|
|
f"duplicate evidence id {evidence_id!r} (also used in {seen[evidence_id]})",
|
|
)
|
|
else:
|
|
seen[evidence_id] = f"{key}[{index}]"
|
|
known_ids.append(evidence_id)
|
|
|
|
for index, section in enumerate(document.get("thematicNarrative", [])):
|
|
if not isinstance(section, dict):
|
|
continue
|
|
path = f"thematicNarrative[{index}].evidenceRefs"
|
|
refs = section.get("evidenceRefs")
|
|
if not isinstance(refs, list):
|
|
continue
|
|
for ref in refs:
|
|
if ref not in known_ids:
|
|
result.add(path, f"unknown evidence id {ref!r}")
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Structural validation (explicit schema implementation).
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
def _expect_object(result: ValidationResult, path: str, value: Any) -> Optional[Dict[str, Any]]:
|
|
if not isinstance(value, dict):
|
|
result.add(path, f"expected object, got {type(value).__name__}")
|
|
return None
|
|
return value
|
|
|
|
|
|
def _check_enum(result: ValidationResult, path: str, value: Any, allowed: Tuple[str, ...]) -> None:
|
|
if value not in allowed:
|
|
result.add(path, f"invalid value {value!r}; allowed: {', '.join(allowed)}")
|
|
|
|
|
|
def _check_text(result: ValidationResult, path: str, value: Any, max_length: int, min_length: int = 1) -> None:
|
|
if not isinstance(value, str):
|
|
result.add(path, f"expected string, got {type(value).__name__}")
|
|
return
|
|
if len(value) < min_length:
|
|
result.add(path, f"shorter than minimum length {min_length}")
|
|
if len(value) > max_length:
|
|
result.add(path, f"longer than maximum length {max_length}")
|
|
_text_guard(result, path, value)
|
|
|
|
|
|
def _check_uuid(result: ValidationResult, path: str, value: Any) -> None:
|
|
if not isinstance(value, str) or not UUID_PATTERN.match(value):
|
|
result.add(path, f"invalid uuid {value!r}")
|
|
|
|
|
|
def _check_hash(result: ValidationResult, path: str, value: Any, pattern: "re.Pattern[str]", length: int) -> None:
|
|
if not isinstance(value, str) or not pattern.match(value) or len(value) != length:
|
|
result.add(path, f"invalid hex hash {value!r}")
|
|
|
|
|
|
def _check_array(
|
|
result: ValidationResult,
|
|
path: str,
|
|
value: Any,
|
|
max_items: int,
|
|
min_items: int = 0,
|
|
) -> Optional[List[Any]]:
|
|
if not isinstance(value, list):
|
|
result.add(path, f"expected array, got {type(value).__name__}")
|
|
return None
|
|
if len(value) > max_items:
|
|
result.add(path, f"longer than maximum items {max_items}")
|
|
if len(value) < min_items:
|
|
result.add(path, f"shorter than minimum items {min_items}")
|
|
return value
|
|
|
|
|
|
def _check_keys(
|
|
result: ValidationResult,
|
|
path: str,
|
|
value: Dict[str, Any],
|
|
required: Tuple[str, ...],
|
|
allowed: Optional[Tuple[str, ...]] = None,
|
|
) -> None:
|
|
permitted = required if allowed is None else allowed
|
|
missing = [key for key in required if key not in value]
|
|
if missing:
|
|
result.add(path, "missing required keys: " + ", ".join(missing))
|
|
extra = [key for key in value if key not in permitted]
|
|
if extra:
|
|
result.add(path, "unexpected keys: " + ", ".join(extra))
|
|
|
|
|
|
def _validate_subject(result: ValidationResult, path: str, subject: Any) -> None:
|
|
value = _expect_object(result, path, subject)
|
|
if value is None:
|
|
return
|
|
_check_keys(result, path, value, ("displayName", "birthTimeStatus", "birthPlaceLabel"))
|
|
_check_text(result, f"{path}.displayName", value.get("displayName"), TEXT_LIMITS["displayName"])
|
|
_check_enum(result, f"{path}.birthTimeStatus", value.get("birthTimeStatus"), BIRTH_TIME_STATUSES)
|
|
_check_text(result, f"{path}.birthPlaceLabel", value.get("birthPlaceLabel"), TEXT_LIMITS["birthPlaceLabel"])
|
|
|
|
|
|
def _validate_provenance(result: ValidationResult, path: str, provenance: Any) -> None:
|
|
value = _expect_object(result, path, provenance)
|
|
if value is None:
|
|
return
|
|
_check_keys(
|
|
result,
|
|
path,
|
|
value,
|
|
("skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"),
|
|
)
|
|
commit = value.get("skillSourceCommit")
|
|
if commit is not None:
|
|
_check_hash(result, f"{path}.skillSourceCommit", commit, SHA1_PATTERN, 40)
|
|
_check_hash(result, f"{path}.skillSnapshotSha256", value.get("skillSnapshotSha256"), SHA256_PATTERN, 64)
|
|
_check_hash(result, f"{path}.calculationHash", value.get("calculationHash"), SHA256_PATTERN, 64)
|
|
_check_hash(result, f"{path}.evidenceHash", value.get("evidenceHash"), SHA256_PATTERN, 64)
|
|
if value.get("reportContractVersion") != REPORT_CONTRACT_VERSION:
|
|
result.add(f"{path}.reportContractVersion", f"must be {REPORT_CONTRACT_VERSION!r}")
|
|
|
|
|
|
def _validate_executive_summary(result: ValidationResult, path: str, summary: Any) -> None:
|
|
value = _expect_object(result, path, summary)
|
|
if value is None:
|
|
return
|
|
_check_keys(result, path, value, ("headline", "summary", "priorities", "overallClaimStatus"))
|
|
_check_text(result, f"{path}.headline", value.get("headline"), TEXT_LIMITS["headline"])
|
|
_check_text(result, f"{path}.summary", value.get("summary"), TEXT_LIMITS["summary"])
|
|
priorities = _check_array(result, f"{path}.priorities", value.get("priorities"), ARRAY_LIMITS["priorities"])
|
|
if priorities is not None:
|
|
for index, priority in enumerate(priorities):
|
|
_check_text(result, f"{path}.priorities[{index}]", priority, TEXT_LIMITS["priority"])
|
|
_check_enum(result, f"{path}.overallClaimStatus", value.get("overallClaimStatus"), CLAIM_STATUSES)
|
|
|
|
|
|
def _validate_chart(result: ValidationResult, path: str, chart: Any) -> None:
|
|
value = _expect_object(result, path, chart)
|
|
if value is None:
|
|
return
|
|
_check_keys(result, path, value, ("id", "title", "houses", "claimStatus"), ("id", "title", "houses", "claimStatus", "planets"))
|
|
_check_enum(result, f"{path}.id", value.get("id"), CHART_IDS)
|
|
_check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["chartTitle"])
|
|
_check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES)
|
|
|
|
houses = _check_array(result, f"{path}.houses", value.get("houses"), ARRAY_LIMITS["houses"])
|
|
seen_houses: List[int] = []
|
|
if houses is not None:
|
|
for index, house in enumerate(houses):
|
|
house_path = f"{path}.houses[{index}]"
|
|
house_value = _expect_object(result, house_path, house)
|
|
if house_value is None:
|
|
continue
|
|
_check_keys(result, house_path, house_value, ("houseNumber", "sign", "occupants"))
|
|
house_number = house_value.get("houseNumber")
|
|
if house_number in seen_houses:
|
|
result.add(house_path, f"duplicate houseNumber {house_number!r}")
|
|
if isinstance(house_number, int) and not isinstance(house_number, bool) and house_number in HOUSE_NUMBERS:
|
|
seen_houses.append(house_number)
|
|
elif not isinstance(house_number, bool):
|
|
result.add(f"{house_path}.houseNumber", f"must be integer 1..12, got {house_number!r}")
|
|
_check_text(result, f"{house_path}.sign", house_value.get("sign"), TEXT_LIMITS["sign"])
|
|
occupants = _check_array(result, f"{house_path}.occupants", house_value.get("occupants"), ARRAY_LIMITS["occupants"])
|
|
if occupants is not None:
|
|
for occupant_index, occupant in enumerate(occupants):
|
|
_check_text(result, f"{house_path}.occupants[{occupant_index}]", occupant, TEXT_LIMITS["occupant"])
|
|
|
|
planets = None
|
|
if "planets" in value:
|
|
planets = _check_array(result, f"{path}.planets", value.get("planets"), ARRAY_LIMITS["planets"])
|
|
if planets is not None:
|
|
for index, planet in enumerate(planets):
|
|
planet_path = f"{path}.planets[{index}]"
|
|
planet_value = _expect_object(result, planet_path, planet)
|
|
if planet_value is None:
|
|
continue
|
|
_check_keys(result, planet_path, planet_value, ("name", "sign", "longitudeDegrees", "houseNumber", "retrograde"))
|
|
_check_text(result, f"{planet_path}.name", planet_value.get("name"), TEXT_LIMITS["planetName"])
|
|
_check_text(result, f"{planet_path}.sign", planet_value.get("sign"), TEXT_LIMITS["sign"])
|
|
longitude = planet_value.get("longitudeDegrees")
|
|
if not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or not (0 <= longitude < 360):
|
|
result.add(f"{planet_path}.longitudeDegrees", f"must be number 0..360 (360 excluded), got {longitude!r}")
|
|
house_number = planet_value.get("houseNumber")
|
|
if not isinstance(house_number, int) or isinstance(house_number, bool) or house_number not in HOUSE_NUMBERS:
|
|
result.add(f"{planet_path}.houseNumber", f"must be integer 1..12, got {house_number!r}")
|
|
if not isinstance(planet_value.get("retrograde"), bool):
|
|
result.add(f"{planet_path}.retrograde", "must be boolean")
|
|
|
|
|
|
def _validate_thematic_section(result: ValidationResult, path: str, section: Any) -> None:
|
|
value = _expect_object(result, path, section)
|
|
if value is None:
|
|
return
|
|
_check_keys(result, path, value, ("id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs"))
|
|
section_id = value.get("id")
|
|
if not isinstance(section_id, str) or not SECTION_ID_PATTERN.match(section_id):
|
|
result.add(f"{path}.id", f"invalid section id {section_id!r}")
|
|
_check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["sectionTitle"])
|
|
_check_text(result, f"{path}.narrative", value.get("narrative"), TEXT_LIMITS["narrative"])
|
|
actions = _check_array(result, f"{path}.actions", value.get("actions"), ARRAY_LIMITS["actions"])
|
|
if actions is not None:
|
|
for index, action in enumerate(actions):
|
|
_check_text(result, f"{path}.actions[{index}]", action, TEXT_LIMITS["action"])
|
|
caveats = _check_array(result, f"{path}.caveats", value.get("caveats"), ARRAY_LIMITS["caveats"])
|
|
if caveats is not None:
|
|
for index, caveat in enumerate(caveats):
|
|
_check_text(result, f"{path}.caveats[{index}]", caveat, TEXT_LIMITS["caveat"])
|
|
_check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES)
|
|
refs = _check_array(result, f"{path}.evidenceRefs", value.get("evidenceRefs"), ARRAY_LIMITS["evidenceRefs"])
|
|
if refs is not None:
|
|
for index, ref in enumerate(refs):
|
|
if not isinstance(ref, str) or not EVIDENCE_ID_PATTERN.match(ref):
|
|
result.add(f"{path}.evidenceRefs[{index}]", f"invalid evidence id {ref!r}")
|
|
|
|
|
|
def _validate_technique_row(result: ValidationResult, path: str, row: Any) -> None:
|
|
value = _expect_object(result, path, row)
|
|
if value is None:
|
|
return
|
|
_check_keys(result, path, value, ("id", "techniqueId", "techniqueName", "status", "used"), ("id", "techniqueId", "techniqueName", "status", "used", "notes"))
|
|
evidence_id = value.get("id")
|
|
if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id):
|
|
result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}")
|
|
technique_id = value.get("techniqueId")
|
|
if not isinstance(technique_id, str) or not TECHNIQUE_ID_PATTERN.match(technique_id):
|
|
result.add(f"{path}.techniqueId", f"invalid technique id {technique_id!r}")
|
|
_check_text(result, f"{path}.techniqueName", value.get("techniqueName"), TEXT_LIMITS["techniqueName"])
|
|
_check_enum(result, f"{path}.status", value.get("status"), TECHNIQUE_STATUSES)
|
|
if not isinstance(value.get("used"), bool):
|
|
result.add(f"{path}.used", "must be boolean")
|
|
if "notes" in value:
|
|
_check_text(result, f"{path}.notes", value.get("notes"), TEXT_LIMITS["notes"], min_length=0)
|
|
|
|
|
|
def _validate_conflict_row(result: ValidationResult, path: str, row: Any) -> None:
|
|
value = _expect_object(result, path, row)
|
|
if value is None:
|
|
return
|
|
_check_keys(result, path, value, ("id", "description", "impact", "status"))
|
|
evidence_id = value.get("id")
|
|
if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id):
|
|
result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}")
|
|
_check_text(result, f"{path}.description", value.get("description"), TEXT_LIMITS["conflictDescription"])
|
|
_check_text(result, f"{path}.impact", value.get("impact"), TEXT_LIMITS["conflictImpact"])
|
|
_check_enum(result, f"{path}.status", value.get("status"), CONFLICT_STATUSES)
|
|
|
|
|
|
def _validate_calculation_row(result: ValidationResult, path: str, row: Any) -> None:
|
|
value = _expect_object(result, path, row)
|
|
if value is None:
|
|
return
|
|
_check_keys(result, path, value, ("id", "label", "value", "source"))
|
|
evidence_id = value.get("id")
|
|
if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id):
|
|
result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}")
|
|
_check_text(result, f"{path}.label", value.get("label"), TEXT_LIMITS["evidenceLabel"])
|
|
_check_text(result, f"{path}.value", value.get("value"), TEXT_LIMITS["evidenceValue"])
|
|
_check_text(result, f"{path}.source", value.get("source"), TEXT_LIMITS["evidenceSource"])
|
|
|
|
|
|
def _validate_evidence_appendix(result: ValidationResult, path: str, appendix: Any) -> None:
|
|
value = _expect_object(result, path, appendix)
|
|
if value is None:
|
|
return
|
|
_check_keys(
|
|
result,
|
|
path,
|
|
value,
|
|
("expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"),
|
|
)
|
|
if not isinstance(value.get("expandedByDefault"), bool):
|
|
result.add(f"{path}.expandedByDefault", "must be boolean")
|
|
rows = _check_array(result, f"{path}.techniqueAudit", value.get("techniqueAudit"), ARRAY_LIMITS["techniqueAudit"])
|
|
if rows is not None:
|
|
for index, row in enumerate(rows):
|
|
_validate_technique_row(result, f"{path}.techniqueAudit[{index}]", row)
|
|
rows = _check_array(result, f"{path}.conflicts", value.get("conflicts"), ARRAY_LIMITS["conflicts"])
|
|
if rows is not None:
|
|
for index, row in enumerate(rows):
|
|
_validate_conflict_row(result, f"{path}.conflicts[{index}]", row)
|
|
rows = _check_array(result, f"{path}.calculationEvidence", value.get("calculationEvidence"), ARRAY_LIMITS["calculationEvidence"])
|
|
if rows is not None:
|
|
for index, row in enumerate(rows):
|
|
_validate_calculation_row(result, f"{path}.calculationEvidence[{index}]", row)
|
|
blocked = _check_array(result, f"{path}.blockedTechniques", value.get("blockedTechniques"), ARRAY_LIMITS["blockedTechniques"])
|
|
if blocked is not None:
|
|
for index, technique in enumerate(blocked):
|
|
_check_text(result, f"{path}.blockedTechniques[{index}]", technique, TEXT_LIMITS["blockedTechnique"])
|
|
|
|
|
|
def _validate_chart_set(result: ValidationResult, charts: List[Any]) -> None:
|
|
"""Runtime chart-set semantics: exactly one D1, unique ids, D1 complete houses."""
|
|
seen_ids: List[str] = []
|
|
d1_chart: Optional[Dict[str, Any]] = None
|
|
for index, chart in enumerate(charts):
|
|
if not isinstance(chart, dict):
|
|
continue
|
|
chart_id = chart.get("id")
|
|
if isinstance(chart_id, str):
|
|
if chart_id in seen_ids:
|
|
result.add(f"charts[{index}].id", f"duplicate chart id {chart_id!r}")
|
|
seen_ids.append(chart_id)
|
|
if chart_id == "D1":
|
|
d1_chart = chart
|
|
d1_count = sum(1 for chart_id in seen_ids if chart_id == "D1")
|
|
if d1_count != 1:
|
|
result.add("charts", f"must contain exactly one D1 chart, found {d1_count}")
|
|
if d1_chart is not None:
|
|
houses = d1_chart.get("houses")
|
|
if not isinstance(houses, list):
|
|
result.add("charts[D1].houses", "D1 chart must declare houses")
|
|
return
|
|
numbers = []
|
|
for house in houses:
|
|
if isinstance(house, dict) and isinstance(house.get("houseNumber"), int) \
|
|
and not isinstance(house.get("houseNumber"), bool):
|
|
numbers.append(house["houseNumber"])
|
|
if sorted(numbers) != list(range(1, 13)):
|
|
result.add("charts[D1].houses", "D1 chart must contain all twelve house numbers 1..12 exactly once")
|
|
elif len(set(numbers)) != 12:
|
|
result.add("charts[D1].houses", "D1 chart contains duplicate house numbers")
|
|
|
|
|
|
def validate_report_document(document: Any) -> ValidationResult:
|
|
result = ValidationResult(valid=True)
|
|
if not isinstance(document, dict):
|
|
result.add("(root)", f"expected object, got {type(document).__name__}")
|
|
result.valid = False
|
|
return result
|
|
|
|
_check_keys(
|
|
result,
|
|
"(root)",
|
|
document,
|
|
(
|
|
"schemaVersion",
|
|
"reportId",
|
|
"reportType",
|
|
"presentationMode",
|
|
"generatedAt",
|
|
"subject",
|
|
"provenance",
|
|
"executiveSummary",
|
|
"charts",
|
|
"thematicNarrative",
|
|
"evidenceAppendix",
|
|
"disclaimer",
|
|
),
|
|
)
|
|
if document.get("schemaVersion") != SCHEMA_VERSION:
|
|
result.add("schemaVersion", f"must be {SCHEMA_VERSION!r}")
|
|
_check_uuid(result, "reportId", document.get("reportId"))
|
|
_check_enum(result, "reportType", document.get("reportType"), REPORT_TYPES)
|
|
_check_enum(result, "presentationMode", document.get("presentationMode"), PRESENTATION_MODES)
|
|
generated_at = document.get("generatedAt")
|
|
if not isinstance(generated_at, str) or not ISO8601_PATTERN.match(generated_at):
|
|
result.add("generatedAt", f"invalid ISO-8601 timestamp {generated_at!r}")
|
|
|
|
_validate_subject(result, "subject", document.get("subject"))
|
|
_validate_provenance(result, "provenance", document.get("provenance"))
|
|
_validate_executive_summary(result, "executiveSummary", document.get("executiveSummary"))
|
|
|
|
charts = _check_array(result, "charts", document.get("charts"), ARRAY_LIMITS["charts"], min_items=1)
|
|
if charts is not None:
|
|
for index, chart in enumerate(charts):
|
|
_validate_chart(result, f"charts[{index}]", chart)
|
|
_validate_chart_set(result, charts)
|
|
|
|
sections = _check_array(result, "thematicNarrative", document.get("thematicNarrative"), ARRAY_LIMITS["thematicNarrative"])
|
|
seen_section_ids: List[str] = []
|
|
if sections is not None:
|
|
for index, section in enumerate(sections):
|
|
_validate_thematic_section(result, f"thematicNarrative[{index}]", section)
|
|
section_id = section.get("id") if isinstance(section, dict) else None
|
|
if isinstance(section_id, str):
|
|
if section_id in seen_section_ids:
|
|
result.add(f"thematicNarrative[{index}].id", f"duplicate section id {section_id!r}")
|
|
seen_section_ids.append(section_id)
|
|
|
|
_validate_evidence_appendix(result, "evidenceAppendix", document.get("evidenceAppendix"))
|
|
_check_text(result, "disclaimer", document.get("disclaimer"), TEXT_LIMITS["disclaimer"])
|
|
|
|
# Semantic guards (only when the structural shape is usable; all access is
|
|
# defensive so arbitrary/malformed JSON can never raise).
|
|
if (
|
|
isinstance(document.get("evidenceAppendix"), dict)
|
|
and all(
|
|
isinstance(document["evidenceAppendix"].get(key), list)
|
|
for key in ("techniqueAudit", "conflicts", "calculationEvidence")
|
|
)
|
|
and isinstance(document.get("thematicNarrative"), list)
|
|
and isinstance(document.get("provenance"), dict)
|
|
and isinstance(document.get("charts"), list)
|
|
and isinstance(document.get("executiveSummary"), dict)
|
|
):
|
|
_evidence_refs(result, document)
|
|
expected_hash = compute_evidence_hash(document)
|
|
if document["provenance"].get("evidenceHash") != expected_hash:
|
|
result.add("provenance.evidenceHash", f"does not match computed evidence hash {expected_hash}")
|
|
|
|
blocked_texts: List[Tuple[str, str]] = []
|
|
summary = document["executiveSummary"]
|
|
if summary.get("overallClaimStatus") == "blocked":
|
|
blocked_texts.append(("headline", summary.get("headline")))
|
|
blocked_texts.append(("summary", summary.get("summary")))
|
|
for index, priority in enumerate(summary.get("priorities", [])):
|
|
blocked_texts.append((f"priorities[{index}]", priority))
|
|
for index, chart in enumerate(document["charts"]):
|
|
if isinstance(chart, dict) and chart.get("claimStatus") == "blocked":
|
|
blocked_texts.append((f"charts[{index}].title", chart.get("title")))
|
|
for index, section in enumerate(document["thematicNarrative"]):
|
|
if isinstance(section, dict) and section.get("claimStatus") == "blocked":
|
|
blocked_texts.append((f"thematicNarrative[{index}].title", section.get("title")))
|
|
blocked_texts.append((f"thematicNarrative[{index}].narrative", section.get("narrative")))
|
|
for action_index, action in enumerate(section.get("actions", [])):
|
|
blocked_texts.append((f"thematicNarrative[{index}].actions[{action_index}]", action))
|
|
for caveat_index, caveat in enumerate(section.get("caveats", [])):
|
|
blocked_texts.append((f"thematicNarrative[{index}].caveats[{caveat_index}]", caveat))
|
|
for path, text in blocked_texts:
|
|
_blocked_determinism(result, path, "blocked", [(path, text)])
|
|
|
|
size = serialized_bytes(document)
|
|
if size > MAX_SERIALIZED_BYTES:
|
|
result.add("(size)", f"serialized document is {size} bytes, exceeding {MAX_SERIALIZED_BYTES}")
|
|
|
|
result.valid = not result.errors
|
|
return result
|
|
|
|
|
|
def is_valid_report_document(document: Any) -> bool:
|
|
return validate_report_document(document).valid
|
|
|
|
|
|
def load_report_document(path: str) -> Dict[str, Any]:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def parse_report_document_json(text: str) -> ValidationResult:
|
|
try:
|
|
document = json.loads(text)
|
|
except json.JSONDecodeError as error:
|
|
result = ValidationResult(valid=False)
|
|
result.add("(json)", f"invalid JSON: {error}")
|
|
return result
|
|
return validate_report_document(document)
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
if not args:
|
|
print("usage: python3 scripts/personal_report_contract.py <report-document.json>", file=sys.stderr)
|
|
return 2
|
|
path = args[0]
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
except OSError as error:
|
|
print(f"unable to read {path}: {error}", file=sys.stderr)
|
|
return 2
|
|
result = parse_report_document_json(text)
|
|
if result.valid:
|
|
try:
|
|
size = serialized_bytes(json.loads(text))
|
|
except ValueError:
|
|
size = 0
|
|
print(f"valid: {path} ({size} bytes)")
|
|
return 0
|
|
print(f"invalid: {path}", file=sys.stderr)
|
|
for error in result.errors:
|
|
print(f" - {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|