Spaces:
Running
Running
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 | |
| class Issue: | |
| code: str | |
| message: str | |
| stage: str | |
| class CheckReport: | |
| ok: bool | |
| issues: tuple[Issue, ...] | |
| 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 [] | |