f-id / src /id /generator /solver.py
marcodsn's picture
Initial Gradio Space
0423b99
Raw
History Blame Contribute Delete
3.84 kB
"""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,
)