"""Constructed-correct solvability checker. Rather than prove solvability with a SAT solver, we verify a small set of structural invariants that, taken together, guarantee a fair, uniquely-solvable case: 1. every reference resolves; 2. exactly one culprit; 3. the time of death sits inside the murder window; 4. the culprit's alibi is breakable by a discoverable, non-red-herring clue; 5. every innocent is cleared (witnessed or off-scene) across the murder window; 6. every minimal-set clue is discoverable without interrogation. Each failure carries the generation ``stage`` to regenerate, so the pipeline can repair the smallest slice instead of starting over. """ from __future__ import annotations from dataclasses import dataclass from ..schemas.case import CaseFile from .discoverability import undiscoverable_minimal_clues @dataclass(frozen=True) class Issue: code: str message: str stage: str @dataclass(frozen=True) class CheckReport: ok: bool issues: tuple[Issue, ...] @property def failed_stages(self) -> frozenset[str]: return frozenset(i.stage for i in self.issues) def summary(self) -> str: if self.ok: return "solvable=True unique=True discoverable=True" return "; ".join(f"[{i.code}/{i.stage}] {i.message}" for i in self.issues) def check(case: CaseFile) -> CheckReport: issues: list[Issue] = [] issues += _check_references(case) # Reference failures make the rest unreliable; stop early. if not issues: issues += _check_single_culprit(case) issues += _check_time_of_death(case) issues += _check_alibi_breakable(case) issues += _check_innocents_cleared(case) issues += _check_discoverability(case) return CheckReport(ok=not issues, issues=tuple(issues)) def _check_references(case: CaseFile) -> list[Issue]: issues: list[Issue] = [] loc_ids = {loc.loc_id for loc in case.setting.locations} sus_ids = {s.sus_id for s in case.suspects} clue_ids = {c.clue_id for c in case.clues} fact_ids = {f.fact_id for f in case.facts} def loc(ref: str | None, where: str, stage: str) -> None: if ref is not None and ref not in loc_ids: issues.append(Issue("bad_loc_ref", f"{where} references missing location {ref!r}", stage)) loc(case.victim.found_at_loc_id, "victim.found_at", "skeleton") loc(case.weapon.origin_loc_id, "weapon.origin", "skeleton") for c in case.clues: loc(c.discoverable_at_loc_id, f"clue {c.clue_id}", "clues") if c.points_to_sus_id and c.points_to_sus_id not in sus_ids: issues.append(Issue("bad_sus_ref", f"clue {c.clue_id} points to missing suspect", "clues")) if c.supports_fact_id and c.supports_fact_id not in fact_ids: issues.append(Issue("bad_fact_ref", f"clue {c.clue_id} supports missing fact", "clues")) if case.solution.culprit_sus_id not in sus_ids: issues.append(Issue("bad_solution", "solution culprit does not exist", "assign")) if case.culprit.sus_id not in sus_ids: issues.append(Issue("bad_culprit", "culprit suspect does not exist", "assign")) for cid in case.solution.minimal_clue_set: if cid not in clue_ids: issues.append(Issue("bad_minimal", f"minimal clue {cid} does not exist", "clues")) return issues def _check_single_culprit(case: CaseFile) -> list[Issue]: culprits = [s.sus_id for s in case.suspects if s.is_culprit] if len(culprits) != 1: return [Issue("culprit_count", f"expected exactly 1 culprit, found {len(culprits)}", "assign")] if culprits[0] != case.solution.culprit_sus_id or culprits[0] != case.culprit.sus_id: return [Issue("culprit_mismatch", "is_culprit / solution / culprit disagree", "assign")] return [] def _check_time_of_death(case: CaseFile) -> list[Issue]: if not case.setting.murder_window.covers(case.victim.time_of_death): return [Issue("tod_window", "time of death is not inside the murder window", "skeleton")] return [] def _check_alibi_breakable(case: CaseFile) -> list[Issue]: breakers = case.culprit.alibi_lie.contradicted_by_clue_ids if not breakers: return [Issue("no_breaker", "culprit alibi has no contradicting clue", "clues")] by_id = {c.clue_id: c for c in case.clues} real_breakers = [cid for cid in breakers if cid in by_id and not by_id[cid].is_red_herring] if not real_breakers: return [Issue("herring_breaker", "culprit alibi is only broken by red herrings", "clues")] culprit = case.suspect(case.culprit.sus_id) has_lie = any(set(lie.breaks_on) & set(breakers) for lie in culprit.anchored_lies) if not has_lie: return [Issue("no_anchor", "culprit has no anchored lie broken by the alibi clues", "clues")] return [] def _check_innocents_cleared(case: CaseFile) -> list[Issue]: issues: list[Issue] = [] crime_loc = case.victim.found_at_loc_id tod = case.victim.time_of_death for s in case.suspects: if s.is_culprit: continue overlapping = [w for w in s.true_whereabouts if w.window.overlaps(tod)] if not overlapping: issues.append(Issue("uncovered", f"{s.name} has no whereabouts during the murder", "timeline")) continue at_scene_unwitnessed = any( w.loc_id == crime_loc and not w.co_present_sus_ids for w in overlapping ) if at_scene_unwitnessed: issues.append( Issue("ambiguous", f"{s.name} was at the scene unwitnessed - case is ambiguous", "timeline") ) return issues def _check_discoverability(case: CaseFile) -> list[Issue]: if not case.solution.minimal_clue_set: return [Issue("empty_minimal", "solution has no minimal clue set", "clues")] bad = undiscoverable_minimal_clues(case) if bad: return [Issue("undiscoverable", f"minimal clues not reachable in play: {', '.join(bad)}", "clues")] return []