Files
Jyotisha/scripts/personal_report_contract.py
T
2026-08-15 05:10:37 +08:00

872 lines
42 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ReportDocument v1/v2 contract validator (stdlib only).
ReportDocument v2 is the canonical current contract. Stored v1 documents remain
readable while producers and renderers migrate. The v2 JSON Schema, the Zod
contract, and this validator share structural and semantic rules. Evidence
hashes are always recomputed here; they are never trusted as model self-report.
"""
from __future__ import annotations
import hashlib
import json
import re
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
V1_SCHEMA_VERSION = "report_document.v1"
V2_SCHEMA_VERSION = "report_document.v2"
CURRENT_SCHEMA_VERSION = V2_SCHEMA_VERSION
# Deprecated producer compatibility: existing Python callers and tests used
# SCHEMA_VERSION for v1 document assembly. New code must use CURRENT_SCHEMA_VERSION.
SCHEMA_VERSION = V1_SCHEMA_VERSION
REPORT_CONTRACT_VERSION = "1"
CURRENT_REPORT_CONTRACT_VERSION = "2"
MAX_SERIALIZED_BYTES = 1_572_864
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")
REPORT_DEPTHS = ("concise", "standard", "deep", "research")
BIRTH_TIME_STATUSES = ("reported", "candidate", "accepted", "confirmed")
TECHNIQUE_STATUSES = ("verified", "partial", "blocked")
CONFLICT_STATUSES = ("unresolved", "partial", "resolved")
REPORT_ACTION_PRIORITIES = ("now", "next", "watch")
V1_CHART_IDS = ("D1", "D9", "D10")
CHART_IDS = ("D1", "D2", "D9", "D10", "D11", "D24")
HOUSE_NUMBERS = tuple(range(1, 13))
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}$")
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$")
SKILL_VERSION_PATTERN = re.compile(
r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
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}$")
V1_SECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
SECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,95}$")
THEME_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]{0,95}$")
TECHNIQUE_ID_PATTERN = re.compile(r"^[a-z0-9_.-]{1,80}$")
# Keep names and regular-expression meaning aligned with the TS side.
FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = (
("html_tag", r"<\s*/?\s*[a-z][^>]*>"),
("event_handler", r"\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*="),
("style_attribute", r"\bstyle\s*="),
("css_at_rule", r"@(?:import|media|supports|font-face|keyframes)\b"),
("css_rule", r"(?:^|[}\s])(?:[.#]?[a-z][a-z0-9_-]*)(?:\s+[.#]?[a-z][a-z0-9_-]*)*\s*\{[^{}]*\}"),
("css_declaration", r"(?:^|[;{\s])(?:color|background(?:-color)?|font(?:-family|-size|-weight)?|display|position|margin|padding|width|height|grid|flex|border|transform|animation)\s*:\s*[^;\n{}]+[;}]?"),
("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"),
("medical_diagnosis", r"(?:你|命主)(?:已经|已|必将|一定会|确定)?(?:患有|罹患|得了)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|(?:确诊为|诊断为)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|\b(?:you|the native)\s+(?:definitely\s+)?(?:have|has|will develop)\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b|\bdiagnosed with\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b"),
("deterministic_financial_promise", r"(?:保证收益|保本保收益|稳赚不赔|稳赚|必赚|确定(?:盈利|获利|回报)|一定会(?:赚钱|盈利|获利))|\b(?:guaranteed|certain|risk-free)\s+(?:profit|return|gain)s?\b|\bwill definitely\s+(?:profit|earn|make money)\b"),
)
FORBIDDEN_COMPILED = tuple((name, re.compile(pattern, re.IGNORECASE)) for name, pattern in FORBIDDEN_PATTERNS)
DETERMINISTIC_PHRASES: Tuple[str, ...] = (
"必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑",
"guaranteed", "definitely will", "certainly will", "will certainly", "is certain to",
)
DETERMINISTIC_COMPILED = tuple(re.compile(re.escape(phrase), re.IGNORECASE) for phrase in DETERMINISTIC_PHRASES)
DATE_CLAIM_PATTERN = re.compile(
r"(?:^|[^0-9])(?:19|20)\d{2}(?:年(?:0?[1-9]|1[0-2])月(?:(?:0?[1-9]|[12]\d|3[01])日)?|[-/.](?:0?[1-9]|1[0-2])(?:[-/.](?:0?[1-9]|[12]\d|3[01]))?)(?:[^0-9]|$)",
re.IGNORECASE,
)
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,
"phaseLabel": 200,
"narrative": 4000,
"action": 400,
"actionNote": 1000,
"caveat": 400,
"keyFactor": 400,
"timingNote": 400,
"reason": 2000,
"missingEvidence": 400,
"conflictNote": 500,
"techniqueName": 160,
"notes": 500,
"conflictDescription": 1000,
"conflictImpact": 500,
"evidenceLabel": 160,
"evidenceValue": 500,
"evidenceSource": 200,
"blockedTechnique": 120,
"disclaimer": 2000,
}
REQUIRED_THEME_CHARTS: Dict[str, Tuple[str, ...]] = {
"career": ("D10",),
"wealth": ("D2", "D11"),
"marriage": ("D9",),
"education": ("D24",),
}
@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}")
def _canonical_evidence(document: Dict[str, Any]) -> Dict[str, Any]:
appendix = document.get("evidenceAppendix")
if not isinstance(appendix, dict):
appendix = {}
def rows(key: str) -> List[Any]:
value = appendix.get(key)
return value if isinstance(value, list) else []
technique_rows: List[Dict[str, Any]] = []
for raw in rows("techniqueAudit"):
row = raw if isinstance(raw, dict) else {}
item = {
"id": row.get("id"),
"techniqueId": row.get("techniqueId"),
"techniqueName": row.get("techniqueName"),
"status": row.get("status"),
"used": row.get("used"),
}
if "notes" in row:
item["notes"] = row.get("notes")
technique_rows.append(item)
return {
"techniqueAudit": technique_rows,
"conflicts": [
{key: (raw if isinstance(raw, dict) else {}).get(key) for key in ("id", "description", "impact", "status")}
for raw in rows("conflicts")
],
"calculationEvidence": [
{key: (raw if isinstance(raw, dict) else {}).get(key) for key in ("id", "label", "value", "source")}
for raw in rows("calculationEvidence")
],
}
def compute_evidence_hash(document: Dict[str, Any]) -> str:
payload = json.dumps(_canonical_evidence(document), ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def serialized_bytes(document: Any) -> int:
return len(json.dumps(document, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
def _expect_object(result: ValidationResult, path: str, value: Any) -> Optional[Dict[str, Any]]:
if not isinstance(value, dict):
result.add(path, f"must be object, got {type(value).__name__}")
return None
return value
def _check_keys(
result: ValidationResult,
path: str,
value: Dict[str, Any],
required: Sequence[str],
allowed: Optional[Sequence[str]] = None,
) -> None:
required_set = set(required)
allowed_set = set(allowed or required)
missing = sorted(required_set - set(value))
extra = sorted(set(value) - allowed_set)
if missing:
result.add(path, f"missing required keys {missing}")
if extra:
result.add(path, f"unexpected keys {extra}")
def _check_text(
result: ValidationResult,
path: str,
value: Any,
max_length: int,
min_length: int = 1,
) -> Optional[str]:
if not isinstance(value, str):
result.add(path, "must be string")
return None
if len(value) < min_length or len(value) > max_length:
result.add(path, f"length must be {min_length}..{max_length}")
return value
def _check_pattern(result: ValidationResult, path: str, value: Any, pattern: re.Pattern[str], label: str) -> None:
if not isinstance(value, str) or pattern.fullmatch(value) is None:
result.add(path, f"invalid {label} {value!r}")
def _check_enum(result: ValidationResult, path: str, value: Any, allowed: Sequence[str]) -> None:
if value not in allowed:
result.add(path, f"must be one of {tuple(allowed)!r}, got {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, "must be array")
return None
if len(value) < min_items or len(value) > max_items:
result.add(path, f"item count must be {min_items}..{max_items}")
return value
def _check_text_array(
result: ValidationResult,
path: str,
value: Any,
max_items: int,
max_length: int,
min_items: int = 0,
) -> Optional[List[Any]]:
values = _check_array(result, path, value, max_items, min_items)
if values is not None:
for index, item in enumerate(values):
_check_text(result, f"{path}[{index}]", item, max_length)
return values
def _check_evidence_refs(
result: ValidationResult,
path: str,
value: Any,
min_items: int = 0,
) -> Optional[List[Any]]:
refs = _check_array(result, path, value, 24, min_items)
if refs is not None:
for index, ref in enumerate(refs):
_check_pattern(result, f"{path}[{index}]", ref, EVIDENCE_ID_PATTERN, "evidence id")
return refs
def _validate_subject(result: ValidationResult, value: Any) -> None:
subject = _expect_object(result, "subject", value)
if subject is None:
return
required = ("displayName", "birthTimeStatus", "birthPlaceLabel")
_check_keys(result, "subject", subject, required)
_check_text(result, "subject.displayName", subject.get("displayName"), TEXT_LIMITS["displayName"])
_check_enum(result, "subject.birthTimeStatus", subject.get("birthTimeStatus"), BIRTH_TIME_STATUSES)
_check_text(result, "subject.birthPlaceLabel", subject.get("birthPlaceLabel"), TEXT_LIMITS["birthPlaceLabel"])
def _validate_provenance(result: ValidationResult, value: Any, version: str) -> None:
provenance = _expect_object(result, "provenance", value)
if provenance is None:
return
common = ("skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion")
required = common if version == V1_SCHEMA_VERSION else ("skillName", "skillVersion", *common)
allowed = ("skillName", "skillVersion", *common)
_check_keys(result, "provenance", provenance, required, allowed)
if "skillName" in provenance:
_check_pattern(result, "provenance.skillName", provenance.get("skillName"), SKILL_NAME_PATTERN, "skill name")
if isinstance(provenance.get("skillName"), str) and len(provenance["skillName"]) > 120:
result.add("provenance.skillName", "length must be 1..120")
if "skillVersion" in provenance:
_check_pattern(result, "provenance.skillVersion", provenance.get("skillVersion"), SKILL_VERSION_PATTERN, "skill version")
if isinstance(provenance.get("skillVersion"), str) and len(provenance["skillVersion"]) > 80:
result.add("provenance.skillVersion", "length must be 1..80")
commit = provenance.get("skillSourceCommit")
if commit is not None:
_check_pattern(result, "provenance.skillSourceCommit", commit, SHA1_PATTERN, "commit sha")
for key in ("skillSnapshotSha256", "calculationHash", "evidenceHash"):
_check_pattern(result, f"provenance.{key}", provenance.get(key), SHA256_PATTERN, "sha256 hex")
expected_contract = REPORT_CONTRACT_VERSION if version == V1_SCHEMA_VERSION else CURRENT_REPORT_CONTRACT_VERSION
if provenance.get("reportContractVersion") != expected_contract:
result.add("provenance.reportContractVersion", f"must equal {expected_contract!r}")
def _validate_executive_summary(result: ValidationResult, value: Any, version: str) -> None:
summary = _expect_object(result, "executiveSummary", value)
if summary is None:
return
required = ["headline", "summary", "priorities", "overallClaimStatus"]
if version == V2_SCHEMA_VERSION:
required.append("evidenceRefs")
_check_keys(result, "executiveSummary", summary, required)
_check_text(result, "executiveSummary.headline", summary.get("headline"), TEXT_LIMITS["headline"])
_check_text(result, "executiveSummary.summary", summary.get("summary"), TEXT_LIMITS["summary"])
_check_text_array(result, "executiveSummary.priorities", summary.get("priorities"), 8, TEXT_LIMITS["priority"])
_check_enum(result, "executiveSummary.overallClaimStatus", summary.get("overallClaimStatus"), CLAIM_STATUSES)
if version == V2_SCHEMA_VERSION:
_check_evidence_refs(result, "executiveSummary.evidenceRefs", summary.get("evidenceRefs"), 1)
def _validate_house(result: ValidationResult, path: str, value: Any) -> None:
house = _expect_object(result, path, value)
if house is None:
return
required = ("houseNumber", "sign", "occupants")
_check_keys(result, path, house, required)
number = house.get("houseNumber")
if not isinstance(number, int) or isinstance(number, bool) or number not in HOUSE_NUMBERS:
result.add(f"{path}.houseNumber", f"must be integer 1..12, got {number!r}")
_check_text(result, f"{path}.sign", house.get("sign"), TEXT_LIMITS["sign"])
_check_text_array(result, f"{path}.occupants", house.get("occupants"), 12, TEXT_LIMITS["occupant"])
def _validate_planet(result: ValidationResult, path: str, value: Any) -> None:
planet = _expect_object(result, path, value)
if planet is None:
return
required = ("name", "sign", "longitudeDegrees", "houseNumber", "retrograde")
_check_keys(result, path, planet, required)
_check_text(result, f"{path}.name", planet.get("name"), TEXT_LIMITS["planetName"])
_check_text(result, f"{path}.sign", planet.get("sign"), TEXT_LIMITS["sign"])
longitude = planet.get("longitudeDegrees")
if not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or not (0 <= longitude < 360):
result.add(f"{path}.longitudeDegrees", f"must be number 0..360 (360 excluded), got {longitude!r}")
house_number = planet.get("houseNumber")
if not isinstance(house_number, int) or isinstance(house_number, bool) or house_number not in HOUSE_NUMBERS:
result.add(f"{path}.houseNumber", f"must be integer 1..12, got {house_number!r}")
if not isinstance(planet.get("retrograde"), bool):
result.add(f"{path}.retrograde", "must be boolean")
def _validate_chart(result: ValidationResult, path: str, value: Any, version: str) -> None:
chart = _expect_object(result, path, value)
if chart is None:
return
required = ["id", "title", "houses", "claimStatus"]
allowed = [*required, "planets"]
if version == V2_SCHEMA_VERSION:
required.append("evidenceRefs")
allowed.append("evidenceRefs")
_check_keys(result, path, chart, required, allowed)
_check_enum(result, f"{path}.id", chart.get("id"), V1_CHART_IDS if version == V1_SCHEMA_VERSION else CHART_IDS)
_check_text(result, f"{path}.title", chart.get("title"), TEXT_LIMITS["chartTitle"])
_check_enum(result, f"{path}.claimStatus", chart.get("claimStatus"), CLAIM_STATUSES)
houses = _check_array(result, f"{path}.houses", chart.get("houses"), 12)
seen_houses: List[int] = []
if houses is not None:
for index, house in enumerate(houses):
_validate_house(result, f"{path}.houses[{index}]", house)
number = house.get("houseNumber") if isinstance(house, dict) else None
if isinstance(number, int) and not isinstance(number, bool):
if number in seen_houses:
result.add(f"{path}.houses[{index}]", f"duplicate houseNumber {number!r}")
seen_houses.append(number)
if "planets" in chart:
planets = _check_array(result, f"{path}.planets", chart.get("planets"), 12)
if planets is not None:
for index, planet in enumerate(planets):
_validate_planet(result, f"{path}.planets[{index}]", planet)
if version == V2_SCHEMA_VERSION:
_check_evidence_refs(result, f"{path}.evidenceRefs", chart.get("evidenceRefs"), 1)
def _validate_thematic_section(result: ValidationResult, path: str, value: Any, version: str) -> None:
section = _expect_object(result, path, value)
if section is None:
return
required = ["id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs"]
if version == V2_SCHEMA_VERSION:
required.insert(1, "theme")
_check_keys(result, path, section, required)
_check_pattern(
result,
f"{path}.id",
section.get("id"),
V1_SECTION_ID_PATTERN if version == V1_SCHEMA_VERSION else SECTION_ID_PATTERN,
"section id",
)
if version == V2_SCHEMA_VERSION:
_check_pattern(result, f"{path}.theme", section.get("theme"), THEME_ID_PATTERN, "theme id")
_check_text(result, f"{path}.title", section.get("title"), TEXT_LIMITS["sectionTitle"])
_check_text(result, f"{path}.narrative", section.get("narrative"), TEXT_LIMITS["narrative"])
_check_text_array(result, f"{path}.actions", section.get("actions"), 12, TEXT_LIMITS["action"])
_check_text_array(result, f"{path}.caveats", section.get("caveats"), 12, TEXT_LIMITS["caveat"])
_check_enum(result, f"{path}.claimStatus", section.get("claimStatus"), CLAIM_STATUSES)
_check_evidence_refs(result, f"{path}.evidenceRefs", section.get("evidenceRefs"), 1 if version == V2_SCHEMA_VERSION else 0)
def _validate_evidence_appendix(result: ValidationResult, value: Any) -> None:
appendix = _expect_object(result, "evidenceAppendix", value)
if appendix is None:
return
required = ("expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques")
_check_keys(result, "evidenceAppendix", appendix, required)
if not isinstance(appendix.get("expandedByDefault"), bool):
result.add("evidenceAppendix.expandedByDefault", "must be boolean")
audit = _check_array(result, "evidenceAppendix.techniqueAudit", appendix.get("techniqueAudit"), 100)
if audit is not None:
for index, raw in enumerate(audit):
path = f"evidenceAppendix.techniqueAudit[{index}]"
row = _expect_object(result, path, raw)
if row is None:
continue
required_row = ("id", "techniqueId", "techniqueName", "status", "used")
_check_keys(result, path, row, required_row, (*required_row, "notes"))
_check_pattern(result, f"{path}.id", row.get("id"), EVIDENCE_ID_PATTERN, "evidence id")
_check_pattern(result, f"{path}.techniqueId", row.get("techniqueId"), TECHNIQUE_ID_PATTERN, "technique id")
_check_text(result, f"{path}.techniqueName", row.get("techniqueName"), TEXT_LIMITS["techniqueName"])
_check_enum(result, f"{path}.status", row.get("status"), TECHNIQUE_STATUSES)
if not isinstance(row.get("used"), bool):
result.add(f"{path}.used", "must be boolean")
if "notes" in row:
_check_text(result, f"{path}.notes", row.get("notes"), TEXT_LIMITS["notes"], 0)
conflicts = _check_array(result, "evidenceAppendix.conflicts", appendix.get("conflicts"), 50)
if conflicts is not None:
for index, raw in enumerate(conflicts):
path = f"evidenceAppendix.conflicts[{index}]"
row = _expect_object(result, path, raw)
if row is None:
continue
required_row = ("id", "description", "impact", "status")
_check_keys(result, path, row, required_row)
_check_pattern(result, f"{path}.id", row.get("id"), EVIDENCE_ID_PATTERN, "evidence id")
_check_text(result, f"{path}.description", row.get("description"), TEXT_LIMITS["conflictDescription"])
_check_text(result, f"{path}.impact", row.get("impact"), TEXT_LIMITS["conflictImpact"])
_check_enum(result, f"{path}.status", row.get("status"), CONFLICT_STATUSES)
calculations = _check_array(result, "evidenceAppendix.calculationEvidence", appendix.get("calculationEvidence"), 100)
if calculations is not None:
for index, raw in enumerate(calculations):
path = f"evidenceAppendix.calculationEvidence[{index}]"
row = _expect_object(result, path, raw)
if row is None:
continue
required_row = ("id", "label", "value", "source")
_check_keys(result, path, row, required_row)
_check_pattern(result, f"{path}.id", row.get("id"), EVIDENCE_ID_PATTERN, "evidence id")
_check_text(result, f"{path}.label", row.get("label"), TEXT_LIMITS["evidenceLabel"])
_check_text(result, f"{path}.value", row.get("value"), TEXT_LIMITS["evidenceValue"])
_check_text(result, f"{path}.source", row.get("source"), TEXT_LIMITS["evidenceSource"])
_check_text_array(result, "evidenceAppendix.blockedTechniques", appendix.get("blockedTechniques"), 100, TEXT_LIMITS["blockedTechnique"])
def _validate_natal_foundation(result: ValidationResult, value: Any) -> None:
foundation = _expect_object(result, "natalFoundation", value)
if foundation is None:
return
required = ("title", "narrative", "keyFactors", "caveats", "claimStatus", "evidenceRefs")
_check_keys(result, "natalFoundation", foundation, required)
_check_text(result, "natalFoundation.title", foundation.get("title"), TEXT_LIMITS["sectionTitle"])
_check_text(result, "natalFoundation.narrative", foundation.get("narrative"), TEXT_LIMITS["narrative"])
_check_text_array(result, "natalFoundation.keyFactors", foundation.get("keyFactors"), 12, TEXT_LIMITS["keyFactor"])
_check_text_array(result, "natalFoundation.caveats", foundation.get("caveats"), 12, TEXT_LIMITS["caveat"])
_check_enum(result, "natalFoundation.claimStatus", foundation.get("claimStatus"), CLAIM_STATUSES)
_check_evidence_refs(result, "natalFoundation.evidenceRefs", foundation.get("evidenceRefs"), 1)
def _validate_current_phase(result: ValidationResult, value: Any) -> None:
if value is None:
return
phase = _expect_object(result, "currentPhase", value)
if phase is None:
return
required = ("title", "phaseLabel", "narrative", "timingNotes", "caveats", "claimStatus", "evidenceRefs")
_check_keys(result, "currentPhase", phase, required)
_check_text(result, "currentPhase.title", phase.get("title"), TEXT_LIMITS["sectionTitle"])
_check_text(result, "currentPhase.phaseLabel", phase.get("phaseLabel"), TEXT_LIMITS["phaseLabel"])
_check_text(result, "currentPhase.narrative", phase.get("narrative"), TEXT_LIMITS["narrative"])
_check_text_array(result, "currentPhase.timingNotes", phase.get("timingNotes"), 12, TEXT_LIMITS["timingNote"])
_check_text_array(result, "currentPhase.caveats", phase.get("caveats"), 12, TEXT_LIMITS["caveat"])
_check_enum(result, "currentPhase.claimStatus", phase.get("claimStatus"), CLAIM_STATUSES)
_check_evidence_refs(result, "currentPhase.evidenceRefs", phase.get("evidenceRefs"), 1)
def _validate_action_notes(result: ValidationResult, value: Any) -> None:
notes = _check_array(result, "actionNotes", value, 24, 1)
if notes is None:
return
for index, raw in enumerate(notes):
path = f"actionNotes[{index}]"
note = _expect_object(result, path, raw)
if note is None:
continue
required = ("id", "title", "note", "priority", "evidenceRefs")
_check_keys(result, path, note, required)
_check_pattern(result, f"{path}.id", note.get("id"), SECTION_ID_PATTERN, "section id")
_check_text(result, f"{path}.title", note.get("title"), TEXT_LIMITS["sectionTitle"])
_check_text(result, f"{path}.note", note.get("note"), TEXT_LIMITS["actionNote"])
_check_enum(result, f"{path}.priority", note.get("priority"), REPORT_ACTION_PRIORITIES)
_check_evidence_refs(result, f"{path}.evidenceRefs", note.get("evidenceRefs"), 1)
def _validate_blocked_disclosures(result: ValidationResult, value: Any) -> None:
sections = _check_array(result, "blockedConflictDisclosure", value, 12)
if sections is None:
return
for index, raw in enumerate(sections):
path = f"blockedConflictDisclosure[{index}]"
section = _expect_object(result, path, raw)
if section is None:
continue
required = ("theme", "title", "reason", "missingEvidence", "conflictNotes", "evidenceRefs", "claimStatus")
_check_keys(result, path, section, required)
_check_pattern(result, f"{path}.theme", section.get("theme"), THEME_ID_PATTERN, "theme id")
_check_text(result, f"{path}.title", section.get("title"), TEXT_LIMITS["sectionTitle"])
_check_text(result, f"{path}.reason", section.get("reason"), TEXT_LIMITS["reason"])
_check_text_array(result, f"{path}.missingEvidence", section.get("missingEvidence"), 24, TEXT_LIMITS["missingEvidence"], 1)
_check_text_array(result, f"{path}.conflictNotes", section.get("conflictNotes"), 24, TEXT_LIMITS["conflictNote"])
_check_evidence_refs(result, f"{path}.evidenceRefs", section.get("evidenceRefs"))
if section.get("claimStatus") != "blocked":
result.add(f"{path}.claimStatus", "must equal 'blocked'")
def _text_leaves(value: Any, path: str = "") -> Iterable[Tuple[str, str]]:
if isinstance(value, str):
yield path or "(root)", value
elif isinstance(value, list):
for index, entry in enumerate(value):
yield from _text_leaves(entry, f"{path}[{index}]")
elif isinstance(value, dict):
for key, entry in value.items():
yield from _text_leaves(entry, f"{path}.{key}" if path else key)
def _known_evidence_ids(document: Dict[str, Any]) -> Tuple[set[str], List[Tuple[str, str]]]:
appendix = document.get("evidenceAppendix")
known: set[str] = set()
duplicates: List[Tuple[str, str]] = []
locations: Dict[str, str] = {}
if not isinstance(appendix, dict):
return known, duplicates
for key in ("techniqueAudit", "conflicts", "calculationEvidence"):
rows = appendix.get(key)
if not isinstance(rows, list):
continue
for index, row in enumerate(rows):
evidence_id = row.get("id") if isinstance(row, dict) else None
if not isinstance(evidence_id, str):
continue
location = f"{key}[{index}]"
if evidence_id in locations:
duplicates.append((evidence_id, f"{locations[evidence_id]} and {location}"))
else:
locations[evidence_id] = location
known.add(evidence_id)
return known, duplicates
def _evidence_ref_entries(document: Dict[str, Any], version: str) -> Iterable[Tuple[str, List[Any]]]:
sections = document.get("thematicNarrative")
if isinstance(sections, list):
for index, section in enumerate(sections):
if isinstance(section, dict) and isinstance(section.get("evidenceRefs"), list):
path = str(section.get("id")) if version == V1_SCHEMA_VERSION else f"thematicNarrative[{index}].evidenceRefs"
yield path, section["evidenceRefs"]
if version != V2_SCHEMA_VERSION:
return
for path, value in (
("executiveSummary.evidenceRefs", document.get("executiveSummary")),
("natalFoundation.evidenceRefs", document.get("natalFoundation")),
("currentPhase.evidenceRefs", document.get("currentPhase")),
):
if isinstance(value, dict) and isinstance(value.get("evidenceRefs"), list):
yield path, value["evidenceRefs"]
for key in ("actionNotes", "charts", "blockedConflictDisclosure"):
rows = document.get(key)
if isinstance(rows, list):
for index, row in enumerate(rows):
if isinstance(row, dict) and isinstance(row.get("evidenceRefs"), list):
yield f"{key}[{index}].evidenceRefs", row["evidenceRefs"]
def _semantic_guards(result: ValidationResult, document: Dict[str, Any], version: str) -> None:
charts = document.get("charts")
if isinstance(charts, list):
ids = [chart.get("id") for chart in charts if isinstance(chart, dict)]
d1_count = sum(chart_id == "D1" for chart_id in ids)
if d1_count != 1:
result.add("charts", f"must contain exactly one D1 chart, found {d1_count}")
seen: set[Any] = set()
for chart_id in ids:
if chart_id in seen:
result.add("charts", f"duplicate chart id {chart_id}")
seen.add(chart_id)
d1 = next((chart for chart in charts if isinstance(chart, dict) and chart.get("id") == "D1"), None)
if isinstance(d1, dict) and isinstance(d1.get("houses"), list):
numbers = [house.get("houseNumber") for house in d1["houses"] if isinstance(house, dict)]
if len(numbers) != 12 or set(numbers) != set(HOUSE_NUMBERS):
result.add("charts.D1.houses", "D1 chart must contain all twelve house numbers 1..12 exactly once")
known_ids, duplicates = _known_evidence_ids(document)
for evidence_id, locations in duplicates:
result.add("evidenceAppendix", f"duplicate evidence id {evidence_id} used in {locations}")
for path, refs in _evidence_ref_entries(document, version):
for ref in refs:
if isinstance(ref, str) and ref not in known_ids:
result.add(path, f"unknown evidence id {ref}")
blocked_values: List[Tuple[str, Any]] = []
summary = document.get("executiveSummary")
if isinstance(summary, dict) and summary.get("overallClaimStatus") == "blocked":
blocked_values.append(("executiveSummary", summary))
if isinstance(charts, list):
blocked_values.extend(
(f"charts[{index}]", chart)
for index, chart in enumerate(charts)
if isinstance(chart, dict) and chart.get("claimStatus") == "blocked"
)
sections = document.get("thematicNarrative")
if isinstance(sections, list):
blocked_values.extend(
(f"thematicNarrative[{index}]", section)
for index, section in enumerate(sections)
if isinstance(section, dict) and section.get("claimStatus") == "blocked"
)
if version == V2_SCHEMA_VERSION:
for path, value in (("natalFoundation", document.get("natalFoundation")), ("currentPhase", document.get("currentPhase"))):
if isinstance(value, dict) and value.get("claimStatus") == "blocked":
blocked_values.append((path, value))
disclosures = document.get("blockedConflictDisclosure")
if isinstance(disclosures, list):
blocked_values.extend((f"blockedConflictDisclosure[{index}]", row) for index, row in enumerate(disclosures) if isinstance(row, dict))
for base_path, value in blocked_values:
for path, text in _text_leaves(value, base_path):
if any(pattern.search(text) for pattern in DETERMINISTIC_COMPILED):
result.add(path, "blocked section contains deterministic prediction")
if version == V2_SCHEMA_VERSION:
requested = document.get("requestedThemes")
thematic = document.get("thematicNarrative")
disclosures = document.get("blockedConflictDisclosure")
if isinstance(requested, list) and isinstance(thematic, list) and isinstance(disclosures, list):
requested_counts: Dict[str, int] = {}
for theme in requested:
if isinstance(theme, str):
requested_counts[theme] = requested_counts.get(theme, 0) + 1
for theme, count in requested_counts.items():
if count != 1:
result.add("requestedThemes", f"must contain {theme} exactly once, found {count}")
coverage: Dict[str, int] = {}
requested_set = set(theme for theme in requested if isinstance(theme, str))
for index, section in enumerate(thematic):
theme = section.get("theme") if isinstance(section, dict) else None
if isinstance(theme, str):
coverage[theme] = coverage.get(theme, 0) + 1
if theme not in requested_set:
result.add(f"thematicNarrative[{index}].theme", f"covers unrequested theme {theme}")
for index, section in enumerate(disclosures):
theme = section.get("theme") if isinstance(section, dict) else None
if isinstance(theme, str):
coverage[theme] = coverage.get(theme, 0) + 1
if theme not in requested_set:
result.add(f"blockedConflictDisclosure[{index}].theme", f"covers unrequested theme {theme}")
for theme in requested_set:
count = coverage.get(theme, 0)
if count != 1:
result.add("requestedThemes", f"requested theme {theme} must have exactly one thematic section or blocked disclosure, found {count}")
chart_ids = set(chart.get("id") for chart in charts if isinstance(chart, dict)) if isinstance(charts, list) else set()
for index, section in enumerate(thematic):
theme = section.get("theme") if isinstance(section, dict) else None
if not isinstance(theme, str):
continue
for required_chart in REQUIRED_THEME_CHARTS.get(theme, ()):
if required_chart not in chart_ids:
result.add(f"thematicNarrative[{index}]", f"theme {theme} requires structured {required_chart} chart data or a blocked disclosure")
date_sections: List[Tuple[str, Any, List[Any]]] = []
for path, value in (
("executiveSummary", document.get("executiveSummary")),
("natalFoundation", document.get("natalFoundation")),
("currentPhase", document.get("currentPhase")),
):
if isinstance(value, dict) and isinstance(value.get("evidenceRefs"), list):
date_sections.append((path, value, value["evidenceRefs"]))
for key in ("thematicNarrative", "actionNotes", "blockedConflictDisclosure"):
rows = document.get(key)
if isinstance(rows, list):
for index, row in enumerate(rows):
if isinstance(row, dict) and isinstance(row.get("evidenceRefs"), list):
date_sections.append((f"{key}[{index}]", row, row["evidenceRefs"]))
for path, value, refs in date_sections:
if not refs and any(DATE_CLAIM_PATTERN.search(text) for _, text in _text_leaves(value)):
result.add(path, "date claim requires evidenceRefs")
for path, text in _text_leaves(document):
hits = [name for name, pattern in FORBIDDEN_COMPILED if pattern.search(text)]
if hits:
result.add(path, f"forbidden content {','.join(hits)}")
provenance = document.get("provenance")
appendix = document.get("evidenceAppendix")
if isinstance(provenance, dict) and isinstance(appendix, dict):
expected_hash = compute_evidence_hash(document)
if provenance.get("evidenceHash") != expected_hash:
result.add("provenance.evidenceHash", f"does not match computed evidence hash {expected_hash}")
try:
size = serialized_bytes(document)
except (TypeError, ValueError, OverflowError) as error:
result.add("(size)", f"unable to serialize document: {error}")
else:
if size > MAX_SERIALIZED_BYTES:
result.add("(size)", f"serialized document is {size} bytes, exceeding {MAX_SERIALIZED_BYTES}")
def validate_report_document(document: Any) -> ValidationResult:
result = ValidationResult(valid=False)
if not isinstance(document, dict):
result.add("(root)", f"must be object, got {type(document).__name__}")
return result
version = document.get("schemaVersion")
if version not in (V1_SCHEMA_VERSION, V2_SCHEMA_VERSION):
result.add("schemaVersion", f"must be one of {(V1_SCHEMA_VERSION, V2_SCHEMA_VERSION)!r}, got {version!r}")
return result
v1_required = (
"schemaVersion", "reportId", "reportType", "presentationMode", "generatedAt", "subject", "provenance",
"executiveSummary", "charts", "thematicNarrative", "evidenceAppendix", "disclaimer",
)
v2_required = (
"schemaVersion", "reportId", "reportType", "presentationMode", "depth", "requestedThemes", "generatedAt",
"subject", "provenance", "executiveSummary", "natalFoundation", "currentPhase", "actionNotes", "charts",
"thematicNarrative", "blockedConflictDisclosure", "evidenceAppendix", "disclaimer",
)
required = v1_required if version == V1_SCHEMA_VERSION else v2_required
_check_keys(result, "(root)", document, required)
_check_pattern(result, "reportId", document.get("reportId"), UUID_PATTERN, "uuid")
_check_enum(result, "reportType", document.get("reportType"), REPORT_TYPES)
_check_enum(result, "presentationMode", document.get("presentationMode"), PRESENTATION_MODES)
if version == V2_SCHEMA_VERSION:
_check_enum(result, "depth", document.get("depth"), REPORT_DEPTHS)
themes = _check_array(result, "requestedThemes", document.get("requestedThemes"), 12, 1)
if themes is not None:
for index, theme in enumerate(themes):
_check_pattern(result, f"requestedThemes[{index}]", theme, THEME_ID_PATTERN, "theme id")
_check_pattern(result, "generatedAt", document.get("generatedAt"), ISO8601_PATTERN, "ISO-8601 timestamp")
_validate_subject(result, document.get("subject"))
_validate_provenance(result, document.get("provenance"), version)
_validate_executive_summary(result, document.get("executiveSummary"), version)
if version == V2_SCHEMA_VERSION:
_validate_natal_foundation(result, document.get("natalFoundation"))
_validate_current_phase(result, document.get("currentPhase"))
_validate_action_notes(result, document.get("actionNotes"))
charts = _check_array(result, "charts", document.get("charts"), 3 if version == V1_SCHEMA_VERSION else 6, 1)
if charts is not None:
for index, chart in enumerate(charts):
_validate_chart(result, f"charts[{index}]", chart, version)
sections = _check_array(result, "thematicNarrative", document.get("thematicNarrative"), 12)
seen_section_ids: set[str] = set()
if sections is not None:
for index, section in enumerate(sections):
_validate_thematic_section(result, f"thematicNarrative[{index}]", section, version)
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.add(section_id)
if version == V2_SCHEMA_VERSION:
_validate_blocked_disclosures(result, document.get("blockedConflictDisclosure"))
_validate_evidence_appendix(result, document.get("evidenceAppendix"))
_check_text(result, "disclaimer", document.get("disclaimer"), TEXT_LIMITS["disclaimer"])
_semantic_guards(result, document, version)
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 (TypeError, 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())