File size: 3,490 Bytes
4b09d2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461b859
 
 
 
 
 
 
4b09d2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#!/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())