"""Drive the full game loop in MOCK_LLM mode through the real handlers. Run: MOCK_LLM=1 python tests/drive_mock.py Verifies: question loop, accusation staging, record schema, hidden analyst capture, demand-verdict path, forced-Q20 path, parse ladder, and the no-lie-leak invariant. """ import inspect import json import os import sys os.environ["MOCK_LLM"] = "1" sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import app # noqa: E402 app.time.sleep = lambda s: None # fast-forward all theater def consume(gen): last = None for out in gen: last = out return last def play_game(detective_choice, lie="C", answers=8, demand_at=None): detective_card = detective_choice st = app.new_state() outs = app.start_case("I once got locked in a museum overnight", "I have eaten dinner with a famous actor", "I broke my arm at a wedding", lie, detective_card, True, st) st, chat = outs[0], outs[1] consume(app.detective_turn(st, chat)) n = 0 while st["phase"] == "interrogation" and n < answers + 25: n += 1 if demand_at and n == demand_at: app.demand_verdict(st) consume(app.detective_turn(st, [])) break _, st, chat = app.add_answer(f"It happened in spring, answer {n}.", st, []) consume(app.detective_turn(st, chat)) return st def main(): fails = [] def check(name, cond, detail=""): print((" ok " if cond else " FAIL") + f" {name}" + (f" — {detail}" if detail and not cond else "")) if not cond: fails.append(name) print("== game 1: normal play vs Pip (mock accuses at 6 answers) ==") st = play_game(app.CHARACTERS["pip"]["choice_label"], lie="C") check("start_case returns 13 outs", len(app.start_case( "aaa one two three", "bbb one two three", "ccc one two three", "A", app.CHARACTERS["pip"]["choice_label"], True, app.new_state())) == 13) check("reached verdict", st["phase"] == "verdict_staged", st["phase"]) check("accusation exists", bool(st["accusation"])) check("scored BEFORE flip", st["last_result"] == "win" and st["saved_path"] is not None) check("intro preserved in chat", bool(app._chat_from_state(st)[0]["content"])) check("mock accused B", st["accusation"]["lie"] == "B", str(st["accusation"])) check("player won (lie was C)", st["last_result"] == "win") check("analyst ran", len(st["hidden_analysis"]) >= 1) check("record saved", st["saved_path"] and os.path.exists(st["saved_path"])) rec = json.loads(open(st["saved_path"]).read()) for key in ["schema_version", "opponent", "consent", "claims", "labels", "conversation", "hidden_analysis", "guess", "safety", "dataset"]: check(f"record has {key}", key in rec) check("record: was_correct False", rec["guess"]["was_correct"] is False) check("record: guesser=model", rec["guess"]["guesser"] == "model") check("record: disclosure flag", rec["consent"]["recording_disclosure_shown"] is True) check("record: lie label kept", rec["labels"]["lie"] == "C") print("== flip card ==") flip = app.flip_card(st) check("flip returns 9 outs", len(flip) == 9, str(len(flip))) sh = flip[8]["value"] if isinstance(flip[8], dict) else str(flip[8]) check("share row has replay + 3 platforms", "/replay#d=" in sh and all(k in sh for k in ("twitter.com", "facebook.com", "linkedin.com"))) import base64 as _b64, zlib as _zlib m = __import__("re").search(r"/replay#d=([A-Za-z0-9_\-]+)", sh) payload = m.group(1) payload += "=" * (-len(payload) % 4) replay = json.loads(_zlib.decompress(_b64.urlsafe_b64decode(payload))) check("replay payload roundtrips", replay["lie"] == "C" and len(replay["qa"]) >= 5 and replay["det"] == app.CHARACTERS["pip"]["name"], str(replay)[:120]) check("replay url reasonable length", len(m.group(0)) < 6000, str(len(m.group(0)))) check("replay keeps the theater", any(e.get("w") for e in replay["qa"]) and len(replay["acc"].get("w", [])) >= 1, str([e.get("w") for e in replay["qa"]])[:120]) check("record keeps the theater", any(e.get("waits") for e in rec["conversation"])) check("replay excludes hidden analysis", "suspicion" not in json.dumps(replay)) check("phase done", st["phase"] == "done") check("streak counted", st["session"]["streak"] == 1) share = flip[3]["value"] if isinstance(flip[3], dict) else flip[3] check("share grid present", "●" in str(share)) check("summons after pip win", str(flip[7]).find(app.BTN_SUMMONS) >= 0 or (isinstance(flip[7], dict) and flip[7].get("value") == app.BTN_SUMMONS)) print("== next_case / rematch arity ==") check("next_case 13 outs", len(app.next_case(st)) == 13) check("rematch 13 outs", len(app.rematch_other(st)) == 13) print("== game 2: demand verdict at 3 vs Marlowe ==") st2 = play_game(app.CHARACTERS["marlowe"]["choice_label"], lie="B", demand_at=4) check("verdict after demand", st2["phase"] == "verdict_staged", st2["phase"]) check("demanded flag", st2["accusation"]["demanded"] is True) check("player caught (lie was B, mock accuses B)", st2["accusation"]["lie"] == "B") app.flip_card(st2) check("loss counted", st2["session"]["losses"] == 1) print("== parse ladder ==") a, r, q, l, w, c, fb = app.parse_detective('{"action":"ask","question":"Where did it happen?","remark":"Mm.","lie":"A","confidence":"low"}', [], False) check("clean ask parse", a == "ask" and q == "Where did it happen?" and fb == 0) a, r, q, l, w, c, fb = app.parse_detective("So which one is fake? Tell me about the wedding?", [], False) check("salvage raw as hunch", a == "ask" and fb == 1, f"{a},{fb}") a, r, q, l, w, c, fb = app.parse_detective("garbage with no letter", [{"turn": 1, "q": "about A?", "a": "x"}], True) check("forced accuse letter-scan/fallback", a == "accuse" and l in "ABC", f"{a},{l},{fb}") a, r, q, l, w, c, fb = app.parse_detective('{"action":"accuse","lie":"C","why":"vague","confidence":"high"}', [], True) check("clean accuse parse", a == "accuse" and l == "C" and fb == 0) print("== accuse gate ==") prof_hi = {f"suspicion_{k}": "high" for k in "ABC"} prof_lo = {f"suspicion_{k}": "low" for k in "ABC"} check("blocked before Q5", not app._accuse_allowed(4, "high", prof_hi)) check("high conf passes at Q5", app._accuse_allowed(5, "high", prof_hi)) check("snap guard blocks weak analyst at Q6", not app._accuse_allowed(6, "high", prof_lo)) check("medium passes at Q8", app._accuse_allowed(8, "medium", prof_lo)) check("medium blocked at Q6", not app._accuse_allowed(6, "medium", prof_hi)) print("== letter scan (article-'a' bug regression) ==") check("prose article not matched as A", app._scan_letter("It was a stretch from the start. The claim about the arm — claim C.") == "C") check("bare letter", app._scan_letter(" b. ") == "B") check("claim-prefixed lowercase", app._scan_letter("i think claim b is the lie") == "B") check("capital standalone", app._scan_letter("The lie is B, obviously") == "B") check("no letter → None", app._scan_letter("no letters here at all") is None) print("== placeholder-question guard ==") a, r, q, l, w, c, fb = app.parse_detective('{"action":"ask","question":"empty","confidence":"low"}', [], False) check("literal 'empty' rejected", fb == 3 and not q, f"{q!r},{fb}") a, r, q, l, w, c, fb = app.parse_detective('{"action":"ask","question":"","confidence":"low"}', [], False) check("spec echo rejected", fb == 3 and not q, f"{q!r},{fb}") print("== demand unlock parity ==") st3 = app.new_state() st3["phase"] = "interrogation" st3["qa"] = [{"turn": i, "q": f"q{i} about A?", "a": "ans"} for i in range(1, 4)] app.demand_verdict(st3) check("3 answered → demand set", st3["demand"] is True) print("== transcript budget ==") big_qa = [{"turn": i, "q": "Where were you that day exactly?" * 3, "a": "Well it is a long story involving many details " * 10} for i in range(1, 21)] block = app._transcript_block(big_qa) check("late-game transcript capped", len(block) <= app.TRANSCRIPT_CHAR_BUDGET + 200, str(len(block))) check("omission marker present", "omitted" in block) print("== observer-notes scrub + near-dupe guard (live-play regressions) ==") a, r, q, l, w, c, fb = app.parse_detective( '{"action":"accuse","lie":"B","why":"It contradicts the timeline from the observer notes.","confidence":"high"}', [], True) check("observer notes scrubbed from why", "observer" not in w.lower() and "my notes" in w, w) prev = [{"turn": 1, "q": "Did you fall asleep during your birthday party at a specific time or place?", "a": "x"}] check("near-dupe question detected", app._is_repeat_question("Did you fall asleep during the party at a specific location or time?", prev)) check("fresh question passes", not app._is_repeat_question("Who brought the cake to the party?", prev)) print("== no-lie-leak invariant ==") claims = {"A": "a1", "B": "b2", "C": "c3"} qa = [{"turn": 1, "q": "About A?", "a": "yes"}] for msgs in (app._detective_prompt("pip", claims, qa, None, False), app._analyst_prompt(claims, qa)): text = json.dumps(msgs).lower() check("prompt has no lie phrase", "the lie is" not in text and "lie_label" not in text) src = inspect.getsource(app._detective_prompt) + inspect.getsource(app._analyst_prompt) check("prompt builders never touch lie_label", "lie_label" not in src) print() if fails: print(f"FAILED: {len(fails)} check(s): {fails}") sys.exit(1) print("ALL CHECKS PASSED") if __name__ == "__main__": main() def extra_checks(): """Letters-to-words layer (appended suite).""" fails = [] def check(name, cond, detail=""): print((" ok " if cond else " FAIL") + f" {name}" + (f" — {detail}" if detail and not cond else "")) if not cond: fails.append(name) print("== claims spoken as words, not letters ==") claims = {"A": "I taught my neighbour's dog to sigh on command", "B": "I get jury-duty letters addressed to my cat", "C": "I locked myself out during my own housewarming"} out = app._humanize_letters("About claim A — what happened right before that?", claims) check("'claim A' replaced with quoted words", "claim A" not in out and "dog" in out, out) out = app._humanize_letters("The claim b answers stayed vague.", claims) check("lowercase 'claim b' replaced", "claim b" not in out and "jury" in out, out) out = app._humanize_letters("Grab a coffee. A good one.", claims) check("bare articles untouched", out == "Grab a coffee. A good one.", out) snip = app._claim_snippet(claims["A"]) check("snippet quoted + truncated", snip.startswith("“") and snip.endswith("”") and len(snip) <= 50, snip) st = app.new_state() st["claims"] = claims st["model_key"] = "marlowe" st["qa"] = [{"turn": 1, "q": "Who else was there when the dog sighed?", "a": "x"}] fq = app._fallback_question(st) check("fallback has no unformatted slots", "{C}" not in fq and "{A}" not in fq, fq) check("least-questioned avoids the dog claim", app._least_questioned_claim(st["qa"], claims) in ("B", "C")) check("docket lists all claims (escaped)", all(app.html_lib.escape(claims[k]) in app._docket_html(claims) for k in "ABC")) check("empty docket has empty state", "No claims on file" in app._docket_html(None)) print("== vagueness detection ==") check("'kind of' is vague", app._is_vague_answer("kind of")) check("'I guess it was somewhere in town' is vague", app._is_vague_answer("I guess it was somewhere in town")) check("'can't remember, a while ago' is vague", app._is_vague_answer("can't remember, it was a while ago")) check("specific answer with detail is not vague", not app._is_vague_answer("At the takeout place on Fifth Street, a Tuesday in March")) check("specific with number is not vague", not app._is_vague_answer("It took 3 weeks and my friend Sam helped")) print("== vagueness-reactive play ==") qa = [{"turn": 1, "q": "q1", "a": "somewhere I think", "vague": True}, {"turn": 2, "q": "q2", "a": "kind of", "vague": True}] lv, streak, total = app._vague_stats(qa) check("streak counted", lv and streak == 2 and total == 2, f"{lv},{streak},{total}") stv = app.new_state() stv["claims"] = claims stv["model_key"] = "pip" stv["qa"] = qa check("streak bank selected", app._fallback_bank_key(stv) == "fq_streak") stv["qa"] = qa[:1] check("vague bank selected", app._fallback_bank_key(stv) == "fq_vague") stv["qa"] = [{"turn": 1, "q": "q1", "a": "Fifth Street on a Tuesday", "vague": False}] check("specific bank selected", app._fallback_bank_key(stv) == "fq_specific") stv["qa"] = qa fq2 = app._fallback_question(stv) check("streak fallback formats {A}", "{A}" not in fq2 and "{C}" not in fq2, fq2) prof_lo = {f"suspicion_{k}": "low" for k in "ABC"} check("stonewall loosens gate (medium at Q6)", app._accuse_allowed(6, "medium", prof_lo, vague_streak=3)) check("no stonewall keeps gate (medium at Q6)", not app._accuse_allowed(6, "medium", prof_lo, vague_streak=2)) msgs = app._detective_prompt("pip", claims, qa, None, False) check("prompt carries stonewall steering", "STONEWALLING" in msgs[0]["content"]) check("vague wait deck exists for both", all(len(app.CHARACTERS[k]["vague_waits"]) >= 8 for k in ("pip", "marlowe"))) return fails if __name__ == "__main__": _extra = extra_checks() if _extra: print(f"EXTRA FAILED: {_extra}") sys.exit(1) print("EXTRA CHECKS PASSED")