oracle / check_db.py
vivek gangadharan
base1
112812c
Raw
History Blame Contribute Delete
7.15 kB
#!/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))