Spaces:
Running
Running
File size: 6,011 Bytes
414dc55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | """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 []
|