case0 / src /case_zero /solver /checker.py
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
"""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 []