#!/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())