ccr-platform / packages /construct_library /validate_constructs.py
devaanand's picture
fix(constructs): correct the review pass after a validity review
461b859
Raw
History Blame Contribute Delete
3.49 kB
#!/usr/bin/env python3
"""Validate construct YAML files and print each construct's item hash.
Run: python packages/construct_library/validate_constructs.py
The item hash (sha256 of normalized item text + order + flags + language + version)
is the construct's identity in run metadata - this validator is the reference
implementation of that hash (design doc §10.1).
"""
import hashlib
import sys
from pathlib import Path
import yaml
REQUIRED = {"construct_id", "version", "name", "language", "citation", "verification_status", "items"}
VALID_STATUS = {"draft", "needs_verification", "verified", "archived"}
def item_hash(construct: dict) -> str:
parts = [str(construct["language"]), str(construct["version"])]
for i, item in enumerate(construct["items"]):
parts.append(f"{i}|{' '.join(str(item['text']).split())}|{bool(item.get('reverse_scored', False))}")
return hashlib.sha256("\n".join(parts).encode()).hexdigest()
def main() -> int:
constructs_dir = Path(__file__).parent / "constructs"
files = sorted(constructs_dir.glob("*.yaml"))
errors: list[str] = []
seen: dict[tuple, Path] = {}
if not files:
errors.append("No construct files found.")
for f in files:
c = yaml.safe_load(f.read_text())
name = f.name
missing = REQUIRED - c.keys()
if missing:
errors.append(f"{name}: missing fields {sorted(missing)}")
continue
if c["verification_status"] not in VALID_STATUS:
errors.append(f"{name}: invalid verification_status '{c['verification_status']}'")
if not c["items"]:
errors.append(f"{name}: no items")
continue
for i, item in enumerate(c["items"]):
if not str(item.get("text", "")).strip():
errors.append(f"{name}: item {i} has empty text")
if "reverse_scored" not in item:
errors.append(f"{name}: item {i} missing explicit reverse_scored flag")
texts = [" ".join(str(i["text"]).split()).lower() for i in c["items"]]
if len(texts) != len(set(texts)):
errors.append(f"{name}: duplicate item texts")
key = (c["construct_id"], c["version"])
if key in seen:
errors.append(f"{name}: duplicate (construct_id, version) with {seen[key].name} - versions are append-only")
seen[key] = f
if all(i.get("reverse_scored") for i in c["items"]):
# v1 records reverse flags and does not adjust for them
# (adjustment_strategy: none), so a run on this construct scores in
# the OPPOSITE direction to its name. Runs say so at scoring time via
# CONSTRUCT_ALL_ITEMS_REVERSED; the design doc's exclude_reversed
# strategy, which would block the aggregate, is not implemented yet.
print(f" WARN {name}: ALL items reverse-scored - a higher score means the text "
"expresses the OPPOSITE of this construct (no reverse adjustment is applied).")
if not errors or all(name not in e for e in errors):
print(f" OK {name} v{c['version']} items={len(c['items'])} hash={item_hash(c)[:16]} [{c['verification_status']}]")
if errors:
print("CONSTRUCT LIBRARY INVALID:")
for e in errors:
print(f" ERROR: {e}")
return 1
print(f"construct library OK - {len(files)} construct(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())