Spaces:
Running on Zero
Running on Zero
| """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 | |
| class SolverReport: | |
| solved: bool | |
| unique: bool | |
| fair: bool | |
| named_culprit: str | |
| deduction: str | |
| fairness_detail: str | |
| notes: str = "" | |
| 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, | |
| ) | |