Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """Validate the Oracle's attribute database. | |
| Run this whenever you add or edit items in data/*.json: | |
| python check_db.py # check every category | |
| python check_db.py vegetable # check one category | |
| It reports four things and exits non-zero if any fail: | |
| 1. COMPLETE — every item defines every attribute for its category | |
| (missing values become "unknown" and cause bad guesses). | |
| 2. UNIQUE — no two items share the exact same attribute vector | |
| (identical twins can never be told apart). | |
| 3. GUESSABLE — playing each item's own attributes drives the engine | |
| to guess that item (and only it) within the question limit. | |
| 4. BALANCED — each attribute actually splits the set (warn-only; an | |
| attribute that's all-yes or all-no is dead weight). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import sys | |
| import engine | |
| from discovery import CATEGORY_ATTRS, ATTR_MEANING as MEANING | |
| CATEGORIES = ["animal", "fruit", "vegetable"] | |
| MAX_QUESTIONS = 20 | |
| GREEN, RED, YEL, DIM, BOLD, OFF = ( | |
| "\033[32m", "\033[31m", "\033[33m", "\033[2m", "\033[1m", "\033[0m") | |
| def _ok(msg): | |
| print(f" {GREEN}PASS{OFF} {msg}") | |
| def _fail(msg): | |
| print(f" {RED}FAIL{OFF} {msg}") | |
| def _warn(msg): | |
| print(f" {YEL}WARN{OFF} {msg}") | |
| def _template(cat, it): | |
| """A ready-to-paste attribute block: keeps existing values, fills the rest | |
| with false and a <-- FILL marker so you know what to set.""" | |
| order = CATEGORY_ATTRS[cat] | |
| attrs = it.get("attributes", {}) | |
| lines = [] | |
| for a in order: | |
| if a in attrs: | |
| lines.append(f' "{a}": {str(attrs[a]).lower()},') | |
| else: | |
| lines.append(f' "{a}": false, <-- FILL ({MEANING.get(a, a)})') | |
| return " \"attributes\": {\n" + "\n".join(lines) + "\n }" | |
| def check_complete(cat, items): | |
| """Every item must define every category attribute.""" | |
| need = set(CATEGORY_ATTRS[cat]) | |
| bad = [] | |
| for it in items: | |
| missing = need - set(it.get("attributes", {})) | |
| extra = set(it.get("attributes", {})) - need | |
| if not it.get("attributes"): | |
| bad.append((it, "EMPTY — no attributes set", missing)) | |
| elif missing or extra: | |
| note = [] | |
| if missing: | |
| note.append(f"missing {sorted(missing)}") | |
| if extra: | |
| note.append(f"remove unknown {sorted(extra)}") | |
| bad.append((it, "; ".join(note), missing)) | |
| if bad: | |
| _fail(f"{len(bad)} completeness issue(s):") | |
| for it, note, _missing in bad: | |
| print(f" - {it['name']}: {note}") | |
| # show a copy-paste template so the user knows exactly what to fill | |
| for it, _note, missing in bad: | |
| if missing or not it.get("attributes"): | |
| print(f"\n {DIM}fill-in for '{it['name']}':{OFF}") | |
| print(_template(cat, it)) | |
| return False | |
| _ok(f"all {len(items)} items define all {len(need)} attributes") | |
| return True | |
| def check_unique(cat, items): | |
| """No two items may have an identical attribute vector.""" | |
| need = sorted(CATEGORY_ATTRS[cat]) | |
| seen = {} | |
| clashes = [] | |
| for it in items: | |
| key = tuple(it["attributes"].get(a) for a in need) | |
| if key in seen: | |
| clashes.append((seen[key], it["name"])) | |
| else: | |
| seen[key] = it["name"] | |
| if clashes: | |
| _fail(f"{len(clashes)} identical pair(s) — these can never be distinguished:") | |
| for a, b in clashes: | |
| print(f" - {a} == {b}") | |
| return False | |
| _ok(f"all {len(items)} items have a unique attribute fingerprint") | |
| return True | |
| def _play(cat, secret, items): | |
| """Simulate a game where the answers come from `secret`'s real attributes. | |
| Returns (guess_name, num_questions, trace).""" | |
| truth = secret["attributes"] | |
| asked, history, trace = 0, [], [] | |
| while True: | |
| facts = [{"attribute": h["attribute"], "answer": h["answer"]} for h in history] | |
| cands = engine.filter_candidates(items, facts) | |
| names = [c["name"] for c in cands] | |
| if len(names) <= 1 or asked >= MAX_QUESTIONS: | |
| return (names[0] if names else None), asked, trace | |
| attr = engine.choose_attribute(cat, cands, [h["attribute"] for h in history]) | |
| if attr is None: | |
| return names[0], asked, trace | |
| val = truth.get(attr) | |
| ans = "yes" if val is True else "no" if val is False else "unknown" | |
| trace.append(f"{attr}={ans}({len(names)})") | |
| history.append({"attribute": attr, "answer": ans}) | |
| if ans != "unknown": | |
| asked += 1 | |
| def check_guessable(cat, items, verbose=False): | |
| """Playing each item's own attributes must guess that item.""" | |
| fails, qcounts = [], [] | |
| for secret in items: | |
| guess, nq, trace = _play(cat, secret, items) | |
| qcounts.append(nq) | |
| if guess != secret["name"]: | |
| fails.append((secret["name"], guess, trace)) | |
| elif verbose: | |
| print(f" {DIM}{secret['name']:14} in {nq:2}q {' '.join(trace)}{OFF}") | |
| if fails: | |
| _fail(f"{len(fails)} item(s) not guessed correctly:") | |
| for name, guess, trace in fails: | |
| print(f" - {name}: got {guess!r} [{' '.join(trace)}]") | |
| return False | |
| avg = sum(qcounts) / len(qcounts) if qcounts else 0 | |
| _ok(f"all {len(items)} items guessed correctly " | |
| f"(avg {avg:.1f}q, worst {max(qcounts)}q)") | |
| return True | |
| def check_balance(cat, items): | |
| """Warn about attributes that don't split the set (dead weight).""" | |
| dead = [] | |
| for attr in CATEGORY_ATTRS[cat]: | |
| yes = sum(1 for it in items if it["attributes"].get(attr) is True) | |
| no = sum(1 for it in items if it["attributes"].get(attr) is False) | |
| if yes == 0 or no == 0: | |
| dead.append(f"{attr} (yes={yes}, no={no})") | |
| if dead: | |
| _warn(f"{len(dead)} attribute(s) never split the set:") | |
| for d in dead: | |
| print(f" - {d}") | |
| else: | |
| _ok("every attribute splits the set at least once") | |
| return True # warning only — never fails the run | |
| def check_category(cat, verbose=False): | |
| items = list(engine.load_items(cat)) | |
| print(f"\n{BOLD}=== {cat} ({len(items)} items) ==={OFF}") | |
| if not items: | |
| _fail("no items loaded") | |
| return False | |
| results = [ | |
| check_complete(cat, items), | |
| check_unique(cat, items), | |
| check_guessable(cat, items, verbose), | |
| ] | |
| check_balance(cat, items) # advisory | |
| return all(results) | |
| def main(argv): | |
| verbose = "-v" in argv or "--verbose" in argv | |
| cats = [a for a in argv[1:] if not a.startswith("-")] or CATEGORIES | |
| engine.load_items.cache_clear() | |
| results = [check_category(c, verbose) for c in cats] # check ALL, no short-circuit | |
| ok = all(results) | |
| print() | |
| if ok: | |
| print(f"{GREEN}{BOLD}✓ DB OK — all checks passed.{OFF}") | |
| return 0 | |
| print(f"{RED}{BOLD}✗ DB has problems — see FAILs above.{OFF}") | |
| return 1 | |
| if __name__ == "__main__": | |
| sys.exit(main(sys.argv)) | |