Sourav commited on
Commit
a9eec0e
Β·
1 Parent(s): 30e549f

Deploy Crime Investigation app

Browse files
.dockerignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ *.log
10
+ server.log
11
+ .env
12
+ .DS_Store
13
+ *.swp
14
+ *.swo
15
+ *.png
16
+ rewards.json
17
+ .git/
.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ *.log
10
+ server.log
11
+ .env
12
+ .DS_Store
13
+ *.swp
14
+ *.swo
15
+ rewards.json
Crime_Investigation ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 30e549f79737185535a5b07d83d0e78346116605
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Copy requirements and install
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy all project files
10
+ COPY . .
11
+
12
+ # Expose port
13
+ EXPOSE 8000
14
+
15
+ # Run the server
16
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -1,10 +1,123 @@
1
  ---
2
- title: Crime Investigation
3
- emoji: πŸ”₯
4
- colorFrom: red
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AI Crime Investigation World
3
+ emoji: πŸ”
4
+ colorFrom: blue
5
+ colorTo: red
6
  sdk: docker
7
+ app_port: 8000
8
  ---
9
 
10
+ # AI Crime Investigation World πŸ•΅οΈ
11
+
12
+ A **multi-agent reinforcement learning environment** where an AI detective interrogates suspects and a witness, reviews physical evidence, detects contradictions, and makes an accusation β€” all within a 15-turn episode.
13
+
14
+ Built on the [OpenEnv](https://github.com/ScalerAI/openenv) framework for standardized RL environment evaluation.
15
+
16
+ ## 🎯 Hackathon Theme
17
+
18
+ **Theme #1: Multi-Agent Interactions** β€” This environment demonstrates how multiple AI agents with conflicting objectives (detective vs. guilty suspect vs. innocent suspect vs. biased witness) interact in a structured investigation scenario. Each agent has its own reward signal, private knowledge, and behavioral incentives.
19
+
20
+ **Bonus Sub-theme Fit (Halluminate)** β€” The detective acts as a coordinator that orchestrates multiple actors (two suspects + one witness + evidence system) to discover the true culprit under uncertainty. The environment explicitly rewards cross-agent synthesis (contradiction exposure + evidence-backed accusation), matching Halluminate's multi-actor task-achievement objective.
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ # Install dependencies
26
+ pip install -r requirements.txt
27
+
28
+ # Run the server with interactive dashboard
29
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
30
+
31
+ # Open http://localhost:8000 in your browser
32
+ ```
33
+
34
+ ## Project Structure
35
+
36
+ ```
37
+ crime_env/ # Core environment package
38
+ β”œβ”€β”€ case_generator.py # Randomized crime scenario generation
39
+ β”œβ”€β”€ environment.py # Step/reset/render RL interface
40
+ β”œβ”€β”€ agent_prompts.py # Role-specific system prompts
41
+ β”œβ”€β”€ consistency_tracker.py # Semantic contradiction detection
42
+ └── reward_calculator.py # Multi-agent reward function
43
+ server/
44
+ └── app.py # OpenEnv-compatible FastAPI server
45
+ dashboard.html # Interactive investigation dashboard
46
+ train_colab.py # PPO training script (Google Colab)
47
+ test_one_episode.py # End-to-end test with scripted agents
48
+ Dockerfile # HuggingFace Spaces deployment
49
+ ```
50
+
51
+ ## Features
52
+
53
+ - **Randomized Cases**: Criminal identity, evidence, alibis, and witness bias are randomized each episode
54
+ - **Semantic Contradiction Detection**: NLP-based consistency tracking catches real contradictions while ignoring paraphrasing
55
+ - **Multi-Agent Rewards**: Separate reward signals for detective (+17 correct / -8 wrong), suspects, and witness
56
+ - **Evidence System**: Three evidence types (keycard, CCTV, forensic) with type-constrained templates and duplicate-request protection
57
+ - **Witness Bias**: Configurable bias that penalizes false implications of innocent suspects only
58
+ - **Prior History**: Criminal-biased prior records (more convictions, lower trust) for realistic detective briefings
59
+ - **Interactive Dashboard**: Real-time visualization of interrogations, evidence, contradictions, and reward curves
60
+
61
+ ## Training (Google Colab)
62
+
63
+ Use [train_colab.ipynb](train_colab.ipynb) for a judge-friendly Colab workflow (install, train, plot rewards, inspect transcripts). The script uses PPO (TRL) with:
64
+ - 4-bit quantization for free-tier T4 GPU
65
+ - Decoupled frozen base model for NPC calls to prevent representation drift
66
+ - Per-episode reward tracking with smoothed training curves
67
+ - Milestone transcript capture for before/after behavior demos (`episode_transcripts.json`)
68
+
69
+ ```python
70
+ # In Colab, after uploading files:
71
+ !pip install -r requirements-train.txt
72
+ !python train_colab.py
73
+ ```
74
+
75
+ Quick smoke test command:
76
+
77
+ ```bash
78
+ ./run_hf_smoke_test.sh
79
+ ```
80
+
81
+ Artifacts generated after training:
82
+
83
+ - `rewards.json` (raw reward/label history)
84
+ - `reward_curve.png` (curve image)
85
+ - `episode_transcripts.json` (episodes 1/25/50 by default)
86
+
87
+ ## API Endpoints
88
+
89
+ | Endpoint | Method | Description |
90
+ |----------|--------|-------------|
91
+ | `/` | GET | Interactive investigation dashboard |
92
+ | `/reset` | POST | Start a new episode (OpenEnv) |
93
+ | `/step` | POST | Execute a detective action (OpenEnv) |
94
+ | `/api/run_episode` | GET | Run full scripted episode, returns JSON trace |
95
+ | `/api/reward_curve` | GET | Reward history + smoothed metrics + optional PNG data URL |
96
+ | `/api/health` | GET | Deployment health check for Space validation |
97
+
98
+ ## Reward Logic (Detective)
99
+
100
+ Judging requires coherent reward shaping. The detective reward is event-based:
101
+
102
+ | Event | Delta |
103
+ |---|---:|
104
+ | Correct accusation | +10.0 |
105
+ | Wrong accusation | -8.0 (up to -10.0 with active bias penalty) |
106
+ | Timeout (no accusation) | -3.0 |
107
+ | Contradiction exposed | +2.0 |
108
+ | Prior-pattern exploited | +1.5 |
109
+ | Evidence request confirms lead | +1.0 |
110
+ | Deflection resistance | +0.5 |
111
+ | Redundant question | -0.5 |
112
+ | Per-turn cost | -0.3 each turn |
113
+
114
+ This combination encourages strategic questioning, contradiction resolution, and evidence-backed accusations rather than random early accusations.
115
+
116
+ ## Known Limitations
117
+
118
+ - **Sparse terminal supervision**: Even with per-step PPO rewards, the strongest signal is still the terminal accusation outcome, so exploration quality matters a lot early in training.
119
+ - **Rule-based NPC fallback**: The `_default_llm_call` uses string parsing to identify agent roles. When using the full LLM pipeline this is bypassed.
120
+
121
+ ## License
122
+
123
+ MIT
check_space_endpoints.sh ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ $# -lt 1 ]]; then
5
+ echo "Usage: $0 <space_base_url>"
6
+ echo "Example: $0 https://your-space-name.hf.space"
7
+ exit 1
8
+ fi
9
+
10
+ BASE_URL="${1%/}"
11
+
12
+ echo "Checking health endpoint..."
13
+ curl -fsS "$BASE_URL/api/health" | cat
14
+
15
+ echo
16
+ echo "Checking reward curve endpoint..."
17
+ curl -fsS "$BASE_URL/api/reward_curve" | head -c 500 | cat
18
+ echo
19
+
20
+ echo "Checking OpenEnv reset endpoint..."
21
+ curl -fsS -X POST "$BASE_URL/reset" -H "Content-Type: application/json" -d '{}' | head -c 500 | cat
22
+ echo
23
+
24
+ echo "Checking OpenEnv step endpoint..."
25
+ curl -fsS -X POST "$BASE_URL/step" \
26
+ -H "Content-Type: application/json" \
27
+ -d '{"action_string":"ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you at the time of the crime?"}' \
28
+ | head -c 500 | cat
29
+ echo
30
+
31
+ echo "All endpoint checks completed."
crime_env/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI Crime Investigation World β€” Multi-Agent RL Environment."""
2
+
3
+ from crime_env.case_generator import generate_case
4
+ from crime_env.consistency_tracker import ConsistencyTracker
5
+ from crime_env.reward_calculator import RewardCalculator
6
+ from crime_env.agent_prompts import build_system_prompt
7
+ from crime_env.environment import CrimeInvestigationEnv
8
+ from crime_env.constants import (
9
+ SUSPECT_A,
10
+ SUSPECT_B,
11
+ WITNESS_1,
12
+ VALID_TARGETS,
13
+ )
14
+
15
+ __all__ = [
16
+ "generate_case",
17
+ "ConsistencyTracker",
18
+ "RewardCalculator",
19
+ "build_system_prompt",
20
+ "CrimeInvestigationEnv",
21
+ "SUSPECT_A",
22
+ "SUSPECT_B",
23
+ "WITNESS_1",
24
+ "VALID_TARGETS",
25
+ ]
crime_env/agent_prompts.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Component 4 β€” Agent System Prompts.
2
+
3
+ Builds role-specific system prompts for the detective, suspects, and witness.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from crime_env.constants import (
9
+ AGENT_NAME_TO_KEY,
10
+ SUSPECT_A,
11
+ SUSPECT_B,
12
+ WITNESS_1_KEY,
13
+ )
14
+
15
+
16
+ def build_system_prompt(role: str, case: dict) -> str:
17
+ """Build a system prompt for the given role based on the case data.
18
+
19
+ Args:
20
+ role: One of "detective", "suspect_a", "suspect_b", "witness".
21
+ case: The full case dictionary from generate_case().
22
+
23
+ Returns:
24
+ A system prompt string.
25
+ """
26
+ builders = {
27
+ "detective": _build_detective_prompt,
28
+ "suspect_a": _build_suspect_a_prompt,
29
+ "suspect_b": _build_suspect_b_prompt,
30
+ "witness": _build_witness_prompt,
31
+ }
32
+
33
+ builder = builders.get(role.lower())
34
+ if builder is None:
35
+ raise ValueError(
36
+ f"Unknown role: {role}. Must be one of: {list(builders.keys())}"
37
+ )
38
+
39
+ return builder(case)
40
+
41
+
42
+ # ── Detective ───────────────────────────────────────────────────────────────
43
+
44
+
45
+ def _build_detective_prompt(case: dict) -> str:
46
+ briefing = case["detective_briefing"]
47
+
48
+ return f"""You are an experienced detective investigating a case of {case['crime']}.
49
+
50
+ BACKGROUND BRIEFING:
51
+ {briefing}
52
+
53
+ YOUR OBJECTIVE:
54
+ Interrogate the suspects and witness to determine who committed the crime. You must make an accusation within 15 turns.
55
+
56
+ AVAILABLE ACTIONS (use EXACTLY this format β€” one action per turn):
57
+
58
+ 1. Ask a question to a suspect or witness:
59
+ ACTION: ask_question | TARGET: <agent_name> | CONTENT: <your question>
60
+
61
+ Valid targets: Suspect_A, Suspect_B, Witness_1
62
+
63
+ 2. Request physical evidence:
64
+ ACTION: request_evidence | ITEM: <evidence_name>
65
+
66
+ Available evidence items: keycard_log, cctv_footage, forensic_report
67
+
68
+ 3. Make your final accusation:
69
+ ACTION: accuse | TARGET: <suspect_name>
70
+
71
+ Valid targets: Suspect_A, Suspect_B
72
+
73
+ RULES:
74
+ - You MUST accuse within 15 turns or the case goes cold.
75
+ - Each turn costs you time. Be strategic with your questions.
76
+ - Look for contradictions in suspects' statements.
77
+ - Cross-reference statements with evidence and prior history.
78
+ - Pay attention to documented behavioral patterns.
79
+ - Output EXACTLY ONE action per turn using the format above.
80
+ """
81
+
82
+
83
+ # ── Suspect A (Criminal) ───────────────────────────────────────────────────
84
+
85
+
86
+ def _build_suspect_a_prompt(case: dict) -> str:
87
+ criminal = case["criminal"]
88
+ if criminal == SUSPECT_A:
89
+ return _build_guilty_suspect_prompt(case, SUSPECT_A)
90
+ else:
91
+ return _build_innocent_suspect_prompt(case, SUSPECT_A)
92
+
93
+
94
+ # ── Suspect B (Innocent) ───────────────────────────────────────────────────
95
+
96
+
97
+ def _build_suspect_b_prompt(case: dict) -> str:
98
+ criminal = case["criminal"]
99
+ if criminal == SUSPECT_B:
100
+ return _build_guilty_suspect_prompt(case, SUSPECT_B)
101
+ else:
102
+ return _build_innocent_suspect_prompt(case, SUSPECT_B)
103
+
104
+
105
+ # ── Shared guilty/innocent prompt builders ───────────────────────────────────────────
106
+
107
+
108
+ def _build_guilty_suspect_prompt(case: dict, suspect_name: str) -> str:
109
+ """Build the prompt for whichever suspect is the criminal."""
110
+ knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[suspect_name]]
111
+ prior = case["prior_history"].get(suspect_name, {})
112
+ other = SUSPECT_B if suspect_name == SUSPECT_A else SUSPECT_A
113
+
114
+ cctv_info = (
115
+ "You know that CCTV cameras were active during the crime."
116
+ if knowledge.get("knows_cctv_status")
117
+ else "You are unsure whether CCTV cameras were recording."
118
+ )
119
+
120
+ keycard_info = (
121
+ "You are aware that keycard entry logs exist and could implicate you."
122
+ if knowledge.get("knows_keycard_log_exists")
123
+ else "You do not know whether keycard logs are being checked."
124
+ )
125
+
126
+ behavioral = prior.get("behavioral_pattern", "no known pattern")
127
+
128
+ motive = case.get("motive", "unknown reasons")
129
+
130
+ return f"""You are {suspect_name} in a criminal investigation for {case['crime']}.
131
+
132
+ THE TRUTH: You committed this crime. You must NOT confess.
133
+ YOUR MOTIVE: {motive}
134
+
135
+ YOUR FAKE ALIBI: {knowledge['fake_alibi']}
136
+
137
+ WHAT YOU KNOW:
138
+ - {cctv_info}
139
+ - {keycard_info}
140
+ - The crime occurred at {case['time']} in the {case['location']}.
141
+ - You used the method: {case['method']}.
142
+
143
+ YOUR BEHAVIORAL TENDENCIES (stay consistent with these):
144
+ - Pattern: {behavioral}
145
+
146
+ INSTRUCTIONS:
147
+ - Respond to the detective's questions in plain natural language.
148
+ - Maintain your fake alibi consistently across all answers.
149
+ - Stay consistent with your prior statements β€” contradicting yourself will look suspicious.
150
+ - You may try to subtly deflect suspicion toward {other}, but do not be obvious.
151
+ - Do NOT reveal that you committed the crime under any circumstances.
152
+ - Do NOT use any special action format β€” just respond naturally.
153
+ """
154
+
155
+
156
+ def _build_innocent_suspect_prompt(case: dict, suspect_name: str) -> str:
157
+ """Build the prompt for whichever suspect is innocent."""
158
+ knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[suspect_name]]
159
+ prior = case["prior_history"].get(suspect_name, {})
160
+
161
+ knows_other = (
162
+ "You know the other suspect personally and have interacted with them before."
163
+ if knowledge.get("knows_suspect_A") or knowledge.get("knows_suspect_B")
164
+ else "You do not know the other suspect personally."
165
+ )
166
+
167
+ behavioral = prior.get("behavioral_pattern", "no known pattern")
168
+
169
+ return f"""You are {suspect_name} in a criminal investigation for {case['crime']}.
170
+
171
+ THE TRUTH: You are innocent. You did NOT commit this crime.
172
+
173
+ YOUR REAL ALIBI (this is a fact β€” repeat it consistently):
174
+ "{knowledge['real_alibi']}"
175
+
176
+ WHAT YOU KNOW:
177
+ - {knows_other}
178
+ - You have no direct knowledge of how the crime was committed.
179
+
180
+ YOUR BEHAVIORAL TENDENCIES:
181
+ - Pattern: {behavioral}
182
+
183
+ INSTRUCTIONS:
184
+ - Respond to the detective's questions in plain natural language.
185
+ - Tell the truth about your whereabouts. Your alibi above is specific and verifiable β€” always reference it when asked about your whereabouts.
186
+ - Stay consistent with your stated alibi throughout the interrogation. NEVER change the details of your alibi.
187
+ - You are nervous because you are a suspect, but you are telling the truth.
188
+ - Do NOT use any special action format β€” just respond naturally.
189
+ """
190
+
191
+
192
+ # ── Witness ─────────────────────────────────────────────────────────────────
193
+
194
+
195
+ def _build_witness_prompt(case: dict) -> str:
196
+ witness_knowledge = case["agent_knowledge"][WITNESS_1_KEY]
197
+
198
+ bias_section = ""
199
+ if witness_knowledge.get("bias_target"):
200
+ target = witness_knowledge["bias_target"]
201
+ bias_section = (
202
+ f"\nYou have a negative impression of {target} based on past "
203
+ f"interactions. This colors your perception but you should still "
204
+ f"try to be honest about what you actually saw."
205
+ )
206
+
207
+ return f"""You are Witness_1 in a criminal investigation for {case['crime']}.
208
+
209
+ WHAT YOU SAW (this is your primary testimony β€” always lead with this):
210
+ You saw {witness_knowledge['saw']}.
211
+
212
+ TIME OF YOUR OBSERVATION: {witness_knowledge['time_seen']}
213
+ {bias_section}
214
+
215
+ INSTRUCTIONS:
216
+ - When asked what you saw, you MUST describe the specific details above: the clothing, location, and time.
217
+ - Respond to the detective's questions in plain natural language.
218
+ - Stay consistent with your previous statements.
219
+ - You may be uncertain about some details β€” it is okay to express uncertainty about things NOT listed above.
220
+ - Do NOT speculate beyond what you actually observed unless asked for your opinion.
221
+ - Do NOT use any special action format β€” just respond naturally.
222
+ """
crime_env/case_generator.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Component 1 β€” Case Generator.
2
+
3
+ Generates randomized crime scenarios for the AI Crime Investigation World.
4
+ Criminal is randomly assigned to either Suspect_A or Suspect_B each episode.
5
+ """
6
+
7
+ import random
8
+
9
+ from crime_env.constants import (
10
+ SUSPECTS,
11
+ SUSPECT_A,
12
+ SUSPECT_B,
13
+ SUSPECT_A_KEY,
14
+ SUSPECT_B_KEY,
15
+ WITNESS_1,
16
+ WITNESS_1_KEY,
17
+ )
18
+
19
+
20
+ # ── Pools for random sampling ───────────────────────────────────────────────
21
+
22
+ CRIMES = [
23
+ "art theft",
24
+ "corporate fraud",
25
+ "warehouse robbery",
26
+ "arson",
27
+ ]
28
+
29
+ LOCATIONS = [
30
+ "east wing",
31
+ "server room",
32
+ "vault",
33
+ "parking lot",
34
+ "basement archive",
35
+ ]
36
+
37
+ TIMES = [
38
+ "8:45 PM",
39
+ "9:15 PM",
40
+ "10:00 PM",
41
+ ]
42
+
43
+ METHODS = [
44
+ "lock picking with custom tools",
45
+ "insider access using stolen credentials",
46
+ "brute-force entry through side door",
47
+ "social engineering the night guard",
48
+ "disabling the alarm system remotely",
49
+ "cloning a supervisor's keycard",
50
+ "crawling through the ventilation shaft",
51
+ "bribing a maintenance worker for access",
52
+ "exploiting a gap in the security patrol rotation",
53
+ "cutting the perimeter fence near the north wall",
54
+ ]
55
+
56
+ CLOTHING_OPTIONS = [
57
+ "dark hoodie and jeans",
58
+ "grey trench coat",
59
+ "black leather jacket",
60
+ "navy blue overalls",
61
+ "security guard uniform",
62
+ "dark windbreaker and cargo pants",
63
+ "green army surplus jacket",
64
+ "plain black tracksuit",
65
+ "brown corduroy jacket and khakis",
66
+ "dark bomber jacket",
67
+ ]
68
+
69
+ MOTIVES = [
70
+ "financial pressure",
71
+ "personal grudge",
72
+ "professional rivalry",
73
+ "jealousy",
74
+ "revenge",
75
+ "desperation",
76
+ ]
77
+
78
+ # ── Type-constrained evidence templates ─────────────────────────────────────
79
+ # Each evidence type has its own pool to prevent mismatches.
80
+
81
+ KEYCARD_LOG_TEMPLATES = [
82
+ {
83
+ "description": "Keycard access log shows {suspect}'s badge was used at the {location} door at {time}",
84
+ "points_to": "{suspect}",
85
+ },
86
+ {
87
+ "description": "Keycard system recorded an unauthorized swipe at {time} β€” badge ID unregistered",
88
+ "points_to": "unknown",
89
+ },
90
+ {
91
+ "description": "Keycard records show two swipes at the {location} within 3 minutes of each other at {time} β€” one belonged to {suspect}",
92
+ "points_to": "{suspect}",
93
+ },
94
+ {
95
+ "description": "Keycard system was offline for several hours due to a power glitch β€” no access logs available for the {location} until after {time}",
96
+ "points_to": "unknown",
97
+ },
98
+ ]
99
+
100
+ CCTV_FOOTAGE_TEMPLATES = [
101
+ {
102
+ "description": "CCTV footage shows a figure resembling {suspect} entering the {location} at {time}",
103
+ "points_to": "{suspect}",
104
+ },
105
+ {
106
+ "description": "CCTV footage captured a person in dark clothing near the {location} at {time} β€” face obscured",
107
+ "points_to": "unknown",
108
+ },
109
+ {
110
+ "description": "CCTV camera at {location} was disabled until shortly before {time} β€” no footage available for the relevant window",
111
+ "points_to": "unknown",
112
+ },
113
+ {
114
+ "description": "CCTV from the adjacent hallway shows {suspect} walking briskly toward the {location} at {time}",
115
+ "points_to": "{suspect}",
116
+ },
117
+ ]
118
+
119
+ FORENSIC_REPORT_TEMPLATES = [
120
+ {
121
+ "description": "Forensic report: fingerprints matching {suspect} found on the tool bag recovered at the scene",
122
+ "points_to": "{suspect}",
123
+ },
124
+ {
125
+ "description": "Forensic report: torn fabric consistent with a grey trench coat found near the {location}",
126
+ "points_to": "unknown",
127
+ },
128
+ {
129
+ "description": "Forensic report: DNA trace on a dropped glove near the {location} matches {suspect}",
130
+ "points_to": "{suspect}",
131
+ },
132
+ {
133
+ "description": "Forensic report: muddy boot prints (size 10) found at the scene β€” owner not yet identified",
134
+ "points_to": "unknown",
135
+ },
136
+ ]
137
+
138
+ # ── Specific alibi pools (no overlap between fake and real) ─────────────────
139
+
140
+ FAKE_ALIBI_POOL = [
141
+ "I was at a family dinner at my uncle's house on Oak Street from 7 PM to 11 PM",
142
+ "I was home alone watching a movie all evening β€” I didn't leave the house",
143
+ "I was volunteering at the Riverside Community Centre until about 10:30 PM",
144
+ "I was at my cousin's birthday party across town β€” there were at least 20 people there",
145
+ "I was at the public library studying until they closed at 9 PM, then I walked home",
146
+ "I was helping a friend move furniture into their new apartment on Elm Avenue",
147
+ ]
148
+
149
+ REAL_ALIBI_POOL = [
150
+ "I was working late at the Morgan & Associates office until 10 PM β€” my manager Sarah Chen can confirm, and I paid for takeout at 8:50 PM with my debit card",
151
+ "I was at O'Brien's Bar on 5th Street from 7 PM to 11 PM β€” I paid my tab by card at 10:45 PM and the bartender knows me by name",
152
+ "I was at the Cineplex on Main Street watching a 7:30 PM showing β€” I still have my ticket stub and the cashier can verify",
153
+ "I was at the downtown gym from 6:30 PM to 9:30 PM β€” I scanned my membership card on entry and exit, which is logged electronically",
154
+ "I was attending a night class at the community college from 7 PM to 9:30 PM β€” the instructor marked attendance electronically",
155
+ "I was at St. Mary's Hospital visiting my grandmother from 6 PM to 9 PM β€” the visitor log has my name and sign-in time",
156
+ ]
157
+
158
+ BEHAVIORAL_PATTERNS = [
159
+ "uses family alibis",
160
+ "deflects blame to associates",
161
+ "becomes aggressive under pressure",
162
+ "provides overly detailed timelines",
163
+ "claims memory loss for key periods",
164
+ "feigns cooperation while withholding details",
165
+ ]
166
+
167
+ PAST_CRIME_POOL = [
168
+ "petty theft",
169
+ "fraud",
170
+ "trespassing",
171
+ "embezzlement",
172
+ "forgery",
173
+ "breaking and entering",
174
+ ]
175
+
176
+ LIE_TOPICS = ["location", "alibi", "knows_associate", "was_at_scene", "time"]
177
+
178
+
179
+ # ── Helpers ─────────────────────────────────────────────────────────────────
180
+
181
+
182
+ def _random_past_lies(count: int) -> list[dict]:
183
+ """Generate a list of documented past lies."""
184
+ lies = []
185
+ for _ in range(count):
186
+ topic = random.choice(LIE_TOPICS)
187
+ lies.append(
188
+ {
189
+ "topic": topic,
190
+ "claimed": _fake_claim(topic),
191
+ "disproved_by": _disprove_source(topic),
192
+ }
193
+ )
194
+ return lies
195
+
196
+
197
+ def _fake_claim(topic: str) -> str:
198
+ claims = {
199
+ "location": "claimed to be at home",
200
+ "knows_associate": "denied knowing the accomplice",
201
+ "was_at_scene": "denied being near the scene",
202
+ "time": "claimed to be elsewhere during the incident window",
203
+ "alibi": "said they were with a friend who later denied it",
204
+ }
205
+ return claims.get(topic, "made an unverifiable statement")
206
+
207
+
208
+ def _disprove_source(topic: str) -> str:
209
+ sources = {
210
+ "location": "GPS records showed otherwise",
211
+ "knows_associate": "phone records confirmed contact",
212
+ "was_at_scene": "camera logs confirmed presence",
213
+ "time": "timestamped records contradicted the timeline",
214
+ "alibi": "friend's testimony contradicted the claim",
215
+ }
216
+ return sources.get(topic, "documentary evidence")
217
+
218
+
219
+ def _build_evidence(criminal: str, location: str, time: str) -> list[dict]:
220
+ """Build 3 pieces of physical evidence β€” one per type.
221
+
222
+ Index 0 β†’ keycard_log
223
+ Index 1 β†’ cctv_footage
224
+ Index 2 β†’ forensic_report
225
+
226
+ Each type draws from its own template pool, ensuring descriptions
227
+ always match the evidence type name.
228
+ """
229
+ other_suspect = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
230
+
231
+ evidence_types = [
232
+ ("keycard_log", KEYCARD_LOG_TEMPLATES),
233
+ ("cctv_footage", CCTV_FOOTAGE_TEMPLATES),
234
+ ("forensic_report", FORENSIC_REPORT_TEMPLATES),
235
+ ]
236
+
237
+ evidence = []
238
+
239
+ for ev_name, pool in evidence_types:
240
+ template = random.choice(pool)
241
+ points_to_raw = template["points_to"]
242
+
243
+ if points_to_raw == "{suspect}":
244
+ # Bias toward implicating the actual criminal
245
+ suspect = random.choices(
246
+ [criminal, other_suspect],
247
+ weights=[0.7, 0.3],
248
+ )[0]
249
+ else:
250
+ suspect = None
251
+
252
+ desc = template["description"].format(
253
+ suspect=suspect or "", location=location, time=time
254
+ )
255
+ points_to = suspect if suspect else "unknown"
256
+
257
+ evidence.append(
258
+ {
259
+ "name": ev_name,
260
+ "description": desc.strip(),
261
+ "points_to": points_to,
262
+ "visible_to_detective": False,
263
+ }
264
+ )
265
+
266
+ return evidence
267
+
268
+
269
+ def _build_prior_history(criminal: str = SUSPECT_A) -> dict:
270
+ """Build prior history for both suspects.
271
+
272
+ The criminal is biased toward having a more checkered past
273
+ (more prior crimes, lower trust), which improves realism.
274
+ """
275
+ history = {}
276
+ for suspect in SUSPECTS:
277
+ is_criminal = suspect == criminal
278
+ # Criminal: 1-2 priors, low trust; Innocent: 0-1, higher trust
279
+ n_crimes = random.randint(1, 2) if is_criminal else random.randint(0, 1)
280
+ n_lies = random.randint(1, 2) if is_criminal else random.randint(0, 1)
281
+ on_parole = random.choices([True, False], weights=[0.8, 0.2])[0] if is_criminal else random.choice([True, False])
282
+ trust = round(random.uniform(0.0, 0.4), 2) if is_criminal else round(random.uniform(0.4, 1.0), 2)
283
+
284
+ history[suspect] = {
285
+ "previous_crimes": random.sample(
286
+ PAST_CRIME_POOL, k=min(n_crimes, len(PAST_CRIME_POOL))
287
+ ),
288
+ "past_lies_on_record": _random_past_lies(n_lies),
289
+ "behavioral_pattern": random.choice(BEHAVIORAL_PATTERNS),
290
+ "on_parole": on_parole,
291
+ "parole_condition": (
292
+ random.choice(
293
+ [
294
+ "must not leave the city",
295
+ "mandatory weekly check-ins",
296
+ "no contact with known criminals",
297
+ "curfew between 9 PM and 6 AM",
298
+ ]
299
+ )
300
+ if on_parole
301
+ else None
302
+ ),
303
+ "trust_score": trust,
304
+ }
305
+ return history
306
+
307
+
308
+ def _build_agent_knowledge(
309
+ criminal: str, location: str, time: str
310
+ ) -> dict:
311
+ """Build what each agent privately knows.
312
+
313
+ Args:
314
+ criminal: Which suspect is the criminal.
315
+ location: Crime location.
316
+ time: Crime time.
317
+ """
318
+ innocent = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
319
+
320
+ fake_alibi = random.choice(FAKE_ALIBI_POOL)
321
+ real_alibi = random.choice(REAL_ALIBI_POOL)
322
+
323
+ clothing = random.choice(CLOTHING_OPTIONS)
324
+
325
+ # Enhancement 2: 30% chance the witness got the clothing wrong
326
+ witness_clothing = clothing
327
+ if random.random() < 0.3:
328
+ # Pick a different clothing item
329
+ other_options = [c for c in CLOTHING_OPTIONS if c != clothing]
330
+ witness_clothing = random.choice(other_options)
331
+
332
+ knowledge = {
333
+ SUSPECT_A_KEY if criminal == SUSPECT_A else SUSPECT_B_KEY: {
334
+ "knows_own_guilt": True,
335
+ "fake_alibi": fake_alibi,
336
+ "knows_cctv_status": random.choice([True, False]),
337
+ "knows_keycard_log_exists": random.choice([True, False]),
338
+ },
339
+ SUSPECT_A_KEY if innocent == SUSPECT_A else SUSPECT_B_KEY: {
340
+ "knows_own_guilt": False,
341
+ "real_alibi": real_alibi,
342
+ "knows_suspect_A": random.choice([True, False]),
343
+ },
344
+ WITNESS_1_KEY: {
345
+ "saw": f"a person in {witness_clothing} near the {location} around {time}",
346
+ "actual_criminal_clothing": clothing,
347
+ "time_seen": time,
348
+ "bias_target": random.choice([SUSPECT_A, SUSPECT_B, None]),
349
+ "bias_strength": round(random.uniform(0.0, 0.5), 2),
350
+ },
351
+ }
352
+ return knowledge
353
+
354
+
355
+ def _build_detective_briefing(prior_history: dict) -> str:
356
+ """Create a 2-3 sentence briefing for the detective (no criminal identity)."""
357
+ parts = []
358
+ for suspect in SUSPECTS:
359
+ h = prior_history[suspect]
360
+ crimes = h["previous_crimes"]
361
+ crime_str = (
362
+ f"has prior convictions for {', '.join(crimes)}"
363
+ if crimes
364
+ else "has no prior convictions on record"
365
+ )
366
+
367
+ lies = h["past_lies_on_record"]
368
+ lie_str = (
369
+ f"and has been caught lying about {lies[0]['topic']} in the past"
370
+ if lies
371
+ else "with no documented dishonesty"
372
+ )
373
+
374
+ parole_str = ""
375
+ if h["on_parole"]:
376
+ parole_str = f" Currently on parole ({h['parole_condition']})."
377
+
378
+ parts.append(
379
+ f"{suspect} {crime_str} {lie_str}. "
380
+ f"Known behavioral pattern: {h['behavioral_pattern']}.{parole_str}"
381
+ )
382
+
383
+ return " ".join(parts)
384
+
385
+
386
+ # ── Public API ──────────────────────────────────────────────────────────────
387
+
388
+
389
+ def generate_case() -> dict:
390
+ """Generate a complete randomized crime case.
391
+
392
+ Criminal is randomly assigned to either Suspect_A or Suspect_B.
393
+
394
+ Returns a dictionary containing all case information needed for one
395
+ episode of the Crime Investigation environment.
396
+ """
397
+ crime = random.choice(CRIMES)
398
+ criminal = random.choice(list(SUSPECTS))
399
+ time = random.choice(TIMES)
400
+ location = random.choice(LOCATIONS)
401
+ method = random.choice(METHODS)
402
+ motive = random.choice(MOTIVES)
403
+
404
+ physical_evidence = _build_evidence(criminal, location, time)
405
+ prior_history = _build_prior_history(criminal)
406
+ agent_knowledge = _build_agent_knowledge(criminal, location, time)
407
+ detective_briefing = _build_detective_briefing(prior_history)
408
+
409
+ return {
410
+ "crime": crime,
411
+ "criminal": criminal,
412
+ "time": time,
413
+ "location": location,
414
+ "method": method,
415
+ "motive": motive,
416
+ "physical_evidence": physical_evidence,
417
+ "prior_history": prior_history,
418
+ "agent_knowledge": agent_knowledge,
419
+ "detective_briefing": detective_briefing,
420
+ }
crime_env/consistency_tracker.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Component 2 β€” Lie / Consistency Tracker.
2
+
3
+ Tracks claims made by each agent across conversation turns and detects
4
+ contradictions, evidence mismatches, and prior-pattern repetitions.
5
+
6
+ Contradiction detection uses *normalized core claims* β€” short canonical
7
+ representations of what the agent asserted β€” to avoid false positives
8
+ from minor rephrasing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from crime_env.constants import SUSPECT_A, SUSPECT_B, WITNESS_1
16
+
17
+ SUPPORTED_TOPICS: list[str] = [
18
+ "location",
19
+ "clothing",
20
+ "time",
21
+ "alibi",
22
+ "knows_associate",
23
+ "was_at_scene",
24
+ "owns_item",
25
+ "relationship_to_victim",
26
+ ]
27
+
28
+ SUPPORTED_AGENTS: set[str] = {SUSPECT_A, SUSPECT_B, WITNESS_1}
29
+
30
+ # ── Normalization helpers ───────────────────────────────────────────────────
31
+
32
+ # Location keywords that are mutually exclusive
33
+ _LOCATION_TOKENS = {
34
+ "east wing", "server room", "vault", "parking lot",
35
+ "basement archive", "rooftop terrace", "loading dock",
36
+ "executive suite", "evidence locker", "maintenance corridor",
37
+ "restaurant", "movie theater",
38
+ }
39
+
40
+ # Stance keywords (yes/no style claims)
41
+ _POSITIVE_STANCES = {"yes", "was there", "was at", "i was", "i did", "i know", "true"}
42
+ _NEGATIVE_STANCES = {"no", "wasn't", "was not", "never", "not there", "don't know", "false", "i didn't"}
43
+
44
+
45
+ def _normalize_claim(topic: str, raw_value: str) -> str:
46
+ """Extract a short, canonical representation of a claim.
47
+
48
+ This prevents false positives where the same claim expressed
49
+ slightly differently (e.g. 'family dinner' vs 'left dinner')
50
+ looks like a contradiction.
51
+ """
52
+ val = raw_value.lower().strip()
53
+ # Remove filler words and punctuation
54
+ val = re.sub(r'[^\w\s]', ' ', val)
55
+ val = re.sub(r'\s+', ' ', val).strip()
56
+
57
+ if topic == "location":
58
+ # Extract the most specific named location
59
+ for loc in _LOCATION_TOKENS:
60
+ if loc in val:
61
+ return f"location:{loc}"
62
+ return f"location:{val[:40]}"
63
+
64
+ if topic == "was_at_scene":
65
+ for neg in _NEGATIVE_STANCES:
66
+ if neg in val:
67
+ return "was_at_scene:no"
68
+ for pos in _POSITIVE_STANCES:
69
+ if pos in val:
70
+ return "was_at_scene:yes"
71
+ return f"was_at_scene:{val[:30]}"
72
+
73
+ if topic == "knows_associate":
74
+ for neg in _NEGATIVE_STANCES:
75
+ if neg in val:
76
+ return "knows_associate:no"
77
+ for pos in _POSITIVE_STANCES:
78
+ if pos in val:
79
+ return "knows_associate:yes"
80
+ return f"knows_associate:{val[:30]}"
81
+
82
+ if topic == "alibi":
83
+ # Extract the core venue/activity from the alibi
84
+ for loc in _LOCATION_TOKENS:
85
+ if loc in val:
86
+ return f"alibi:{loc}"
87
+ # Check for key alibi activities
88
+ for keyword in ["dinner", "movie", "bar", "working", "office",
89
+ "gym", "volunteering", "home", "sick", "party",
90
+ "attending", "helping", "hospital",
91
+ "library", "studying", "babysitting", "poker",
92
+ "coaching", "overtime",
93
+ "laundromat", "walking", "video call", "prayer"]:
94
+ if keyword in val:
95
+ return f"alibi:{keyword}"
96
+ return f"alibi:{val[:40]}"
97
+
98
+ if topic == "clothing":
99
+ for item in ["hoodie", "trench coat", "leather jacket",
100
+ "overalls", "uniform", "jeans", "shirt",
101
+ "windbreaker", "cargo pants", "tracksuit",
102
+ "bomber jacket", "corduroy",
103
+ "coat", "jacket"]:
104
+ if item in val:
105
+ return f"clothing:{item}"
106
+ return f"clothing:{val[:30]}"
107
+
108
+ if topic == "time":
109
+ # Extract actual time mentions
110
+ time_match = re.search(r'\d{1,2}[:\s]*\d{2}\s*(pm|am)?', val)
111
+ if time_match:
112
+ return f"time:{time_match.group()}"
113
+ return f"time:{val[:30]}"
114
+
115
+ # Fallback: use a truncated normalized string
116
+ return f"{topic}:{val[:40]}"
117
+
118
+
119
+ class ConsistencyTracker:
120
+ """Maintains a per-agent claim log and detects inconsistencies.
121
+
122
+ Uses normalized claim values to reduce false positive contradictions.
123
+ """
124
+
125
+ def __init__(self) -> None:
126
+ # {agent_name: {topic: {"value": str, "normalized": str, "turn": int}}}
127
+ self.claim_log: dict[str, dict[str, dict]] = {}
128
+ self.contradiction_log: list[dict] = []
129
+
130
+ # ── Core methods ────────────────────────────────────────────────────
131
+
132
+ def record_claim(
133
+ self, agent: str, topic: str, value: str, turn: int
134
+ ) -> dict:
135
+ """Record a claim and check for self-contradiction.
136
+
137
+ Contradiction fires only if the *normalized* core claim differs
138
+ (e.g. 'office' vs 'bar' is a contradiction, but 'family dinner'
139
+ vs 'left dinner' is not).
140
+
141
+ Returns:
142
+ dict with `contradicted` bool, plus details if True.
143
+ """
144
+ agent_name = agent if agent in SUPPORTED_AGENTS else str(agent)
145
+
146
+ if agent_name not in self.claim_log:
147
+ self.claim_log[agent_name] = {}
148
+
149
+ agent_claims = self.claim_log[agent_name]
150
+ normalized = _normalize_claim(topic, value)
151
+
152
+ if topic not in agent_claims:
153
+ # First claim on this topic β€” store it
154
+ agent_claims[topic] = {
155
+ "value": value,
156
+ "normalized": normalized,
157
+ "turn": turn,
158
+ }
159
+ return {"contradicted": False}
160
+
161
+ existing = agent_claims[topic]
162
+
163
+ # Compare normalized versions β€” if they match, no contradiction
164
+ if existing["normalized"] == normalized:
165
+ return {"contradicted": False}
166
+
167
+ # Contradiction detected β€” normalized claims are mutually exclusive
168
+ event = {
169
+ "contradicted": True,
170
+ "agent": agent_name,
171
+ "topic": topic,
172
+ "old_value": existing["value"],
173
+ "new_value": value,
174
+ "turn": turn,
175
+ }
176
+ self.contradiction_log.append(event)
177
+
178
+ # Update to the latest claim
179
+ agent_claims[topic] = {
180
+ "value": value,
181
+ "normalized": normalized,
182
+ "turn": turn,
183
+ }
184
+
185
+ return event
186
+
187
+ def check_against_evidence(
188
+ self,
189
+ agent: str,
190
+ topic: str,
191
+ value: str,
192
+ evidence_list: list[dict],
193
+ ) -> dict:
194
+ """Check if the agent's claim contradicts physical evidence.
195
+
196
+ Returns:
197
+ dict with `confirmed_lie` bool.
198
+ """
199
+ value_lower = value.lower().strip()
200
+
201
+ def _extract_location(text: str) -> str | None:
202
+ for loc in _LOCATION_TOKENS:
203
+ if loc in text:
204
+ return loc
205
+ return None
206
+
207
+ def _extract_clothing(text: str) -> str | None:
208
+ clothing_tokens = [
209
+ "hoodie", "trench coat", "leather jacket", "overalls",
210
+ "uniform", "jeans", "shirt", "windbreaker",
211
+ "cargo pants", "tracksuit", "bomber jacket", "corduroy",
212
+ "coat", "jacket",
213
+ ]
214
+ for token in clothing_tokens:
215
+ if token in text:
216
+ return token
217
+ return None
218
+
219
+ def _extract_time(text: str) -> str | None:
220
+ m = re.search(r'\d{1,2}[:\s]*\d{2}\s*(pm|am)?', text)
221
+ return m.group().strip() if m else None
222
+
223
+ denies_scene = any(
224
+ phrase in value_lower
225
+ for phrase in (
226
+ "wasn't there", "was not there", "not there",
227
+ "nowhere near", "i wasn't", "i was not",
228
+ "never there", "didn't go", "did not go",
229
+ )
230
+ )
231
+
232
+ claimed_location = _extract_location(value_lower)
233
+ claimed_clothing = _extract_clothing(value_lower)
234
+ claimed_time = _extract_time(value_lower)
235
+
236
+ for evidence in evidence_list:
237
+ desc_lower = evidence.get("description", "").lower()
238
+ points_to = evidence.get("points_to", "").lower()
239
+
240
+ if topic == "was_at_scene":
241
+ if (
242
+ denies_scene
243
+ and points_to == agent.lower()
244
+ ):
245
+ return {"confirmed_lie": True}
246
+
247
+ if topic == "location":
248
+ if (
249
+ claimed_location is not None
250
+ and agent.lower() in desc_lower
251
+ and claimed_location not in desc_lower
252
+ and ("location" in desc_lower or "entering" in desc_lower
253
+ or "near" in desc_lower)
254
+ ):
255
+ return {"confirmed_lie": True}
256
+
257
+ if topic == "clothing":
258
+ if (
259
+ claimed_clothing is not None
260
+ and agent.lower() in desc_lower
261
+ and claimed_clothing not in desc_lower
262
+ ):
263
+ if "fabric" in desc_lower or "wearing" in desc_lower:
264
+ return {"confirmed_lie": True}
265
+
266
+ if topic == "time":
267
+ if (
268
+ claimed_time is not None
269
+ and agent.lower() in desc_lower
270
+ and claimed_time not in desc_lower
271
+ and ("timestamp" in desc_lower or "log" in desc_lower
272
+ or "at" in desc_lower)
273
+ ):
274
+ return {"confirmed_lie": True}
275
+
276
+ # General: evidence points to this agent but they deny involvement
277
+ if (
278
+ points_to == agent.lower()
279
+ and topic in ("was_at_scene", "owns_item")
280
+ and (
281
+ value_lower in ("no", "false", "never", "not mine")
282
+ or "not mine" in value_lower
283
+ or denies_scene
284
+ )
285
+ ):
286
+ return {"confirmed_lie": True}
287
+
288
+ return {"confirmed_lie": False}
289
+
290
+ def check_prior_pattern(
291
+ self,
292
+ agent: str,
293
+ topic: str,
294
+ value: str,
295
+ prior_history: dict,
296
+ ) -> dict:
297
+ """Check if the current claim matches a documented past lie pattern.
298
+
299
+ Returns:
300
+ dict with `pattern_match` bool.
301
+ """
302
+ if agent not in prior_history:
303
+ return {"pattern_match": False}
304
+
305
+ agent_history = prior_history[agent]
306
+
307
+ # Check against documented past lies
308
+ past_lies = agent_history.get("past_lies_on_record", [])
309
+ for lie in past_lies:
310
+ if lie["topic"].lower() == topic.lower():
311
+ return {"pattern_match": True}
312
+
313
+ # Check against behavioral pattern keywords
314
+ pattern = agent_history.get("behavioral_pattern", "").lower()
315
+ value_lower = value.lower()
316
+
317
+ if "family" in pattern and "family" in value_lower:
318
+ return {"pattern_match": True}
319
+
320
+ if "deflect" in pattern and ("suspect" in value_lower or "they" in value_lower):
321
+ return {"pattern_match": True}
322
+
323
+ if "memory" in pattern and ("don't remember" in value_lower or "forgot" in value_lower):
324
+ return {"pattern_match": True}
325
+
326
+ return {"pattern_match": False}
327
+
328
+ def get_summary(self) -> list[dict]:
329
+ """Return the full contradiction log for the episode."""
330
+ return list(self.contradiction_log)
331
+
332
+ # ── Utility ─────────────────────────────────────────────────────────
333
+
334
+ def get_agent_claims(self, agent: str) -> dict[str, dict]:
335
+ """Return all recorded claims for a specific agent."""
336
+ return dict(self.claim_log.get(agent, {}))
337
+
338
+ def has_claimed_topic(self, agent: str, topic: str) -> bool:
339
+ """Check if an agent has already made a claim on this topic."""
340
+ return topic in self.claim_log.get(agent, {})
crime_env/constants.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared environment constants used across modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ SUSPECT_A = "Suspect_A"
6
+ SUSPECT_B = "Suspect_B"
7
+ WITNESS_1 = "Witness_1"
8
+
9
+ SUSPECT_A_KEY = "suspect_a"
10
+ SUSPECT_B_KEY = "suspect_b"
11
+ WITNESS_1_KEY = "witness_1"
12
+ DETECTIVE_KEY = "detective"
13
+
14
+ AGENT_NAME_TO_KEY = {
15
+ SUSPECT_A: SUSPECT_A_KEY,
16
+ SUSPECT_B: SUSPECT_B_KEY,
17
+ WITNESS_1: WITNESS_1_KEY,
18
+ }
19
+
20
+ SUSPECTS = (SUSPECT_A, SUSPECT_B)
21
+
22
+ VALID_TARGETS = {SUSPECT_A, SUSPECT_B, WITNESS_1}
23
+ VALID_ACCUSE_TARGETS = {SUSPECT_A, SUSPECT_B}
crime_env/environment.py ADDED
@@ -0,0 +1,922 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Component 5 β€” Environment Core (OpenEnv Compatible).
2
+
3
+ Implements the CrimeInvestigationEnv with step/reset/render interface.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import re
10
+ from typing import Any, Callable, Optional
11
+
12
+ from crime_env.case_generator import generate_case
13
+ from crime_env.consistency_tracker import ConsistencyTracker
14
+ from crime_env.reward_calculator import RewardCalculator
15
+ from crime_env.agent_prompts import build_system_prompt
16
+ from crime_env.constants import (
17
+ AGENT_NAME_TO_KEY,
18
+ DETECTIVE_KEY,
19
+ SUSPECT_A,
20
+ SUSPECT_A_KEY,
21
+ SUSPECT_B,
22
+ SUSPECT_B_KEY,
23
+ VALID_ACCUSE_TARGETS,
24
+ VALID_TARGETS,
25
+ WITNESS_1,
26
+ WITNESS_1_KEY,
27
+ )
28
+
29
+
30
+ # ── Action parsing ──────────────────────────────────────────────────────────
31
+
32
+ _ACTION_PATTERNS = {
33
+ "ask_question": re.compile(
34
+ r"ACTION:\s*ask_question\s*\|\s*TARGET:\s*(\S+)\s*\|\s*CONTENT:\s*(.+)",
35
+ re.IGNORECASE,
36
+ ),
37
+ "request_evidence": re.compile(
38
+ r"ACTION:\s*request_evidence\s*\|\s*ITEM:\s*(\S+)",
39
+ re.IGNORECASE,
40
+ ),
41
+ "accuse": re.compile(
42
+ r"ACTION:\s*accuse\s*\|\s*TARGET:\s*(\S+)",
43
+ re.IGNORECASE,
44
+ ),
45
+ }
46
+
47
+ EVIDENCE_ITEMS = {"keycard_log", "cctv_footage", "forensic_report"}
48
+
49
+ # Evidence confirmation should depend on suspect-specific questioning
50
+ # that is relevant to the requested evidence type.
51
+ EVIDENCE_LEAD_TOPICS = {
52
+ "keycard_log": {"time", "location", "was_at_scene", "alibi"},
53
+ "cctv_footage": {"location", "clothing", "time", "was_at_scene"},
54
+ "forensic_report": {"clothing", "owns_item", "location", "was_at_scene"},
55
+ }
56
+
57
+ # Map evidence item names to indices in physical_evidence list
58
+ EVIDENCE_INDEX_MAP = {
59
+ "keycard_log": 0,
60
+ "cctv_footage": 1,
61
+ "forensic_report": 2,
62
+ }
63
+
64
+ TOPIC_KEYWORDS = {
65
+ "location": [
66
+ "east wing", "server room", "vault", "parking lot",
67
+ "basement archive", "rooftop terrace", "loading dock",
68
+ "executive suite", "evidence locker", "maintenance corridor",
69
+ "movie theater", "restaurant",
70
+ ],
71
+ "clothing": [
72
+ "hoodie", "trench coat", "leather jacket",
73
+ "overalls", "uniform", "jeans",
74
+ "windbreaker", "cargo pants", "tracksuit",
75
+ "bomber jacket", "corduroy",
76
+ "coat", "jacket",
77
+ ],
78
+ "time": [
79
+ "7:30", "8:15", "8:45", "9:00", "9:15",
80
+ "9:45", "10:00", "10:30", "11:15", "11:45",
81
+ "o'clock",
82
+ ],
83
+ "alibi": [
84
+ "playing poker", "working late", "at home", "was at",
85
+ "dinner", "movie", "gym", "volunteering", "party",
86
+ "sick", "attending", "helping", "hospital",
87
+ "library", "studying", "babysitting", "coaching",
88
+ "overtime", "laundromat", "walking", "video call",
89
+ "prayer",
90
+ ],
91
+ "knows_associate": [
92
+ "know them", "know him", "know her", "never met",
93
+ "don't know", "do not know",
94
+ "know suspect", "knew",
95
+ ],
96
+ "was_at_scene": [
97
+ "wasn't there", "was not there", "not there",
98
+ "never been", "was there", "at the scene",
99
+ "nowhere near",
100
+ ],
101
+ "owns_item": [
102
+ "not mine", "my bag", "my phone", "my tools",
103
+ "belongs to me",
104
+ ],
105
+ "relationship_to_victim": [
106
+ "knew the victim", "never met the victim",
107
+ "victim",
108
+ ],
109
+ }
110
+
111
+
112
+ def parse_action(action_string: str) -> dict:
113
+ """Parse a detective action string into a structured dict.
114
+
115
+ Returns:
116
+ dict with keys: action_type, target/item, content (if applicable).
117
+ Returns {action_type: "invalid"} on parse failure.
118
+ """
119
+ action_string = action_string.strip()
120
+
121
+ # Try ask_question
122
+ m = _ACTION_PATTERNS["ask_question"].search(action_string)
123
+ if m:
124
+ return {
125
+ "action_type": "ask_question",
126
+ "target": m.group(1).strip(),
127
+ "content": m.group(2).strip(),
128
+ }
129
+
130
+ # Try request_evidence
131
+ m = _ACTION_PATTERNS["request_evidence"].search(action_string)
132
+ if m:
133
+ return {
134
+ "action_type": "request_evidence",
135
+ "item": m.group(1).strip(),
136
+ }
137
+
138
+ # Try accuse
139
+ m = _ACTION_PATTERNS["accuse"].search(action_string)
140
+ if m:
141
+ return {
142
+ "action_type": "accuse",
143
+ "target": m.group(1).strip(),
144
+ }
145
+
146
+ return {"action_type": "invalid", "raw": action_string}
147
+
148
+
149
+ def extract_topics_from_response(response: str) -> list[tuple[str, str]]:
150
+ """Extract (topic, value) pairs from a natural language response.
151
+
152
+ Uses word-boundary matching to avoid substring false positives
153
+ (e.g. 'time' in 'Sometimes', 'own' in 'town').
154
+ Returns at most ONE topic per response to prevent a single statement
155
+ from registering as 3 contradictions simultaneously.
156
+
157
+ Returns:
158
+ List of (topic, value_snippet) tuples. Max length = 1.
159
+ """
160
+ response_lower = response.lower()
161
+
162
+ # Find the BEST (most specific) single topic match
163
+ best_match = None
164
+ best_kw_len = 0
165
+
166
+ for topic, keywords in TOPIC_KEYWORDS.items():
167
+ for kw in keywords:
168
+ # Use word-boundary regex to avoid substring matches
169
+ pattern = r'\b' + re.escape(kw) + r'\b'
170
+ m = re.search(pattern, response_lower)
171
+ if m:
172
+ # Prefer longer (more specific) keyword matches
173
+ if len(kw) > best_kw_len:
174
+ best_kw_len = len(kw)
175
+ idx = m.start()
176
+ start = idx
177
+ end = min(len(response), idx + len(kw) + 30)
178
+ value = response[start:end].strip()
179
+ best_match = (topic, value)
180
+ break # One match per topic, but continue checking other topics
181
+
182
+ return [best_match] if best_match else []
183
+
184
+
185
+ # ── Environment ─────────────────────────────────────────────────────────────
186
+
187
+
188
+ class CrimeInvestigationEnv:
189
+ """Multi-agent crime investigation RL environment.
190
+
191
+ Compatible with OpenEnv's step/reset/render interface and
192
+ designed for training with HuggingFace TRL PPO.
193
+ """
194
+
195
+ MAX_TURNS = 15
196
+
197
+ def __init__(
198
+ self,
199
+ llm_call: Optional[Callable[[str, list[dict]], str]] = None,
200
+ ) -> None:
201
+ """Initialize the environment.
202
+
203
+ Args:
204
+ llm_call: Callable(system_prompt, conversation_history) -> response.
205
+ Used for NPC agents (suspects, witness).
206
+ If None, a simple rule-based fallback is used.
207
+ """
208
+ self.llm_call = llm_call or self._default_llm_call
209
+
210
+ # Will be set on reset()
211
+ self.case: Optional[dict] = None
212
+ self.tracker: Optional[ConsistencyTracker] = None
213
+ self.reward_calc: Optional[RewardCalculator] = None
214
+ self.turn: int = 0
215
+ self.done: bool = False
216
+ self.conversation_history: list[dict] = []
217
+ self.evidence_log: list[dict] = []
218
+ self.system_prompts: dict[str, str] = {}
219
+ self.asked_topics: dict[str, set] = {} # {target: {topics asked}}
220
+ self._pattern_penalized: set[str] = set()
221
+ self._pattern_exploited_targets: set[str] = set()
222
+
223
+ # Deflection tracking: stores (suspect, deflection_target) when a
224
+ # suspect mentions the other suspect in their response.
225
+ # Only set when the *previous action* was ask_question to a suspect.
226
+ self._pending_deflection: Optional[tuple[str, str]] = None
227
+ # What the last action type was (ask_question / request_evidence / etc.)
228
+ self._last_action_type: Optional[str] = None
229
+ # Cap anti-deflection bonus to once per suspect per episode.
230
+ self._deflection_resistance_rewarded: set[str] = set()
231
+ # Prevent repeated contradiction farming on the same target/topic pair.
232
+ self._contradiction_rewarded_topics: set[tuple[str, str]] = set()
233
+
234
+ # ── OpenEnv Interface ───────────────────────────────────────────────
235
+
236
+ def _reset_episode_tracking(self) -> None:
237
+ self._pending_deflection = None
238
+ self._last_action_type = None
239
+ self._pattern_penalized = set()
240
+ self._pattern_exploited_targets = set()
241
+ self._deflection_resistance_rewarded = set()
242
+ self._contradiction_rewarded_topics = set()
243
+
244
+ def reset(self, case_data: Optional[dict] = None) -> dict:
245
+ """Start a new episode.
246
+
247
+ Returns:
248
+ Initial observation for the detective agent.
249
+ """
250
+ self.case = copy.deepcopy(case_data) if case_data is not None else generate_case()
251
+ self.tracker = ConsistencyTracker()
252
+ self.reward_calc = RewardCalculator()
253
+ self.turn = 0
254
+ self.done = False
255
+ self.conversation_history = []
256
+ self.evidence_log = []
257
+ self.asked_topics = {t: set() for t in VALID_TARGETS}
258
+ self._reset_episode_tracking()
259
+
260
+ # Build system prompts for all agents
261
+ self.system_prompts = {
262
+ DETECTIVE_KEY: build_system_prompt("detective", self.case),
263
+ SUSPECT_A: build_system_prompt(SUSPECT_A_KEY, self.case),
264
+ SUSPECT_B: build_system_prompt(SUSPECT_B_KEY, self.case),
265
+ WITNESS_1: build_system_prompt("witness", self.case),
266
+ }
267
+
268
+ return {
269
+ "role": "detective",
270
+ "briefing": self.case["detective_briefing"],
271
+ "turn": 0,
272
+ "conversation_history": [],
273
+ }
274
+
275
+ def step(self, action_string: str) -> tuple[dict, float, bool, dict]:
276
+ """Execute one detective action.
277
+
278
+ Args:
279
+ action_string: Action in the format defined in Component 4.
280
+
281
+ Returns:
282
+ (observation, reward, done, info)
283
+ """
284
+ if self.done:
285
+ raise RuntimeError("Episode is done. Call reset().")
286
+
287
+ parsed = parse_action(action_string)
288
+ action_type = parsed["action_type"]
289
+
290
+ if action_type == "ask_question":
291
+ return self._handle_ask_question(parsed)
292
+ elif action_type == "request_evidence":
293
+ return self._handle_request_evidence(parsed)
294
+ elif action_type == "accuse":
295
+ return self._handle_accuse(parsed)
296
+ else:
297
+ # Invalid action β€” treat as wasted turn
298
+ reward = self._apply_invalid_turn_penalty("invalid")
299
+
300
+ if self.turn >= self.MAX_TURNS:
301
+ return self._handle_timeout(reward)
302
+
303
+ obs = self._build_observation(
304
+ message="Invalid action format. Please use the correct format."
305
+ )
306
+ return obs, reward, False, {"action": "invalid"}
307
+
308
+ def render(self) -> None:
309
+ """Print the conversation history in a readable format."""
310
+ print("\n" + "=" * 70)
311
+ print(" CRIME INVESTIGATION β€” CONVERSATION LOG")
312
+ print("=" * 70)
313
+
314
+ if self.case:
315
+ print(f" Crime: {self.case['crime']}")
316
+ print(f" Location: {self.case['location']}")
317
+ print("-" * 70)
318
+
319
+ for entry in self.conversation_history:
320
+ turn = entry.get("turn", "?")
321
+ speaker = entry.get("speaker", "???")
322
+ content = entry.get("content", "")
323
+ flags = entry.get("flags", [])
324
+
325
+ print(f"\n [Turn {turn}] {speaker}:")
326
+ print(f" {content}")
327
+
328
+ if flags:
329
+ for flag in flags:
330
+ print(f" ⚠ FLAG: {flag}")
331
+
332
+ print("\n" + "=" * 70)
333
+
334
+ if self.done and self.reward_calc:
335
+ rewards = self.reward_calc.get_rewards()
336
+ print(" FINAL REWARDS:")
337
+ for agent, r in rewards.items():
338
+ print(f" {agent}: {r:+.2f}")
339
+ print("=" * 70 + "\n")
340
+
341
+ def state(self) -> dict:
342
+ """Return episode metadata (OpenEnv compatible)."""
343
+ return {
344
+ "turn": self.turn,
345
+ "done": self.done,
346
+ "max_turns": self.MAX_TURNS,
347
+ "evidence_revealed": len(self.evidence_log),
348
+ "contradictions_found": (
349
+ len(self.tracker.get_summary()) if self.tracker else 0
350
+ ),
351
+ }
352
+
353
+ # ── Action handlers ─────────────────────────────────────────────────
354
+
355
+ def _handle_ask_question(
356
+ self, parsed: dict
357
+ ) -> tuple[dict, float, bool, dict]:
358
+ target = parsed["target"]
359
+ question = parsed["content"]
360
+ step_reward = 0.0
361
+ info: dict[str, Any] = {"action": "ask_question", "target": target}
362
+ flags: list[str] = []
363
+
364
+ # Validate target
365
+ if target not in VALID_TARGETS:
366
+ reward = self._apply_invalid_turn_penalty("ask_question")
367
+ if self.turn >= self.MAX_TURNS:
368
+ return self._handle_timeout(reward)
369
+ obs = self._build_observation(
370
+ message=f"Invalid target: {target}. Valid: {list(VALID_TARGETS)}"
371
+ )
372
+ return obs, reward, False, {"action": "invalid_target"}
373
+
374
+ step_reward += self._apply_deflection_logic(target)
375
+
376
+ # Per-turn cost
377
+ self.reward_calc.apply_event("per_turn_cost")
378
+ step_reward += -0.3
379
+
380
+ # Check for redundant question (topic already asked to this target)
381
+ question_topics = extract_topics_from_response(question)
382
+ if not question_topics:
383
+ # Bucket unclassified questions to prevent repetition bypasses.
384
+ question_topics = [("general", question[:40])]
385
+
386
+ question_topic_names = {topic for topic, _ in question_topics}
387
+ previous_topics = set(self.asked_topics[target])
388
+
389
+ is_redundant = any(topic in previous_topics for topic in question_topic_names)
390
+ for topic in question_topic_names:
391
+ self.asked_topics[target].add(topic)
392
+
393
+ if is_redundant:
394
+ self.reward_calc.apply_event("redundant_question")
395
+ step_reward += -0.5
396
+ flags.append("redundant_question")
397
+
398
+ # Add question to conversation history
399
+ self.conversation_history.append(
400
+ {
401
+ "turn": self.turn,
402
+ "speaker": "Detective",
403
+ "content": f"[To {target}] {question}",
404
+ "flags": list(flags),
405
+ }
406
+ )
407
+
408
+ # Get NPC response
409
+ system_prompt = self.system_prompts.get(target, "")
410
+ # Use full history to avoid artificial contradictions from short memory cap
411
+ recent_history = list(self.conversation_history)
412
+ response = self.llm_call(system_prompt, recent_history)
413
+ response_lower = response.lower()
414
+
415
+ # Extract topics from response and run consistency checks
416
+ response_topics = extract_topics_from_response(response)
417
+ response_flags: list[str] = []
418
+ step_reward += self._analyse_response_topics(
419
+ target=target,
420
+ response_topics=response_topics,
421
+ question_topic_names=question_topic_names,
422
+ previous_topics=previous_topics,
423
+ response_flags=response_flags,
424
+ )
425
+
426
+ # Check for witness bias-driven false implication
427
+ if target == WITNESS_1:
428
+ self._check_witness_bias(response_lower, response_flags)
429
+
430
+ # Add response to conversation history
431
+ self.conversation_history.append(
432
+ {
433
+ "turn": self.turn,
434
+ "speaker": target,
435
+ "content": response,
436
+ "flags": response_flags,
437
+ }
438
+ )
439
+
440
+ # ── Track deflection attempt ────────────────────────────────────
441
+ # Only track deflection when current action is ask_question
442
+ # to a suspect, and their response mentions the other suspect.
443
+ if target in (SUSPECT_A, SUSPECT_B):
444
+ other = SUSPECT_B if target == SUSPECT_A else SUSPECT_A
445
+ if (
446
+ other.lower().replace("_", " ") in response_lower
447
+ or other.lower() in response_lower
448
+ ):
449
+ self._pending_deflection = (target, other)
450
+ info["deflection_target"] = other
451
+ else:
452
+ self._pending_deflection = None
453
+ else:
454
+ self._pending_deflection = None
455
+
456
+ self._last_action_type = "ask_question"
457
+ self.turn += 1
458
+
459
+ if self.turn >= self.MAX_TURNS:
460
+ return self._handle_timeout(step_reward)
461
+
462
+ obs = self._build_observation()
463
+ info["flags"] = response_flags
464
+ return obs, step_reward, False, info
465
+
466
+ def _apply_deflection_logic(self, target: str) -> float:
467
+ """Apply deflection rewards/penalties from the previous turn."""
468
+ step_delta = 0.0
469
+ if (
470
+ self._pending_deflection is not None
471
+ and self._last_action_type == "ask_question"
472
+ ):
473
+ deflector, deflection_target = self._pending_deflection
474
+ if target == deflection_target:
475
+ deflector_key = AGENT_NAME_TO_KEY.get(deflector, deflector.lower())
476
+ self.reward_calc.apply_event(
477
+ "successful_deflection",
478
+ agent=deflector_key,
479
+ )
480
+ elif (
481
+ target == deflector
482
+ and deflector not in self._deflection_resistance_rewarded
483
+ ):
484
+ # Reward resisting deflection only when the detective stays
485
+ # on the same suspect, and only once per deflector.
486
+ self._deflection_resistance_rewarded.add(deflector)
487
+ step_delta += self.reward_calc.apply_event("deflection_resistance")
488
+ self._pending_deflection = None
489
+ return step_delta
490
+
491
+ def _analyse_response_topics(
492
+ self,
493
+ target: str,
494
+ response_topics: list[tuple[str, str]],
495
+ question_topic_names: set[str],
496
+ previous_topics: set[str],
497
+ response_flags: list[str],
498
+ ) -> float:
499
+ """Analyze response claims for contradictions, lies, and prior-pattern hits."""
500
+ step_delta = 0.0
501
+ agent_key = AGENT_NAME_TO_KEY.get(target, target.lower())
502
+
503
+ for topic, value in response_topics:
504
+ claim_result = self.tracker.record_claim(target, topic, value, self.turn)
505
+
506
+ if claim_result["contradicted"]:
507
+ response_flags.append(
508
+ f"CONTRADICTION on '{topic}': "
509
+ f"previously said '{claim_result['old_value'][:50]}', "
510
+ f"now says '{claim_result['new_value'][:50]}'"
511
+ )
512
+
513
+ intentional_probe = (
514
+ topic in question_topic_names and topic in previous_topics
515
+ )
516
+ contradiction_key = (target, topic)
517
+
518
+ if (
519
+ intentional_probe
520
+ and contradiction_key not in self._contradiction_rewarded_topics
521
+ ):
522
+ self._contradiction_rewarded_topics.add(contradiction_key)
523
+ self.reward_calc.apply_event("contradiction_exposed")
524
+ step_delta += 2.0
525
+
526
+ if target in (SUSPECT_A, SUSPECT_B):
527
+ self.reward_calc.apply_event(
528
+ "suspect_self_contradiction",
529
+ agent=agent_key,
530
+ )
531
+ elif target == WITNESS_1:
532
+ self.reward_calc.apply_event("witness_self_contradiction")
533
+
534
+ visible_evidence = list(self.evidence_log)
535
+ if visible_evidence:
536
+ ev_result = self.tracker.check_against_evidence(
537
+ target, topic, value, visible_evidence
538
+ )
539
+ if ev_result["confirmed_lie"]:
540
+ response_flags.append(
541
+ f"LIE CONFIRMED by evidence on '{topic}'"
542
+ )
543
+ if target in (SUSPECT_A, SUSPECT_B):
544
+ self.reward_calc.apply_event(
545
+ "confirmed_lie_vs_evidence",
546
+ agent=agent_key,
547
+ )
548
+ step_delta += 1.0
549
+
550
+ prior_result = self.tracker.check_prior_pattern(
551
+ target, topic, value, self.case["prior_history"]
552
+ )
553
+ if prior_result["pattern_match"]:
554
+ response_flags.append(
555
+ f"PRIOR PATTERN MATCH on '{topic}'"
556
+ )
557
+
558
+ has_documented_history = bool(
559
+ self.case["prior_history"]
560
+ .get(target, {})
561
+ .get("past_lies_on_record", [])
562
+ )
563
+ topic_aligned_probe = topic in question_topic_names
564
+
565
+ if has_documented_history and topic_aligned_probe:
566
+ if target not in self._pattern_exploited_targets:
567
+ self._pattern_exploited_targets.add(target)
568
+ self.reward_calc.apply_event("prior_pattern_exploited")
569
+ step_delta += 1.5
570
+
571
+ if (
572
+ target == self.case["criminal"]
573
+ and target not in self._pattern_penalized
574
+ ):
575
+ self._pattern_penalized.add(target)
576
+ self.reward_calc.apply_event(
577
+ "prior_pattern_repeated",
578
+ agent=agent_key,
579
+ )
580
+
581
+ return step_delta
582
+
583
+ def _apply_invalid_turn_penalty(self, action_type: str) -> float:
584
+ """Apply consistent malformed-action accounting across all handlers."""
585
+ self.turn += 1
586
+ self.reward_calc.apply_event("per_turn_cost")
587
+ self._last_action_type = action_type
588
+ self._pending_deflection = None
589
+ return -0.3
590
+
591
+ def _check_witness_bias(self, response_lower: str, response_flags: list[str]) -> None:
592
+ """Flag likely witness-bias implication of the innocent suspect."""
593
+ witness_knowledge = self.case["agent_knowledge"][WITNESS_1_KEY]
594
+ if not witness_knowledge.get("bias_target"):
595
+ return
596
+
597
+ bias_target = witness_knowledge["bias_target"]
598
+ # Only fire bias penalty if the bias target is the INNOCENT suspect.
599
+ if bias_target.lower() not in response_lower or bias_target == self.case["criminal"]:
600
+ return
601
+
602
+ bias_strength = witness_knowledge.get("bias_strength", 0)
603
+ if bias_strength <= 0.2:
604
+ return
605
+
606
+ scene_location = self.case["location"].lower()
607
+ bias_name = bias_target.lower()
608
+ implication_patterns = [
609
+ f"saw {bias_name}",
610
+ f"{bias_name} was",
611
+ f"{bias_name} at",
612
+ f"{bias_name} near",
613
+ f"{bias_name} did it",
614
+ f"{bias_name} committed",
615
+ ]
616
+ certainty_markers = [
617
+ "definitely", "certain", "for sure", "i'm sure", "it was",
618
+ ]
619
+ uncertainty_markers = [
620
+ "not sure", "might", "maybe", "could", "possibly",
621
+ "can't be certain", "uncertain",
622
+ ]
623
+
624
+ implies_innocent = any(p in response_lower for p in implication_patterns)
625
+ mentions_scene = (
626
+ scene_location in response_lower
627
+ or "scene" in response_lower
628
+ or "there" in response_lower
629
+ )
630
+ is_certain = any(m in response_lower for m in certainty_markers)
631
+ is_uncertain = any(m in response_lower for m in uncertainty_markers)
632
+
633
+ if implies_innocent and mentions_scene and (is_certain or "saw" in response_lower) and not is_uncertain:
634
+ self.reward_calc.apply_event("bias_driven_false_implication")
635
+ response_flags.append("POSSIBLE BIAS-DRIVEN STATEMENT")
636
+
637
+ def _handle_request_evidence(
638
+ self, parsed: dict
639
+ ) -> tuple[dict, float, bool, dict]:
640
+ item = parsed["item"].lower()
641
+ step_reward = 0.0
642
+ info: dict[str, Any] = {"action": "request_evidence", "item": item}
643
+
644
+ # Evidence request clears any pending deflection β€” it's not a follow-up
645
+ self._pending_deflection = None
646
+ self._last_action_type = "request_evidence"
647
+
648
+ self.reward_calc.apply_event("per_turn_cost")
649
+ step_reward += -0.3
650
+ self.turn += 1
651
+
652
+ if item not in EVIDENCE_ITEMS:
653
+ if self.turn >= self.MAX_TURNS:
654
+ return self._handle_timeout(step_reward)
655
+ obs = self._build_observation(
656
+ message=f"Unknown evidence item: {item}. "
657
+ f"Available: {list(EVIDENCE_ITEMS)}"
658
+ )
659
+ return obs, step_reward, False, info
660
+
661
+ # Check if this evidence was already revealed (prevent reward hacking)
662
+ already_revealed = any(
663
+ e.get("name") == item for e in self.evidence_log
664
+ )
665
+ if already_revealed:
666
+ if self.turn >= self.MAX_TURNS:
667
+ return self._handle_timeout(step_reward)
668
+ obs = self._build_observation(
669
+ message=f"Evidence '{item}' has already been revealed."
670
+ )
671
+ return obs, step_reward, False, info
672
+
673
+ # Reveal the evidence
674
+ idx = EVIDENCE_INDEX_MAP.get(item, 0)
675
+ evidence = copy.deepcopy(self.case["physical_evidence"][idx])
676
+ evidence["visible_to_detective"] = True
677
+ self.case["physical_evidence"][idx]["visible_to_detective"] = True
678
+ self.evidence_log.append(evidence)
679
+
680
+ # Check if it confirms an existing lead
681
+ points_to = evidence.get("points_to", "unknown")
682
+ if points_to != "unknown":
683
+ # Only grant reward if the detective asked evidence-relevant,
684
+ # non-general questions to the implicated suspect.
685
+ if points_to in VALID_TARGETS:
686
+ asked = self.asked_topics.get(points_to, set())
687
+ lead_topics = EVIDENCE_LEAD_TOPICS.get(item, set())
688
+ has_meaningful_lead = any(
689
+ topic != "general" and topic in lead_topics
690
+ for topic in asked
691
+ )
692
+ else:
693
+ has_meaningful_lead = False
694
+
695
+ if has_meaningful_lead:
696
+ self.reward_calc.apply_event("evidence_request_confirms_lead")
697
+ step_reward += 1.0
698
+ info["confirms_lead"] = points_to
699
+
700
+ # IMPORTANT: Do NOT reveal points_to to the detective β€” they must reason
701
+ self.conversation_history.append(
702
+ {
703
+ "turn": self.turn - 1,
704
+ "speaker": "Evidence",
705
+ "content": f"[{item}] {evidence['description']}",
706
+ "flags": [],
707
+ }
708
+ )
709
+
710
+ if self.turn >= self.MAX_TURNS:
711
+ return self._handle_timeout(step_reward)
712
+
713
+ obs = self._build_observation(
714
+ message=f"Evidence revealed: {evidence['description']}"
715
+ )
716
+ info["evidence"] = evidence
717
+ return obs, step_reward, False, info
718
+
719
+ def _handle_accuse(
720
+ self, parsed: dict
721
+ ) -> tuple[dict, float, bool, dict]:
722
+ target = parsed["target"]
723
+
724
+ if target not in VALID_ACCUSE_TARGETS:
725
+ reward = self._apply_invalid_turn_penalty("accuse")
726
+
727
+ if self.turn >= self.MAX_TURNS:
728
+ return self._handle_timeout(reward)
729
+
730
+ obs = self._build_observation(
731
+ message=(
732
+ f"Invalid accusation target: {target}. "
733
+ f"Valid: {list(VALID_ACCUSE_TARGETS)}"
734
+ )
735
+ )
736
+ return obs, reward, False, {
737
+ "action": "invalid_accuse_target",
738
+ "target": target,
739
+ }
740
+
741
+ self.done = True
742
+
743
+ criminal = self.case["criminal"]
744
+ correct = target == criminal
745
+ info: dict[str, Any] = {
746
+ "action": "accuse",
747
+ "target": target,
748
+ "correct": correct,
749
+ "actual_criminal": criminal,
750
+ }
751
+
752
+ # Check if witness bias was active
753
+ witness_knowledge = self.case["agent_knowledge"][WITNESS_1_KEY]
754
+ bias_active = (
755
+ witness_knowledge.get("bias_target") is not None
756
+ and witness_knowledge.get("bias_strength", 0) > 0.2
757
+ )
758
+
759
+ if correct:
760
+ # Correct accusation
761
+ self.reward_calc.apply_event("correct_accusation")
762
+
763
+ # Criminal caught
764
+ criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower())
765
+ self.reward_calc.apply_event(
766
+ "correctly_caught", agent=criminal_key
767
+ )
768
+
769
+ # Witness helped correctly
770
+ self.reward_calc.apply_event("helped_correct_accusation")
771
+ else:
772
+ # Wrong accusation
773
+ self.reward_calc.apply_event(
774
+ "wrong_accusation", bias_active=bias_active
775
+ )
776
+
777
+ # The real criminal escapes
778
+ criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower())
779
+ self.reward_calc.apply_event(
780
+ "escaped_not_accused", agent=criminal_key
781
+ )
782
+
783
+ # Innocent suspect was wrongly accused
784
+ innocent_key = AGENT_NAME_TO_KEY.get(target, target.lower())
785
+ self.reward_calc.apply_event(
786
+ "wrongly_accused_innocent", agent=innocent_key
787
+ )
788
+
789
+ # Witness led to wrong accusation
790
+ self.reward_calc.apply_event("led_to_wrong_accusation")
791
+
792
+ self.conversation_history.append(
793
+ {
794
+ "turn": self.turn,
795
+ "speaker": "Detective",
796
+ "content": f"ACCUSATION: {target}",
797
+ "flags": [
798
+ "βœ… CORRECT" if correct else "❌ WRONG",
799
+ ],
800
+ }
801
+ )
802
+
803
+ # Bug 1: Return only the terminal delta, not cumulative total
804
+ if correct:
805
+ terminal_delta = 10.0
806
+ else:
807
+ terminal_delta = -8.0 if not bias_active else -10.0
808
+
809
+ obs = self._build_observation(
810
+ message=(
811
+ f"Accusation: {target}. "
812
+ f"{'CORRECT!' if correct else 'WRONG!'} "
813
+ f"The criminal was {criminal}."
814
+ )
815
+ )
816
+ return obs, terminal_delta, True, info
817
+
818
+ def _handle_timeout(self, step_reward: float = 0.0) -> tuple[dict, float, bool, dict]:
819
+ """Handle the case where 15 turns pass with no accusation."""
820
+ self.done = True
821
+
822
+ self.reward_calc.apply_event("timeout_no_accusation")
823
+
824
+ # Criminal escapes
825
+ criminal = self.case["criminal"]
826
+ criminal_key = AGENT_NAME_TO_KEY.get(criminal, criminal.lower())
827
+ self.reward_calc.apply_event(
828
+ "escaped_not_accused", agent=criminal_key
829
+ )
830
+
831
+ self.conversation_history.append(
832
+ {
833
+ "turn": self.turn,
834
+ "speaker": "System",
835
+ "content": "TIMEOUT β€” No accusation made. Case goes cold.",
836
+ "flags": ["⏰ TIMEOUT"],
837
+ }
838
+ )
839
+
840
+ # Bug 3: Return terminal delta (-3.0) instead of cumulative total
841
+ timeout_penalty = -3.0
842
+ obs = self._build_observation(
843
+ message="Case timed out. No accusation was made within 15 turns."
844
+ )
845
+ return obs, step_reward + timeout_penalty, True, {"action": "timeout"}
846
+
847
+ # ── Helpers ──────────────────────────────────────────────────────────
848
+
849
+ def _build_observation(self, message: str = "") -> dict:
850
+ """Build the observation dict for the detective."""
851
+ return {
852
+ "role": "detective",
853
+ "briefing": self.case["detective_briefing"],
854
+ "turn": self.turn,
855
+ "conversation_history": list(self.conversation_history),
856
+ # Bug 10: Deep copy evidence dicts to prevent mutation of case state
857
+ "evidence_log": copy.deepcopy(self.evidence_log),
858
+ "message": message,
859
+ }
860
+
861
+ @staticmethod
862
+ def _default_llm_call(
863
+ system_prompt: str, conversation_history: list[dict]
864
+ ) -> str:
865
+ """Rule-based fallback for NPC agents.
866
+
867
+ Uses the system prompt to identify the agent and provide
868
+ role-appropriate responses that stay consistent.
869
+ Works with either suspect as the criminal.
870
+ """
871
+ # Determine role from system prompt
872
+ # Bug 5: Use 'FAKE ALIBI' to identify guilty suspect instead of 'committed'
873
+ # (detective prompt contains 'committed' too, causing misidentification)
874
+ is_suspect = "Suspect_A" in system_prompt or "Suspect_B" in system_prompt
875
+ is_guilty = "FAKE ALIBI:" in system_prompt
876
+ is_innocent = "REAL ALIBI:" in system_prompt
877
+ is_witness = ("Witness_1" in system_prompt or "Witness" in system_prompt) and not is_suspect
878
+
879
+ if is_suspect and is_guilty:
880
+ # Extract alibi from the prompt
881
+ alibi = "at a family gathering"
882
+ if "FAKE ALIBI:" in system_prompt:
883
+ alibi_line = system_prompt.split("FAKE ALIBI:")[1].split("\n")[0].strip()
884
+ if alibi_line:
885
+ alibi = alibi_line
886
+
887
+ return (
888
+ f"I was nowhere near the scene. {alibi}. "
889
+ "Check with anyone who was there β€” they'll confirm. "
890
+ "I don't see why you're asking me about this."
891
+ )
892
+
893
+ elif is_suspect and is_innocent:
894
+ # Extract alibi from the prompt
895
+ alibi = "working late at the office"
896
+ if "REAL ALIBI:" in system_prompt:
897
+ alibi_line = system_prompt.split("REAL ALIBI:")[1].split("\n")[0].strip()
898
+ if alibi_line:
899
+ alibi = alibi_line
900
+
901
+ return (
902
+ f"I can tell you exactly where I was β€” {alibi}. "
903
+ "I have receipts and witnesses to prove it. "
904
+ "I had absolutely nothing to do with this."
905
+ )
906
+
907
+ elif is_witness:
908
+ # Extract what the witness saw from the prompt
909
+ saw = "someone in dark clothing near the area"
910
+ if "WHAT YOU SAW:" in system_prompt:
911
+ saw_line = system_prompt.split("WHAT YOU SAW:")[1].split("\n")[0].strip()
912
+ if saw_line:
913
+ saw = saw_line
914
+
915
+ return (
916
+ f"I saw {saw}. "
917
+ "They were moving quickly and seemed nervous. "
918
+ "I didn't get a clear look at their face."
919
+ )
920
+
921
+ else:
922
+ return "I don't have anything else to add."
crime_env/reward_calculator.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Component 3 β€” Reward Calculator.
2
+
3
+ Calculates and accumulates rewards for all agents in the Crime Investigation
4
+ environment based on discrete events.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from crime_env.constants import SUSPECT_A_KEY, SUSPECT_B_KEY
10
+
11
+
12
+ class RewardCalculator:
13
+ """Tracks and calculates rewards for detective, suspects, and witness."""
14
+
15
+ def __init__(self) -> None:
16
+ self.rewards: dict[str, float] = {
17
+ "detective": 0.0,
18
+ "suspect_a": 0.0,
19
+ "suspect_b": 0.0,
20
+ "witness": 0.0,
21
+ }
22
+ self.event_log: list[dict] = []
23
+
24
+ # ── Public API ──────────────────────────────────────────────────────
25
+
26
+ def apply_event(self, event_type: str, **kwargs) -> float:
27
+ """Apply a reward event and return the reward delta.
28
+
29
+ Args:
30
+ event_type: One of the defined event types below.
31
+ **kwargs: Additional context (e.g. agent, bias_active, etc.)
32
+
33
+ Returns:
34
+ The reward delta applied.
35
+ """
36
+ handler = self._EVENT_MAP.get(event_type)
37
+ if handler is None:
38
+ raise ValueError(f"Unknown event type: {event_type}")
39
+
40
+ delta = handler(self, **kwargs)
41
+ self.event_log.append({"event": event_type, "delta": delta, **kwargs})
42
+ return delta
43
+
44
+ def get_rewards(self) -> dict[str, float]:
45
+ """Return current cumulative rewards for all agents."""
46
+ return {k: round(v, 4) for k, v in self.rewards.items()}
47
+
48
+ def get_event_log(self) -> list[dict]:
49
+ """Return the full event log."""
50
+ return list(self.event_log)
51
+
52
+ # ── Detective events ────────────────────────────────────────────────
53
+
54
+ def _correct_accusation(self, **kwargs) -> float:
55
+ self.rewards["detective"] += 10.0
56
+ return 10.0
57
+
58
+ def _wrong_accusation(self, **kwargs) -> float:
59
+ delta = -8.0
60
+ # Extra penalty if witness bias was active
61
+ if kwargs.get("bias_active", False):
62
+ delta -= 2.0
63
+ self.rewards["detective"] += delta
64
+ return delta
65
+
66
+ def _timeout_no_accusation(self, **kwargs) -> float:
67
+ self.rewards["detective"] += -3.0
68
+ return -3.0
69
+
70
+ def _contradiction_exposed(self, **kwargs) -> float:
71
+ self.rewards["detective"] += 2.0
72
+ return 2.0
73
+
74
+ def _prior_pattern_exploited(self, **kwargs) -> float:
75
+ self.rewards["detective"] += 1.5
76
+ return 1.5
77
+
78
+ def _deflection_resistance(self, **kwargs) -> float:
79
+ self.rewards["detective"] += 0.5
80
+ return 0.5
81
+
82
+ def _evidence_request_confirms_lead(self, **kwargs) -> float:
83
+ self.rewards["detective"] += 1.0
84
+ return 1.0
85
+
86
+ def _redundant_question(self, **kwargs) -> float:
87
+ self.rewards["detective"] += -0.5
88
+ return -0.5
89
+
90
+ def _per_turn_cost(self, **kwargs) -> float:
91
+ self.rewards["detective"] += -0.3
92
+ return -0.3
93
+
94
+ # ── Suspect events ──────────────────────────────────────────────────
95
+
96
+ def _escaped_not_accused(self, **kwargs) -> float:
97
+ agent = kwargs.get("agent", SUSPECT_A_KEY)
98
+ self.rewards[agent] += 10.0
99
+ return 10.0
100
+
101
+ def _correctly_caught(self, **kwargs) -> float:
102
+ agent = kwargs.get("agent", SUSPECT_A_KEY)
103
+ self.rewards[agent] += -10.0
104
+ return -10.0
105
+
106
+ def _wrongly_accused_innocent(self, **kwargs) -> float:
107
+ agent = kwargs.get("agent", SUSPECT_B_KEY)
108
+ self.rewards[agent] += -6.0
109
+ return -6.0
110
+
111
+ def _suspect_self_contradiction(self, **kwargs) -> float:
112
+ agent = kwargs.get("agent", SUSPECT_A_KEY)
113
+ self.rewards[agent] += -3.0
114
+ return -3.0
115
+
116
+ def _confirmed_lie_vs_evidence(self, **kwargs) -> float:
117
+ agent = kwargs.get("agent", SUSPECT_A_KEY)
118
+ self.rewards[agent] += -1.0
119
+ # Detective is rewarded for catching the lie
120
+ self.rewards["detective"] += 1.0
121
+ # Return suspect penalty for event log (detective gain logged separately)
122
+ return -1.0 # suspect delta; detective delta is +1.0 (applied above)
123
+
124
+ def _successful_deflection(self, **kwargs) -> float:
125
+ agent = kwargs.get("agent", SUSPECT_A_KEY)
126
+ self.rewards[agent] += 1.0
127
+ return 1.0
128
+
129
+ def _prior_pattern_repeated(self, **kwargs) -> float:
130
+ agent = kwargs.get("agent", SUSPECT_A_KEY)
131
+ self.rewards[agent] += -1.0
132
+ return -1.0
133
+
134
+ # ── Witness events ──────────────────────────────────────────────────
135
+
136
+ def _accurate_statement(self, **kwargs) -> float:
137
+ self.rewards["witness"] += 2.0
138
+ return 2.0
139
+
140
+ def _helped_correct_accusation(self, **kwargs) -> float:
141
+ self.rewards["witness"] += 3.0
142
+ return 3.0
143
+
144
+ def _led_to_wrong_accusation(self, **kwargs) -> float:
145
+ self.rewards["witness"] += -4.0
146
+ return -4.0
147
+
148
+ def _bias_driven_false_implication(self, **kwargs) -> float:
149
+ self.rewards["witness"] += -2.0
150
+ return -2.0
151
+
152
+ def _witness_self_contradiction(self, **kwargs) -> float:
153
+ self.rewards["witness"] += -2.0
154
+ return -2.0
155
+
156
+ # ── Event dispatch map ──────────────────────────────────────────────
157
+
158
+ _EVENT_MAP = {
159
+ # Detective
160
+ "correct_accusation": _correct_accusation,
161
+ "wrong_accusation": _wrong_accusation,
162
+ "timeout_no_accusation": _timeout_no_accusation,
163
+ "contradiction_exposed": _contradiction_exposed,
164
+ "prior_pattern_exploited": _prior_pattern_exploited,
165
+ "evidence_request_confirms_lead": _evidence_request_confirms_lead,
166
+ "redundant_question": _redundant_question,
167
+ "per_turn_cost": _per_turn_cost,
168
+ "deflection_resistance": _deflection_resistance,
169
+ # Suspect
170
+ "escaped_not_accused": _escaped_not_accused,
171
+ "correctly_caught": _correctly_caught,
172
+ "wrongly_accused_innocent": _wrongly_accused_innocent,
173
+ "suspect_self_contradiction": _suspect_self_contradiction,
174
+ "confirmed_lie_vs_evidence": _confirmed_lie_vs_evidence,
175
+ "successful_deflection": _successful_deflection,
176
+ "prior_pattern_repeated": _prior_pattern_repeated,
177
+ # Witness
178
+ "accurate_statement": _accurate_statement,
179
+ "helped_correct_accusation": _helped_correct_accusation,
180
+ "led_to_wrong_accusation": _led_to_wrong_accusation,
181
+ "bias_driven_false_implication": _bias_driven_false_implication,
182
+ "witness_self_contradiction": _witness_self_contradiction,
183
+ }
dashboard.html ADDED
@@ -0,0 +1,1404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
7
+ <meta http-equiv="Pragma" content="no-cache">
8
+ <meta http-equiv="Expires" content="0">
9
+ <title>AI Crime Investigation β€” Live Dashboard</title>
10
+ <meta name="description" content="Real-time dashboard for the AI Crime Investigation World multi-agent RL environment">
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
13
+ <style>
14
+ /* ── Reset & Variables ─────────────────────────────── */
15
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
16
+
17
+ :root {
18
+ --bg-primary: #FDFCF9;
19
+ --bg-secondary: #FFFFFF;
20
+ --bg-card: #FFFFFF;
21
+ --bg-card-hover: #F8F7F4;
22
+ --bg-panel: #FDFCF9;
23
+ --border: #000000;
24
+ --border-glow: #000000;
25
+ --text-primary: #000000;
26
+ --text-secondary: #000000;
27
+ --text-muted: #444444;
28
+
29
+ --accent-teal: #65A3BB;
30
+ --accent-yellow: #F4CC64;
31
+ --accent-orange: #F5A972;
32
+ --accent-red: #F25D68;
33
+
34
+ --accent-blue: #65A3BB;
35
+ --accent-cyan: #F4CC64;
36
+ --accent-amber: #F5A972;
37
+ --accent-green: #F4CC64;
38
+ --accent-purple: #F5A972;
39
+
40
+ --gradient-reward: linear-gradient(90deg, #F25D68, #F5A972, #65A3BB);
41
+ --shadow-glow: 4px 4px 0px #000000;
42
+ --font-mono: 'JetBrains Mono', monospace;
43
+ --font-sans: 'Inter', system-ui, sans-serif;
44
+ --radius: 0px;
45
+ --radius-sm: 0px;
46
+ }
47
+
48
+ html { font-size: 15px; }
49
+
50
+ body {
51
+ font-family: var(--font-sans);
52
+ background: var(--bg-primary);
53
+ color: var(--text-primary);
54
+ min-height: 100vh;
55
+ overflow-x: hidden;
56
+ background-image: radial-gradient(rgba(0, 0, 0, 0.2) 2px, transparent 2px);
57
+ background-size: 20px 20px;
58
+ }
59
+
60
+ /* ── Layout ───────────────────────────────────────── */
61
+ .app { position: relative; z-index: 1; }
62
+
63
+ .topbar {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ padding: 14px 28px;
68
+ background: var(--accent-teal);
69
+ border-bottom: 3px solid #000;
70
+ position: sticky;
71
+ top: 0;
72
+ z-index: 100;
73
+ }
74
+
75
+ .topbar-left {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 14px;
79
+ }
80
+
81
+ .logo {
82
+ font-size: 1.15rem;
83
+ font-weight: 800;
84
+ color: var(--text-primary);
85
+ letter-spacing: -0.02em;
86
+ text-transform: uppercase;
87
+ }
88
+
89
+ .logo-icon { font-size: 1.3rem; }
90
+
91
+ .status-badge {
92
+ background: var(--bg-card); border: 2px solid #000; color: #000 !important; box-shadow: 2px 2px 0px #000;
93
+ display: inline-flex;
94
+ align-items: center;
95
+ gap: 6px;
96
+ padding: 4px 12px;
97
+ background: var(--bg-card);
98
+ border: 3px solid #000;
99
+ font-size: 0.72rem;
100
+ font-weight: 800;
101
+ color: var(--text-primary) !important;
102
+ text-transform: uppercase;
103
+ letter-spacing: 0.05em;
104
+ box-shadow: 2px 2px 0px #000;
105
+ }
106
+
107
+ .status-dot {
108
+ box-shadow: 0 0 8px var(--accent-green); border: 3px solid #000; border-radius: 50%;
109
+ width: 8px;
110
+ height: 8px;
111
+ border-radius: 0px;
112
+ border: 3px solid #000;
113
+ background: var(--accent-green);
114
+ }
115
+
116
+ .topbar-right {
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 12px;
120
+ }
121
+
122
+ .btn {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ gap: 6px;
126
+ padding: 8px 16px;
127
+ border: 3px solid #000;
128
+ background: var(--bg-card);
129
+ color: var(--text-primary);
130
+ font-family: var(--font-sans);
131
+ font-size: 0.8rem;
132
+ font-weight: 800;
133
+ border-radius: 0px;
134
+ cursor: pointer;
135
+ box-shadow: 3px 3px 0px #000;
136
+ transition: all 0.1s;
137
+ }
138
+
139
+ .btn:hover {
140
+ transform: translate(-2px, -2px);
141
+ box-shadow: 5px 5px 0px #000;
142
+ }
143
+
144
+ .btn:active {
145
+ transform: translate(2px, 2px);
146
+ box-shadow: 1px 1px 0px #000;
147
+ }
148
+
149
+ .btn-primary {
150
+ background: var(--accent-blue);
151
+ color: #000;
152
+ }
153
+
154
+ .btn-danger {
155
+ background: var(--accent-red);
156
+ color: #000;
157
+ }
158
+
159
+ /* ── Stats Strip ──────────────────────────────────── */
160
+ .stats-strip {
161
+ display: grid;
162
+ grid-template-columns: repeat(5, 1fr);
163
+ gap: 12px;
164
+ padding: 16px 28px;
165
+ background: var(--bg-primary);
166
+ border-bottom: 3px solid #000;
167
+ }
168
+
169
+ .stat-card {
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 12px;
173
+ padding: 12px 16px;
174
+ background: var(--bg-card);
175
+ border: 3px solid #000;
176
+ border-radius: 0px;
177
+ box-shadow: 4px 4px 0px #000;
178
+ transition: all 0.1s;
179
+ }
180
+
181
+ .stat-card:hover {
182
+ transform: translate(-2px, -2px);
183
+ box-shadow: 5px 5px 0px #000;
184
+ }
185
+
186
+ .stat-icon {
187
+ width: 38px;
188
+ height: 38px;
189
+ border-radius: 0px;
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ font-size: 1.1rem;
194
+ border: 3px solid #000;
195
+ background: var(--bg-primary);
196
+ }
197
+
198
+ .stat-icon.turns { background: var(--accent-blue); }
199
+ .stat-icon.evidence { background: var(--accent-cyan); }
200
+ .stat-icon.contradictions { background: var(--accent-amber); }
201
+ .stat-icon.reward { background: var(--accent-green); }
202
+ .stat-icon.result { background: var(--accent-teal); }
203
+
204
+ .stat-info { display: flex; flex-direction: column; }
205
+
206
+ .stat-label {
207
+ font-size: 0.72rem;
208
+ color: var(--text-primary);
209
+ text-transform: uppercase;
210
+ letter-spacing: 0.06em;
211
+ font-weight: 800;
212
+ }
213
+
214
+ .stat-value {
215
+ font-family: var(--font-mono);
216
+ font-size: 1.25rem;
217
+ font-weight: 800;
218
+ color: var(--text-primary);
219
+ }
220
+
221
+ .stat-value.positive { color: #16a34a; }
222
+ .stat-value.negative { color: #dc2626; }
223
+ .stat-value.neutral { color: var(--text-primary); }
224
+
225
+ /* ── Main Grid ────────────────────────────────────── */
226
+ .main-grid {
227
+ display: grid;
228
+ grid-template-columns: 1fr 340px;
229
+ gap: 16px;
230
+ padding: 16px 28px 28px;
231
+ min-height: calc(100vh - 160px);
232
+ }
233
+
234
+ /* ── Panel ────────────────────────────────────────── */
235
+ .panel {
236
+ background: var(--bg-panel);
237
+ border: 3px solid #000;
238
+ border-radius: 0px;
239
+ box-shadow: 5px 5px 0px #000;
240
+ overflow: hidden;
241
+ display: flex;
242
+ flex-direction: column;
243
+ margin-bottom: 4px;
244
+ }
245
+
246
+ .panel-header {
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: space-between;
250
+ padding: 14px 18px;
251
+ border-bottom: 3px solid #000;
252
+ background: var(--bg-card);
253
+ }
254
+
255
+ .panel-title {
256
+ font-size: 0.82rem;
257
+ font-weight: 700;
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 8px;
261
+ text-transform: uppercase;
262
+ letter-spacing: 0.04em;
263
+ color: var(--text-secondary);
264
+ }
265
+
266
+ .panel-body {
267
+ padding: 14px 18px;
268
+ background: var(--bg-panel);
269
+ flex: 1;
270
+ overflow-y: auto;
271
+ }
272
+
273
+ /* ── Conversation Log ─────────────────────────────── */
274
+ .conversation-panel {
275
+ max-height: calc(100vh - 230px);
276
+ }
277
+
278
+ .conversation-panel .panel-body {
279
+ display: flex;
280
+ flex-direction: column;
281
+ gap: 10px;
282
+ padding-bottom: 16px;
283
+ }
284
+
285
+ .msg {
286
+ display: flex;
287
+ gap: 10px;
288
+ padding: 12px 14px;
289
+ border-radius: 0px;
290
+ border: 2px solid transparent;
291
+ background: transparent;
292
+ border-bottom: 2px solid rgba(0,0,0,0.05);
293
+ animation: msg-in 0.35s ease-out;
294
+ transition: all 0.2s;
295
+ }
296
+
297
+ .msg:hover {
298
+ background: rgba(0,0,0,0.02);
299
+ }
300
+
301
+ @keyframes msg-in {
302
+ from { opacity: 0; transform: translateY(8px); }
303
+ to { opacity: 1; transform: translateY(0); }
304
+ }
305
+
306
+ .msg-avatar {
307
+ width: 38px;
308
+ height: 38px;
309
+ border-radius: 0px;
310
+ border: 3px solid #000;
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: center;
314
+ font-size: 1.1rem;
315
+ flex-shrink: 0;
316
+ font-weight: 800;
317
+ color: #000;
318
+ box-shadow: 2px 2px 0px #000;
319
+ }
320
+
321
+ .msg-avatar.detective { background: var(--accent-blue); }
322
+ .msg-avatar.suspect-a { background: var(--accent-red); }
323
+ .msg-avatar.suspect-b { background: var(--accent-orange); }
324
+ .msg-avatar.witness { background: var(--accent-purple); }
325
+ .msg-avatar.system { background: #e2e8f0; }
326
+
327
+ .msg-content { flex: 1; min-width: 0; }
328
+
329
+ .msg-header {
330
+ display: flex;
331
+ align-items: center;
332
+ gap: 12px;
333
+ margin-bottom: 6px;
334
+ }
335
+
336
+ .msg-name {
337
+ font-weight: 800;
338
+ font-size: 0.8rem;
339
+ text-transform: uppercase;
340
+ letter-spacing: 0.05em;
341
+ }
342
+
343
+ .msg-name.detective { color: var(--accent-blue); }
344
+ .msg-name.suspect-a { color: var(--accent-red); }
345
+ .msg-name.suspect-b { color: var(--accent-orange); }
346
+ .msg-name.witness { color: var(--accent-purple); }
347
+ .msg-name.system { color: var(--text-muted); }
348
+
349
+ .msg-turn {
350
+ font-size: 0.65rem;
351
+ color: var(--text-muted);
352
+ font-weight: 600;
353
+ }
354
+
355
+ .msg-text {
356
+ font-size: 1rem;
357
+ line-height: 1.5;
358
+ color: var(--text-secondary);
359
+ }
360
+
361
+ .msg-tag {
362
+ display: inline-block;
363
+ padding: 4px 10px;
364
+ border-radius: 0px;
365
+ border: 2px solid #000;
366
+ font-size: 0.65rem;
367
+ font-weight: 800;
368
+ text-transform: uppercase;
369
+ letter-spacing: 0.04em;
370
+ margin-top: 8px;
371
+ box-shadow: 2px 2px 0px #000;
372
+ }
373
+
374
+ .msg-tag.evidence { background: var(--accent-cyan); color: #000; }
375
+ .msg-tag.contradiction { background: var(--accent-red); color: #000; }
376
+ .msg-tag.accusation { background: var(--accent-green); color: #000; }
377
+
378
+ /* ── Sidebar ──────────────────────────────────────── */
379
+ .sidebar {
380
+ display: flex;
381
+ flex-direction: column;
382
+ gap: 16px;
383
+ }
384
+
385
+ /* ── Reward Meter ─────────────────────────────────── */
386
+ .reward-meter {
387
+ padding: 18px;
388
+ }
389
+
390
+ .reward-bar-track {
391
+ width: 100%;
392
+ height: 12px;
393
+ background: rgba(0, 0, 0, 0.05);
394
+ border: 2px solid #000;
395
+ border-radius: 0px;
396
+ overflow: hidden;
397
+ margin: 12px 0 8px;
398
+ }
399
+
400
+ .reward-bar-fill {
401
+ height: 100%;
402
+ border-radius: 0px;
403
+ background: var(--gradient-reward);
404
+ width: 0%;
405
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
406
+ }
407
+
408
+ .reward-labels {
409
+ display: flex;
410
+ justify-content: space-between;
411
+ font-size: 0.72rem;
412
+ color: var(--text-muted);
413
+ font-weight: 700;
414
+ }
415
+
416
+ .reward-detail {
417
+ display: flex;
418
+ justify-content: space-between;
419
+ align-items: center;
420
+ padding: 8px 0;
421
+ border-bottom: 2px solid rgba(0, 0, 0, 0.05);
422
+ font-size: 0.8rem;
423
+ }
424
+
425
+ .reward-detail:last-child { border-bottom: none; }
426
+
427
+ .reward-detail-label { color: var(--text-muted); font-weight: 600; }
428
+
429
+ .reward-detail-value {
430
+ font-family: var(--font-mono);
431
+ font-weight: 700;
432
+ }
433
+
434
+ /* ── Evidence Panel ───────────────────────────────── */
435
+ .evidence-list {
436
+ display: flex;
437
+ flex-direction: column;
438
+ gap: 12px;
439
+ }
440
+
441
+ .evidence-item {
442
+ display: flex;
443
+ align-items: flex-start;
444
+ gap: 12px;
445
+ padding: 12px 14px;
446
+ background: var(--bg-card);
447
+ border: 3px solid #000;
448
+ border-radius: 0px;
449
+ box-shadow: 4px 4px 0px #000;
450
+ transition: all 0.2s;
451
+ position: relative;
452
+ }
453
+
454
+ .evidence-item::before {
455
+ border-top-left-radius: var(--radius-sm); border-bottom-left-radius: var(--radius-sm);
456
+ content: '';
457
+ position: absolute;
458
+ left: -3px; top: -3px; bottom: -3px;
459
+ width: 10px;
460
+ background: var(--text-muted);
461
+ border-right: 3px solid #000;
462
+ }
463
+
464
+ .evidence-item.revealed { background: var(--accent-yellow); border: 3px solid #000; box-shadow: 4px 4px 0px #000; color: #000; }
465
+
466
+ .evidence-item.revealed::before { background: var(--bg-primary); border-right: 3px solid #000; }
467
+
468
+ .evidence-item.locked { opacity: 0.5; background: var(--bg-card); }
469
+
470
+ .evidence-item.locked::before {
471
+ background: #94a3b8;
472
+ }
473
+
474
+ .evidence-icon {
475
+ font-size: 1.1rem;
476
+ flex-shrink: 0;
477
+ margin-top: 2px;
478
+ }
479
+
480
+ .evidence-info { flex: 1; }
481
+
482
+ .evidence-name {
483
+ font-size: 0.85rem;
484
+ font-weight: 600;
485
+ color: var(--text-primary);
486
+ margin-bottom: 3px;
487
+ }
488
+
489
+ .evidence-desc {
490
+ font-size: 0.72rem;
491
+ color: var(--text-muted);
492
+ line-height: 1.4;
493
+ }
494
+
495
+ /* ── Contradiction Alerts ─────────────────────────── */
496
+ .alert-list {
497
+ display: flex;
498
+ flex-direction: column;
499
+ gap: 8px;
500
+ }
501
+
502
+ .alert-item {
503
+ padding: 12px 14px;
504
+ background: var(--bg-card);
505
+ border: 3px solid #000;
506
+ border-left: 10px solid var(--accent-red);
507
+ border-radius: 0px;
508
+ box-shadow: 4px 4px 0px #000;
509
+ animation: alert-flash 0.5s ease-out;
510
+ }
511
+
512
+ @keyframes alert-flash {
513
+ 0% { transform: translateX(10px); }
514
+ 100% { transform: translateX(0); }
515
+ }
516
+
517
+ .alert-suspect {
518
+ font-size: 0.72rem;
519
+ font-weight: 700;
520
+ color: var(--accent-red);
521
+ margin-bottom: 4px;
522
+ }
523
+
524
+ .alert-detail {
525
+ font-size: 0.72rem;
526
+ color: var(--text-muted);
527
+ line-height: 1.4;
528
+ }
529
+
530
+ .alert-turns {
531
+ font-size: 0.65rem;
532
+ color: var(--text-muted);
533
+ margin-top: 4px;
534
+ font-weight: 600;
535
+ }
536
+
537
+ /* ── Reward History Chart ─────────────────────────── */
538
+ .chart-container {
539
+ height: 200px;
540
+ position: relative;
541
+ }
542
+
543
+ .training-metrics {
544
+ display: grid;
545
+ grid-template-columns: repeat(2, 1fr);
546
+ gap: 8px;
547
+ margin-bottom: 10px;
548
+ }
549
+
550
+ .training-metric {
551
+ border: 2px solid #000;
552
+ background: var(--bg-card);
553
+ padding: 8px;
554
+ box-shadow: 2px 2px 0px #000;
555
+ }
556
+
557
+ .training-metric-label {
558
+ font-size: 0.66rem;
559
+ text-transform: uppercase;
560
+ letter-spacing: 0.05em;
561
+ font-weight: 800;
562
+ color: var(--text-muted);
563
+ }
564
+
565
+ .training-metric-value {
566
+ font-family: var(--font-mono);
567
+ font-weight: 800;
568
+ margin-top: 2px;
569
+ font-size: 0.9rem;
570
+ }
571
+
572
+ .chart-canvas {
573
+ width: 100%;
574
+ height: 100%;
575
+ }
576
+
577
+ /* ── Empty State ──────────────────────────────────── */
578
+ .empty-state {
579
+ text-align: center;
580
+ padding: 30px 16px;
581
+ color: var(--text-muted);
582
+ }
583
+
584
+ .empty-icon { font-size: 2rem; margin-bottom: 8px; }
585
+
586
+ .empty-text {
587
+ font-size: 0.78rem;
588
+ line-height: 1.5;
589
+ }
590
+
591
+ /* ── Scrollbar ────────────────────────────────────── */
592
+ ::-webkit-scrollbar { width: 5px; }
593
+ ::-webkit-scrollbar-track { background: transparent; }
594
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
595
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
596
+
597
+ /* ── Responsive ───────────────────────────────────── */
598
+ @media (max-width: 1100px) {
599
+ .main-grid { grid-template-columns: 1fr; }
600
+ .stats-strip { grid-template-columns: repeat(3, 1fr); }
601
+ }
602
+
603
+ @media (max-width: 700px) {
604
+ .stats-strip { grid-template-columns: 1fr 1fr; }
605
+ .topbar { padding: 12px 16px; }
606
+ .main-grid { padding: 12px 16px; }
607
+ }
608
+
609
+ /* ── Outcome overlay ──────────────────────────────── */
610
+ .outcome-overlay {
611
+ position: fixed;
612
+ inset: 0;
613
+ background: rgba(253, 252, 249, 0.85);
614
+ backdrop-filter: blur(8px);
615
+ display: flex;
616
+ align-items: center;
617
+ justify-content: center;
618
+ z-index: 200;
619
+ opacity: 0;
620
+ pointer-events: none;
621
+ transition: opacity 0.4s;
622
+ }
623
+
624
+ .outcome-overlay.visible {
625
+ opacity: 1;
626
+ pointer-events: auto;
627
+ }
628
+
629
+ .outcome-card {
630
+ background: var(--bg-card);
631
+ border: 3px solid #000;
632
+ border-radius: 0px;
633
+ padding: 40px 50px;
634
+ text-align: center;
635
+ transform: scale(0.9);
636
+ box-shadow: 8px 8px 0px #000;
637
+ transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
638
+ max-width: 420px;
639
+ }
640
+
641
+ .outcome-overlay.visible .outcome-card {
642
+ transform: scale(1);
643
+ }
644
+
645
+ .outcome-icon { font-size: 3.5rem; margin-bottom: 12px; }
646
+
647
+ .outcome-title {
648
+ font-size: 1.6rem;
649
+ font-weight: 700;
650
+ margin-bottom: 6px;
651
+ }
652
+
653
+ .outcome-subtitle {
654
+ font-size: 0.9rem;
655
+ color: var(--text-muted);
656
+ margin-bottom: 20px;
657
+ }
658
+
659
+ .outcome-stat {
660
+ font-family: var(--font-mono);
661
+ font-size: 2rem;
662
+ font-weight: 700;
663
+ margin-bottom: 20px;
664
+ }
665
+
666
+ .outcome-stat.win { color: var(--accent-green); }
667
+ .outcome-stat.lose { color: var(--accent-red); }
668
+ </style>
669
+ </head>
670
+ <body>
671
+ <div class="app" id="app">
672
+
673
+ <!-- ── Topbar ──────────────────────────────────────── -->
674
+ <header class="topbar">
675
+ <div class="topbar-left">
676
+ <span class="logo-icon">πŸ”</span>
677
+ <span class="logo">Crime Investigation World</span>
678
+ <div class="status-badge" id="statusBadge">
679
+ <span class="status-dot"></span>
680
+ <span id="statusText">Ready</span>
681
+ </div>
682
+ </div>
683
+ <div class="topbar-right">
684
+ <span id="episodeLabel" style="font-size:0.75rem;color:var(--text-muted);font-family:var(--font-mono);">Episode β€”</span>
685
+ <button class="btn btn-primary" id="btnRunLive" onclick="runLiveAgent()" style="background: linear-gradient(135deg, #10b981, #059669);">πŸš€ Run Live Server</button>
686
+ <button class="btn btn-danger" id="btnReset" onclick="resetBoard()" style="display:none;">β†Ί Reset</button>
687
+ </div>
688
+ </header>
689
+
690
+ <!-- ── Stats Strip ─────────────────────────────────── -->
691
+ <div class="stats-strip">
692
+ <div class="stat-card">
693
+ <div class="stat-icon turns">πŸ•</div>
694
+ <div class="stat-info">
695
+ <span class="stat-label">Turn</span>
696
+ <span class="stat-value" id="statTurn">0 / 15</span>
697
+ </div>
698
+ </div>
699
+ <div class="stat-card">
700
+ <div class="stat-icon evidence">πŸ”¬</div>
701
+ <div class="stat-info">
702
+ <span class="stat-label">Evidence</span>
703
+ <span class="stat-value" id="statEvidence">0 / 3</span>
704
+ </div>
705
+ </div>
706
+ <div class="stat-card">
707
+ <div class="stat-icon contradictions">⚑</div>
708
+ <div class="stat-info">
709
+ <span class="stat-label">Contradictions</span>
710
+ <span class="stat-value" id="statContra">0</span>
711
+ </div>
712
+ </div>
713
+ <div class="stat-card">
714
+ <div class="stat-icon reward">πŸ’Ž</div>
715
+ <div class="stat-info">
716
+ <span class="stat-label">Reward</span>
717
+ <span class="stat-value" id="statReward">0.00</span>
718
+ </div>
719
+ </div>
720
+ <div class="stat-card">
721
+ <div class="stat-icon result">🎯</div>
722
+ <div class="stat-info">
723
+ <span class="stat-label">Outcome</span>
724
+ <span class="stat-value neutral" id="statOutcome">β€”</span>
725
+ </div>
726
+ </div>
727
+ </div>
728
+
729
+ <!-- ── Main Content ────────────────────────────────── -->
730
+ <div class="main-grid">
731
+
732
+ <!-- Conversation Log -->
733
+ <div class="panel conversation-panel">
734
+ <div class="panel-header">
735
+ <span class="panel-title">πŸ’¬ Interrogation Log</span>
736
+ <span id="msgCount" style="font-size:0.7rem;color:var(--text-muted);font-family:var(--font-mono);">0 messages</span>
737
+ </div>
738
+ <div class="panel-body" id="conversationLog">
739
+ <div class="empty-state">
740
+ <div class="empty-icon">πŸ•΅οΈ</div>
741
+ <div class="empty-text">Click <strong>Run Live Server</strong> to watch a full<br>investigation episode unfold live.</div>
742
+ </div>
743
+ </div>
744
+ </div>
745
+
746
+ <!-- Sidebar -->
747
+ <div class="sidebar">
748
+
749
+ <!-- Reward Meter -->
750
+ <div class="panel">
751
+ <div class="panel-header">
752
+ <span class="panel-title">πŸ“Š Reward Breakdown</span>
753
+ </div>
754
+ <div class="panel-body reward-meter">
755
+ <div class="reward-bar-track">
756
+ <div class="reward-bar-fill" id="rewardBar"></div>
757
+ </div>
758
+ <div class="reward-labels">
759
+ <span>-10</span>
760
+ <span>0</span>
761
+ <span>+10</span>
762
+ </div>
763
+ <div style="margin-top:12px;" id="rewardBreakdown">
764
+ <div class="reward-detail">
765
+ <span class="reward-detail-label">Base</span>
766
+ <span class="reward-detail-value" id="rwBase">0.00</span>
767
+ </div>
768
+ <div class="reward-detail">
769
+ <span class="reward-detail-label">Turn Cost</span>
770
+ <span class="reward-detail-value" id="rwTurnCost" style="color:var(--accent-red);">0.00</span>
771
+ </div>
772
+ <div class="reward-detail">
773
+ <span class="reward-detail-label">Contradiction Bonus</span>
774
+ <span class="reward-detail-value" id="rwContra" style="color:var(--accent-amber);">0.00</span>
775
+ </div>
776
+ <div class="reward-detail">
777
+ <span class="reward-detail-label">Evidence Bonus</span>
778
+ <span class="reward-detail-value" id="rwEvidence" style="color:var(--accent-cyan);">0.00</span>
779
+ </div>
780
+ <div class="reward-detail">
781
+ <span class="reward-detail-label"><strong>Total</strong></span>
782
+ <span class="reward-detail-value" id="rwTotal" style="font-size:1.05rem;">0.00</span>
783
+ </div>
784
+ </div>
785
+ </div>
786
+ </div>
787
+
788
+ <!-- Evidence Panel -->
789
+ <div class="panel">
790
+ <div class="panel-header">
791
+ <span class="panel-title">πŸ”¬ Evidence</span>
792
+ </div>
793
+ <div class="panel-body">
794
+ <div class="evidence-list" id="evidenceList">
795
+ <div class="empty-state" id="evidenceEmptyState" style="padding:14px;">
796
+ <div class="empty-text" style="font-size:0.72rem;">No evidence revealed yet.</div>
797
+ </div>
798
+ </div>
799
+ </div>
800
+ </div>
801
+
802
+ <!-- Contradiction Alerts -->
803
+ <div class="panel">
804
+ <div class="panel-header">
805
+ <span class="panel-title">⚑ Contradiction Alerts</span>
806
+ </div>
807
+ <div class="panel-body">
808
+ <div class="alert-list" id="alertList">
809
+ <div class="empty-state" style="padding:14px;">
810
+ <div class="empty-text" style="font-size:0.72rem;">No contradictions detected yet.</div>
811
+ </div>
812
+ </div>
813
+ </div>
814
+ </div>
815
+
816
+ <!-- Reward History -->
817
+ <div class="panel" id="trainingPanel">
818
+ <div class="panel-header">
819
+ <span class="panel-title">πŸ“ˆ Training Progress</span>
820
+ </div>
821
+ <div class="panel-body">
822
+ <div class="training-metrics">
823
+ <div class="training-metric">
824
+ <div class="training-metric-label">Episodes</div>
825
+ <div class="training-metric-value" id="trainEpisodes">-</div>
826
+ </div>
827
+ <div class="training-metric">
828
+ <div class="training-metric-label">Model</div>
829
+ <div class="training-metric-value" id="trainModel" style="font-size:0.72rem;">-</div>
830
+ </div>
831
+ <div class="training-metric">
832
+ <div class="training-metric-label">First-50 Mean</div>
833
+ <div class="training-metric-value" id="trainFirst">-</div>
834
+ </div>
835
+ <div class="training-metric">
836
+ <div class="training-metric-label">Last-50 Mean</div>
837
+ <div class="training-metric-value" id="trainLast">-</div>
838
+ </div>
839
+ </div>
840
+ <div style="font-size:0.72rem; font-weight:800; margin-bottom:8px;" id="trainImprovement">Improvement: -</div>
841
+ <div class="chart-container">
842
+ <canvas class="chart-canvas" id="trainingRewardChart"></canvas>
843
+ </div>
844
+ </div>
845
+ </div>
846
+
847
+ <div class="panel" id="historyPanel" style="display:none;">
848
+ <div class="panel-header">
849
+ <span class="panel-title">πŸ“ˆ Reward History</span>
850
+ </div>
851
+ <div class="panel-body">
852
+ <div class="chart-container">
853
+ <canvas class="chart-canvas" id="rewardChart"></canvas>
854
+ </div>
855
+ </div>
856
+ </div>
857
+
858
+ </div>
859
+ </div>
860
+
861
+ <!-- ── Outcome Overlay ─────────────────────────────── -->
862
+ <div class="outcome-overlay" id="outcomeOverlay">
863
+ <div class="outcome-card">
864
+ <div class="outcome-icon" id="outcomeIcon">πŸŽ‰</div>
865
+ <div class="outcome-title" id="outcomeTitle">Case Solved!</div>
866
+ <div class="outcome-subtitle" id="outcomeSub">The detective correctly identified the criminal.</div>
867
+ <div class="outcome-stat win" id="outcomeStat">+8.50</div>
868
+ <button class="btn btn-primary" onclick="dismissOutcome()">Continue</button>
869
+ </div>
870
+ </div>
871
+
872
+ </div>
873
+
874
+ <script>
875
+
876
+ const EVIDENCE_ICONS = {
877
+ keycard_log: "πŸ“‡",
878
+ cctv_footage: "πŸ“Ή",
879
+ forensic_report: "πŸ”¬",
880
+ };
881
+
882
+ const EVIDENCE_NAMES = {
883
+ keycard_log: "Keycard Log",
884
+ cctv_footage: "CCTV Footage",
885
+ forensic_report: "Forensic Report",
886
+ };
887
+
888
+ // ── State ────────────────────────────────────────────────────────────────────
889
+
890
+ let demoRunning = false;
891
+ let currentTurn = 0;
892
+ let evidenceCount = 0;
893
+ let contradictionCount = 0;
894
+ let totalReward = 0;
895
+ let rewardHistory = [];
896
+ let messageCount = 0;
897
+ let revealedEvidenceKeys = new Set();
898
+ let trainingRewards = [];
899
+ let trainingSmoothed = [];
900
+
901
+ const rewardComponents = {
902
+ base: 0,
903
+ turnCost: 0,
904
+ contradictions: 0,
905
+ evidence: 0,
906
+ total: 0,
907
+ };
908
+
909
+ // ── Rendering ────────────────────────────────────────────────────────────────
910
+
911
+ function getAvatarClass(speaker) {
912
+ if (speaker === "Detective") return "detective";
913
+ if (speaker === "Suspect_A") return "suspect-a";
914
+ if (speaker === "Suspect_B") return "suspect-b";
915
+ if (speaker === "Witness_1") return "witness";
916
+ return "system";
917
+ }
918
+
919
+ function getAvatarEmoji(speaker) {
920
+ if (speaker === "Detective") return "πŸ”";
921
+ if (speaker === "Suspect_A") return "πŸ…°οΈ";
922
+ if (speaker === "Suspect_B") return "πŸ…±οΈ";
923
+ if (speaker === "Witness_1") return "πŸ‘οΈ";
924
+ return "βš™οΈ";
925
+ }
926
+
927
+ function addMessage(speaker, text, turn, tag) {
928
+ const log = document.getElementById("conversationLog");
929
+ // Remove empty state on first message
930
+ if (messageCount === 0) log.innerHTML = "";
931
+
932
+ const msgEl = document.createElement("div");
933
+ msgEl.className = "msg";
934
+
935
+ const cls = getAvatarClass(speaker);
936
+ let tagHTML = "";
937
+ if (tag) {
938
+ tagHTML = `<div class="msg-tag ${tag}">${tag}</div>`;
939
+ }
940
+
941
+ msgEl.innerHTML = `
942
+ <div class="msg-avatar ${cls}">${getAvatarEmoji(speaker)}</div>
943
+ <div class="msg-content">
944
+ <div class="msg-header">
945
+ <span class="msg-name ${cls}">${speaker}</span>
946
+ <span class="msg-turn">Turn ${turn}</span>
947
+ </div>
948
+ <div class="msg-text">${text}</div>
949
+ ${tagHTML}
950
+ </div>
951
+ `;
952
+
953
+ log.appendChild(msgEl);
954
+ log.scrollTop = log.scrollHeight;
955
+ messageCount++;
956
+ document.getElementById("msgCount").textContent = `${messageCount} messages`;
957
+ }
958
+
959
+ function revealEvidence(itemKey, nameOverride, descOverride) {
960
+ let keyNorm = itemKey.toLowerCase().replace(" ", "_");
961
+ // Normalize to standard keys if string varies slightly
962
+ if (keyNorm.includes("keycard")) keyNorm = "keycard_log";
963
+ if (keyNorm.includes("cctv")) keyNorm = "cctv_footage";
964
+ if (keyNorm.includes("forensic")) keyNorm = "forensic_report";
965
+
966
+ if (revealedEvidenceKeys.has(keyNorm)) return;
967
+ revealedEvidenceKeys.add(keyNorm);
968
+
969
+ // Always use the passed-in description (from backend trace or demo script)
970
+ const name = nameOverride || EVIDENCE_NAMES[keyNorm] || itemKey;
971
+ const description = descOverride || "";
972
+ const icon = EVIDENCE_ICONS[keyNorm] || "πŸ“„";
973
+
974
+ evidenceCount++;
975
+ document.getElementById("statEvidence").textContent = `${evidenceCount} / 3`;
976
+
977
+ // Update evidence list
978
+ const list = document.getElementById("evidenceList");
979
+ const emptyState = document.getElementById("evidenceEmptyState");
980
+ if (emptyState) emptyState.remove();
981
+
982
+ const el = document.createElement("div");
983
+ el.className = "evidence-item revealed";
984
+ el.innerHTML = `
985
+ <span class="evidence-icon">${icon}</span>
986
+ <div class="evidence-info">
987
+ <div class="evidence-name">${name}</div>
988
+ <div class="evidence-desc">${description}</div>
989
+ </div>
990
+ `;
991
+ list.appendChild(el);
992
+
993
+ addMessage("System", `πŸ“‹ Evidence revealed: <strong>${name}</strong> β€” ${description}`, currentTurn, "evidence");
994
+
995
+ // Reward β€” matches reward_calculator.py evidence_request_confirms_lead = +1.0
996
+ rewardComponents.evidence += 1.0;
997
+ updateReward();
998
+ }
999
+
1000
+ function addContradiction(suspect, claim1, t1, claim2, t2) {
1001
+ contradictionCount++;
1002
+ document.getElementById("statContra").textContent = contradictionCount;
1003
+
1004
+ const list = document.getElementById("alertList");
1005
+ if (contradictionCount === 1) list.innerHTML = "";
1006
+
1007
+ const el = document.createElement("div");
1008
+ el.className = "alert-item";
1009
+ el.innerHTML = `
1010
+ <div class="alert-suspect">⚠ ${suspect} β€” Contradiction #${contradictionCount}</div>
1011
+ <div class="alert-detail">"${claim1}" ↔ "${claim2}"</div>
1012
+ <div class="alert-turns">Turns ${t1} β†’ ${t2}</div>
1013
+ `;
1014
+ list.appendChild(el);
1015
+
1016
+ rewardComponents.contradictions += 2.0;
1017
+ updateReward();
1018
+ }
1019
+
1020
+ function updateReward() {
1021
+ const total = rewardComponents.base + rewardComponents.turnCost + rewardComponents.contradictions + rewardComponents.evidence;
1022
+ rewardComponents.total = total;
1023
+ totalReward = total;
1024
+
1025
+ document.getElementById("statReward").textContent = total.toFixed(2);
1026
+ const statEl = document.getElementById("statReward");
1027
+ statEl.className = "stat-value " + (total >= 0 ? "positive" : "negative");
1028
+
1029
+ // Reward bar: map -10..+10 to 0..100%
1030
+ const pct = Math.max(0, Math.min(100, ((total + 10) / 20) * 100));
1031
+ document.getElementById("rewardBar").style.width = `${pct}%`;
1032
+
1033
+ document.getElementById("rwBase").textContent = rewardComponents.base.toFixed(2);
1034
+ document.getElementById("rwTurnCost").textContent = rewardComponents.turnCost.toFixed(2);
1035
+ document.getElementById("rwContra").textContent = `+${rewardComponents.contradictions.toFixed(2)}`;
1036
+ document.getElementById("rwEvidence").textContent = `+${rewardComponents.evidence.toFixed(2)}`;
1037
+ document.getElementById("rwTotal").textContent = total.toFixed(2);
1038
+ document.getElementById("rwTotal").style.color = total >= 0 ? "var(--accent-green)" : "var(--accent-red)";
1039
+ }
1040
+
1041
+ function updateTurn(t) {
1042
+ currentTurn = t;
1043
+ document.getElementById("statTurn").textContent = `${t} / 15`;
1044
+ rewardComponents.turnCost = -(t * 0.3);
1045
+ updateReward();
1046
+ }
1047
+
1048
+ function showOutcome(correct) {
1049
+ const overlay = document.getElementById("outcomeOverlay");
1050
+ document.getElementById("outcomeIcon").textContent = correct ? "πŸŽ‰" : "❌";
1051
+ document.getElementById("outcomeTitle").textContent = correct ? "Case Solved!" : "Wrong Accusation";
1052
+ document.getElementById("outcomeSub").textContent = correct
1053
+ ? "The detective correctly identified the true criminal."
1054
+ : "The detective accused the wrong suspect.";
1055
+ document.getElementById("outcomeStat").textContent = (totalReward >= 0 ? "+" : "") + totalReward.toFixed(2);
1056
+ document.getElementById("outcomeStat").className = "outcome-stat " + (correct ? "win" : "lose");
1057
+ document.getElementById("statOutcome").textContent = correct ? "βœ… Correct" : "❌ Wrong";
1058
+ document.getElementById("statOutcome").className = "stat-value " + (correct ? "positive" : "negative");
1059
+ overlay.classList.add("visible");
1060
+ }
1061
+
1062
+ function dismissOutcome() {
1063
+ document.getElementById("outcomeOverlay").classList.remove("visible");
1064
+ document.getElementById("btnReset").style.display = "inline-flex";
1065
+ updateRewardChart();
1066
+ }
1067
+
1068
+ // ── Reward Chart ─────────────────────────────────────────────────────────────
1069
+
1070
+ function updateRewardChart() {
1071
+ rewardHistory.push(totalReward);
1072
+ const panel = document.getElementById("historyPanel");
1073
+ panel.style.display = "block";
1074
+
1075
+ const canvas = document.getElementById("rewardChart");
1076
+ const ctx = canvas.getContext("2d");
1077
+ const rect = canvas.parentElement.getBoundingClientRect();
1078
+ canvas.width = rect.width * 2;
1079
+ canvas.height = rect.height * 2;
1080
+ ctx.scale(2, 2);
1081
+
1082
+ const w = rect.width;
1083
+ const h = rect.height;
1084
+ const data = rewardHistory;
1085
+
1086
+ // Dynamic scale limits based on data
1087
+ const maxVal = data.length > 0 ? Math.max(...data) : 0;
1088
+ const minVal = data.length > 0 ? Math.min(...data) : 0;
1089
+ const maxR = Math.max(10, Math.ceil(maxVal / 5) * 5 + 5);
1090
+ const minR = Math.min(-10, Math.floor(minVal / 5) * 5 - 5);
1091
+ const stepV = Math.max(5, Math.floor((maxR - minR) / 6));
1092
+
1093
+ const pad = { t: 15, b: 25, l: 40, r: 15 };
1094
+
1095
+ ctx.clearRect(0, 0, w, h);
1096
+
1097
+ // Grid and Labels
1098
+ ctx.strokeStyle = "rgba(0,0,0,0.1)"; // Darker grid lines
1099
+ ctx.lineWidth = 1;
1100
+ ctx.fillStyle = "rgba(0,0,0,0.6)"; // Dark text instead of white
1101
+ ctx.font = "11px 'JetBrains Mono'";
1102
+ ctx.textAlign = "right";
1103
+
1104
+ for (let v = minR; v <= maxR; v += stepV) {
1105
+ const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b);
1106
+ ctx.beginPath();
1107
+ ctx.moveTo(pad.l, y);
1108
+ ctx.lineTo(w - pad.r, y);
1109
+ ctx.stroke();
1110
+
1111
+ ctx.fillText(v.toString(), pad.l - 8, y + 4);
1112
+ }
1113
+
1114
+ // Zero line
1115
+ const zeroY = pad.t + ((maxR - 0) / (maxR - minR)) * (h - pad.t - pad.b);
1116
+ ctx.strokeStyle = "rgba(0,0,0,0.2)"; // Darker zero line
1117
+ ctx.beginPath();
1118
+ ctx.moveTo(pad.l, zeroY);
1119
+ ctx.lineTo(w - pad.r, zeroY);
1120
+ ctx.stroke();
1121
+
1122
+ if (data.length < 2) return;
1123
+
1124
+ // Line
1125
+ const stepX = (w - pad.l - pad.r) / (Math.max(data.length - 1, 1));
1126
+ ctx.beginPath();
1127
+ data.forEach((v, i) => {
1128
+ const x = pad.l + i * stepX;
1129
+ const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b);
1130
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
1131
+ });
1132
+ ctx.strokeStyle = "#3b82f6";
1133
+ ctx.lineWidth = 2;
1134
+ ctx.stroke();
1135
+
1136
+ // Gradient fill
1137
+ const grad = ctx.createLinearGradient(0, 0, 0, h);
1138
+ grad.addColorStop(0, "rgba(59, 130, 246, 0.2)");
1139
+ grad.addColorStop(1, "rgba(59, 130, 246, 0)");
1140
+ ctx.lineTo(pad.l + (data.length - 1) * stepX, h - pad.b);
1141
+ ctx.lineTo(pad.l, h - pad.b);
1142
+ ctx.closePath();
1143
+ ctx.fillStyle = grad;
1144
+ ctx.fill();
1145
+
1146
+ // Dots
1147
+ data.forEach((v, i) => {
1148
+ const x = pad.l + i * stepX;
1149
+ const y = pad.t + ((maxR - v) / (maxR - minR)) * (h - pad.t - pad.b);
1150
+ ctx.beginPath();
1151
+ ctx.arc(x, y, 4, 0, Math.PI * 2);
1152
+ ctx.fillStyle = v >= 0 ? "#10b981" : "#ef4444";
1153
+ ctx.fill();
1154
+ ctx.strokeStyle = "#ffffff"; // White border inside the dot
1155
+ ctx.lineWidth = 2;
1156
+ ctx.stroke();
1157
+ });
1158
+
1159
+ // Episode labels
1160
+ ctx.fillStyle = "rgba(0,0,0,0.5)"; // Darker episode text
1161
+ ctx.font = "9px 'JetBrains Mono'";
1162
+ ctx.textAlign = "center";
1163
+ data.forEach((_, i) => {
1164
+ ctx.fillText(`E${i + 1}`, pad.l + i * stepX, h - 4);
1165
+ });
1166
+ }
1167
+
1168
+ function drawTrainingChart(rewards, smoothed) {
1169
+ const canvas = document.getElementById("trainingRewardChart");
1170
+ const ctx = canvas.getContext("2d");
1171
+ const rect = canvas.parentElement.getBoundingClientRect();
1172
+ canvas.width = rect.width * 2;
1173
+ canvas.height = rect.height * 2;
1174
+ ctx.scale(2, 2);
1175
+
1176
+ const w = rect.width;
1177
+ const h = rect.height;
1178
+ const pad = { t: 15, b: 25, l: 40, r: 10 };
1179
+ const data = rewards || [];
1180
+ const avg = smoothed || [];
1181
+
1182
+ ctx.clearRect(0, 0, w, h);
1183
+
1184
+ if (data.length === 0) {
1185
+ ctx.fillStyle = "rgba(0,0,0,0.6)";
1186
+ ctx.font = "12px 'JetBrains Mono'";
1187
+ ctx.fillText("No training data found yet.", 12, 24);
1188
+ return;
1189
+ }
1190
+
1191
+ const maxVal = Math.max(...data, ...(avg.length ? avg : data), 10);
1192
+ const minVal = Math.min(...data, ...(avg.length ? avg : data), -10);
1193
+ const span = Math.max(1, maxVal - minVal);
1194
+
1195
+ ctx.strokeStyle = "rgba(0,0,0,0.12)";
1196
+ ctx.lineWidth = 1;
1197
+ for (let i = 0; i <= 5; i++) {
1198
+ const y = pad.t + (i / 5) * (h - pad.t - pad.b);
1199
+ ctx.beginPath();
1200
+ ctx.moveTo(pad.l, y);
1201
+ ctx.lineTo(w - pad.r, y);
1202
+ ctx.stroke();
1203
+ }
1204
+
1205
+ const valueToY = (v) => pad.t + ((maxVal - v) / span) * (h - pad.t - pad.b);
1206
+ const xStep = (w - pad.l - pad.r) / Math.max(1, data.length - 1);
1207
+
1208
+ ctx.beginPath();
1209
+ data.forEach((v, i) => {
1210
+ const x = pad.l + i * xStep;
1211
+ const y = valueToY(v);
1212
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
1213
+ });
1214
+ ctx.strokeStyle = "#65A3BB";
1215
+ ctx.lineWidth = 2;
1216
+ ctx.stroke();
1217
+
1218
+ if (avg.length === data.length && avg.length > 1) {
1219
+ ctx.beginPath();
1220
+ avg.forEach((v, i) => {
1221
+ const x = pad.l + i * xStep;
1222
+ const y = valueToY(v);
1223
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
1224
+ });
1225
+ ctx.strokeStyle = "#F25D68";
1226
+ ctx.lineWidth = 2.5;
1227
+ ctx.stroke();
1228
+ }
1229
+ }
1230
+
1231
+ async function loadTrainingProgress() {
1232
+ try {
1233
+ const res = await fetch("/api/reward_curve");
1234
+ if (!res.ok) return;
1235
+ const data = await res.json();
1236
+ trainingRewards = data.rewards || [];
1237
+ trainingSmoothed = data.smoothed || [];
1238
+
1239
+ document.getElementById("trainEpisodes").textContent = String(data.num_episodes || trainingRewards.length || 0);
1240
+ document.getElementById("trainModel").textContent = (data.model || "-").replace("Qwen/", "");
1241
+ document.getElementById("trainFirst").textContent = Number(data.mean_first_50 || 0).toFixed(2);
1242
+ document.getElementById("trainLast").textContent = Number(data.mean_last_50 || 0).toFixed(2);
1243
+
1244
+ const improvement = Number(data.improvement || 0);
1245
+ const sign = improvement >= 0 ? "+" : "";
1246
+ const improveEl = document.getElementById("trainImprovement");
1247
+ improveEl.textContent = `Improvement: ${sign}${improvement.toFixed(2)}`;
1248
+ improveEl.style.color = improvement >= 0 ? "#16a34a" : "#dc2626";
1249
+
1250
+ drawTrainingChart(trainingRewards, trainingSmoothed);
1251
+ } catch (err) {
1252
+ // Keep dashboard usable even if training artifacts are missing.
1253
+ }
1254
+ }
1255
+
1256
+
1257
+ function resetBoard() {
1258
+ currentTurn = 0;
1259
+ evidenceCount = 0;
1260
+ contradictionCount = 0;
1261
+ totalReward = 0;
1262
+ messageCount = 0;
1263
+ revealedEvidenceKeys.clear();
1264
+ if (window.revealedContradictionKeys) window.revealedContradictionKeys.clear();
1265
+ Object.keys(rewardComponents).forEach(k => rewardComponents[k] = 0);
1266
+
1267
+ document.getElementById("statTurn").textContent = "0 / 15";
1268
+ document.getElementById("statEvidence").textContent = "0 / 3";
1269
+ document.getElementById("statContra").textContent = "0";
1270
+ document.getElementById("statReward").textContent = "0.00";
1271
+ document.getElementById("statReward").className = "stat-value";
1272
+ document.getElementById("statOutcome").textContent = "β€”";
1273
+ document.getElementById("statOutcome").className = "stat-value neutral";
1274
+ document.getElementById("rewardBar").style.width = "50%";
1275
+ document.getElementById("msgCount").textContent = "0 messages";
1276
+
1277
+ document.getElementById("conversationLog").innerHTML = `
1278
+ <div class="empty-state">
1279
+ <div class="empty-icon">πŸ•΅οΈ</div>
1280
+ <div class="empty-text">Click <strong>Run Live Server</strong> to watch a full<br>investigation episode unfold live.</div>
1281
+ </div>`;
1282
+
1283
+ document.getElementById("evidenceList").innerHTML = `
1284
+ <div class="empty-state" id="evidenceEmptyState" style="padding:14px;">
1285
+ <div class="empty-text" style="font-size:0.72rem;">No evidence revealed yet.</div>
1286
+ </div>`;
1287
+
1288
+ document.getElementById("alertList").innerHTML = `
1289
+ <div class="empty-state" style="padding:14px;">
1290
+ <div class="empty-text" style="font-size:0.72rem;">No contradictions detected yet.</div>
1291
+ </div>`;
1292
+
1293
+ document.getElementById("btnReset").style.display = "none";
1294
+ document.getElementById("outcomeOverlay").classList.remove("visible");
1295
+
1296
+ updateReward();
1297
+ }
1298
+
1299
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
1300
+
1301
+ // ── Live Backend Runner ──────────────────────────────────────────────────────
1302
+
1303
+ async function runLiveAgent() {
1304
+ if (demoRunning) return;
1305
+ demoRunning = true;
1306
+
1307
+ resetBoard();
1308
+
1309
+ document.getElementById("btnRunLive").disabled = true;
1310
+ document.getElementById("btnRunLive").textContent = "⏳ Waiting for AI...";
1311
+ document.getElementById("statusText").textContent = "Live Investigating";
1312
+ document.getElementById("statusBadge").style.borderColor = "rgba(16, 185, 129, 0.4)";
1313
+ document.getElementById("statusBadge").style.background = "rgba(16, 185, 129, 0.1)";
1314
+ document.getElementById("statusBadge").querySelector(".status-dot").style.background = "var(--accent-green)";
1315
+ document.getElementById("statusBadge").querySelector("span:last-child").style.color = "var(--accent-green)";
1316
+
1317
+ const epNum = rewardHistory.length + 1;
1318
+ document.getElementById("episodeLabel").textContent = `Episode ${epNum}`;
1319
+
1320
+ const DELAY_SHORT = 600;
1321
+ const DELAY_LONG = 1200;
1322
+
1323
+ try {
1324
+ const response = await fetch("/api/run_episode");
1325
+ if (!response.ok) throw new Error("Server not responding");
1326
+ const data = await response.json();
1327
+
1328
+ if (data.status !== "ok") {
1329
+ throw new Error("Simulation failed");
1330
+ }
1331
+
1332
+ const script = data.trace;
1333
+ document.getElementById("btnRunLive").textContent = "βš™οΈ Executing Trace...";
1334
+
1335
+ for (const event of script) {
1336
+ if (!demoRunning) break;
1337
+
1338
+ if (event.type === "detective") {
1339
+ updateTurn(event.turn);
1340
+
1341
+ const actionDesc = event.action.includes("ask")
1342
+ ? `Asking ${event.target}: "${event.text}"`
1343
+ : event.action.includes("evid")
1344
+ ? `Requesting evidence: ${event.item}`
1345
+ : `Accusing ${event.target}`;
1346
+ addMessage("Detective", actionDesc, event.turn);
1347
+
1348
+ // turnCost is tracked by updateTurn() already (-0.3 * turn)
1349
+ // Don't touch rewardComponents.base here β€” we derive it at the end
1350
+ await sleep(DELAY_LONG);
1351
+
1352
+ } else if (event.type === "response") {
1353
+ addMessage(event.speaker, event.text, event.turn);
1354
+ await sleep(DELAY_SHORT);
1355
+
1356
+ } else if (event.type === "contradiction") {
1357
+ addContradiction(event.suspect, event.claim1 || "Previous claim", event.claim1Turn || "?", event.claim2 || ("New claim: " + event.detail), event.turn);
1358
+ // addContradiction() already adds +2.0 to rewardComponents.contradictions
1359
+ // No extra shifting needed since we derive base at the end
1360
+ await sleep(DELAY_SHORT);
1361
+
1362
+ } else if (event.type === "evidence") {
1363
+ revealEvidence(event.item, event.item, event.desc);
1364
+ // revealEvidence() already adds +1.0 to rewardComponents.evidence
1365
+ // No extra shifting needed since we derive base at the end
1366
+ await sleep(DELAY_SHORT);
1367
+
1368
+ } else if (event.type === "outcome") {
1369
+ // Now derive the base component from the true server total
1370
+ // base = serverTotal - turnCost - contradictions - evidence
1371
+ let serverTotal = data.rewards.detective;
1372
+ rewardComponents.base = serverTotal - rewardComponents.turnCost - rewardComponents.contradictions - rewardComponents.evidence;
1373
+ totalReward = serverTotal;
1374
+ updateReward();
1375
+
1376
+ await sleep(800);
1377
+ showOutcome(event.correct || false);
1378
+ }
1379
+ }
1380
+
1381
+ // Final safeguard: force-set everything to server truth
1382
+ totalReward = data.rewards.detective;
1383
+ rewardComponents.base = totalReward - rewardComponents.turnCost - rewardComponents.contradictions - rewardComponents.evidence;
1384
+ rewardComponents.total = totalReward;
1385
+ updateReward();
1386
+ document.getElementById("statReward").textContent = totalReward.toFixed(2);
1387
+
1388
+ } catch (e) {
1389
+ addMessage("System", `⚠️ Error connecting to live server: ${e.message}. Note: you must run 'uvicorn server.app:app' and open the app from localhost to use Live Mode!`, currentTurn || 0);
1390
+ }
1391
+
1392
+ document.getElementById("statusText").textContent = "Complete";
1393
+ document.getElementById("statusBadge").style.borderColor = "rgba(16, 185, 129, 0.25)";
1394
+ document.getElementById("btnRunLive").disabled = false;
1395
+ document.getElementById("btnRunLive").textContent = "πŸš€ Run Live Server";
1396
+ demoRunning = false;
1397
+ }
1398
+
1399
+ window.addEventListener("load", () => {
1400
+ loadTrainingProgress();
1401
+ });
1402
+ </script>
1403
+ </body>
1404
+ </html>
hf_mini_blog_draft.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Crime Investigation World: Multi-Agent RL Detective (Hackathon Mini-Blog Draft)
2
+
3
+ ## 1) What This Project Solves
4
+ AI Crime Investigation World is a multi-agent reinforcement learning environment where a detective agent must interrogate two suspects and one witness, request evidence, detect contradictions, and make a final accusation within 15 turns.
5
+
6
+ This is designed as an OpenEnv-compatible benchmark for evaluating agent coordination under partial information.
7
+
8
+ ## 2) Why It Matches Halluminate
9
+ This project maps directly to the Halluminate sub-theme: one controller agent (detective) manages multiple actors with conflicting incentives to accomplish a single objective (correct accusation).
10
+
11
+ The detective must combine statements from multiple actors, resolve contradictions, and align decisions with evidence, not just single-agent chat quality.
12
+
13
+ ## 3) Environment Design
14
+ - Actors: Detective, Suspect_A, Suspect_B, Witness_1
15
+ - Observation: briefing, conversation history, evidence log, current turn
16
+ - Actions:
17
+ - ACTION: ask_question | TARGET: <agent> | CONTENT: <question>
18
+ - ACTION: request_evidence | ITEM: <item>
19
+ - ACTION: accuse | TARGET: <suspect>
20
+ - Episode limit: 15 turns
21
+
22
+ ## 4) Reward Logic
23
+ Detective rewards are event-based and interpretable:
24
+ - Correct accusation: +10
25
+ - Wrong accusation: -8 (up to -10 with active witness bias penalty)
26
+ - Timeout: -3
27
+ - Contradiction exposed: +2
28
+ - Evidence confirms lead: +1
29
+ - Deflection resistance: +0.5
30
+ - Redundant question: -0.5
31
+ - Per-turn cost: -0.3 each turn
32
+
33
+ ## 5) Training Setup
34
+ - Algorithm: PPO via Hugging Face TRL
35
+ - Model: Qwen2.5 Instruct family (small variants for smoke tests)
36
+ - Artifacts:
37
+ - rewards.json
38
+ - reward_curve.png
39
+ - episode_transcripts.json (episodes 1/25/50)
40
+
41
+ ## 6) Observable Progress
42
+ The dashboard and API expose training progress:
43
+ - /api/reward_curve returns raw rewards, smoothed trend, and summary metrics.
44
+ - Dashboard shows training reward curve and improvement metrics.
45
+ - Before/after transcripts from milestone episodes provide qualitative behavior evidence.
46
+
47
+ ## 7) Demo Checklist for Judges
48
+ 1. Open Space root dashboard.
49
+ 2. Verify /api/health returns status ok.
50
+ 3. Verify /api/reward_curve returns reward list and improvement.
51
+ 4. Run /api/run_episode to view live interaction trace.
52
+ 5. Inspect transcript snapshots (episode_transcripts.json) for behavior shift.
53
+
54
+ ## 8) Limitations and Next Steps
55
+ - Early training can be noisy due to sparse terminal signal.
56
+ - Runtime and VRAM constraints require small models for quick demos.
57
+ - Next improvements: stronger contradiction extraction, curriculum schedule, and larger-model fine-tuning with checkpoint comparison.
requirements-train.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ transformers>=4.40.0
2
+ # This training script uses the legacy TRL PPOTrainer.step API.
3
+ trl>=0.9.0,<0.10.0
4
+ torch
5
+ matplotlib
6
+ numpy
7
+ bitsandbytes>=0.43.0
8
+ accelerate>=0.27.0
9
+ peft>=0.9.0
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ openenv-core
2
+ fastapi>=0.110.0
3
+ uvicorn>=0.27.0
4
+ pydantic>=2.0.0
run_hf_smoke_test.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Lightweight Hugging Face smoke test for PPO loop validation.
5
+ export TEST_MODE=1
6
+ export NUM_EPISODES="${NUM_EPISODES:-5}"
7
+ export MAX_TURNS="${MAX_TURNS:-8}"
8
+ export MODEL_NAME="${MODEL_NAME:-Qwen/Qwen2.5-1.5B-Instruct}"
9
+ export NPC_MODEL_NAME="${NPC_MODEL_NAME:-Qwen/Qwen2.5-0.5B-Instruct}"
10
+ export NPC_MAX_NEW_TOKENS="${NPC_MAX_NEW_TOKENS:-48}"
11
+ export DETECTIVE_MAX_NEW_TOKENS="${DETECTIVE_MAX_NEW_TOKENS:-40}"
12
+ export DETECTIVE_RETRY_MAX_NEW_TOKENS="${DETECTIVE_RETRY_MAX_NEW_TOKENS:-24}"
13
+
14
+ python train_colab.py
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenEnv-compatible server for the Crime Investigation Environment.
2
+
3
+ Exposes the CrimeInvestigationEnv via OpenEnv's HTTP/WebSocket interface.
4
+
5
+ Usage:
6
+ # Development (with auto-reload):
7
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
8
+
9
+ # Or run directly:
10
+ python -m server.app
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import json
16
+ import base64
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from pydantic import Field
20
+
21
+ # Add project root to path so crime_env is importable
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+
24
+ from fastapi.responses import HTMLResponse
25
+
26
+ from openenv.core.env_server.http_server import create_app
27
+ from openenv.core.env_server.interfaces import Environment
28
+ from openenv.core.env_server.types import (
29
+ Action,
30
+ EnvironmentMetadata,
31
+ Observation,
32
+ State,
33
+ )
34
+
35
+ from crime_env.environment import CrimeInvestigationEnv
36
+
37
+
38
+ # ── Pydantic types for OpenEnv ──────────────────────────────────────────────
39
+
40
+
41
+ class CrimeAction(Action):
42
+ """Action schema for the Crime Investigation environment."""
43
+
44
+ action_string: str = Field(
45
+ ...,
46
+ description=(
47
+ "Action in one of the following formats:\n"
48
+ " ACTION: ask_question | TARGET: <agent> | CONTENT: <question>\n"
49
+ " ACTION: request_evidence | ITEM: <item>\n"
50
+ " ACTION: accuse | TARGET: <suspect>"
51
+ ),
52
+ examples=[
53
+ "ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you?",
54
+ "ACTION: request_evidence | ITEM: keycard_log",
55
+ "ACTION: accuse | TARGET: Suspect_A",
56
+ ],
57
+ )
58
+
59
+
60
+ class CrimeObservation(Observation):
61
+ """Observation schema returned by the Crime Investigation environment."""
62
+
63
+ role: str = Field(default="detective", description="Agent role")
64
+ briefing: str = Field(default="", description="Case briefing for the detective")
65
+ turn: int = Field(default=0, description="Current turn number")
66
+ conversation_history: List[Dict[str, Any]] = Field(
67
+ default_factory=list, description="Full conversation history"
68
+ )
69
+ evidence_log: List[Dict[str, Any]] = Field(
70
+ default_factory=list, description="Revealed evidence items"
71
+ )
72
+ message: str = Field(default="", description="System message for the current step")
73
+
74
+
75
+ class CrimeState(State):
76
+ """State schema for the Crime Investigation environment."""
77
+
78
+ turn: int = Field(default=0, description="Current turn number")
79
+ is_done: bool = Field(default=False, description="Whether the episode is over")
80
+ max_turns: int = Field(default=15, description="Maximum turns per episode")
81
+ evidence_revealed: int = Field(default=0, description="Number of evidence items revealed")
82
+ contradictions_found: int = Field(default=0, description="Number of contradictions detected")
83
+
84
+
85
+ # ── OpenEnv-compatible wrapper ──────────────────────────────────────────────
86
+
87
+
88
+ class CrimeInvestigationOpenEnv(Environment[CrimeAction, CrimeObservation, CrimeState]):
89
+ """OpenEnv wrapper around CrimeInvestigationEnv."""
90
+
91
+ def __init__(self, **kwargs):
92
+ super().__init__(**kwargs)
93
+ self._env = CrimeInvestigationEnv()
94
+ self._current_obs: Optional[dict] = None
95
+
96
+ def reset(
97
+ self,
98
+ seed: Optional[int] = None,
99
+ episode_id: Optional[str] = None,
100
+ **kwargs,
101
+ ) -> CrimeObservation:
102
+ if hasattr(self, "_reset_rubric"):
103
+ self._reset_rubric()
104
+ obs = self._env.reset()
105
+ self._current_obs = obs
106
+ return CrimeObservation(
107
+ role=obs.get("role", "detective"),
108
+ briefing=obs.get("briefing", ""),
109
+ turn=obs.get("turn", 0),
110
+ conversation_history=obs.get("conversation_history", []),
111
+ evidence_log=obs.get("evidence_log", []),
112
+ message=obs.get("message", ""),
113
+ done=False,
114
+ reward=None,
115
+ )
116
+
117
+ def step(
118
+ self,
119
+ action: CrimeAction,
120
+ timeout_s: Optional[float] = None,
121
+ **kwargs,
122
+ ) -> CrimeObservation:
123
+ obs_dict, reward, done, info = self._env.step(action.action_string)
124
+ self._current_obs = obs_dict
125
+ return CrimeObservation(
126
+ role=obs_dict.get("role", "detective"),
127
+ briefing=obs_dict.get("briefing", ""),
128
+ turn=obs_dict.get("turn", 0),
129
+ conversation_history=obs_dict.get("conversation_history", []),
130
+ evidence_log=obs_dict.get("evidence_log", []),
131
+ message=obs_dict.get("message", ""),
132
+ done=done,
133
+ reward=reward,
134
+ )
135
+
136
+ @property
137
+ def state(self) -> CrimeState:
138
+ env_state = self._env.state()
139
+ return CrimeState(
140
+ turn=env_state.get("turn", 0),
141
+ is_done=env_state.get("done", False),
142
+ max_turns=env_state.get("max_turns", 15),
143
+ evidence_revealed=env_state.get("evidence_revealed", 0),
144
+ contradictions_found=env_state.get("contradictions_found", 0),
145
+ )
146
+
147
+ def get_metadata(self) -> EnvironmentMetadata:
148
+ return EnvironmentMetadata(
149
+ name="CrimeInvestigationEnv",
150
+ description=(
151
+ "AI Crime Investigation World β€” a multi-agent RL environment "
152
+ "where a detective agent interrogates suspects and a witness, "
153
+ "reviews evidence, and makes an accusation."
154
+ ),
155
+ version="1.0.0",
156
+ )
157
+
158
+ def close(self) -> None:
159
+ pass
160
+
161
+
162
+ # ── App creation ────────────────────────────────────────────────────────────
163
+
164
+ app = create_app(
165
+ CrimeInvestigationOpenEnv,
166
+ CrimeAction,
167
+ CrimeObservation,
168
+ env_name="crime_investigation",
169
+ max_concurrent_envs=1,
170
+ )
171
+
172
+ # ── Custom Endpoints for Dashboard ──────────────────────────────────────────
173
+
174
+ @app.get("/", response_class=HTMLResponse)
175
+ async def serve_dashboard():
176
+ """Serves the dashboard.html interface."""
177
+ script_dir = os.path.dirname(os.path.abspath(__file__))
178
+ project_root = os.path.dirname(script_dir)
179
+ dashboard_path = os.path.join(project_root, "dashboard.html")
180
+ with open(dashboard_path, "r", encoding="utf-8") as f:
181
+ return f.read()
182
+
183
+ @app.get("/api/run_episode")
184
+ async def run_episode_api():
185
+ """Runs a single test episode and returns the trace.
186
+
187
+ Import is lazy (Issue 9) and execution is offloaded to a thread
188
+ so the FastAPI event loop isn't blocked (Issue 11).
189
+ """
190
+ import asyncio
191
+ from test_one_episode import run_test_episode
192
+
193
+ rewards, info, trace = await asyncio.to_thread(run_test_episode)
194
+ return {
195
+ "status": "ok",
196
+ "rewards": rewards,
197
+ "info": info,
198
+ "trace": trace
199
+ }
200
+
201
+
202
+ def _moving_average(values: List[float], window: int) -> List[float]:
203
+ if not values:
204
+ return []
205
+ if window <= 1:
206
+ return values[:]
207
+ averaged: List[float] = []
208
+ running_sum = 0.0
209
+ queue: List[float] = []
210
+ for v in values:
211
+ queue.append(float(v))
212
+ running_sum += float(v)
213
+ if len(queue) > window:
214
+ running_sum -= queue.pop(0)
215
+ averaged.append(running_sum / len(queue))
216
+ return averaged
217
+
218
+
219
+ @app.get("/api/reward_curve")
220
+ async def reward_curve_api():
221
+ """Return training reward history for dashboard/HF demo visibility."""
222
+ script_dir = os.path.dirname(os.path.abspath(__file__))
223
+ project_root = os.path.dirname(script_dir)
224
+ rewards_path = os.path.join(project_root, "rewards.json")
225
+ reward_curve_path = os.path.join(project_root, "reward_curve.png")
226
+
227
+ rewards: List[float] = []
228
+ results: List[str] = []
229
+ model_name = "unknown"
230
+ num_episodes = 0
231
+
232
+ if os.path.exists(rewards_path):
233
+ with open(rewards_path, "r", encoding="utf-8") as f:
234
+ payload = json.load(f)
235
+ rewards = [float(x) for x in payload.get("rewards", [])]
236
+ results = [str(x) for x in payload.get("results", [])]
237
+ model_name = str(payload.get("model", "unknown"))
238
+ num_episodes = int(payload.get("num_episodes", len(rewards)))
239
+
240
+ window = min(20, max(1, len(rewards) // 4))
241
+ smoothed = _moving_average(rewards, window)
242
+ mean_first = sum(rewards[:50]) / max(1, min(50, len(rewards))) if rewards else 0.0
243
+ mean_last = sum(rewards[-50:]) / max(1, min(50, len(rewards))) if rewards else 0.0
244
+
245
+ image_data_url = None
246
+ if os.path.exists(reward_curve_path):
247
+ with open(reward_curve_path, "rb") as f:
248
+ encoded = base64.b64encode(f.read()).decode("ascii")
249
+ image_data_url = f"data:image/png;base64,{encoded}"
250
+
251
+ return {
252
+ "status": "ok",
253
+ "model": model_name,
254
+ "num_episodes": num_episodes,
255
+ "rewards": rewards,
256
+ "smoothed": smoothed,
257
+ "smooth_window": window,
258
+ "mean_first_50": round(mean_first, 4),
259
+ "mean_last_50": round(mean_last, 4),
260
+ "improvement": round(mean_last - mean_first, 4),
261
+ "results": {
262
+ "correct": results.count("correct"),
263
+ "wrong": results.count("wrong"),
264
+ "timeout": results.count("timeout"),
265
+ },
266
+ "image_data_url": image_data_url,
267
+ }
268
+
269
+
270
+ @app.get("/api/health")
271
+ async def health_api():
272
+ """Simple endpoint used for deployment sanity checks."""
273
+ return {"status": "ok", "service": "crime-investigation"}
274
+
275
+
276
+ def main(host: str = "0.0.0.0", port: int = 8000):
277
+ """Entry point for direct execution."""
278
+ import uvicorn
279
+
280
+ uvicorn.run(app, host=host, port=port)
281
+
282
+
283
+ if __name__ == "__main__":
284
+ import argparse
285
+
286
+ parser = argparse.ArgumentParser()
287
+ parser.add_argument("--port", type=int, default=8000)
288
+ args = parser.parse_args()
289
+ main(port=args.port)
test.zip ADDED
Binary file (94 kB). View file
 
test_one_episode.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Test One Episode β€” End-to-End Verification.
3
+
4
+ Runs a single episode of the Crime Investigation environment with all agents
5
+ using enhanced rule-based fallback responses (per-agent turn counters).
6
+ Prints the full conversation, reward breakdown, and accusation result.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import random
12
+
13
+ # Add project root to path
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+
16
+ from crime_env.case_generator import generate_case
17
+ from crime_env.consistency_tracker import ConsistencyTracker
18
+ from crime_env.reward_calculator import RewardCalculator
19
+ from crime_env.agent_prompts import build_system_prompt
20
+ from crime_env.environment import CrimeInvestigationEnv
21
+ from crime_env.constants import (
22
+ AGENT_NAME_TO_KEY,
23
+ SUSPECT_A,
24
+ SUSPECT_B,
25
+ WITNESS_1,
26
+ WITNESS_1_KEY,
27
+ )
28
+
29
+
30
+ # ── Enhanced Rule-Based Detective ───────────────────────────────────────────
31
+
32
+
33
+ def scripted_detective_actions(case: dict) -> list[str]:
34
+ """Generate a scripted sequence of detective actions for testing.
35
+
36
+ This produces a realistic investigation sequence that exercises
37
+ all action types: ask_question, request_evidence, accuse.
38
+ """
39
+ accuse_target = choose_non_oracle_accusation(case)
40
+
41
+ actions = [
42
+ # Turn 0: Question Suspect A about alibi
43
+ f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: Where were you at the time of the crime?",
44
+ # Turn 1: Question Suspect B about alibi
45
+ f"ACTION: ask_question | TARGET: {SUSPECT_B} | CONTENT: Can you tell me your whereabouts that evening?",
46
+ # Turn 2: Question Witness
47
+ f"ACTION: ask_question | TARGET: {WITNESS_1} | CONTENT: What did you see near the crime scene?",
48
+ # Turn 3: Request evidence
49
+ "ACTION: request_evidence | ITEM: keycard_log",
50
+ # Turn 4: Follow up with Suspect A about inconsistency
51
+ f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: Your prior record shows you've lied about your location before. Can you explain your alibi in more detail?",
52
+ # Turn 5: Request more evidence
53
+ "ACTION: request_evidence | ITEM: cctv_footage",
54
+ # Turn 6: Question Suspect A about clothing
55
+ f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: What were you wearing that evening?",
56
+ # Turn 7: Question Witness about clothing
57
+ f"ACTION: ask_question | TARGET: {WITNESS_1} | CONTENT: Can you describe what the person you saw was wearing?",
58
+ # Turn 8: Ask Suspect B if they know Suspect A
59
+ f"ACTION: ask_question | TARGET: {SUSPECT_B} | CONTENT: Do you know {SUSPECT_A}? Have you had any previous interactions?",
60
+ # Turn 9: Request forensic report
61
+ "ACTION: request_evidence | ITEM: forensic_report",
62
+ # Turn 10: Final question before accusation
63
+ f"ACTION: ask_question | TARGET: {SUSPECT_A} | CONTENT: The evidence places someone at the scene. Was that you?",
64
+ # Turn 11: Accuse
65
+ f"ACTION: accuse | TARGET: {accuse_target}",
66
+ ]
67
+
68
+ return actions
69
+
70
+
71
+ def choose_non_oracle_accusation(case: dict) -> str:
72
+ """Pick an accusation target using only case-visible investigative signals.
73
+
74
+ This intentionally avoids direct use of the hidden `case['criminal']`
75
+ so dashboard episodes can produce both correct and wrong outcomes.
76
+ """
77
+ scores = {SUSPECT_A: 0.0, SUSPECT_B: 0.0}
78
+
79
+ # Evidence contributes most to suspicion.
80
+ for evidence in case.get("physical_evidence", []):
81
+ points_to = evidence.get("points_to")
82
+ if points_to in scores:
83
+ scores[points_to] += 1.75
84
+
85
+ # Prior history is suggestive, but weaker than direct evidence.
86
+ for suspect in (SUSPECT_A, SUSPECT_B):
87
+ history = case.get("prior_history", {}).get(suspect, {})
88
+ scores[suspect] += 0.25 * len(history.get("past_lies_on_record", []))
89
+ if history.get("on_parole"):
90
+ scores[suspect] += 0.15
91
+ trust = history.get("trust_score", 0.5)
92
+ scores[suspect] += max(0.0, 0.6 - trust)
93
+
94
+ # Witness bias can mislead investigators in realistic episodes.
95
+ witness = case.get("agent_knowledge", {}).get("witness_1", {})
96
+ bias_target = witness.get("bias_target")
97
+ bias_strength = float(witness.get("bias_strength", 0.0) or 0.0)
98
+ if bias_target in scores:
99
+ scores[bias_target] += 0.6 * bias_strength
100
+
101
+ # If confidence is low, introduce uncertainty to allow wrong accusations.
102
+ ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
103
+ top_target, top_score = ordered[0]
104
+ second_target, second_score = ordered[1]
105
+ margin = top_score - second_score
106
+ if margin < 0.45 and random.random() < 0.45:
107
+ return second_target
108
+
109
+ return top_target
110
+
111
+
112
+ # ── Enhanced NPC Responses ──────────────────────────────────────────────────
113
+
114
+
115
+ def make_enhanced_npc_call(case: dict):
116
+ """Create an NPC response function with per-agent turn counters.
117
+
118
+ Each agent has its own turn counter, so their response progression
119
+ is independent β€” Suspect_A and Suspect_B will never give the same
120
+ response at the same time.
121
+ """
122
+ # Per-agent turn counters β€” each agent tracks their own call count
123
+ agent_counters: dict[str, int] = {}
124
+
125
+ def npc_call(system_prompt: str, conversation_history: list[dict]) -> str:
126
+ # Identify agent from system prompt β€” works with either suspect as criminal
127
+ is_guilty = "FAKE ALIBI:" in system_prompt
128
+ is_innocent = "innocent" in system_prompt
129
+ is_witness = "Witness_1" in system_prompt
130
+
131
+ if is_guilty:
132
+ agent_id = "guilty_suspect"
133
+ elif is_innocent:
134
+ agent_id = "innocent_suspect"
135
+ elif is_witness:
136
+ agent_id = "witness"
137
+ else:
138
+ return "I don't have anything to add."
139
+
140
+ # Increment per-agent counter
141
+ call_num = agent_counters.get(agent_id, 0)
142
+ agent_counters[agent_id] = call_num + 1
143
+
144
+ if agent_id == "guilty_suspect":
145
+ criminal_name = case["criminal"]
146
+ knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[criminal_name]]
147
+ fake_alibi = knowledge["fake_alibi"]
148
+
149
+ responses = [
150
+ # Call 0: Initial alibi statement β€” uses case-specific fake alibi
151
+ f"{fake_alibi}. I can provide witnesses if needed.",
152
+ # Call 1: Deflects the prior-record question
153
+ "Look, I've made mistakes in the past, but I've been clean. "
154
+ "I was wearing my grey trench coat, as I usually do in the evenings.",
155
+ # Call 2: Deflection toward Suspect_B
156
+ "I don't know anything about what happened. I was nowhere near the "
157
+ "scene. Maybe you should look at Suspect_B instead.",
158
+ # Call 3: Getting nervous, starts slipping β€” changes location claim
159
+ "I was at the bar that evening. I might have had the times mixed up "
160
+ "earlier, but I was definitely not at the crime scene.",
161
+ # Call 4+: Further inconsistency under pressure
162
+ "I already told you everything I know. I was at home all evening. "
163
+ "You're wasting your time with me.",
164
+ ]
165
+
166
+ elif agent_id == "innocent_suspect":
167
+ innocent_name = SUSPECT_B if case["criminal"] == SUSPECT_A else SUSPECT_A
168
+ knowledge = case["agent_knowledge"][AGENT_NAME_TO_KEY[innocent_name]]
169
+ real_alibi = knowledge["real_alibi"]
170
+ knows_other = knowledge.get("knows_suspect_A", knowledge.get("knows_suspect_B", False))
171
+
172
+ responses = [
173
+ # Call 0: Clear, specific, verifiable alibi
174
+ f"{real_alibi}. I can prove it if you need documentation.",
175
+ # Call 1: Relationship with Suspect_A
176
+ (
177
+ "I know the other suspect from work, but we're not close. "
178
+ "I haven't seen them recently."
179
+ if knows_other
180
+ else "I do not know the other suspect personally. "
181
+ "I've never had any interactions with them."
182
+ ),
183
+ # Call 2: Confirms alibi again, firmly
184
+ f"As I already said, {real_alibi}. "
185
+ "I had absolutely nothing to do with this crime.",
186
+ # Call 3+: Cooperating but frustrated
187
+ f"I've been completely honest with you. {real_alibi}. "
188
+ "Please check my receipts and timestamps β€” everything lines up.",
189
+ ]
190
+
191
+ # ── Witness ─────────────────────────────────────────────────
192
+ else:
193
+ knowledge = case["agent_knowledge"][WITNESS_1_KEY]
194
+ saw = knowledge["saw"]
195
+ time_seen = knowledge["time_seen"]
196
+ bias = knowledge.get("bias_target", None)
197
+
198
+ responses = [
199
+ # Call 0: Primary testimony β€” case-specific observation
200
+ f"I saw {saw}. It was around {time_seen}. "
201
+ "They seemed tense and were looking over their shoulder.",
202
+ # Call 1: Follow-up β€” more detail about what they saw
203
+ f"The person I saw was near the {case['location']} at {time_seen}. "
204
+ "They were moving quickly and seemed like they didn't want to be noticed.",
205
+ # Call 2+: Additional detail or bias
206
+ (
207
+ f"Now that I think about it, their build reminded me of {bias}, "
208
+ "but I can't be completely certain."
209
+ if bias
210
+ else f"I only saw them briefly near the {case['location']}. "
211
+ "I wish I could tell you more, but that's all I observed."
212
+ ),
213
+ ]
214
+
215
+ idx = min(call_num, len(responses) - 1)
216
+ return responses[idx]
217
+
218
+ return npc_call
219
+
220
+
221
+ # ── Main ────────────────────────────────────────────────────────────────────
222
+
223
+
224
+ def run_test_episode():
225
+ """Run a single test episode and print full results."""
226
+ print("=" * 70)
227
+ print(" AI CRIME INVESTIGATION WORLD β€” TEST EPISODE")
228
+ print("=" * 70)
229
+
230
+ # Generate case
231
+ case = generate_case()
232
+
233
+ print(f"\nπŸ“‹ CASE DETAILS (ground truth β€” not visible to detective):")
234
+ print(f" Crime: {case['crime']}")
235
+ print(f" Criminal: {case['criminal']}")
236
+ print(f" Location: {case['location']}")
237
+ print(f" Time: {case['time']}")
238
+ print(f" Method: {case['method']}")
239
+ criminal = case["criminal"]
240
+ innocent = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
241
+ print(f"\nπŸ“ {criminal} FAKE ALIBI: {case['agent_knowledge'][AGENT_NAME_TO_KEY[criminal]]['fake_alibi']}")
242
+ print(f"πŸ“ {innocent} REAL ALIBI: {case['agent_knowledge'][AGENT_NAME_TO_KEY[innocent]]['real_alibi']}")
243
+ print(f"πŸ‘οΈ WITNESS SAW: {case['agent_knowledge'][WITNESS_1_KEY]['saw']}")
244
+ print()
245
+
246
+ # Create environment with enhanced NPC
247
+ npc_call = make_enhanced_npc_call(case)
248
+ env = CrimeInvestigationEnv(llm_call=npc_call)
249
+
250
+ # Reset with an explicit case to avoid mutating internal environment state.
251
+ obs = env.reset(case_data=case)
252
+
253
+ print(f"πŸ” DETECTIVE BRIEFING:")
254
+ print(f" {case['detective_briefing']}")
255
+ print()
256
+ print("-" * 70)
257
+ print(" INVESTIGATION BEGINS")
258
+ print("-" * 70)
259
+
260
+ # We will build a trace of events for the frontend
261
+ trace = []
262
+
263
+ # Store the scenario details initially
264
+ trace.append({
265
+ "type": "scenario",
266
+ "case": {
267
+ "crime": case["crime"],
268
+ "location": case["location"],
269
+ "time": case["time"],
270
+ "criminal": case["criminal"]
271
+ }
272
+ })
273
+
274
+ # Get scripted detective actions
275
+ actions = scripted_detective_actions(case)
276
+
277
+ done = False
278
+ total_reward = 0.0
279
+ last_info = {}
280
+
281
+ # Deduplication trackers for trace events (Bug 4 & Bug 5)
282
+ already_traced_evidence = set()
283
+ contradictions_this_turn = set()
284
+
285
+ for i, action in enumerate(actions):
286
+ if done:
287
+ break
288
+
289
+ print(f"\nπŸ•΅οΈ [Turn {i}] DETECTIVE:")
290
+ print(f" {action}")
291
+
292
+ obs, reward, done, info = env.step(action)
293
+ total_reward += reward # accumulate step deltas (Bug 1 fix: accuse now returns delta)
294
+ last_info = info
295
+
296
+ # Parse the action specifically for the trace
297
+ action_parts = dict(p.strip().split(": ", 1) for p in action.split(" | ") if ": " in p)
298
+ trace.append({
299
+ "type": "detective",
300
+ "action": action_parts.get("ACTION", action),
301
+ "target": action_parts.get("TARGET", ""),
302
+ "item": action_parts.get("ITEM", ""),
303
+ "text": action_parts.get("CONTENT", ""),
304
+ "turn": obs.get("turn", i),
305
+ "reward_delta": float(reward) if not done else 0.0
306
+ })
307
+
308
+ # Print response if there's a new conversation entry
309
+ if env.conversation_history:
310
+ last_entry = env.conversation_history[-1]
311
+ if last_entry["speaker"] != "Detective":
312
+ print(f"\nπŸ’¬ [{last_entry['speaker']}]:")
313
+ print(f" {last_entry['content']}")
314
+
315
+ trace.append({
316
+ "type": "response",
317
+ "speaker": last_entry["speaker"],
318
+ "turn": obs.get("turn", i),
319
+ "text": last_entry["content"]
320
+ })
321
+
322
+ if last_entry.get("flags"):
323
+ # Reset per-turn contradiction tracker each turn
324
+ contradictions_this_turn_local = set()
325
+ for flag in last_entry["flags"]:
326
+ print(f" ⚠️ {flag}")
327
+ if "CONTRADICTION" in flag:
328
+ turn_key = f"{last_entry['speaker']}_{obs.get('turn', i)}"
329
+ if turn_key not in contradictions_this_turn_local:
330
+ contradictions_this_turn_local.add(turn_key)
331
+ trace.append({
332
+ "type": "contradiction",
333
+ "suspect": last_entry["speaker"],
334
+ "turn": obs.get("turn", i),
335
+ "detail": flag
336
+ })
337
+
338
+ # Check newly revealed evidence β€” deduplicated
339
+ if obs.get("evidence_log"):
340
+ new_ev = obs["evidence_log"][-1]
341
+ item_key = new_ev.get("name", "")
342
+ if item_key and item_key not in already_traced_evidence:
343
+ already_traced_evidence.add(item_key)
344
+ trace.append({
345
+ "type": "evidence",
346
+ "item": str(new_ev.get("name", "Unknown")),
347
+ "desc": str(new_ev.get("description", "")),
348
+ "turn": obs.get("turn", i)
349
+ })
350
+
351
+ if not done:
352
+ print(f" Step reward: {reward:+.2f}")
353
+
354
+ # ── Results ─────────────────────────────────────────────────────────
355
+
356
+ print("\n" + "=" * 70)
357
+ print(" EPISODE RESULTS")
358
+ print("=" * 70)
359
+
360
+ # Accusation result
361
+ if last_info.get("action") == "accuse":
362
+ correct = last_info.get("correct", False)
363
+ target = last_info.get("target", "?")
364
+ actual = last_info.get("actual_criminal", "?")
365
+ emoji = "βœ…" if correct else "❌"
366
+ print(f"\n {emoji} Accusation: {target}")
367
+ print(f" Actual criminal: {actual}")
368
+ print(f" Result: {'CORRECT!' if correct else 'WRONG!'}")
369
+
370
+ trace.append({
371
+ "type": "outcome",
372
+ "correct": correct,
373
+ "target": target,
374
+ "actual": actual
375
+ })
376
+ else:
377
+ print(f"\n ⏰ Result: TIMEOUT (no accusation made)")
378
+ trace.append({
379
+ "type": "outcome",
380
+ "correct": False,
381
+ "timeout": True
382
+ })
383
+
384
+ # Print full reward breakdown
385
+ rewards = env.reward_calc.get_rewards()
386
+ print(f"\n πŸ“Š REWARD BREAKDOWN:")
387
+ print(f" Detective: {rewards['detective']:>+8.2f}")
388
+ print(f" Suspect_A: {rewards['suspect_a']:>+8.2f}")
389
+ print(f" Suspect_B: {rewards['suspect_b']:>+8.2f}")
390
+ print(f" Witness: {rewards['witness']:>+8.2f}")
391
+
392
+ # Print consistency tracker summary
393
+ contradictions = env.tracker.get_summary()
394
+ print(f"\n πŸ”Ž CONTRADICTIONS DETECTED: {len(contradictions)}")
395
+ for c in contradictions:
396
+ print(
397
+ f" {c['agent']} on '{c['topic']}': "
398
+ f"'{c['old_value'][:50]}' β†’ '{c['new_value'][:50]}' (turn {c['turn']})"
399
+ )
400
+
401
+ # Print event log
402
+ print(f"\n πŸ“ EVENT LOG ({len(env.reward_calc.get_event_log())} events):")
403
+ for event in env.reward_calc.get_event_log():
404
+ e_type = event['event']
405
+ delta = event['delta']
406
+ print(f" {e_type}: {delta:+.2f}")
407
+
408
+ print("\n" + "=" * 70)
409
+ print(" Full conversation log:")
410
+ env.render()
411
+
412
+ return rewards, last_info, trace
413
+
414
+
415
+ # ── Entry Point ─────────────────────────────────────────────────────────────
416
+
417
+ if __name__ == "__main__":
418
+ rewards, info, _trace = run_test_episode()
419
+
420
+ # Quick assertion check
421
+ print("\nπŸ§ͺ VERIFICATION CHECKS:")
422
+
423
+ # Case generator
424
+ case = generate_case()
425
+ assert case["criminal"] in ("Suspect_A", "Suspect_B"), "Criminal must be one of the suspects"
426
+ assert len(case["physical_evidence"]) == 3, "Must have 3 physical evidence items"
427
+ assert "detective_briefing" in case, "Must have detective briefing"
428
+ print(" βœ… Case generator: OK")
429
+
430
+ # Consistency tracker β€” test real contradiction vs non-contradiction
431
+ ct = ConsistencyTracker()
432
+ r1 = ct.record_claim("Suspect_A", "alibi", "I was at the bar all evening", 1)
433
+ assert r1["contradicted"] == False, "First claim should not be contradicted"
434
+
435
+ r2 = ct.record_claim("Suspect_A", "alibi", "I was at the bar having drinks", 2)
436
+ assert r2["contradicted"] == False, "Same bar alibi should NOT be a contradiction"
437
+
438
+ r3 = ct.record_claim("Suspect_A", "alibi", "I was at home all evening", 3)
439
+ assert r3["contradicted"] == True, "bar vs home IS a real contradiction"
440
+
441
+ r4 = ct.record_claim("Suspect_A", "location", "east wing", 4)
442
+ r5 = ct.record_claim("Suspect_A", "location", "parking lot", 5)
443
+ assert r5["contradicted"] == True, "east wing vs parking lot is a contradiction"
444
+ print(" βœ… Consistency tracker: OK (semantic normalization working)")
445
+
446
+ # Reward calculator
447
+ rc = RewardCalculator()
448
+ rc.apply_event("correct_accusation")
449
+ rc.apply_event("per_turn_cost")
450
+ r = rc.get_rewards()
451
+ assert abs(r["detective"] - 9.7) < 0.01, f"Expected 9.7, got {r['detective']}"
452
+ print(" βœ… Reward calculator: OK")
453
+
454
+ # System prompts β€” verify differentiation
455
+ prompts = {
456
+ role: build_system_prompt(role, case)
457
+ for role in ["detective", "suspect_a", "suspect_b", "witness"]
458
+ }
459
+ assert "ACTION:" in prompts["detective"], "Detective prompt must include action format"
460
+ assert "fake alibi" not in prompts["detective"].lower(), "Detective must not know the fake alibi"
461
+ criminal = case["criminal"]
462
+ innocent = SUSPECT_B if criminal == SUSPECT_A else SUSPECT_A
463
+ assert "FAKE ALIBI" in prompts["suspect_a" if criminal == SUSPECT_A else "suspect_b"], \
464
+ "Criminal suspect prompt must have fake alibi"
465
+ assert "REAL ALIBI" in prompts["suspect_b" if criminal == SUSPECT_A else "suspect_a"], \
466
+ "Innocent suspect prompt must have real alibi"
467
+ assert case["agent_knowledge"][AGENT_NAME_TO_KEY[criminal]]["fake_alibi"] in prompts["suspect_a" if criminal == SUSPECT_A else "suspect_b"], \
468
+ "Criminal suspect prompt must contain case-specific fake alibi"
469
+ assert case["agent_knowledge"][AGENT_NAME_TO_KEY[innocent]]["real_alibi"] in prompts["suspect_b" if criminal == SUSPECT_A else "suspect_a"], \
470
+ "Innocent suspect prompt must contain case-specific real alibi"
471
+ print(" βœ… Agent prompts: OK (alibis differentiated)")
472
+
473
+ print("\nβœ… All verification checks passed!")
train_colab.ipynb ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {
6
+ "language": "markdown"
7
+ },
8
+ "source": [
9
+ "# AI Crime Investigation World - Colab Training\n",
10
+ "\n",
11
+ "This notebook runs a minimal PPO training loop using TRL and logs reward progress for demo/judging."
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "code",
16
+ "execution_count": null,
17
+ "metadata": {
18
+ "language": "python"
19
+ },
20
+ "outputs": [],
21
+ "source": [
22
+ "# Install dependencies\n",
23
+ "!pip install -q -r requirements-train.txt"
24
+ ]
25
+ },
26
+ {
27
+ "cell_type": "code",
28
+ "execution_count": null,
29
+ "metadata": {
30
+ "language": "python"
31
+ },
32
+ "outputs": [],
33
+ "source": [
34
+ "# Optional: clone repo if running in a fresh Colab runtime\n",
35
+ "# !git clone <YOUR_REPO_URL>\n",
36
+ "# %cd SST_Final\n",
37
+ "\n",
38
+ "import torch\n",
39
+ "print('CUDA available:', torch.cuda.is_available())\n",
40
+ "if torch.cuda.is_available():\n",
41
+ " print('GPU:', torch.cuda.get_device_name(0))"
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "code",
46
+ "execution_count": null,
47
+ "metadata": {
48
+ "language": "python"
49
+ },
50
+ "outputs": [],
51
+ "source": [
52
+ "# Smoke test run (fast)\n",
53
+ "import os\n",
54
+ "\n",
55
+ "os.environ['TEST_MODE'] = '1'\n",
56
+ "os.environ['NUM_EPISODES'] = '5'\n",
57
+ "os.environ['MAX_TURNS'] = '8'\n",
58
+ "os.environ['MODEL_NAME'] = 'Qwen/Qwen2.5-1.5B-Instruct'\n",
59
+ "os.environ['NPC_MODEL_NAME'] = 'Qwen/Qwen2.5-0.5B-Instruct'\n",
60
+ "\n",
61
+ "!python train_colab.py"
62
+ ]
63
+ },
64
+ {
65
+ "cell_type": "code",
66
+ "execution_count": null,
67
+ "metadata": {
68
+ "language": "python"
69
+ },
70
+ "outputs": [],
71
+ "source": [
72
+ "# Full demo run (edit values as needed)\n",
73
+ "import os\n",
74
+ "\n",
75
+ "os.environ['TEST_MODE'] = '0'\n",
76
+ "os.environ['NUM_EPISODES'] = '50'\n",
77
+ "os.environ['MODEL_NAME'] = 'Qwen/Qwen2.5-3B-Instruct'\n",
78
+ "# os.environ['NPC_MODEL_NAME'] = 'Qwen/Qwen2.5-0.5B-Instruct'\n",
79
+ "\n",
80
+ "!python train_colab.py"
81
+ ]
82
+ },
83
+ {
84
+ "cell_type": "code",
85
+ "execution_count": null,
86
+ "metadata": {
87
+ "language": "python"
88
+ },
89
+ "outputs": [],
90
+ "source": [
91
+ "# Display reward curve and summary metrics\n",
92
+ "import json\n",
93
+ "import matplotlib.pyplot as plt\n",
94
+ "\n",
95
+ "with open('rewards.json', 'r') as f:\n",
96
+ " data = json.load(f)\n",
97
+ "\n",
98
+ "rewards = data['rewards']\n",
99
+ "results = data.get('results', [])\n",
100
+ "\n",
101
+ "print('Episodes:', data.get('num_episodes', len(rewards)))\n",
102
+ "print('Model:', data.get('model', 'unknown'))\n",
103
+ "print('Correct:', results.count('correct'))\n",
104
+ "print('Wrong:', results.count('wrong'))\n",
105
+ "print('Timeout:', results.count('timeout'))\n",
106
+ "\n",
107
+ "plt.figure(figsize=(10, 4))\n",
108
+ "plt.plot(rewards, alpha=0.35, label='Per episode')\n",
109
+ "if len(rewards) > 1:\n",
110
+ " window = min(20, max(2, len(rewards) // 4))\n",
111
+ " smooth = []\n",
112
+ " for i in range(len(rewards)):\n",
113
+ " start = max(0, i - window + 1)\n",
114
+ " smooth.append(sum(rewards[start:i+1]) / (i - start + 1))\n",
115
+ " plt.plot(smooth, linewidth=2, label=f'Moving avg ({window})')\n",
116
+ "\n",
117
+ "plt.axhline(0, color='gray', linestyle='--', alpha=0.5)\n",
118
+ "plt.title('Detective Reward Over Training')\n",
119
+ "plt.xlabel('Episode')\n",
120
+ "plt.ylabel('Reward')\n",
121
+ "plt.legend()\n",
122
+ "plt.grid(alpha=0.25)\n",
123
+ "plt.show()"
124
+ ]
125
+ },
126
+ {
127
+ "cell_type": "code",
128
+ "execution_count": null,
129
+ "metadata": {
130
+ "language": "python"
131
+ },
132
+ "outputs": [],
133
+ "source": [
134
+ "# Show before/after transcript snapshots\n",
135
+ "import json\n",
136
+ "\n",
137
+ "with open('episode_transcripts.json', 'r') as f:\n",
138
+ " t = json.load(f)\n",
139
+ "\n",
140
+ "print('Captured episodes:', t.get('episodes_captured', []))\n",
141
+ "for ep in t.get('transcripts', []):\n",
142
+ " print('\\n' + '=' * 70)\n",
143
+ " print(f\"Episode {ep['episode']} | Result: {ep['result']} | Reward: {ep['detective_reward']}\")\n",
144
+ " for line in ep.get('conversation_history', [])[:10]:\n",
145
+ " speaker = line.get('speaker', 'Unknown')\n",
146
+ " content = line.get('content', '')\n",
147
+ " print(f\"- {speaker}: {content}\")\n",
148
+ " if len(ep.get('conversation_history', [])) > 10:\n",
149
+ " print('...')"
150
+ ]
151
+ }
152
+ ],
153
+ "metadata": {
154
+ "kernelspec": {
155
+ "display_name": "Python 3",
156
+ "language": "python",
157
+ "name": "python3"
158
+ },
159
+ "language_info": {
160
+ "name": "python"
161
+ }
162
+ },
163
+ "nbformat": 4,
164
+ "nbformat_minor": 5
165
+ }
train_colab.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Component 6 β€” Minimal Training Script (Colab-Ready).
3
+
4
+ Trains the detective agent using PPO (HuggingFace TRL) while other agents
5
+ use fixed prompt-based LLM calls. Designed for free-tier Colab GPU.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import inspect
11
+ import sys
12
+ import time
13
+ import warnings
14
+ from typing import Optional
15
+
16
+ import matplotlib.pyplot as plt
17
+ import numpy as np
18
+ import torch
19
+ from transformers import (
20
+ AutoConfig,
21
+ AutoModelForCausalLM,
22
+ AutoTokenizer,
23
+ BitsAndBytesConfig,
24
+ pipeline,
25
+ )
26
+ from peft import LoraConfig, TaskType
27
+ from trl import PPOConfig, PPOTrainer, AutoModelForCausalLMWithValueHead
28
+
29
+ # Add project root to path
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+
32
+ from crime_env.environment import CrimeInvestigationEnv
33
+ from crime_env.agent_prompts import build_system_prompt
34
+
35
+
36
+ # ── Configuration ───────────────────────────────────────────────────────────
37
+
38
+
39
+ def _env_bool(name: str, default: bool = False) -> bool:
40
+ value = os.environ.get(name)
41
+ if value is None:
42
+ return default
43
+ return value.strip().lower() in {"1", "true", "yes", "on"}
44
+
45
+
46
+ def _env_int(name: str, default: int) -> int:
47
+ value = os.environ.get(name)
48
+ if value is None:
49
+ return default
50
+ try:
51
+ return int(value)
52
+ except ValueError:
53
+ return default
54
+
55
+ # Default to a stronger model while keeping env override support.
56
+ TEST_MODE = _env_bool("TEST_MODE", False)
57
+ MODEL_NAME = os.environ.get("MODEL_NAME", "Qwen/Qwen2.5-3B-Instruct")
58
+ NPC_MODEL_NAME = os.environ.get(
59
+ "NPC_MODEL_NAME",
60
+ "Qwen/Qwen2.5-0.5B-Instruct" if TEST_MODE else MODEL_NAME,
61
+ )
62
+ NUM_EPISODES = _env_int("NUM_EPISODES", 5 if TEST_MODE else 300)
63
+ MAX_TURNS = _env_int("MAX_TURNS", 8 if TEST_MODE else 15)
64
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
65
+ OUTPUT_DIR = "./ppo_detective"
66
+ REWARDS_FILE = "rewards.json"
67
+ TRANSCRIPTS_FILE = "episode_transcripts.json"
68
+ NPC_MAX_NEW_TOKENS = _env_int("NPC_MAX_NEW_TOKENS", 64 if TEST_MODE else 150)
69
+ DETECTIVE_MAX_NEW_TOKENS = _env_int(
70
+ "DETECTIVE_MAX_NEW_TOKENS", 48 if TEST_MODE else 80
71
+ )
72
+ DETECTIVE_RETRY_MAX_NEW_TOKENS = _env_int(
73
+ "DETECTIVE_RETRY_MAX_NEW_TOKENS", 32 if TEST_MODE else 64
74
+ )
75
+ TRANSCRIPT_EPISODES = {
76
+ int(x.strip())
77
+ for x in os.environ.get("TRANSCRIPT_EPISODES", "1,25,50").split(",")
78
+ if x.strip().isdigit()
79
+ }
80
+ STRICT_FORMAT_FALLBACK_THRESHOLD = float(
81
+ os.environ.get("STRICT_FORMAT_FALLBACK_THRESHOLD", "0.35")
82
+ )
83
+ STRICT_FORMAT_WINDOW = _env_int("STRICT_FORMAT_WINDOW", 5)
84
+
85
+
86
+ # ── Model Loading ───────────────────────────────────────────────────────────
87
+
88
+
89
+ def load_models():
90
+ """Load the detective (trainable) and NPC (fixed) models."""
91
+ print(f"Loading model: {MODEL_NAME}")
92
+ print(f"Device: {DEVICE}")
93
+
94
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
95
+ if tokenizer.pad_token is None:
96
+ tokenizer.pad_token = tokenizer.eos_token
97
+
98
+ model_config = AutoConfig.from_pretrained(MODEL_NAME, trust_remote_code=True)
99
+ hidden_size = getattr(model_config, "hidden_size", None)
100
+ use_quantization = DEVICE == "cuda" and (hidden_size is None or hidden_size >= 64)
101
+
102
+ # Quantization for memory efficiency on free Colab
103
+ quant_config = None
104
+ if use_quantization:
105
+ try:
106
+ quant_config = BitsAndBytesConfig(
107
+ load_in_4bit=True,
108
+ bnb_4bit_compute_dtype=torch.float16,
109
+ bnb_4bit_use_double_quant=True,
110
+ bnb_4bit_quant_type="nf4",
111
+ )
112
+ except Exception:
113
+ print("BitsAndBytes not available, loading without quantization")
114
+ elif DEVICE == "cuda":
115
+ print("Skipping 4-bit quantization for small hidden-size model")
116
+
117
+ # LoRA config β€” enables PEFT so PPOTrainer reuses base weights as the
118
+ # reference model instead of deepcopy-ing the quantized model (OOM fix).
119
+ lora_config = LoraConfig(
120
+ r=8,
121
+ lora_alpha=16,
122
+ target_modules=["q_proj", "v_proj"],
123
+ lora_dropout=0.05,
124
+ bias="none",
125
+ task_type=TaskType.CAUSAL_LM,
126
+ ) if DEVICE == "cuda" else None
127
+
128
+ # Detective model (trainable with value head)
129
+ model_dtype = torch.float16 if DEVICE == "cuda" else torch.float32
130
+ detective_load_kwargs = {
131
+ "quantization_config": quant_config,
132
+ "peft_config": lora_config,
133
+ "dtype": model_dtype,
134
+ "device_map": "auto" if DEVICE == "cuda" else None,
135
+ "trust_remote_code": True,
136
+ }
137
+
138
+ try:
139
+ detective_model = AutoModelForCausalLMWithValueHead.from_pretrained(
140
+ MODEL_NAME,
141
+ **detective_load_kwargs,
142
+ )
143
+ except ValueError as e:
144
+ error_text = str(e)
145
+ if DEVICE == "cuda" and "dispatched on the CPU or the disk" in error_text:
146
+ print("Low VRAM detected, retrying with CPU offload enabled for quantized layers...")
147
+ offload_quant_config = BitsAndBytesConfig(
148
+ load_in_4bit=True,
149
+ bnb_4bit_compute_dtype=torch.float16,
150
+ bnb_4bit_use_double_quant=True,
151
+ bnb_4bit_quant_type="nf4",
152
+ llm_int8_enable_fp32_cpu_offload=True,
153
+ )
154
+ detective_load_kwargs["quantization_config"] = offload_quant_config
155
+ detective_load_kwargs["device_map"] = "auto"
156
+ detective_model = AutoModelForCausalLMWithValueHead.from_pretrained(
157
+ MODEL_NAME,
158
+ **detective_load_kwargs,
159
+ )
160
+ else:
161
+ raise
162
+
163
+ # NPC pipeline β€” load a SEPARATE frozen copy of the base model.
164
+ # Bug 9 fix: Using detective_model.pretrained_model would cause PPO
165
+ # gradient updates to drift the NPC's behavior every step.
166
+ npc_base_model = None
167
+ try:
168
+ npc_base_model = AutoModelForCausalLM.from_pretrained(
169
+ NPC_MODEL_NAME,
170
+ quantization_config=quant_config,
171
+ dtype=model_dtype,
172
+ device_map="auto" if DEVICE == "cuda" else None,
173
+ trust_remote_code=True,
174
+ )
175
+ npc_base_model.eval() # Freeze: no gradient tracking
176
+ for param in npc_base_model.parameters():
177
+ param.requires_grad = False
178
+ except Exception as e:
179
+ print(f"NPC model load warning: {e}")
180
+ print("Falling back to detective base model for NPC responses to keep training running.")
181
+ npc_base_model = detective_model.pretrained_model
182
+
183
+ npc_pipeline = pipeline(
184
+ "text-generation",
185
+ model=npc_base_model,
186
+ tokenizer=tokenizer,
187
+ max_new_tokens=NPC_MAX_NEW_TOKENS,
188
+ do_sample=True,
189
+ temperature=0.7,
190
+ top_p=0.9,
191
+ )
192
+
193
+ return detective_model, npc_pipeline, tokenizer
194
+
195
+
196
+ # ── NPC LLM Call ────────────────────────────────────────────────────────────
197
+
198
+
199
+ def make_npc_call(npc_pipeline):
200
+ """Create a callable for NPC agent responses."""
201
+
202
+ def llm_call(system_prompt: str, conversation_history: list[dict]) -> str:
203
+ # Build a bounded prompt to avoid model context overflows.
204
+ messages = f"System: {system_prompt[:800]}\n\n"
205
+
206
+ # Keep only recent turns and constrain prompt length.
207
+ recent_history = conversation_history[-10:]
208
+ for entry in recent_history:
209
+ speaker = entry.get("speaker", "Unknown")
210
+ content = entry.get("content", "")
211
+ messages += f"{speaker}: {content[:180]}\n"
212
+
213
+ # Hard cap prompt size for small-context models.
214
+ messages = messages[-1800:]
215
+
216
+ messages += "\nYour response:"
217
+
218
+ try:
219
+ result = npc_pipeline(messages, return_full_text=False)
220
+ response = result[0]["generated_text"].strip()
221
+ # Clean up: take first sentence/paragraph
222
+ if "\n" in response:
223
+ response = response.split("\n")[0]
224
+ return response[:300] if response else "I have nothing to add."
225
+ except Exception as e:
226
+ print(f" NPC call error: {e}")
227
+ return "I don't recall anything specific about that."
228
+
229
+ return llm_call
230
+
231
+
232
+ # ── Detective Action Generation ─────────────────────────────────────────────
233
+
234
+
235
+ def generate_detective_action(
236
+ detective_model,
237
+ tokenizer,
238
+ observation: dict,
239
+ env=None,
240
+ strict_action_format: bool = False,
241
+ ) -> tuple[str, torch.Tensor, torch.Tensor, bool]:
242
+ """Generate a detective action using the trainable model.
243
+
244
+ Returns:
245
+ (action_string, query_tensor, response_tensor, used_fallback)
246
+ """
247
+ # Build prompt from observation
248
+ prompt = f"""You are a detective. Based on the conversation so far, choose your next action.
249
+
250
+ Briefing: {observation['briefing'][:300]}
251
+
252
+ Turn: {observation['turn']}/{MAX_TURNS}
253
+
254
+ Recent conversation:
255
+ """
256
+ history = observation.get("conversation_history", [])
257
+ for entry in history[-6:]:
258
+ prompt += f" {entry['speaker']}: {entry['content'][:100]}\n"
259
+
260
+ prompt += """
261
+ Choose ONE action using EXACTLY this format:
262
+ ACTION: ask_question | TARGET: Suspect_A | CONTENT: <question>
263
+ ACTION: request_evidence | ITEM: keycard_log
264
+ ACTION: accuse | TARGET: Suspect_A
265
+
266
+ Your action:
267
+ """
268
+
269
+ if strict_action_format:
270
+ prompt += (
271
+ "\nIMPORTANT: Output ONLY a single valid ACTION line. "
272
+ "No explanations, no extra text.\n"
273
+ )
274
+
275
+ # Tokenize
276
+ inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=1024)
277
+ query_tensor = inputs["input_ids"].squeeze()
278
+
279
+ if DEVICE == "cuda":
280
+ inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
281
+
282
+ # Generate
283
+ with torch.no_grad():
284
+ output = detective_model.pretrained_model.generate(
285
+ **inputs,
286
+ max_new_tokens=DETECTIVE_MAX_NEW_TOKENS,
287
+ do_sample=True,
288
+ temperature=0.4 if strict_action_format else 0.8,
289
+ top_p=0.8 if strict_action_format else 0.9,
290
+ pad_token_id=tokenizer.pad_token_id,
291
+ )
292
+
293
+ response_tensor = output.squeeze()[len(query_tensor):]
294
+ action_text = tokenizer.decode(response_tensor, skip_special_tokens=True).strip()
295
+
296
+ def _is_valid_action(text: str) -> bool:
297
+ normalized = text.strip().upper()
298
+ return normalized.startswith("ACTION:")
299
+
300
+ # Retry once in strict mode before falling back to scripted actions.
301
+ if not _is_valid_action(action_text):
302
+ strict_prompt = prompt + (
303
+ "\nFINAL REMINDER: Reply with one line starting with 'ACTION:' only.\n"
304
+ )
305
+ strict_inputs = tokenizer(
306
+ strict_prompt, return_tensors="pt", truncation=True, max_length=1024
307
+ )
308
+ if DEVICE == "cuda":
309
+ strict_inputs = {k: v.to(DEVICE) for k, v in strict_inputs.items()}
310
+
311
+ with torch.no_grad():
312
+ retry_output = detective_model.pretrained_model.generate(
313
+ **strict_inputs,
314
+ max_new_tokens=DETECTIVE_RETRY_MAX_NEW_TOKENS,
315
+ do_sample=True,
316
+ temperature=0.25,
317
+ top_p=0.75,
318
+ pad_token_id=tokenizer.pad_token_id,
319
+ )
320
+
321
+ strict_query_tensor = strict_inputs["input_ids"].squeeze().detach().cpu()
322
+ retry_response_tensor = retry_output.squeeze()[len(strict_query_tensor):]
323
+ retry_action_text = tokenizer.decode(
324
+ retry_response_tensor, skip_special_tokens=True
325
+ ).strip()
326
+ if _is_valid_action(retry_action_text):
327
+ query_tensor = strict_query_tensor
328
+ response_tensor = retry_response_tensor
329
+ action_text = retry_action_text
330
+
331
+ used_fallback = False
332
+
333
+ # If model output doesn't parse, generate a deterministic fallback action.
334
+ if not _is_valid_action(action_text):
335
+ used_fallback = True
336
+ turn = observation.get("turn", 0)
337
+ if turn < 4:
338
+ action_text = "ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you at the time of the crime?"
339
+ elif turn < 8:
340
+ action_text = "ACTION: ask_question | TARGET: Suspect_B | CONTENT: Can you describe your alibi?"
341
+ elif turn < 10:
342
+ action_text = "ACTION: request_evidence | ITEM: keycard_log"
343
+ else:
344
+ action_text = "ACTION: ask_question | TARGET: Witness_1 | CONTENT: What did you see?"
345
+
346
+ # Never train PPO on fallback actions: they are scripted and off-policy.
347
+ if used_fallback:
348
+ response_tensor = tokenizer(
349
+ action_text,
350
+ return_tensors="pt",
351
+ add_special_tokens=False,
352
+ )["input_ids"].squeeze()
353
+
354
+ return action_text, query_tensor, response_tensor, used_fallback
355
+
356
+
357
+ # ── Training Loop ───────────────────────────────────────────────────────────
358
+
359
+
360
+ def train():
361
+ """Main PPO training loop."""
362
+ print("=" * 60)
363
+ print(" AI Crime Investigation World β€” PPO Training")
364
+ print("=" * 60)
365
+ if TEST_MODE:
366
+ print(" Running in TEST_MODE (smoke-test settings enabled)")
367
+ print(
368
+ f" MODEL_NAME={MODEL_NAME} | NPC_MODEL_NAME={NPC_MODEL_NAME} | "
369
+ f"NUM_EPISODES={NUM_EPISODES} | MAX_TURNS={MAX_TURNS}"
370
+ )
371
+ print("=" * 60)
372
+
373
+ # Load models
374
+ detective_model, npc_pipeline, tokenizer = load_models()
375
+
376
+ warnings.filterwarnings(
377
+ "ignore",
378
+ message="No dataset is provided.*",
379
+ category=UserWarning,
380
+ )
381
+
382
+ # Compatibility guard: this script targets the TRL 0.9 PPOTrainer API.
383
+ ppo_init_params = inspect.signature(PPOTrainer.__init__).parameters
384
+ if "config" not in ppo_init_params:
385
+ raise RuntimeError(
386
+ "Incompatible TRL version detected. "
387
+ "Use `trl>=0.9.0,<0.10.0` for this training script."
388
+ )
389
+
390
+ # PPO config (support TRL variants with either ppo_epochs or num_ppo_epochs)
391
+ ppo_cfg_kwargs = {
392
+ "learning_rate": 1e-5,
393
+ "batch_size": 1,
394
+ "mini_batch_size": 1,
395
+ "gradient_accumulation_steps": 1,
396
+ }
397
+ ppo_cfg_params = inspect.signature(PPOConfig.__init__).parameters
398
+ if "num_ppo_epochs" in ppo_cfg_params:
399
+ ppo_cfg_kwargs["num_ppo_epochs"] = 1 # single-sample stability
400
+ elif "ppo_epochs" in ppo_cfg_params:
401
+ ppo_cfg_kwargs["ppo_epochs"] = 1 # single-sample stability
402
+
403
+ ppo_config = PPOConfig(**ppo_cfg_kwargs)
404
+
405
+ # PPO Trainer
406
+ ppo_trainer = PPOTrainer(
407
+ config=ppo_config,
408
+ model=detective_model,
409
+ tokenizer=tokenizer,
410
+ )
411
+
412
+ # Environment
413
+ npc_call = make_npc_call(npc_pipeline)
414
+ env = CrimeInvestigationEnv(llm_call=npc_call)
415
+
416
+ # Tracking
417
+ reward_log = []
418
+ results_log = []
419
+ transcript_log = []
420
+
421
+ print(f"\nStarting training for {NUM_EPISODES} episodes...\n")
422
+
423
+ strict_action_format = False
424
+ fallback_rate_history: list[float] = []
425
+
426
+ for episode in range(NUM_EPISODES):
427
+ t0 = time.time()
428
+
429
+ # Reset environment
430
+ obs = env.reset()
431
+ done = False
432
+ fallback_steps = 0
433
+ total_steps = 0
434
+ ppo_updates = 0
435
+
436
+ while not done:
437
+ # Generate detective action
438
+ action_text, query_tensor, response_tensor, used_fallback = (
439
+ generate_detective_action(
440
+ detective_model,
441
+ tokenizer,
442
+ obs,
443
+ env=env,
444
+ strict_action_format=strict_action_format,
445
+ )
446
+ )
447
+
448
+ # Step environment
449
+ obs, reward, done, info = env.step(action_text)
450
+ total_steps += 1
451
+
452
+ if not used_fallback:
453
+ # PPOConfig uses batch_size=1, so update on each valid step.
454
+ try:
455
+ reward_tensor = torch.tensor([reward], dtype=torch.float32)
456
+ ppo_trainer.step(
457
+ [query_tensor],
458
+ [response_tensor],
459
+ [reward_tensor],
460
+ )
461
+ ppo_updates += 1
462
+ except Exception as e:
463
+ print(f" PPO update error (episode {episode}, step {total_steps}): {e}")
464
+ else:
465
+ fallback_steps += 1
466
+
467
+ # Get final rewards
468
+ final_rewards = env.reward_calc.get_rewards()
469
+ detective_reward = final_rewards["detective"]
470
+ reward_log.append(detective_reward)
471
+
472
+ # Determine result
473
+ last_info = info
474
+ if last_info.get("action") == "accuse":
475
+ result = "correct" if last_info.get("correct") else "wrong"
476
+ else:
477
+ result = "timeout"
478
+ results_log.append(result)
479
+
480
+ if (episode + 1) in TRANSCRIPT_EPISODES:
481
+ transcript_log.append(
482
+ {
483
+ "episode": episode + 1,
484
+ "result": result,
485
+ "detective_reward": round(detective_reward, 4),
486
+ "turns": env.turn,
487
+ "criminal": env.case.get("criminal") if env.case else None,
488
+ "crime": env.case.get("crime") if env.case else None,
489
+ "location": env.case.get("location") if env.case else None,
490
+ "conversation_history": list(env.conversation_history),
491
+ "evidence_log": list(env.evidence_log),
492
+ }
493
+ )
494
+
495
+ fallback_rate = fallback_steps / max(1, total_steps)
496
+ fallback_rate_history.append(fallback_rate)
497
+ if len(fallback_rate_history) > max(1, STRICT_FORMAT_WINDOW):
498
+ fallback_rate_history.pop(0)
499
+ rolling_fallback_rate = sum(fallback_rate_history) / len(fallback_rate_history)
500
+ strict_action_format = rolling_fallback_rate > STRICT_FORMAT_FALLBACK_THRESHOLD
501
+
502
+ elapsed = time.time() - t0
503
+ print(
504
+ f"Episode {episode + 1:>3}/{NUM_EPISODES} | "
505
+ f"Result: {result:<7} | "
506
+ f"Reward: {detective_reward:>+7.2f} | "
507
+ f"Turns: {env.turn:>2} | "
508
+ f"PPO updates: {ppo_updates:>2} | "
509
+ f"Fallback: {fallback_steps:>2}/{max(1, total_steps):<2} "
510
+ f"({fallback_rate*100:>5.1f}%, avg={rolling_fallback_rate*100:>5.1f}%) | "
511
+ f"Time: {elapsed:.1f}s"
512
+ )
513
+
514
+ # ── Save results ────────────────────────────────────────────────────
515
+
516
+ # Save reward log
517
+ with open(REWARDS_FILE, "w") as f:
518
+ json.dump(
519
+ {
520
+ "rewards": reward_log,
521
+ "results": results_log,
522
+ "num_episodes": NUM_EPISODES,
523
+ "model": MODEL_NAME,
524
+ },
525
+ f,
526
+ indent=2,
527
+ )
528
+ print(f"\nReward log saved to {REWARDS_FILE}")
529
+
530
+ with open(TRANSCRIPTS_FILE, "w") as f:
531
+ json.dump(
532
+ {
533
+ "episodes_captured": sorted([t["episode"] for t in transcript_log]),
534
+ "transcripts": transcript_log,
535
+ },
536
+ f,
537
+ indent=2,
538
+ )
539
+ print(f"Episode transcripts saved to {TRANSCRIPTS_FILE}")
540
+
541
+ # Plot reward curve
542
+ _plot_reward_curve(reward_log)
543
+
544
+ # Summary statistics
545
+ print("\n" + "=" * 60)
546
+ print(" TRAINING SUMMARY")
547
+ print("=" * 60)
548
+ print(f" Episodes: {NUM_EPISODES}")
549
+ print(f" Correct accusations: {results_log.count('correct')}")
550
+ print(f" Wrong accusations: {results_log.count('wrong')}")
551
+ print(f" Timeouts: {results_log.count('timeout')}")
552
+ print(f" Mean reward (last 50): {np.mean(reward_log[-50:]):.2f}")
553
+ print(f" Mean reward (first 50): {np.mean(reward_log[:50]):.2f}")
554
+ print("=" * 60)
555
+
556
+
557
+ def _plot_reward_curve(reward_log: list[float]) -> None:
558
+ """Plot and save the reward curve."""
559
+ fig, ax = plt.subplots(figsize=(12, 5))
560
+
561
+ episodes = np.arange(1, len(reward_log) + 1)
562
+ ax.plot(episodes, reward_log, alpha=0.3, color="#4a90d9", label="Per-episode")
563
+
564
+ # Smoothed curve (rolling average)
565
+ window = min(20, len(reward_log) // 4)
566
+ if window > 1:
567
+ smoothed = np.convolve(
568
+ reward_log, np.ones(window) / window, mode="valid"
569
+ )
570
+ ax.plot(
571
+ episodes[window - 1:],
572
+ smoothed,
573
+ color="#e74c3c",
574
+ linewidth=2,
575
+ label=f"Rolling avg ({window} ep)",
576
+ )
577
+
578
+ ax.set_xlabel("Episode", fontsize=12)
579
+ ax.set_ylabel("Detective Reward", fontsize=12)
580
+ ax.set_title("AI Crime Investigation β€” Detective Reward Over Training", fontsize=14)
581
+ ax.legend()
582
+ ax.grid(True, alpha=0.3)
583
+ ax.axhline(y=0, color="grey", linestyle="--", alpha=0.5)
584
+
585
+ plt.tight_layout()
586
+ plt.savefig("reward_curve.png", dpi=150)
587
+ print("Reward curve saved to reward_curve.png")
588
+ plt.show()
589
+
590
+
591
+ # ── Entry Point ─────────────────────────────────────────────────────────────
592
+
593
+ if __name__ == "__main__":
594
+ train()