case0 / src /case_zero /engine /director.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
raw
history blame
3.62 kB
"""The Director - deterministic adjudication of an interrogation turn.
This is the mechanical authority. The model's ``internal`` block is advisory; whether
a lie is caught, a contradiction fires, or a fact is genuinely revealed is decided
here by comparing presented evidence and claimed reveals against ground truth. A
table lookup, never a second LLM call - so the per-turn path is exactly one model
call, and a jailbroken confession in prose changes nothing here.
"""
from __future__ import annotations
from dataclasses import dataclass
from ..schemas.case import CaseFile
from ..schemas.enums import Relevance
from ..schemas.interrogation import InterrogationTurn
from ..schemas.suspect import Suspect
from .game_state import SuspectState
from .relevance import assess_relevance
_STRESS_ON_BREAK = 0.40
_STRESS_ON_DIRECT = 0.15
_STRESS_ON_TANGENTIAL = 0.05
_STRESS_DECAY = 0.95
_RAPPORT_NEUTRAL = 0.02
_RAPPORT_ON_BREAK = -0.12
@dataclass(frozen=True)
class Adjudication:
relevance: Relevance
is_contradiction: bool
broken_lie_id: str | None
revealed_fact_ids: tuple[str, ...]
stress_after: float
rapport_after: float
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def _grounded_reveals(
suspect: Suspect, turn: InterrogationTurn, already: frozenset[str]
) -> tuple[str, ...]:
"""Accept a model-claimed reveal only if the suspect actually knows that fact and
it is not one they must conceal. Keeps reveals grounded in the suspect's slice."""
known = set(suspect.knows_facts)
concealed = set(suspect.must_lie_about)
out: list[str] = []
for fid in turn.internal.revealed_fact_ids:
if fid in known and fid not in concealed and fid not in already:
out.append(fid)
return tuple(out)
def adjudicate(
case: CaseFile,
suspect: Suspect,
state: SuspectState,
turn: InterrogationTurn,
presented_clue_id: str | None,
) -> Adjudication:
result = assess_relevance(case, suspect, presented_clue_id)
is_contradiction = False
broken_lie_id: str | None = None
reveals: list[str] = list(_grounded_reveals(suspect, turn, state.revealed_fact_ids))
if result.relevance is Relevance.BREAKING:
lie_id = result.broken_lie_id
# Fire once: dedup by the anchored lie when known, else by the presented clue.
if lie_id is not None:
already_fired = lie_id in state.broken_lie_ids
else:
already_fired = presented_clue_id is not None and presented_clue_id in state.evidence_shown
if not already_fired:
is_contradiction = True
broken_lie_id = lie_id
if lie_id is not None:
lie = next((x for x in suspect.anchored_lies if x.lie_id == lie_id), None)
if lie and lie.truth_ref and lie.truth_ref not in reveals:
reveals.append(lie.truth_ref)
stress = state.stress * _STRESS_DECAY
if result.relevance is Relevance.BREAKING:
stress += _STRESS_ON_BREAK
elif result.relevance is Relevance.DIRECT:
stress += _STRESS_ON_DIRECT
elif result.relevance is Relevance.TANGENTIAL:
stress += _STRESS_ON_TANGENTIAL
rapport = state.rapport + (_RAPPORT_ON_BREAK if is_contradiction else _RAPPORT_NEUTRAL)
return Adjudication(
relevance=result.relevance,
is_contradiction=is_contradiction,
broken_lie_id=broken_lie_id,
revealed_fact_ids=tuple(reveals),
stress_after=_clamp(stress, 0.0, 1.0),
rapport_after=_clamp(rapport, -1.0, 1.0),
)