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 []