"""Solvability solver + uniqueness gate (Section 9.5–9.6). An automated detective is given *only* the player-available surface (environment + what characters could be made to reveal + clue graph) and must (a) name the culprit and (b) show the deduction path. Then uniqueness: no other suspect is equally consistent. Plus the deterministic fairness (reachability) check. """ from __future__ import annotations from dataclasses import dataclass from ..engine.clues import ClueGraph from ..llm.client import LLMClient from ..llm.prompts import PromptRegistry from ..worldio import World @dataclass class SolverReport: solved: bool unique: bool fair: bool named_culprit: str deduction: str fairness_detail: str notes: str = "" @property def ok(self) -> bool: return self.solved and self.unique and self.fair _STOPWORDS = {"the", "a", "an", "mr", "mrs", "ms", "miss", "dr", "of"} def _name_tokens(name: str) -> set[str]: raw = name.lower().replace(".", " ").replace(",", " ") return {t for t in raw.split() if len(t) >= 3 and t not in _STOPWORDS} def _name_matches(a: str, b: str) -> bool: """Tolerant culprit-name match: equality, containment, or a shared significant name token (handles 'Mara' vs 'Mara Voss' vs 'the caretaker').""" na, nb = a.strip().lower(), b.strip().lower() if not na or not nb: return False if na == nb or na in nb or nb in na: return True return bool(_name_tokens(a) & _name_tokens(b)) def _surface(world: World) -> dict[str, object]: """The player-available surface — never includes solution/truth fields.""" return { "setting": world.world_md[:1500], "characters": [ {"name": c.name, "role": c.role, "cover": c.cover} for c in world.characters.values() ], "environment": [ {"id": o.id, "location": o.location, "description": o.description_true, "evidential": o.evidential} for o in world.environment ], "clues": [ {"id": n.id, "reveals": n.reveals, "sources": n.sources, "unlocks": n.unlocks, "exonerates": n.exonerates, "required": n.required_for_solution} for n in world.clues ], } def run_solver( client: LLMClient, prompts: PromptRegistry, world: World ) -> SolverReport: graph = ClueGraph(world.clues) fairness = graph.fairness() surface = _surface(world) suspects = [c.name for c in world.suspects] prompt = prompts.render( "solver/detective_pass.md.j2", surface=surface, suspects=suspects, ) try: data, _ = client.complete_json( tier="solver", task="solver_pass", user=prompt, ) except Exception as exc: return SolverReport( solved=False, unique=False, fair=fairness.ok, named_culprit="", deduction="", fairness_detail=fairness.details, notes=f"solver error: {exc}", ) named = str(data.get("culprit", "")).strip() deduction = str(data.get("deduction", "")) unique = bool(data.get("unique", False)) # Match tolerantly against the actual guilty character (the canonical # answer) and the solution's culprit string. Generated text phrases names # inconsistently ("Mara" / "Mara Voss" / "the caretaker"), so exact equality # is too strict; compare on shared name tokens / containment. targets = {world.solution.culprit} targets.update(c.name for c in world.characters.values() if c.guilty) solved = any(_name_matches(named, t) for t in targets if t) return SolverReport( solved=solved, unique=unique, fair=fairness.ok, named_culprit=named, deduction=deduction, fairness_detail=fairness.details, )