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