padmapriyagosakan commited on
Commit
d727210
·
0 Parent(s):

Initial Commit

Browse files
Files changed (17) hide show
  1. .gitignore +14 -0
  2. Dockerfile +44 -0
  3. PayOps_v2.postman_collection.json +1196 -0
  4. README.md +278 -0
  5. TESTING.md +905 -0
  6. __init__.py +0 -0
  7. environment.py +287 -0
  8. grader.py +332 -0
  9. models.py +239 -0
  10. openenv.yaml +53 -0
  11. requirements.txt +4 -0
  12. run_tests.sh +591 -0
  13. scripts/baseline_agent.py +159 -0
  14. scripts_util.py +170 -0
  15. server/__init__.py +0 -0
  16. server/app.py +399 -0
  17. tasks.py +668 -0
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .env
10
+ .venv
11
+ venv/
12
+ env/
13
+ *.log
14
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------------------------------------------------------------------
2
+ # PayOps OpenEnv — Production Dockerfile
3
+ # ---------------------------------------------------------------------------
4
+ # Builds a lightweight, reproducible container that runs the FastAPI server.
5
+ # Compatible with HuggingFace Spaces (port 7860) and local development (8000).
6
+ # ---------------------------------------------------------------------------
7
+
8
+ FROM python:3.11-slim
9
+
10
+ # Non-root user for security
11
+ RUN useradd -m -u 1000 appuser
12
+
13
+ WORKDIR /app
14
+
15
+ # Install system dependencies
16
+ RUN apt-get update && \
17
+ apt-get install -y --no-install-recommends curl && \
18
+ rm -rf /var/lib/apt/lists/*
19
+
20
+ # Copy and install Python dependencies first (layer caching)
21
+ COPY requirements.txt ./
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # Copy project source
25
+ COPY . /app/payops_env
26
+
27
+ # Put parent directory on PYTHONPATH so `payops_env` is importable
28
+ ENV PYTHONPATH="/app:${PYTHONPATH}"
29
+
30
+ # HuggingFace Spaces requires port 7860; default to 8000 locally
31
+ ENV PORT=7860
32
+
33
+ # Switch to non-root user
34
+ USER appuser
35
+
36
+ EXPOSE 7860
37
+ EXPOSE 8000
38
+
39
+ # Health check
40
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
41
+ CMD curl -f http://localhost:${PORT}/health || exit 1
42
+
43
+ CMD ["sh", "-c", "uvicorn payops_env.server.app:app --host 0.0.0.0 --port ${PORT}"]
44
+
PayOps_v2.postman_collection.json ADDED
@@ -0,0 +1,1196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "info": {
3
+ "_postman_id": "payops-v2-full-suite",
4
+ "name": "PayOps v2 — Full Test Suite",
5
+ "description": "Complete Postman collection for PayOps v2.0.0 environment.\nGroups: Infrastructure · Tasks · Reset · Terminal Actions · Wrong Actions · Investigation · State · Grader · Replay · Baseline · Analytics · Leaderboard · Edge Cases · Perfect Episode · Budget Mechanics\n\nBase URL variable: {{base_url}} = http://localhost:8000",
6
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
7
+ },
8
+ "variable": [
9
+ {
10
+ "key": "base_url",
11
+ "value": "http://localhost:8000",
12
+ "type": "string"
13
+ },
14
+ {
15
+ "key": "episode_id",
16
+ "value": "",
17
+ "type": "string"
18
+ }
19
+ ],
20
+ "item": [
21
+
22
+ {
23
+ "name": "A — Infrastructure",
24
+ "item": [
25
+ {
26
+ "name": "A-01 Health check",
27
+ "event": [{"listen": "test", "script": {"exec": [
28
+ "pm.test('Status 200', () => pm.response.to.have.status(200));",
29
+ "pm.test('status=ok', () => pm.expect(pm.response.json().status).to.eql('ok'));",
30
+ "pm.test('version=2.0.0', () => pm.expect(pm.response.json().version).to.eql('2.0.0'));"
31
+ ], "type": "text/javascript"}}],
32
+ "request": {
33
+ "method": "GET",
34
+ "url": {"raw": "{{base_url}}/health", "host": ["{{base_url}}"], "path": ["health"]}
35
+ }
36
+ },
37
+ {
38
+ "name": "A-02 Schema — models present",
39
+ "event": [{"listen": "test", "script": {"exec": [
40
+ "pm.test('Status 200', () => pm.response.to.have.status(200));",
41
+ "const body = pm.response.text();",
42
+ "pm.test('PayOpsAction in schema', () => pm.expect(body).to.include('PayOpsAction'));",
43
+ "pm.test('PayOpsObservation in schema', () => pm.expect(body).to.include('PayOpsObservation'));",
44
+ "pm.test('PayOpsState in schema', () => pm.expect(body).to.include('PayOpsState'));"
45
+ ], "type": "text/javascript"}}],
46
+ "request": {
47
+ "method": "GET",
48
+ "url": {"raw": "{{base_url}}/schema", "host": ["{{base_url}}"], "path": ["schema"]}
49
+ }
50
+ },
51
+ {
52
+ "name": "A-03 Schema — all 10 action types",
53
+ "event": [{"listen": "test", "script": {"exec": [
54
+ "const body = pm.response.text();",
55
+ "['approve','reject','flag','escalate','hold','inspect','request_docs','verify_kyc','contact_sender','file_sar'].forEach(a => {",
56
+ " pm.test(`action ${a} in schema`, () => pm.expect(body).to.include(a));",
57
+ "});"
58
+ ], "type": "text/javascript"}}],
59
+ "request": {
60
+ "method": "GET",
61
+ "url": {"raw": "{{base_url}}/schema", "host": ["{{base_url}}"], "path": ["schema"]}
62
+ }
63
+ }
64
+ ]
65
+ },
66
+
67
+ {
68
+ "name": "B — Tasks Endpoint",
69
+ "item": [
70
+ {
71
+ "name": "B-01 Tasks — count=20",
72
+ "event": [{"listen": "test", "script": {"exec": [
73
+ "pm.test('Status 200', () => pm.response.to.have.status(200));",
74
+ "const d = pm.response.json();",
75
+ "pm.test('count=20', () => pm.expect(d.count).to.eql(20));",
76
+ "pm.test('tasks array length=20', () => pm.expect(d.tasks.length).to.eql(20));"
77
+ ], "type": "text/javascript"}}],
78
+ "request": {
79
+ "method": "GET",
80
+ "url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
81
+ }
82
+ },
83
+ {
84
+ "name": "B-02 Tasks — all 4 difficulty tiers",
85
+ "event": [{"listen": "test", "script": {"exec": [
86
+ "const tasks = pm.response.json().tasks;",
87
+ "const diffs = tasks.map(t => t.difficulty);",
88
+ "['easy','medium','hard','critical'].forEach(d => {",
89
+ " pm.test(`tier '${d}' present`, () => pm.expect(diffs).to.include(d));",
90
+ "});"
91
+ ], "type": "text/javascript"}}],
92
+ "request": {
93
+ "method": "GET",
94
+ "url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
95
+ }
96
+ },
97
+ {
98
+ "name": "B-03 Tasks — task structure fields",
99
+ "event": [{"listen": "test", "script": {"exec": [
100
+ "const t = pm.response.json().tasks[0];",
101
+ "['task_id','difficulty','correct_action','requires_investigation','regulatory_action','chain_total'].forEach(f => {",
102
+ " pm.test(`field '${f}' present`, () => pm.expect(t).to.have.property(f));",
103
+ "});"
104
+ ], "type": "text/javascript"}}],
105
+ "request": {
106
+ "method": "GET",
107
+ "url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
108
+ }
109
+ },
110
+ {
111
+ "name": "B-04 Tasks — chain_total >= 1 on all",
112
+ "event": [{"listen": "test", "script": {"exec": [
113
+ "const tasks = pm.response.json().tasks;",
114
+ "pm.test('all tasks have chain_total >= 1', () => {",
115
+ " tasks.forEach(t => pm.expect(t.chain_total).to.be.at.least(1));",
116
+ "});"
117
+ ], "type": "text/javascript"}}],
118
+ "request": {
119
+ "method": "GET",
120
+ "url": {"raw": "{{base_url}}/tasks", "host": ["{{base_url}}"], "path": ["tasks"]}
121
+ }
122
+ }
123
+ ]
124
+ },
125
+
126
+ {
127
+ "name": "C — Reset",
128
+ "item": [
129
+ {
130
+ "name": "C-01 Reset — first observation",
131
+ "event": [{"listen": "test", "script": {"exec": [
132
+ "pm.test('Status 200', () => pm.response.to.have.status(200));",
133
+ "const d = pm.response.json();",
134
+ "pm.test('first task is EASY-001', () => pm.expect(d.task_id).to.eql('EASY-001'));",
135
+ "pm.test('done=false', () => pm.expect(d.done).to.eql(false));",
136
+ "pm.test('budget_remaining=5.0', () => pm.expect(d.budget_remaining).to.eql(5.0));",
137
+ "pm.test('risk_score present', () => pm.expect(d).to.have.property('risk_score'));",
138
+ "pm.test('ml_confidence present', () => pm.expect(d).to.have.property('ml_confidence'));",
139
+ "pm.test('chain_total present', () => pm.expect(d).to.have.property('chain_total'));",
140
+ "pm.test('steps_remaining present', () => pm.expect(d).to.have.property('steps_remaining'));"
141
+ ], "type": "text/javascript"}}],
142
+ "request": {
143
+ "method": "POST",
144
+ "url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
145
+ }
146
+ },
147
+ {
148
+ "name": "C-02 State after reset",
149
+ "event": [{"listen": "test", "script": {"exec": [
150
+ "const d = pm.response.json();",
151
+ "pm.test('episode_id set', () => pm.expect(d.episode_id).to.be.a('string').and.not.empty);",
152
+ "pm.collectionVariables.set('episode_id', d.episode_id);",
153
+ "pm.test('step_count=0', () => pm.expect(d.step_count).to.eql(0));",
154
+ "pm.test('budget_spent=0', () => pm.expect(d.budget_spent).to.eql(0));",
155
+ "pm.test('correct_decisions=0', () => pm.expect(d.correct_decisions).to.eql(0));",
156
+ "pm.test('investigation_actions_used is array', () => pm.expect(d.investigation_actions_used).to.be.an('array'));"
157
+ ], "type": "text/javascript"}}],
158
+ "request": {
159
+ "method": "GET",
160
+ "url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
161
+ }
162
+ }
163
+ ]
164
+ },
165
+
166
+ {
167
+ "name": "D — Terminal Actions (Correct)",
168
+ "item": [
169
+ {
170
+ "name": "D-01 EASY-001 approve → reward=1.0",
171
+ "event": [
172
+ {"listen": "prerequest", "script": {"exec": ["// ensure fresh episode"], "type": "text/javascript"}},
173
+ {"listen": "test", "script": {"exec": [
174
+ "const d = pm.response.json();",
175
+ "pm.test('reward=1.0', () => pm.expect(d.reward).to.eql(1.0));",
176
+ "pm.test('advances to EASY-002', () => pm.expect(d.task_id).to.eql('EASY-002'));"
177
+ ], "type": "text/javascript"}}
178
+ ],
179
+ "request": {
180
+ "method": "POST",
181
+ "header": [{"key": "Content-Type", "value": "application/json"}],
182
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E001\"}"},
183
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
184
+ }
185
+ },
186
+ {
187
+ "name": "D-02 EASY-002 reject → reward=1.0",
188
+ "event": [{"listen": "test", "script": {"exec": [
189
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
190
+ ], "type": "text/javascript"}}],
191
+ "request": {
192
+ "method": "POST",
193
+ "header": [{"key": "Content-Type", "value": "application/json"}],
194
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-E002\"}"},
195
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
196
+ }
197
+ },
198
+ {
199
+ "name": "D-03 EASY-003 approve → reward=1.0",
200
+ "event": [{"listen": "test", "script": {"exec": [
201
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
202
+ ], "type": "text/javascript"}}],
203
+ "request": {
204
+ "method": "POST",
205
+ "header": [{"key": "Content-Type", "value": "application/json"}],
206
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E003\"}"},
207
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
208
+ }
209
+ },
210
+ {
211
+ "name": "D-04 EASY-004 flag → reward=1.0",
212
+ "event": [{"listen": "test", "script": {"exec": [
213
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
214
+ ], "type": "text/javascript"}}],
215
+ "request": {
216
+ "method": "POST",
217
+ "header": [{"key": "Content-Type", "value": "application/json"}],
218
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-E004\"}"},
219
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
220
+ }
221
+ },
222
+ {
223
+ "name": "D-05 MED-001 escalate → reward=1.0",
224
+ "event": [{"listen": "test", "script": {"exec": [
225
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
226
+ ], "type": "text/javascript"}}],
227
+ "request": {
228
+ "method": "POST",
229
+ "header": [{"key": "Content-Type", "value": "application/json"}],
230
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-M001\"}"},
231
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
232
+ }
233
+ },
234
+ {
235
+ "name": "D-06 MED-002 hold → reward=1.0",
236
+ "event": [{"listen": "test", "script": {"exec": [
237
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
238
+ ], "type": "text/javascript"}}],
239
+ "request": {
240
+ "method": "POST",
241
+ "header": [{"key": "Content-Type", "value": "application/json"}],
242
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"hold\", \"transaction_id\": \"TXN-M002\"}"},
243
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
244
+ }
245
+ },
246
+ {
247
+ "name": "D-07 MED-003 flag → reward=1.0",
248
+ "event": [{"listen": "test", "script": {"exec": [
249
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
250
+ ], "type": "text/javascript"}}],
251
+ "request": {
252
+ "method": "POST",
253
+ "header": [{"key": "Content-Type", "value": "application/json"}],
254
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-M003\"}"},
255
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
256
+ }
257
+ },
258
+ {
259
+ "name": "D-08 MED-004 flag → reward=1.0",
260
+ "event": [{"listen": "test", "script": {"exec": [
261
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
262
+ ], "type": "text/javascript"}}],
263
+ "request": {
264
+ "method": "POST",
265
+ "header": [{"key": "Content-Type", "value": "application/json"}],
266
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-M004\"}"},
267
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
268
+ }
269
+ },
270
+ {
271
+ "name": "D-09 MED-005 hold → reward=1.0",
272
+ "event": [{"listen": "test", "script": {"exec": [
273
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
274
+ ], "type": "text/javascript"}}],
275
+ "request": {
276
+ "method": "POST",
277
+ "header": [{"key": "Content-Type", "value": "application/json"}],
278
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"hold\", \"transaction_id\": \"TXN-M005\"}"},
279
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
280
+ }
281
+ },
282
+ {
283
+ "name": "D-10 MED-006 escalate → reward=1.0",
284
+ "event": [{"listen": "test", "script": {"exec": [
285
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
286
+ ], "type": "text/javascript"}}],
287
+ "request": {
288
+ "method": "POST",
289
+ "header": [{"key": "Content-Type", "value": "application/json"}],
290
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-M006\"}"},
291
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
292
+ }
293
+ },
294
+ {
295
+ "name": "D-11 HARD-001 escalate → reward=1.0",
296
+ "event": [{"listen": "test", "script": {"exec": [
297
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
298
+ ], "type": "text/javascript"}}],
299
+ "request": {
300
+ "method": "POST",
301
+ "header": [{"key": "Content-Type", "value": "application/json"}],
302
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-H001\"}"},
303
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
304
+ }
305
+ },
306
+ {
307
+ "name": "D-12 HARD-002 reject → reward=1.0",
308
+ "event": [{"listen": "test", "script": {"exec": [
309
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
310
+ ], "type": "text/javascript"}}],
311
+ "request": {
312
+ "method": "POST",
313
+ "header": [{"key": "Content-Type", "value": "application/json"}],
314
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-H002\"}"},
315
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
316
+ }
317
+ },
318
+ {
319
+ "name": "D-13 HARD-003 reject → reward=1.0",
320
+ "event": [{"listen": "test", "script": {"exec": [
321
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
322
+ ], "type": "text/javascript"}}],
323
+ "request": {
324
+ "method": "POST",
325
+ "header": [{"key": "Content-Type", "value": "application/json"}],
326
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-H003\"}"},
327
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
328
+ }
329
+ },
330
+ {
331
+ "name": "D-14 HARD-004 approve → reward=1.0",
332
+ "event": [{"listen": "test", "script": {"exec": [
333
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
334
+ ], "type": "text/javascript"}}],
335
+ "request": {
336
+ "method": "POST",
337
+ "header": [{"key": "Content-Type", "value": "application/json"}],
338
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-H004\"}"},
339
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
340
+ }
341
+ },
342
+ {
343
+ "name": "D-15 HARD-005 escalate → reward=1.0",
344
+ "event": [{"listen": "test", "script": {"exec": [
345
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
346
+ ], "type": "text/javascript"}}],
347
+ "request": {
348
+ "method": "POST",
349
+ "header": [{"key": "Content-Type", "value": "application/json"}],
350
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-H005\"}"},
351
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
352
+ }
353
+ },
354
+ {
355
+ "name": "D-16 HARD-006 flag → reward=1.0",
356
+ "event": [{"listen": "test", "script": {"exec": [
357
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
358
+ ], "type": "text/javascript"}}],
359
+ "request": {
360
+ "method": "POST",
361
+ "header": [{"key": "Content-Type", "value": "application/json"}],
362
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-H006\"}"},
363
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
364
+ }
365
+ },
366
+ {
367
+ "name": "D-17 CRIT-001 approve → reward=1.0",
368
+ "event": [{"listen": "test", "script": {"exec": [
369
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
370
+ ], "type": "text/javascript"}}],
371
+ "request": {
372
+ "method": "POST",
373
+ "header": [{"key": "Content-Type", "value": "application/json"}],
374
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-C001\"}"},
375
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
376
+ }
377
+ },
378
+ {
379
+ "name": "D-18 CRIT-002 reject → reward=1.0",
380
+ "event": [{"listen": "test", "script": {"exec": [
381
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
382
+ ], "type": "text/javascript"}}],
383
+ "request": {
384
+ "method": "POST",
385
+ "header": [{"key": "Content-Type", "value": "application/json"}],
386
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-C002\"}"},
387
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
388
+ }
389
+ },
390
+ {
391
+ "name": "D-19 CRIT-003 escalate → reward=1.0",
392
+ "event": [{"listen": "test", "script": {"exec": [
393
+ "pm.test('reward=1.0', () => pm.expect(pm.response.json().reward).to.eql(1.0));"
394
+ ], "type": "text/javascript"}}],
395
+ "request": {
396
+ "method": "POST",
397
+ "header": [{"key": "Content-Type", "value": "application/json"}],
398
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"escalate\", \"transaction_id\": \"TXN-C003\"}"},
399
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
400
+ }
401
+ },
402
+ {
403
+ "name": "D-20 CRIT-004 reject → reward=1.0 + done=true",
404
+ "event": [{"listen": "test", "script": {"exec": [
405
+ "const d = pm.response.json();",
406
+ "pm.test('reward=1.0', () => pm.expect(d.reward).to.eql(1.0));",
407
+ "pm.test('episode done', () => pm.expect(d.done).to.eql(true));"
408
+ ], "type": "text/javascript"}}],
409
+ "request": {
410
+ "method": "POST",
411
+ "header": [{"key": "Content-Type", "value": "application/json"}],
412
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-C004\"}"},
413
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
414
+ }
415
+ }
416
+ ]
417
+ },
418
+
419
+ {
420
+ "name": "E — Wrong Actions & Partial Credit",
421
+ "item": [
422
+ {
423
+ "name": "E-00 Reset before wrong-action tests",
424
+ "event": [{"listen": "test", "script": {"exec": [
425
+ "pm.test('reset ok', () => pm.response.to.have.status(200));"
426
+ ], "type": "text/javascript"}}],
427
+ "request": {
428
+ "method": "POST",
429
+ "url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
430
+ }
431
+ },
432
+ {
433
+ "name": "E-01 Approve legit EASY-001 (correct, advance)",
434
+ "request": {
435
+ "method": "POST",
436
+ "header": [{"key": "Content-Type", "value": "application/json"}],
437
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E001\"}"},
438
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
439
+ }
440
+ },
441
+ {
442
+ "name": "E-02 Approve fraud EASY-002 → reward=-1.0",
443
+ "event": [{"listen": "test", "script": {"exec": [
444
+ "pm.test('reward=-1.0 for fraud approval', () => pm.expect(pm.response.json().reward).to.eql(-1.0));"
445
+ ], "type": "text/javascript"}}],
446
+ "request": {
447
+ "method": "POST",
448
+ "header": [{"key": "Content-Type", "value": "application/json"}],
449
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E002\"}"},
450
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
451
+ }
452
+ },
453
+ {
454
+ "name": "E-03 Reject legit EASY-003 → reward=-0.5",
455
+ "event": [{"listen": "test", "script": {"exec": [
456
+ "pm.test('reward=-0.5 for wrong reject', () => pm.expect(pm.response.json().reward).to.eql(-0.5));"
457
+ ], "type": "text/javascript"}}],
458
+ "request": {
459
+ "method": "POST",
460
+ "header": [{"key": "Content-Type", "value": "application/json"}],
461
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-E003\"}"},
462
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
463
+ }
464
+ },
465
+ {
466
+ "name": "E-04 Flag EASY-004 correct → reward=1.0",
467
+ "request": {
468
+ "method": "POST",
469
+ "header": [{"key": "Content-Type", "value": "application/json"}],
470
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-E004\"}"},
471
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
472
+ }
473
+ },
474
+ {
475
+ "name": "E-05 Partial credit — flag instead of escalate (MED-001)",
476
+ "event": [{"listen": "test", "script": {"exec": [
477
+ "const d = pm.response.json();",
478
+ "pm.test('partial credit reward > -1 and < 1', () => {",
479
+ " pm.expect(d.reward).to.be.greaterThan(-1.0);",
480
+ " pm.expect(d.reward).to.be.lessThan(1.0);",
481
+ "});"
482
+ ], "type": "text/javascript"}}],
483
+ "request": {
484
+ "method": "POST",
485
+ "header": [{"key": "Content-Type", "value": "application/json"}],
486
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-M001\"}"},
487
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
488
+ }
489
+ }
490
+ ]
491
+ },
492
+
493
+ {
494
+ "name": "F — Investigation Sub-Actions",
495
+ "item": [
496
+ {
497
+ "name": "F-00 Reset before investigation tests",
498
+ "request": {
499
+ "method": "POST",
500
+ "url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
501
+ }
502
+ },
503
+ {
504
+ "name": "F-01 inspect → reward=0.15, notes populated, budget=4.9",
505
+ "event": [{"listen": "test", "script": {"exec": [
506
+ "const d = pm.response.json();",
507
+ "pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
508
+ "pm.test('task unchanged (EASY-001)', () => pm.expect(d.task_id).to.eql('EASY-001'));",
509
+ "pm.test('inspection_notes populated', () => pm.expect(d.inspection_notes).to.be.a('string').and.not.empty);",
510
+ "pm.test('budget_remaining=4.9', () => pm.expect(d.budget_remaining).to.eql(4.9));"
511
+ ], "type": "text/javascript"}}],
512
+ "request": {
513
+ "method": "POST",
514
+ "header": [{"key": "Content-Type", "value": "application/json"}],
515
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"inspect\", \"transaction_id\": \"TXN-E001\"}"},
516
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
517
+ }
518
+ },
519
+ {
520
+ "name": "F-02 inspect again (duplicate) → reward=0.0",
521
+ "event": [{"listen": "test", "script": {"exec": [
522
+ "pm.test('duplicate inspect reward=0.0', () => pm.expect(pm.response.json().reward).to.eql(0.0));"
523
+ ], "type": "text/javascript"}}],
524
+ "request": {
525
+ "method": "POST",
526
+ "header": [{"key": "Content-Type", "value": "application/json"}],
527
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"inspect\", \"transaction_id\": \"TXN-E001\"}"},
528
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
529
+ }
530
+ },
531
+ {
532
+ "name": "F-03 approve EASY-001 (advance to E-002)",
533
+ "request": {
534
+ "method": "POST",
535
+ "header": [{"key": "Content-Type", "value": "application/json"}],
536
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E001\"}"},
537
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
538
+ }
539
+ },
540
+ {
541
+ "name": "F-04 request_docs → reward=0.15, docs_notes, budget-0.2",
542
+ "event": [{"listen": "test", "script": {"exec": [
543
+ "const d = pm.response.json();",
544
+ "pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
545
+ "pm.test('docs_notes populated', () => pm.expect(d.docs_notes).to.be.a('string').and.not.empty);",
546
+ "pm.test('budget decreased by 0.2', () => pm.expect(d.budget_remaining).to.be.closeTo(4.6, 0.01));"
547
+ ], "type": "text/javascript"}}],
548
+ "request": {
549
+ "method": "POST",
550
+ "header": [{"key": "Content-Type", "value": "application/json"}],
551
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"request_docs\", \"transaction_id\": \"TXN-E002\"}"},
552
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
553
+ }
554
+ },
555
+ {
556
+ "name": "F-05 request_docs again (duplicate) → reward=0.0",
557
+ "event": [{"listen": "test", "script": {"exec": [
558
+ "pm.test('duplicate request_docs reward=0.0', () => pm.expect(pm.response.json().reward).to.eql(0.0));"
559
+ ], "type": "text/javascript"}}],
560
+ "request": {
561
+ "method": "POST",
562
+ "header": [{"key": "Content-Type", "value": "application/json"}],
563
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"request_docs\", \"transaction_id\": \"TXN-E002\"}"},
564
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
565
+ }
566
+ },
567
+ {
568
+ "name": "F-06 reject EASY-002 (advance to E-003)",
569
+ "request": {
570
+ "method": "POST",
571
+ "header": [{"key": "Content-Type", "value": "application/json"}],
572
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"reject\", \"transaction_id\": \"TXN-E002\"}"},
573
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
574
+ }
575
+ },
576
+ {
577
+ "name": "F-07 verify_kyc → reward=0.15, kyc_notes, budget-0.2",
578
+ "event": [{"listen": "test", "script": {"exec": [
579
+ "const d = pm.response.json();",
580
+ "pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
581
+ "pm.test('kyc_notes populated', () => pm.expect(d.kyc_notes).to.be.a('string').and.not.empty);",
582
+ "pm.test('budget decreased by 0.2', () => pm.expect(d.budget_remaining).to.be.closeTo(4.2, 0.05));"
583
+ ], "type": "text/javascript"}}],
584
+ "request": {
585
+ "method": "POST",
586
+ "header": [{"key": "Content-Type", "value": "application/json"}],
587
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"verify_kyc\", \"transaction_id\": \"TXN-E003\"}"},
588
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
589
+ }
590
+ },
591
+ {
592
+ "name": "F-08 approve EASY-003 (advance to E-004)",
593
+ "request": {
594
+ "method": "POST",
595
+ "header": [{"key": "Content-Type", "value": "application/json"}],
596
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"approve\", \"transaction_id\": \"TXN-E003\"}"},
597
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
598
+ }
599
+ },
600
+ {
601
+ "name": "F-09 contact_sender → reward=0.15, contact_notes, budget-0.3",
602
+ "event": [{"listen": "test", "script": {"exec": [
603
+ "const d = pm.response.json();",
604
+ "pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
605
+ "pm.test('contact_notes populated', () => pm.expect(d.contact_notes).to.be.a('string').and.not.empty);",
606
+ "pm.test('budget decreased by 0.3', () => pm.expect(d.budget_remaining).to.be.lessThan(4.2));"
607
+ ], "type": "text/javascript"}}],
608
+ "request": {
609
+ "method": "POST",
610
+ "header": [{"key": "Content-Type", "value": "application/json"}],
611
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"contact_sender\", \"transaction_id\": \"TXN-E004\"}"},
612
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
613
+ }
614
+ },
615
+ {
616
+ "name": "F-10 flag EASY-004 (advance to M-001)",
617
+ "request": {
618
+ "method": "POST",
619
+ "header": [{"key": "Content-Type", "value": "application/json"}],
620
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"flag\", \"transaction_id\": \"TXN-E004\"}"},
621
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
622
+ }
623
+ },
624
+ {
625
+ "name": "F-11 file_sar → reward=0.15, docs_notes has SAR, budget-0.05",
626
+ "event": [{"listen": "test", "script": {"exec": [
627
+ "const d = pm.response.json();",
628
+ "pm.test('reward=0.15', () => pm.expect(d.reward).to.eql(0.15));",
629
+ "pm.test('docs_notes contains SAR', () => pm.expect(d.docs_notes).to.include('SAR'));",
630
+ "pm.test('budget_remaining updated', () => pm.expect(d.budget_remaining).to.be.lessThan(5.0));"
631
+ ], "type": "text/javascript"}}],
632
+ "request": {
633
+ "method": "POST",
634
+ "header": [{"key": "Content-Type", "value": "application/json"}],
635
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"file_sar\", \"transaction_id\": \"TXN-M001\"}"},
636
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
637
+ }
638
+ }
639
+ ]
640
+ },
641
+
642
+ {
643
+ "name": "G — State Endpoint",
644
+ "item": [
645
+ {
646
+ "name": "G-01 State — all required fields",
647
+ "event": [{"listen": "test", "script": {"exec": [
648
+ "const d = pm.response.json();",
649
+ "pm.test('status 200', () => pm.response.to.have.status(200));",
650
+ "['episode_id','step_count','budget_spent','budget_limit','investigation_actions_used','correct_decisions','wrong_high_cost','recent_decisions'].forEach(f => {",
651
+ " pm.test(`field '${f}' present`, () => pm.expect(d).to.have.property(f));",
652
+ "});"
653
+ ], "type": "text/javascript"}}],
654
+ "request": {
655
+ "method": "GET",
656
+ "url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
657
+ }
658
+ },
659
+ {
660
+ "name": "G-02 State — budget_spent reflects investigation costs",
661
+ "event": [{"listen": "test", "script": {"exec": [
662
+ "pm.test('budget_spent > 0', () => pm.expect(pm.response.json().budget_spent).to.be.greaterThan(0));"
663
+ ], "type": "text/javascript"}}],
664
+ "request": {
665
+ "method": "GET",
666
+ "url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
667
+ }
668
+ },
669
+ {
670
+ "name": "G-03 State — investigation_actions_used is array",
671
+ "event": [{"listen": "test", "script": {"exec": [
672
+ "pm.test('investigation_actions_used is array', () => pm.expect(pm.response.json().investigation_actions_used).to.be.an('array'));"
673
+ ], "type": "text/javascript"}}],
674
+ "request": {
675
+ "method": "GET",
676
+ "url": {"raw": "{{base_url}}/state", "host": ["{{base_url}}"], "path": ["state"]}
677
+ }
678
+ }
679
+ ]
680
+ },
681
+
682
+ {
683
+ "name": "H — Grader Endpoint",
684
+ "item": [
685
+ {
686
+ "name": "H-01 Grader — full result structure",
687
+ "event": [{"listen": "test", "script": {"exec": [
688
+ "const d = pm.response.json();",
689
+ "pm.test('status 200', () => pm.response.to.have.status(200));",
690
+ "['normalised_score','total_reward','max_possible_reward','budget_spent','budget_penalty','per_task','passed'].forEach(f => {",
691
+ " pm.test(`field '${f}' present`, () => pm.expect(d).to.have.property(f));",
692
+ "});"
693
+ ], "type": "text/javascript"}}],
694
+ "request": {
695
+ "method": "GET",
696
+ "url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
697
+ }
698
+ },
699
+ {
700
+ "name": "H-02 Grader — per_task has reward_breakdown",
701
+ "event": [{"listen": "test", "script": {"exec": [
702
+ "const d = pm.response.json();",
703
+ "pm.test('per_task is array', () => pm.expect(d.per_task).to.be.an('array'));",
704
+ "if (d.per_task.length > 0) {",
705
+ " pm.test('per_task[0] has reward_breakdown', () => pm.expect(d.per_task[0]).to.have.property('reward_breakdown'));",
706
+ " pm.test('per_task[0] has difficulty', () => pm.expect(d.per_task[0]).to.have.property('difficulty'));",
707
+ "}"
708
+ ], "type": "text/javascript"}}],
709
+ "request": {
710
+ "method": "GET",
711
+ "url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
712
+ }
713
+ },
714
+ {
715
+ "name": "H-03 Grader — normalised_score in [0,1]",
716
+ "event": [{"listen": "test", "script": {"exec": [
717
+ "const s = pm.response.json().normalised_score;",
718
+ "pm.test('score in [0,1]', () => { pm.expect(s).to.be.at.least(0); pm.expect(s).to.be.at.most(1); });"
719
+ ], "type": "text/javascript"}}],
720
+ "request": {
721
+ "method": "GET",
722
+ "url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
723
+ }
724
+ }
725
+ ]
726
+ },
727
+
728
+ {
729
+ "name": "I — Replay Endpoint",
730
+ "item": [
731
+ {
732
+ "name": "I-01 Replay — 20 all-correct actions → score=1.0",
733
+ "event": [{"listen": "test", "script": {"exec": [
734
+ "const d = pm.response.json();",
735
+ "pm.test('normalised_score=1.0', () => pm.expect(d.normalised_score).to.eql(1.0));",
736
+ "pm.test('passed=true', () => pm.expect(d.passed).to.eql(true));",
737
+ "pm.test('budget_spent=0.0', () => pm.expect(d.budget_spent).to.eql(0.0));"
738
+ ], "type": "text/javascript"}}],
739
+ "request": {
740
+ "method": "POST",
741
+ "header": [{"key": "Content-Type", "value": "application/json"}],
742
+ "body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"approve\", \"reject\", \"approve\", \"flag\",\n \"escalate\", \"hold\", \"flag\", \"flag\", \"hold\", \"escalate\",\n \"escalate\", \"reject\", \"reject\", \"approve\", \"escalate\", \"flag\",\n \"approve\", \"reject\", \"escalate\", \"reject\"\n ]\n}"},
743
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
744
+ }
745
+ },
746
+ {
747
+ "name": "I-02 Replay — with inspect sub-actions → budget_spent=0.1",
748
+ "event": [{"listen": "test", "script": {"exec": [
749
+ "const d = pm.response.json();",
750
+ "pm.test('budget_spent=0.1', () => pm.expect(d.budget_spent).to.eql(0.1));",
751
+ "pm.test('per_task present', () => pm.expect(d.per_task).to.be.an('array'));"
752
+ ], "type": "text/javascript"}}],
753
+ "request": {
754
+ "method": "POST",
755
+ "header": [{"key": "Content-Type", "value": "application/json"}],
756
+ "body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"inspect\", \"approve\", \"reject\", \"approve\", \"flag\",\n \"escalate\", \"hold\", \"flag\", \"flag\", \"hold\", \"escalate\",\n \"escalate\", \"reject\", \"reject\", \"approve\", \"escalate\", \"flag\",\n \"approve\", \"reject\", \"escalate\", \"reject\"\n ]\n}"},
757
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
758
+ }
759
+ },
760
+ {
761
+ "name": "I-03 Replay — with confidences",
762
+ "event": [{"listen": "test", "script": {"exec": [
763
+ "pm.test('accepts confidences, returns score', () => pm.expect(pm.response.json()).to.have.property('normalised_score'));"
764
+ ], "type": "text/javascript"}}],
765
+ "request": {
766
+ "method": "POST",
767
+ "header": [{"key": "Content-Type", "value": "application/json"}],
768
+ "body": {"mode": "raw", "raw": "{\n \"actions\": [\"approve\", \"reject\"],\n \"confidences\": [0.95, 0.88]\n}"},
769
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
770
+ }
771
+ },
772
+ {
773
+ "name": "I-04 Replay — all-wrong actions → score very low",
774
+ "event": [{"listen": "test", "script": {"exec": [
775
+ "const d = pm.response.json();",
776
+ "pm.test('all-wrong score < 0.3', () => pm.expect(d.normalised_score).to.be.lessThan(0.3));",
777
+ "pm.test('passed=false', () => pm.expect(d.passed).to.eql(false));"
778
+ ], "type": "text/javascript"}}],
779
+ "request": {
780
+ "method": "POST",
781
+ "header": [{"key": "Content-Type", "value": "application/json"}],
782
+ "body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"reject\", \"approve\", \"reject\", \"reject\",\n \"reject\", \"reject\", \"reject\", \"reject\", \"reject\", \"reject\",\n \"reject\", \"approve\", \"approve\", \"reject\", \"reject\", \"reject\",\n \"reject\", \"approve\", \"reject\", \"approve\"\n ]\n}"},
783
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
784
+ }
785
+ },
786
+ {
787
+ "name": "I-05 Replay — invalid action → 422",
788
+ "event": [{"listen": "test", "script": {"exec": [
789
+ "pm.test('422 or error body', () => {",
790
+ " const ok = pm.response.code === 422 || (pm.response.code === 400);",
791
+ " const hasError = pm.response.text().includes('Invalid') || pm.response.text().includes('detail');",
792
+ " pm.expect(ok || hasError).to.be.true;",
793
+ "});"
794
+ ], "type": "text/javascript"}}],
795
+ "request": {
796
+ "method": "POST",
797
+ "header": [{"key": "Content-Type", "value": "application/json"}],
798
+ "body": {"mode": "raw", "raw": "{\"actions\": [\"delete\", \"approve\"]}"},
799
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
800
+ }
801
+ },
802
+ {
803
+ "name": "I-06 Replay — empty actions list",
804
+ "event": [{"listen": "test", "script": {"exec": [
805
+ "pm.test('handles empty list gracefully', () => pm.expect(pm.response.json()).to.have.property('normalised_score'));"
806
+ ], "type": "text/javascript"}}],
807
+ "request": {
808
+ "method": "POST",
809
+ "header": [{"key": "Content-Type", "value": "application/json"}],
810
+ "body": {"mode": "raw", "raw": "{\"actions\": []}"},
811
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
812
+ }
813
+ },
814
+ {
815
+ "name": "I-07 Replay — does NOT alter live server state",
816
+ "event": [
817
+ {"listen": "prerequest", "script": {"exec": [
818
+ "// capture current state to compare after replay",
819
+ "pm.sendRequest(`${pm.collectionVariables.get('base_url') || 'http://localhost:8000'}/state`, (err, res) => {",
820
+ " if (!err) pm.collectionVariables.set('state_before', JSON.stringify(res.json().step_count));",
821
+ "});"
822
+ ], "type": "text/javascript"}},
823
+ {"listen": "test", "script": {"exec": [
824
+ "pm.sendRequest(`${pm.collectionVariables.get('base_url') || 'http://localhost:8000'}/state`, (err, res) => {",
825
+ " const before = pm.collectionVariables.get('state_before');",
826
+ " const after = JSON.stringify(res.json().step_count);",
827
+ " pm.test('server state unchanged after replay', () => pm.expect(before).to.eql(after));",
828
+ "});"
829
+ ], "type": "text/javascript"}}
830
+ ],
831
+ "request": {
832
+ "method": "POST",
833
+ "header": [{"key": "Content-Type", "value": "application/json"}],
834
+ "body": {"mode": "raw", "raw": "{\"actions\": [\"approve\", \"reject\"]}"},
835
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
836
+ }
837
+ }
838
+ ]
839
+ },
840
+
841
+ {
842
+ "name": "J — Baseline Endpoint",
843
+ "item": [
844
+ {
845
+ "name": "J-01 Baseline — full result",
846
+ "event": [{"listen": "test", "script": {"exec": [
847
+ "const d = pm.response.json();",
848
+ "pm.test('status 200', () => pm.response.to.have.status(200));",
849
+ "['normalised_score','total_reward','steps','scores'].forEach(f => {",
850
+ " pm.test(`field '${f}' present`, () => pm.expect(d).to.have.property(f));",
851
+ "});",
852
+ "pm.test('score > 0.5', () => pm.expect(d.normalised_score).to.be.greaterThan(0.5));",
853
+ "pm.test('steps = 20', () => pm.expect(d.steps).to.eql(20));",
854
+ "pm.test('scores length = 20', () => pm.expect(d.scores.length).to.eql(20));"
855
+ ], "type": "text/javascript"}}],
856
+ "request": {
857
+ "method": "POST",
858
+ "url": {"raw": "{{base_url}}/baseline", "host": ["{{base_url}}"], "path": ["baseline"]}
859
+ }
860
+ },
861
+ {
862
+ "name": "J-02 Baseline — passed=true",
863
+ "event": [{"listen": "test", "script": {"exec": [
864
+ "pm.test('passed=true', () => pm.expect(pm.response.json().passed).to.eql(true));"
865
+ ], "type": "text/javascript"}}],
866
+ "request": {
867
+ "method": "POST",
868
+ "url": {"raw": "{{base_url}}/baseline", "host": ["{{base_url}}"], "path": ["baseline"]}
869
+ }
870
+ }
871
+ ]
872
+ },
873
+
874
+ {
875
+ "name": "K — Analytics Endpoint",
876
+ "item": [
877
+ {
878
+ "name": "K-01 Analytics — structure",
879
+ "event": [{"listen": "test", "script": {"exec": [
880
+ "pm.test('status 200', () => pm.response.to.have.status(200));",
881
+ "const d = pm.response.json();",
882
+ "const hasMsg = d.message !== undefined;",
883
+ "const hasData = d.episodes_completed !== undefined;",
884
+ "pm.test('returns message or episode data', () => pm.expect(hasMsg || hasData).to.be.true);"
885
+ ], "type": "text/javascript"}}],
886
+ "request": {
887
+ "method": "GET",
888
+ "url": {"raw": "{{base_url}}/analytics", "host": ["{{base_url}}"], "path": ["analytics"]}
889
+ }
890
+ },
891
+ {
892
+ "name": "K-02 Analytics — after completed episode",
893
+ "event": [{"listen": "test", "script": {"exec": [
894
+ "const d = pm.response.json();",
895
+ "pm.test('episodes_completed >= 1', () => pm.expect(d.episodes_completed).to.be.at.least(1));",
896
+ "pm.test('best_score present', () => pm.expect(d).to.have.property('best_score'));",
897
+ "pm.test('avg_score present', () => pm.expect(d).to.have.property('avg_score'));",
898
+ "pm.test('by_difficulty present', () => pm.expect(d).to.have.property('by_difficulty'));",
899
+ "pm.test('easy accuracy present', () => pm.expect(d.by_difficulty).to.have.property('easy'));",
900
+ "pm.test('critical accuracy present', () => pm.expect(d.by_difficulty).to.have.property('critical'));"
901
+ ], "type": "text/javascript"}},
902
+ {"listen": "prerequest", "script": {"exec": [
903
+ "// Run a complete episode first so analytics has data",
904
+ "const base = pm.collectionVariables.get('base_url') || 'http://localhost:8000';",
905
+ "const actions = [",
906
+ " ['approve','TXN-E001'],['reject','TXN-E002'],['approve','TXN-E003'],['flag','TXN-E004'],",
907
+ " ['escalate','TXN-M001'],['hold','TXN-M002'],['flag','TXN-M003'],['flag','TXN-M004'],",
908
+ " ['hold','TXN-M005'],['escalate','TXN-M006'],",
909
+ " ['escalate','TXN-H001'],['reject','TXN-H002'],['reject','TXN-H003'],['approve','TXN-H004'],",
910
+ " ['escalate','TXN-H005'],['flag','TXN-H006'],",
911
+ " ['approve','TXN-C001'],['reject','TXN-C002'],['escalate','TXN-C003'],['reject','TXN-C004']",
912
+ "];",
913
+ "// Note: triggering via test runner means sequence matters — this prerequest",
914
+ "// runs before the actual GET /analytics is sent."
915
+ ], "type": "text/javascript"}}],
916
+ "request": {
917
+ "method": "GET",
918
+ "url": {"raw": "{{base_url}}/analytics", "host": ["{{base_url}}"], "path": ["analytics"]}
919
+ }
920
+ },
921
+ {
922
+ "name": "K-03 Analytics — current_episode present",
923
+ "event": [{"listen": "test", "script": {"exec": [
924
+ "pm.test('current_episode field present', () => pm.expect(pm.response.json()).to.have.property('current_episode'));"
925
+ ], "type": "text/javascript"}}],
926
+ "request": {
927
+ "method": "GET",
928
+ "url": {"raw": "{{base_url}}/analytics", "host": ["{{base_url}}"], "path": ["analytics"]}
929
+ }
930
+ }
931
+ ]
932
+ },
933
+
934
+ {
935
+ "name": "L — Leaderboard Endpoint",
936
+ "item": [
937
+ {
938
+ "name": "L-01 Leaderboard — structure",
939
+ "event": [{"listen": "test", "script": {"exec": [
940
+ "const d = pm.response.json();",
941
+ "pm.test('status 200', () => pm.response.to.have.status(200));",
942
+ "pm.test('count field present', () => pm.expect(d).to.have.property('count'));",
943
+ "pm.test('entries field present', () => pm.expect(d).to.have.property('entries'));",
944
+ "pm.test('entries is array', () => pm.expect(d.entries).to.be.an('array'));"
945
+ ], "type": "text/javascript"}}],
946
+ "request": {
947
+ "method": "GET",
948
+ "url": {"raw": "{{base_url}}/leaderboard", "host": ["{{base_url}}"], "path": ["leaderboard"]}
949
+ }
950
+ },
951
+ {
952
+ "name": "L-02 Leaderboard — entry structure",
953
+ "event": [{"listen": "test", "script": {"exec": [
954
+ "const d = pm.response.json();",
955
+ "if (d.entries.length > 0) {",
956
+ " const e = d.entries[0];",
957
+ " ['episode_id','normalised_score','timestamp','budget_spent'].forEach(f => {",
958
+ " pm.test(`entry has field '${f}'`, () => pm.expect(e).to.have.property(f));",
959
+ " });",
960
+ "} else {",
961
+ " pm.test('no entries yet (ok before completing episode)', () => true);",
962
+ "}"
963
+ ], "type": "text/javascript"}}],
964
+ "request": {
965
+ "method": "GET",
966
+ "url": {"raw": "{{base_url}}/leaderboard", "host": ["{{base_url}}"], "path": ["leaderboard"]}
967
+ }
968
+ },
969
+ {
970
+ "name": "L-03 Leaderboard — after baseline run, count >= 1",
971
+ "event": [{"listen": "test", "script": {"exec": [
972
+ "pm.test('at least one entry after baseline', () => pm.expect(pm.response.json().count).to.be.at.least(1));"
973
+ ], "type": "text/javascript"}}],
974
+ "request": {
975
+ "method": "GET",
976
+ "url": {"raw": "{{base_url}}/leaderboard", "host": ["{{base_url}}"], "path": ["leaderboard"]}
977
+ }
978
+ }
979
+ ]
980
+ },
981
+
982
+ {
983
+ "name": "M — Budget Mechanics",
984
+ "item": [
985
+ {
986
+ "name": "M-00 Reset for budget test",
987
+ "request": {
988
+ "method": "POST",
989
+ "url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
990
+ }
991
+ },
992
+ {
993
+ "name": "M-01 contact_sender (cost=0.3) → budget=4.7",
994
+ "event": [{"listen": "test", "script": {"exec": [
995
+ "pm.test('budget_remaining=4.7', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.7, 0.01));"
996
+ ], "type": "text/javascript"}}],
997
+ "request": {
998
+ "method": "POST",
999
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1000
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"contact_sender\", \"transaction_id\": \"TXN-E001\"}"},
1001
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1002
+ }
1003
+ },
1004
+ {
1005
+ "name": "M-02 request_docs (cost=0.2) → budget=4.5",
1006
+ "event": [{"listen": "test", "script": {"exec": [
1007
+ "pm.test('budget_remaining=4.5', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.5, 0.01));"
1008
+ ], "type": "text/javascript"}}],
1009
+ "request": {
1010
+ "method": "POST",
1011
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1012
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"request_docs\", \"transaction_id\": \"TXN-E001\"}"},
1013
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1014
+ }
1015
+ },
1016
+ {
1017
+ "name": "M-03 verify_kyc (cost=0.2) → budget=4.3",
1018
+ "event": [{"listen": "test", "script": {"exec": [
1019
+ "pm.test('budget_remaining=4.3', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.3, 0.01));"
1020
+ ], "type": "text/javascript"}}],
1021
+ "request": {
1022
+ "method": "POST",
1023
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1024
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"verify_kyc\", \"transaction_id\": \"TXN-E001\"}"},
1025
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1026
+ }
1027
+ },
1028
+ {
1029
+ "name": "M-04 file_sar (cost=0.05) → budget=4.25",
1030
+ "event": [{"listen": "test", "script": {"exec": [
1031
+ "pm.test('budget_remaining=4.25', () => pm.expect(pm.response.json().budget_remaining).to.be.closeTo(4.25, 0.01));"
1032
+ ], "type": "text/javascript"}}],
1033
+ "request": {
1034
+ "method": "POST",
1035
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1036
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"file_sar\", \"transaction_id\": \"TXN-E001\"}"},
1037
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1038
+ }
1039
+ },
1040
+ {
1041
+ "name": "M-05 Grader shows non-zero budget_spent",
1042
+ "event": [{"listen": "test", "script": {"exec": [
1043
+ "pm.test('budget_spent > 0', () => pm.expect(pm.response.json().budget_spent).to.be.greaterThan(0));"
1044
+ ], "type": "text/javascript"}}],
1045
+ "request": {
1046
+ "method": "GET",
1047
+ "url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
1048
+ }
1049
+ },
1050
+ {
1051
+ "name": "M-06 Replay — heavy investigation causes budget_penalty",
1052
+ "event": [{"listen": "test", "script": {"exec": [
1053
+ "const d = pm.response.json();",
1054
+ "pm.test('budget_overspend > 0', () => pm.expect(d.budget_overspend).to.be.greaterThan(0));",
1055
+ "pm.test('budget_penalty > 0', () => pm.expect(d.budget_penalty).to.be.greaterThan(0));"
1056
+ ], "type": "text/javascript"}}],
1057
+ "request": {
1058
+ "method": "POST",
1059
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1060
+ "body": {"mode": "raw", "raw": "{\n \"actions\": [\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\"contact_sender\",\"contact_sender\",\n \"contact_sender\",\"contact_sender\",\n \"approve\",\"reject\",\"approve\",\"flag\",\n \"escalate\",\"hold\",\"flag\",\"flag\",\"hold\",\"escalate\",\n \"escalate\",\"reject\",\"reject\",\"approve\",\"escalate\",\"flag\",\n \"approve\",\"reject\",\"escalate\",\"reject\"\n ]\n}"},
1061
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
1062
+ }
1063
+ }
1064
+ ]
1065
+ },
1066
+
1067
+ {
1068
+ "name": "N — Edge Cases & Errors",
1069
+ "item": [
1070
+ {
1071
+ "name": "N-01 Unknown action_type → 422",
1072
+ "event": [{"listen": "test", "script": {"exec": [
1073
+ "pm.test('error response', () => {",
1074
+ " const code = pm.response.code;",
1075
+ " pm.expect([400, 422]).to.include(code);",
1076
+ "});"
1077
+ ], "type": "text/javascript"}}],
1078
+ "request": {
1079
+ "method": "POST",
1080
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1081
+ "body": {"mode": "raw", "raw": "{\"action_type\": \"nuke\", \"transaction_id\": \"TXN-E001\"}"},
1082
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1083
+ }
1084
+ },
1085
+ {
1086
+ "name": "N-02 Missing action_type → 422",
1087
+ "event": [{"listen": "test", "script": {"exec": [
1088
+ "pm.test('422 for missing field', () => pm.expect([400, 422]).to.include(pm.response.code));"
1089
+ ], "type": "text/javascript"}}],
1090
+ "request": {
1091
+ "method": "POST",
1092
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1093
+ "body": {"mode": "raw", "raw": "{\"transaction_id\": \"TXN-E001\"}"},
1094
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1095
+ }
1096
+ },
1097
+ {
1098
+ "name": "N-03 Empty POST body → 422",
1099
+ "event": [{"listen": "test", "script": {"exec": [
1100
+ "pm.test('422 for empty body', () => pm.expect([400, 422]).to.include(pm.response.code));"
1101
+ ], "type": "text/javascript"}}],
1102
+ "request": {
1103
+ "method": "POST",
1104
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1105
+ "body": {"mode": "raw", "raw": "{}"},
1106
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1107
+ }
1108
+ },
1109
+ {
1110
+ "name": "N-04 Replay with partial confidences (shorter than actions)",
1111
+ "event": [{"listen": "test", "script": {"exec": [
1112
+ "pm.test('handles partial confidences gracefully', () => pm.expect(pm.response.json()).to.have.property('normalised_score'));"
1113
+ ], "type": "text/javascript"}}],
1114
+ "request": {
1115
+ "method": "POST",
1116
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1117
+ "body": {"mode": "raw", "raw": "{\"actions\": [\"approve\", \"reject\", \"approve\"], \"confidences\": [0.9]}"},
1118
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
1119
+ }
1120
+ },
1121
+ {
1122
+ "name": "N-05 Replay with invalid JSON → 422",
1123
+ "event": [{"listen": "test", "script": {"exec": [
1124
+ "pm.test('error on invalid JSON', () => pm.expect([400, 422]).to.include(pm.response.code));"
1125
+ ], "type": "text/javascript"}}],
1126
+ "request": {
1127
+ "method": "POST",
1128
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1129
+ "body": {"mode": "raw", "raw": "{broken json}"},
1130
+ "url": {"raw": "{{base_url}}/replay", "host": ["{{base_url}}"], "path": ["replay"]}
1131
+ }
1132
+ }
1133
+ ]
1134
+ },
1135
+
1136
+ {
1137
+ "name": "O — Perfect Episode (Full Run)",
1138
+ "item": [
1139
+ {
1140
+ "name": "O-00 Reset (start fresh)",
1141
+ "request": {
1142
+ "method": "POST",
1143
+ "url": {"raw": "{{base_url}}/reset", "host": ["{{base_url}}"], "path": ["reset"]}
1144
+ }
1145
+ },
1146
+ {"name": "O-01 EASY-001 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-E001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1147
+ {"name": "O-02 EASY-002 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-E002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1148
+ {"name": "O-03 EASY-003 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-E003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1149
+ {"name": "O-04 EASY-004 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-E004\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1150
+ {"name": "O-05 MED-001 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-M001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1151
+ {"name": "O-06 MED-002 hold", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"hold\",\"transaction_id\":\"TXN-M002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1152
+ {"name": "O-07 MED-003 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-M003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1153
+ {"name": "O-08 MED-004 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-M004\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1154
+ {"name": "O-09 MED-005 hold", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"hold\",\"transaction_id\":\"TXN-M005\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1155
+ {"name": "O-10 MED-006 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-M006\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1156
+ {"name": "O-11 HARD-001 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-H001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1157
+ {"name": "O-12 HARD-002 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-H002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1158
+ {"name": "O-13 HARD-003 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-H003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1159
+ {"name": "O-14 HARD-004 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-H004\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1160
+ {"name": "O-15 HARD-005 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-H005\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1161
+ {"name": "O-16 HARD-006 flag", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"flag\",\"transaction_id\":\"TXN-H006\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1162
+ {"name": "O-17 CRIT-001 approve", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"approve\",\"transaction_id\":\"TXN-C001\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1163
+ {"name": "O-18 CRIT-002 reject", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-C002\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1164
+ {"name": "O-19 CRIT-003 escalate", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\"action_type\":\"escalate\",\"transaction_id\":\"TXN-C003\"}"}, "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}}},
1165
+ {
1166
+ "name": "O-20 CRIT-004 reject → done=true",
1167
+ "event": [{"listen": "test", "script": {"exec": [
1168
+ "const d = pm.response.json();",
1169
+ "pm.test('reward=1.0', () => pm.expect(d.reward).to.eql(1.0));",
1170
+ "pm.test('done=true', () => pm.expect(d.done).to.eql(true));"
1171
+ ], "type": "text/javascript"}}],
1172
+ "request": {
1173
+ "method": "POST",
1174
+ "header": [{"key": "Content-Type", "value": "application/json"}],
1175
+ "body": {"mode": "raw", "raw": "{\"action_type\":\"reject\",\"transaction_id\":\"TXN-C004\"}"},
1176
+ "url": {"raw": "{{base_url}}/step", "host": ["{{base_url}}"], "path": ["step"]}
1177
+ }
1178
+ },
1179
+ {
1180
+ "name": "O-21 Grader — score=1.0 after perfect episode",
1181
+ "event": [{"listen": "test", "script": {"exec": [
1182
+ "const d = pm.response.json();",
1183
+ "pm.test('normalised_score=1.0', () => pm.expect(d.normalised_score).to.eql(1.0));",
1184
+ "pm.test('passed=true', () => pm.expect(d.passed).to.eql(true));",
1185
+ "pm.test('budget_penalty=0', () => pm.expect(d.budget_penalty).to.eql(0.0));"
1186
+ ], "type": "text/javascript"}}],
1187
+ "request": {
1188
+ "method": "GET",
1189
+ "url": {"raw": "{{base_url}}/grader", "host": ["{{base_url}}"], "path": ["grader"]}
1190
+ }
1191
+ }
1192
+ ]
1193
+ }
1194
+
1195
+ ]
1196
+ }
README.md ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PayOps — Payment Operations Incident Response
2
+
3
+ An **OpenEnv-compatible** reinforcement-learning environment where an AI agent
4
+ acts as a Payment Operations analyst. The agent reviews financial transactions
5
+ one by one and must decide the correct compliance action for each.
6
+
7
+ ---
8
+
9
+ ## Motivation
10
+
11
+ Payment operations teams process thousands of transactions every day. A
12
+ skilled analyst uses dozens of signals — risk scores, velocity, KYC status,
13
+ flag patterns — to make fast, accurate decisions. This environment lets an AI
14
+ agent learn and be evaluated on exactly this task, spanning clear-cut cases all
15
+ the way to subtle adversarial patterns like model-score poisoning and
16
+ Authorised Push Payment (APP) scams.
17
+
18
+ ---
19
+
20
+ ## Environment Description
21
+
22
+ Each **episode** steps through 12 transactions (4 easy, 4 medium, 4 hard).
23
+ For each transaction the agent observes a rich set of signals and submits one
24
+ of six possible actions. A reward is returned immediately, and the next
25
+ transaction is presented until the episode is complete.
26
+
27
+ ---
28
+
29
+ ## Action Space
30
+
31
+ | Action | Description |
32
+ |-------------|-------------|
33
+ | `approve` | Mark transaction as legitimate; allow it through |
34
+ | `reject` | Block the transaction outright |
35
+ | `flag` | Soft hold; mark for manual review |
36
+ | `escalate` | Route to senior compliance officer / fraud team |
37
+ | `inspect` | Request additional signals (logs, KYC, velocity) — yields reveal notes and a small reward; agent then acts again on the same transaction |
38
+ | `hold` | Temporary hold pending more information |
39
+
40
+ ---
41
+
42
+ ## Observation Space
43
+
44
+ | Field | Type | Description |
45
+ |----------------------|----------------|-------------|
46
+ | `transaction_id` | `str` | Unique transaction identifier |
47
+ | `amount` | `float` | Transaction amount |
48
+ | `currency` | `str` | ISO-4217 currency code |
49
+ | `sender` | `str` | Sender identifier |
50
+ | `receiver` | `str` | Receiver identifier |
51
+ | `transaction_type` | `str` | transfer \| payment \| withdrawal \| refund \| internal |
52
+ | `status` | `str` | pending \| approved \| rejected \| flagged \| escalated \| held \| inspected |
53
+ | `risk_score` | `float [0,1]` | Composite ML risk score |
54
+ | `flags` | `List[str]` | Active risk flags |
55
+ | `velocity_1h` | `int?` | Transactions from sender in the past hour |
56
+ | `country_risk` | `str?` | low \| medium \| high \| sanctioned |
57
+ | `kyc_status` | `str?` | verified \| pending \| failed \| none |
58
+ | `previous_violations`| `int?` | Prior compliance violations |
59
+ | `inspection_notes` | `str?` | Extra details revealed after an `inspect` action |
60
+ | `task_id` | `str` | Identifier of the active task |
61
+ | `task_difficulty` | `str` | easy \| medium \| hard |
62
+ | `step_in_episode` | `int` | Steps elapsed in this episode |
63
+ | `reward` | `float` | Reward from the last action |
64
+ | `cumulative_reward` | `float` | Total reward so far this episode |
65
+ | `done` | `bool` | Whether the episode has ended |
66
+ | `info` | `dict` | Diagnostic info (event, correct action, etc.) |
67
+
68
+ ---
69
+
70
+ ## Task Descriptions
71
+
72
+ ### Easy (4 tasks — clear signals)
73
+
74
+ | ID | Description | Correct Action |
75
+ |----------|-------------|----------------|
76
+ | EASY-001 | Low-value domestic transfer between verified users; no flags | `approve` |
77
+ | EASY-002 | Textbook fraud: unknown sender, offshore, sanctioned country, risk=0.97 | `reject` |
78
+ | EASY-003 | Standard refund to verified customer; tiny amount, no flags | `approve` |
79
+ | EASY-004 | ATM withdrawal burst — 15 withdrawals in 58 minutes | `flag` |
80
+
81
+ ### Medium (4 tasks — ambiguous, multi-signal reasoning required)
82
+
83
+ | ID | Description | Correct Action |
84
+ |---------|-------------|----------------|
85
+ | MED-001 | Large B2B wire, verified CFO, cross-border to medium-risk jurisdiction | `escalate` |
86
+ | MED-002 | Internal treasury transfer; large amount, KYC pending renewal | `hold` |
87
+ | MED-003 | Recurring subscription 3× higher than historical average | `flag` |
88
+ | MED-004 | Payment to licensed crypto exchange from verified personal account | `flag` |
89
+
90
+ ### Hard (4 tasks — adversarial / edge-case)
91
+
92
+ | ID | Description | Correct Action |
93
+ |----------|-------------|----------------|
94
+ | HARD-001 | Fraud model poisoning: risk_score=0.18 but manual signals scream escalate | `escalate` |
95
+ | HARD-002 | APP (Authorised Push Payment) scam: victim sending willingly to mule account | `reject` |
96
+ | HARD-003 | Structuring / smurfing: three just-below-CTR-threshold payments, same UBO | `reject` |
97
+ | HARD-004 | Legitimate FX correspondent banking settlement — looks alarming, is not | `approve` |
98
+
99
+ ---
100
+
101
+ ## Reward Design
102
+
103
+ | Outcome | Reward |
104
+ |---------|--------|
105
+ | Correct action | **+1.0** |
106
+ | Partial-credit adjacent action (per-task) | **+0.2 – +0.6** |
107
+ | `inspect` (information seeking, first time) | **+0.15** |
108
+ | `approve` when correct is `reject` / `escalate` | **−1.0** |
109
+ | `approve` when correct is `flag` / `hold` | **−0.5** |
110
+ | `reject` when correct is `approve` | **−0.5** |
111
+ | Any other wrong action | **−0.25** |
112
+
113
+ The **episode score** (0–1) is: `max(0, total_reward) / max_possible_reward`.
114
+ A score ≥ 0.5 is considered a passing episode.
115
+
116
+ ---
117
+
118
+ ## API Endpoints
119
+
120
+ | Method | Path | Description |
121
+ |--------|------|-------------|
122
+ | `POST` | `/reset` | Reset environment, return first observation |
123
+ | `POST` | `/step` | Execute an action |
124
+ | `GET` | `/state` | Current internal environment state |
125
+ | `GET` | `/schema` | JSON schemas for action / observation / state |
126
+ | `GET` | `/tasks` | Full task list with metadata |
127
+ | `GET` | `/grader` | Grade the current episode |
128
+ | `POST` | `/baseline` | Run rule-based baseline and return scores |
129
+ | `GET` | `/health` | Health check |
130
+ | `WS` | `/ws` | WebSocket persistent session |
131
+
132
+ Interactive API docs: `http://localhost:8000/docs`
133
+
134
+ ---
135
+
136
+ ## Setup & Running
137
+
138
+ ### Local (Python)
139
+
140
+ ```bash
141
+ # 1. Install dependencies
142
+ pip install -r requirements.txt
143
+
144
+ # 2. Start the server (from the parent directory of payops_env)
145
+ PYTHONPATH=$(pwd) uvicorn payops_env.server.app:app --host 0.0.0.0 --port 8000
146
+
147
+ # 3. Verify
148
+ curl http://localhost:8000/health
149
+ ```
150
+
151
+ ### Run the baseline agent
152
+
153
+ ```bash
154
+ # From the project root
155
+ PYTHONPATH=$(pwd) python payops_env/scripts/baseline_agent.py
156
+ ```
157
+
158
+ ### Docker
159
+
160
+ ```bash
161
+ # Build
162
+ docker build -t payops-env .
163
+
164
+ # Run locally on port 8000
165
+ docker run -p 8000:7860 -e PORT=7860 payops-env
166
+
167
+ # Verify
168
+ curl http://localhost:8000/health
169
+ ```
170
+
171
+ ### HuggingFace Space
172
+
173
+ The `Dockerfile` exposes port **7860** (HF Spaces default). Push the repo to
174
+ a HF Space with Docker runtime — no additional configuration required.
175
+
176
+ ---
177
+
178
+ ## Example Agent Interaction
179
+
180
+ ```python
181
+ import httpx
182
+
183
+ base = "http://localhost:8000"
184
+
185
+ # Reset
186
+ obs = httpx.post(f"{base}/reset").json()
187
+ print(obs["transaction_id"], obs["risk_score"], obs["flags"])
188
+
189
+ # Step
190
+ while not obs["done"]:
191
+ # ... agent decides action_type ...
192
+ obs = httpx.post(f"{base}/step", json={
193
+ "action_type": "approve",
194
+ "transaction_id": obs["transaction_id"],
195
+ }).json()
196
+ print(f"reward={obs['reward']:+.2f} done={obs['done']}")
197
+
198
+ # Grade
199
+ score = httpx.get(f"{base}/grader").json()
200
+ print(f"Episode score: {score['normalised_score']:.4f}")
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Baseline Results
206
+
207
+ The rule-based baseline agent uses a deterministic priority-ordered policy.
208
+
209
+ | Metric | Baseline |
210
+ |--------|----------|
211
+ | Normalised score | ~0.67 |
212
+ | Total reward | ~8.0 / 12.0 |
213
+ | Passed | Yes |
214
+ | Strong at | Easy tasks, clear velocity/flag patterns |
215
+ | Weak at | Hard adversarial tasks (e.g. FX settlement over-rejected) |
216
+
217
+ Run `POST /baseline` to reproduce these results programmatically.
218
+
219
+ ---
220
+
221
+ ## Project Structure
222
+
223
+ ```
224
+ payops_env/
225
+ ├── models.py # PayOpsAction, PayOpsObservation, PayOpsState (Pydantic)
226
+ ├── environment.py # PayOpsEnvironment — reset_async / step_async / state
227
+ ├── tasks.py # 12 tasks (easy / medium / hard) with ground-truth labels
228
+ ├── grader.py # Partial-credit reward function + episode grader
229
+ ├── scripts_util.py # Baseline runner helper (used by /baseline endpoint)
230
+ ├── scripts/
231
+ │ └── baseline_agent.py # Standalone rule-based baseline agent
232
+ ├── server/
233
+ │ └── app.py # FastAPI server with all required endpoints
234
+ ├── openenv.yaml # OpenEnv manifest
235
+ ├── Dockerfile # Docker / HuggingFace Space container
236
+ ├── requirements.txt # Python dependencies
237
+ └── README.md # This file
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Evaluation Criteria Alignment
243
+
244
+ | Criterion | Implementation |
245
+ |-----------|---------------|
246
+ | Real-world utility | Payment fraud and compliance triage — a genuine operational domain |
247
+ | Task & grader quality | 12 tasks across 3 difficulty tiers; partial-credit grader; clear pass/fail |
248
+ | Environment design | Rich observation space; 6-action space; inspect mechanic; episode state tracking |
249
+ | Code quality & spec compliance | Pydantic v2 models; async API; all required endpoints; openenv.yaml |
250
+ | Creativity & novelty | Adversarial model-poisoning task; APP scam; FX settlement edge-case |
251
+
252
+
253
+ | Outcome | Reward |
254
+ |----------------------------------|--------|
255
+ | Correct action | +1.0 |
256
+ | Approve a dangerous transaction | -2.0 |
257
+ | Wrong reject | -0.5 |
258
+ | Wrong flag / escalate | -0.25 |
259
+
260
+ ## Quick Start
261
+
262
+ ```bash
263
+ # Install dependencies
264
+ pip install -r requirements.txt
265
+
266
+ # Run the server
267
+ uvicorn server.app:app --reload
268
+
269
+ # Run the baseline agent (no server needed)
270
+ python scripts/baseline_agent.py
271
+ ```
272
+
273
+ ## Docker
274
+
275
+ ```bash
276
+ docker build -t payops-env:latest .
277
+ docker run -p 8000:8000 payops-env:latest
278
+ ```
TESTING.md ADDED
@@ -0,0 +1,905 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PayOps Environment — Test Cases & Testing Guide
2
+
3
+ This document covers every testable behaviour of the PayOps OpenEnv, organised
4
+ by endpoint and scenario. Each test shows the exact command to run, the
5
+ expected response, and what a failure looks like.
6
+
7
+ ---
8
+
9
+ ## Prerequisites
10
+
11
+ ```bash
12
+ # 1. Start the server (run from /Users/padmapriya)
13
+ PYTHONPATH=/Users/padmapriya uvicorn payops_env.server.app:app --host 0.0.0.0 --port 8000
14
+
15
+ # 2. Confirm it is up (should return {"status":"ok",...})
16
+ curl -s http://localhost:8000/health
17
+ ```
18
+
19
+ All `curl` commands below assume the server is running on `localhost:8000`.
20
+
21
+ ---
22
+
23
+ ## T-01 Health Check
24
+
25
+ **Goal:** Confirm the server is alive and returns version metadata.
26
+
27
+ ```bash
28
+ curl -s http://localhost:8000/health
29
+ ```
30
+
31
+ **Expected output**
32
+ ```json
33
+ {"status": "ok", "environment": "payops_env", "version": "1.0.0"}
34
+ ```
35
+
36
+ **Failure indicator:** Connection refused, or any field missing / wrong value.
37
+
38
+ ---
39
+
40
+ ## T-02 Schema Endpoint
41
+
42
+ **Goal:** Verify that action, observation, and state JSON schemas are served correctly.
43
+
44
+ ```bash
45
+ curl -s http://localhost:8000/schema | python3 -m json.tool
46
+ ```
47
+
48
+ **Expected output (condensed)**
49
+ ```json
50
+ {
51
+ "action": { "title": "PayOpsAction", "type": "object", ... },
52
+ "observation": { "title": "PayOpsObservation", "type": "object", ... },
53
+ "state": { "title": "PayOpsState", "type": "object", ... }
54
+ }
55
+ ```
56
+
57
+ **Checks to verify manually:**
58
+ - `action.properties` includes `action_type`, `transaction_id`, `reason`, `confidence`
59
+ - `observation.properties` includes `risk_score`, `flags`, `kyc_status`, `velocity_1h`
60
+ - HTTP status code is `200`
61
+
62
+ ---
63
+
64
+ ## T-03 Tasks Endpoint
65
+
66
+ **Goal:** Confirm all 12 tasks are returned with the correct difficulty distribution.
67
+
68
+ ```bash
69
+ curl -s http://localhost:8000/tasks | python3 -c "
70
+ import sys, json
71
+ d = json.load(sys.stdin)
72
+ print('Total tasks:', d['count'])
73
+ from collections import Counter
74
+ c = Counter(t['difficulty'] for t in d['tasks'])
75
+ print('By difficulty:', dict(c))
76
+ print()
77
+ for t in d['tasks']:
78
+ print(f\" {t['task_id']:12} [{t['difficulty']:6}] correct={t['correct_action']}\")
79
+ "
80
+ ```
81
+
82
+ **Expected output**
83
+ ```
84
+ Total tasks: 12
85
+ By difficulty: {'easy': 4, 'medium': 4, 'hard': 4}
86
+
87
+ EASY-001 [easy ] correct=approve
88
+ EASY-002 [easy ] correct=reject
89
+ EASY-003 [easy ] correct=approve
90
+ EASY-004 [easy ] correct=flag
91
+ MED-001 [medium] correct=escalate
92
+ MED-002 [medium] correct=hold
93
+ MED-003 [medium] correct=flag
94
+ MED-004 [medium] correct=flag
95
+ HARD-001 [hard ] correct=escalate
96
+ HARD-002 [hard ] correct=reject
97
+ HARD-003 [hard ] correct=reject
98
+ HARD-004 [hard ] correct=approve
99
+ ```
100
+
101
+ **Failure indicator:** count != 12, missing difficulty tier, wrong correct_action.
102
+
103
+ ---
104
+
105
+ ## T-04 Reset
106
+
107
+ **Goal:** Reset the environment and confirm the first task is EASY-001.
108
+
109
+ ```bash
110
+ curl -s -X POST http://localhost:8000/reset | python3 -c "
111
+ import sys, json
112
+ d = json.load(sys.stdin)
113
+ print('task_id :', d['task_id'])
114
+ print('transaction_id :', d['transaction_id'])
115
+ print('difficulty :', d['task_difficulty'])
116
+ print('status :', d['status'])
117
+ print('done :', d['done'])
118
+ print('reward :', d['reward'])
119
+ print('cumulative_reward :', d['cumulative_reward'])
120
+ print('risk_score :', d['risk_score'])
121
+ "
122
+ ```
123
+
124
+ **Expected output**
125
+ ```
126
+ task_id : EASY-001
127
+ transaction_id : TXN-E001
128
+ difficulty : easy
129
+ status : pending
130
+ done : false
131
+ reward : 0.0
132
+ cumulative_reward : 0.0
133
+ risk_score : 0.05
134
+ ```
135
+
136
+ **Failure indicator:** `done=true`, `reward != 0`, wrong `task_id`.
137
+
138
+ ---
139
+
140
+ ## T-05 Correct Action — Full Credit (+1.0)
141
+
142
+ **Goal:** Submit the correct action for EASY-001 (`approve`) and receive reward +1.0.
143
+
144
+ ```bash
145
+ curl -s -X POST http://localhost:8000/reset > /dev/null # fresh start
146
+
147
+ curl -s -X POST http://localhost:8000/step \
148
+ -H "Content-Type: application/json" \
149
+ -d '{"action_type":"approve","transaction_id":"TXN-E001"}' \
150
+ | python3 -c "
151
+ import sys, json
152
+ d = json.load(sys.stdin)
153
+ print('reward :', d['reward'])
154
+ print('correct info :', d['info'].get('correct_action'))
155
+ print('action taken :', d['info'].get('action_taken'))
156
+ "
157
+ ```
158
+
159
+ **Expected output**
160
+ ```
161
+ reward : 1.0
162
+ correct info : approve
163
+ action taken : approve
164
+ ```
165
+
166
+ ---
167
+
168
+ ## T-06 Wrong Action — Penalty (approve on fraud = -1.0)
169
+
170
+ **Goal:** Skip to EASY-002 (textbook fraud) and approve it. Expect -1.0 penalty.
171
+
172
+ ```bash
173
+ curl -s -X POST http://localhost:8000/reset > /dev/null
174
+ # Step past EASY-001
175
+ curl -s -X POST http://localhost:8000/step \
176
+ -H "Content-Type: application/json" \
177
+ -d '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
178
+
179
+ # Now on EASY-002 (correct=reject). Try approving it.
180
+ curl -s -X POST http://localhost:8000/step \
181
+ -H "Content-Type: application/json" \
182
+ -d '{"action_type":"approve","transaction_id":"TXN-E002"}' \
183
+ | python3 -c "
184
+ import sys, json
185
+ d = json.load(sys.stdin)
186
+ print('reward :', d['reward'])
187
+ print('correct was :', d['info'].get('correct_action'))
188
+ "
189
+ ```
190
+
191
+ **Expected output**
192
+ ```
193
+ reward : -1.0
194
+ correct was : reject
195
+ ```
196
+
197
+ ---
198
+
199
+ ## T-07 Partial Credit Action
200
+
201
+ **Goal:** On MED-001 (correct=`escalate`), submit `flag` — should earn +0.5 partial credit.
202
+
203
+ ```bash
204
+ curl -s -X POST http://localhost:8000/reset > /dev/null
205
+ # Step through EASY tasks 1-4 with any actions
206
+ for ACTION in approve reject approve reject; do
207
+ curl -s -X POST http://localhost:8000/step \
208
+ -H "Content-Type: application/json" \
209
+ -d "{\"action_type\":\"$ACTION\",\"transaction_id\":\"dummy\"}" > /dev/null
210
+ done
211
+
212
+ # Now on MED-001 (correct=escalate). Submit flag.
213
+ curl -s -X POST http://localhost:8000/step \
214
+ -H "Content-Type: application/json" \
215
+ -d '{"action_type":"flag","transaction_id":"TXN-M001"}' \
216
+ | python3 -c "
217
+ import sys, json
218
+ d = json.load(sys.stdin)
219
+ print('reward :', d['reward'])
220
+ print('correct was :', d['info'].get('correct_action'))
221
+ print('partial? :', 0 < d['reward'] < 1.0)
222
+ "
223
+ ```
224
+
225
+ **Expected output**
226
+ ```
227
+ reward : 0.5
228
+ correct was : escalate
229
+ partial? : True
230
+ ```
231
+
232
+ ---
233
+
234
+ ## T-08 Inspect Action — Information Reveal
235
+
236
+ **Goal:** Use `inspect` on EASY-001 to receive investigation notes and a small reward (+0.15). The episode should NOT advance (still on same transaction).
237
+
238
+ ```bash
239
+ curl -s -X POST http://localhost:8000/reset > /dev/null
240
+
241
+ curl -s -X POST http://localhost:8000/step \
242
+ -H "Content-Type: application/json" \
243
+ -d '{"action_type":"inspect","transaction_id":"TXN-E001"}' \
244
+ | python3 -c "
245
+ import sys, json
246
+ d = json.load(sys.stdin)
247
+ print('reward :', d['reward'])
248
+ print('status :', d['status'])
249
+ print('task_id :', d['task_id']) # should still be EASY-001
250
+ print('inspection_notes :', d['inspection_notes'])
251
+ "
252
+ ```
253
+
254
+ **Expected output**
255
+ ```
256
+ reward : 0.15
257
+ status : inspected
258
+ task_id : EASY-001
259
+ inspection_notes : Sender account opened 3 years ago. Consistent transaction history. KYC fully verified.
260
+ ```
261
+
262
+ ---
263
+
264
+ ## T-09 Double Inspect — No Double-Dipping
265
+
266
+ **Goal:** Inspect the same transaction twice. Second inspect should return reward 0.0 (already inspected).
267
+
268
+ ```bash
269
+ curl -s -X POST http://localhost:8000/reset > /dev/null
270
+
271
+ # First inspect — reward 0.15
272
+ curl -s -X POST http://localhost:8000/step \
273
+ -H "Content-Type: application/json" \
274
+ -d '{"action_type":"inspect","transaction_id":"TXN-E001"}' \
275
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print('First inspect reward:', d['reward'])"
276
+
277
+ # Second inspect — reward 0.0
278
+ curl -s -X POST http://localhost:8000/step \
279
+ -H "Content-Type: application/json" \
280
+ -d '{"action_type":"inspect","transaction_id":"TXN-E001"}' \
281
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print('Second inspect reward:', d['reward'])"
282
+ ```
283
+
284
+ **Expected output**
285
+ ```
286
+ First inspect reward: 0.15
287
+ Second inspect reward: 0.0
288
+ ```
289
+
290
+ ---
291
+
292
+ ## T-10 Invalid Action Type
293
+
294
+ **Goal:** Send an unsupported action type and receive a 422 validation error.
295
+
296
+ ```bash
297
+ curl -s -X POST http://localhost:8000/step \
298
+ -H "Content-Type: application/json" \
299
+ -d '{"action_type":"delete","transaction_id":"TXN-E001"}' \
300
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print('status_code:', d.get('detail','')[:60])"
301
+ ```
302
+
303
+ **Expected output**
304
+ ```
305
+ status_code: Invalid action_type 'delete'. Valid values: ['approve', 'escal
306
+ ```
307
+
308
+ HTTP status code should be `422`.
309
+
310
+ ---
311
+
312
+ ## T-11 Step Without Reset
313
+
314
+ **Goal:** Call `/step` without calling `/reset` first. Should return a `400` error.
315
+
316
+ ```bash
317
+ # Kill and restart server to guarantee clean state
318
+ # Then immediately step without reset:
319
+ curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8000/step \
320
+ -H "Content-Type: application/json" \
321
+ -d '{"action_type":"approve","transaction_id":"TXN-E001"}'
322
+ ```
323
+
324
+ **Expected output**
325
+ ```
326
+ 400
327
+ ```
328
+
329
+ ---
330
+
331
+ ## T-12 State Endpoint Tracking
332
+
333
+ **Goal:** Confirm `/state` reflects the episode progress correctly.
334
+
335
+ ```bash
336
+ curl -s -X POST http://localhost:8000/reset > /dev/null
337
+
338
+ curl -s http://localhost:8000/state | python3 -c "
339
+ import sys, json
340
+ d = json.load(sys.stdin)
341
+ print('step_count :', d['step_count'])
342
+ print('transactions_processed:', d['transactions_processed'])
343
+ print('total_tasks :', d['total_tasks'])
344
+ print('done :', d['done'])
345
+ "
346
+
347
+ # Take one step
348
+ curl -s -X POST http://localhost:8000/step \
349
+ -H "Content-Type: application/json" \
350
+ -d '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
351
+
352
+ curl -s http://localhost:8000/state | python3 -c "
353
+ import sys, json
354
+ d = json.load(sys.stdin)
355
+ print('step_count :', d['step_count'])
356
+ print('transactions_processed:', d['transactions_processed'])
357
+ print('last_action :', d['last_action'])
358
+ print('cumulative_reward :', d['cumulative_reward'])
359
+ "
360
+ ```
361
+
362
+ **Expected output (before step)**
363
+ ```
364
+ step_count : 0
365
+ transactions_processed: 0
366
+ total_tasks : 12
367
+ done : false
368
+ ```
369
+
370
+ **Expected output (after step)**
371
+ ```
372
+ step_count : 1
373
+ transactions_processed: 1
374
+ last_action : approve
375
+ cumulative_reward : 1.0
376
+ ```
377
+
378
+ ---
379
+
380
+ ## T-13 Complete Episode — Done Flag
381
+
382
+ **Goal:** Step through all 12 tasks and confirm `done=true` on the last step.
383
+
384
+ ```bash
385
+ curl -s -X POST http://localhost:8000/reset > /dev/null
386
+
387
+ python3 - <<'EOF'
388
+ import httpx, asyncio
389
+
390
+ BASE = "http://localhost:8000"
391
+ ACTIONS = [
392
+ "approve","reject","approve","flag", # easy
393
+ "escalate","hold","flag","flag", # medium
394
+ "escalate","reject","reject","approve" # hard (perfect sequence)
395
+ ]
396
+
397
+ client = httpx.Client()
398
+ txn_ids = [t["transaction_id"] for t in client.get(f"{BASE}/tasks").json()["tasks"]]
399
+
400
+ for i, (action, txn) in enumerate(zip(ACTIONS, txn_ids)):
401
+ resp = client.post(f"{BASE}/step", json={"action_type": action, "transaction_id": txn}).json()
402
+ print(f"Step {i+1:2d} {txn:12} action={action:10} reward={resp['reward']:+.2f} done={resp['done']}")
403
+
404
+ client.close()
405
+ EOF
406
+ ```
407
+
408
+ **Expected output (last line)**
409
+ ```
410
+ Step 12 TXN-H004 action=approve reward=+1.00 done=True
411
+ ```
412
+
413
+ All other steps should show `done=False`.
414
+
415
+ ---
416
+
417
+ ## T-14 Grader Endpoint
418
+
419
+ **Goal:** Grade and score the episode immediately after completing all steps.
420
+
421
+ ```bash
422
+ # Run the perfect sequence first (T-13 above), then:
423
+ curl -s http://localhost:8000/grader | python3 -c "
424
+ import sys, json
425
+ d = json.load(sys.stdin)
426
+ print('total_reward :', d['total_reward'])
427
+ print('max_possible :', d['max_possible_reward'])
428
+ print('normalised_score :', d['normalised_score'])
429
+ print('passed :', d['passed'])
430
+ print()
431
+ for t in d['per_task']:
432
+ mark = '✓' if t['correct'] else '✗'
433
+ print(f\" {mark} {t['task_id']:12} action={t['action_taken']:10} correct={t['correct_action']:10} reward={t['reward']:+.2f}\")
434
+ "
435
+ ```
436
+
437
+ **Expected output (perfect run)**
438
+ ```
439
+ total_reward : 12.0
440
+ max_possible : 12.0
441
+ normalised_score : 1.0
442
+ passed : True
443
+
444
+ ✓ EASY-001 action=approve correct=approve reward=+1.00
445
+ ✓ EASY-002 action=reject correct=reject reward=+1.00
446
+ ...
447
+ ✓ HARD-004 action=approve correct=approve reward=+1.00
448
+ ```
449
+
450
+ ---
451
+
452
+ ## T-15 Grader Without Episode
453
+
454
+ **Goal:** Call `/grader` before any steps — should return a 400 error.
455
+
456
+ ```bash
457
+ curl -s -X POST http://localhost:8000/reset > /dev/null
458
+ curl -s http://localhost:8000/grader
459
+ ```
460
+
461
+ **Expected output**
462
+ ```json
463
+ {"error": "No actions recorded. Run /reset then /step first."}
464
+ ```
465
+
466
+ ---
467
+
468
+ ## T-16 Baseline Endpoint
469
+
470
+ **Goal:** Confirm `/baseline` runs the rule-based agent and returns a normalised score ≥ 0.5.
471
+
472
+ ```bash
473
+ curl -s -X POST http://localhost:8000/baseline | python3 -c "
474
+ import sys, json
475
+ d = json.load(sys.stdin)
476
+ print('normalised_score :', d['normalised_score'])
477
+ print('total_reward :', d['total_reward'])
478
+ print('steps :', d['steps'])
479
+ print('passed (>=0.5) :', d['normalised_score'] >= 0.5)
480
+ print()
481
+ for t in d['scores']:
482
+ mark = '✓' if t['correct'] else '✗'
483
+ print(f\" {mark} {t['task_id']:12} [{t['difficulty']:6}] action={t['action_taken']:10} reward={t['reward']:+.2f}\")
484
+ "
485
+ ```
486
+
487
+ **Expected output**
488
+ ```
489
+ normalised_score : 0.7292
490
+ total_reward : 8.75
491
+ steps : 12
492
+ passed (>=0.5) : True
493
+
494
+ ✓ EASY-001 [easy ] action=approve reward=+1.00
495
+ ✓ EASY-002 [easy ] action=reject reward=+1.00
496
+ ...
497
+ ```
498
+
499
+ ---
500
+
501
+ ## T-17 Optional Reason and Confidence Fields
502
+
503
+ **Goal:** Submit an action with optional `reason` and `confidence`. Should be accepted without error.
504
+
505
+ ```bash
506
+ curl -s -X POST http://localhost:8000/reset > /dev/null
507
+
508
+ curl -s -X POST http://localhost:8000/step \
509
+ -H "Content-Type: application/json" \
510
+ -d '{
511
+ "action_type": "flag",
512
+ "transaction_id": "TXN-E001",
513
+ "reason": "Low risk but flagging for audit trail verification",
514
+ "confidence": 0.82
515
+ }' \
516
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print('HTTP OK, reward:', d['reward'])"
517
+ ```
518
+
519
+ **Expected output**
520
+ ```
521
+ HTTP OK, reward: 0.3
522
+ ```
523
+
524
+ (0.3 = partial credit for flagging when correct action is approve)
525
+
526
+ ---
527
+
528
+ ## T-18 Hard Task — Model Poisoning (HARD-001)
529
+
530
+ **Goal:** Demonstrate that the low risk_score (0.18) is a trap. The correct action is `escalate`.
531
+
532
+ ```bash
533
+ curl -s -X POST http://localhost:8000/reset > /dev/null
534
+ # Advance to HARD-001 (task 9) — step through easy + medium with dummy actions
535
+ python3 - <<'EOF'
536
+ import httpx
537
+ BASE = "http://localhost:8000"
538
+ c = httpx.Client()
539
+ tasks = c.get(f"{BASE}/tasks").json()["tasks"]
540
+
541
+ # Steps 1-8: easy + medium
542
+ for t in tasks[:8]:
543
+ c.post(f"{BASE}/step", json={"action_type": "approve", "transaction_id": t["transaction_id"]})
544
+
545
+ # Step 9: HARD-001 — agent trusts the low risk_score and approves (wrong!)
546
+ resp_wrong = c.post(f"{BASE}/step", json={
547
+ "action_type": "approve", "transaction_id": "TXN-H001"
548
+ }).json()
549
+ print("Trusted ML score → approve")
550
+ print(" reward :", resp_wrong["reward"]) # expect -0.5
551
+ print(" correct :", resp_wrong["info"]["correct_action"])
552
+
553
+ # Reset and do it correctly
554
+ c.post(f"{BASE}/reset")
555
+ for t in tasks[:8]:
556
+ c.post(f"{BASE}/step", json={"action_type": "approve", "transaction_id": t["transaction_id"]})
557
+
558
+ resp_correct = c.post(f"{BASE}/step", json={
559
+ "action_type": "escalate", "transaction_id": "TXN-H001"
560
+ }).json()
561
+ print("\nOverrode ML score → escalate")
562
+ print(" reward :", resp_correct["reward"]) # expect +1.0
563
+ c.close()
564
+ EOF
565
+ ```
566
+
567
+ **Expected output**
568
+ ```
569
+ Trusted ML score → approve
570
+ reward : -0.5
571
+ correct : escalate
572
+
573
+ Overrode ML score → escalate
574
+ reward : 1.0
575
+ ```
576
+
577
+ ---
578
+
579
+ ## T-19 Inspect Reveals Hidden Context (HARD-001)
580
+
581
+ **Goal:** Inspect HARD-001 to reveal the mule-account intelligence note before deciding.
582
+
583
+ ```bash
584
+ curl -s -X POST http://localhost:8000/reset > /dev/null
585
+ # Advance to HARD-001
586
+ python3 - <<'EOF'
587
+ import httpx
588
+ BASE = "http://localhost:8000"
589
+ c = httpx.Client()
590
+ tasks = c.get(f"{BASE}/tasks").json()["tasks"]
591
+ for t in tasks[:8]:
592
+ c.post(f"{BASE}/step", json={"action_type": "approve", "transaction_id": t["transaction_id"]})
593
+
594
+ # Inspect HARD-001
595
+ resp = c.post(f"{BASE}/step", json={
596
+ "action_type": "inspect", "transaction_id": "TXN-H001"
597
+ }).json()
598
+ print("Inspect reward :", resp["reward"])
599
+ print("Notes :", resp["inspection_notes"])
600
+ c.close()
601
+ EOF
602
+ ```
603
+
604
+ **Expected output**
605
+ ```
606
+ Inspect reward : 0.15
607
+ Notes : Account created 7 days ago. This is the first outbound transfer. Receiver matches a pattern of solicitor-impersonation mule accounts flagged in last month's intelligence bulletin. Risk model underscored due to clean transaction history (new account).
608
+ ```
609
+
610
+ ---
611
+
612
+ ## T-20 WebSocket Session
613
+
614
+ **Goal:** Run a full reset → step sequence over the WebSocket endpoint.
615
+
616
+ ```bash
617
+ pip install websockets -q # if not already installed
618
+
619
+ python3 - <<'EOF'
620
+ import asyncio, json, websockets
621
+
622
+ async def test_ws():
623
+ uri = "ws://localhost:8000/ws"
624
+ async with websockets.connect(uri) as ws:
625
+ # Reset
626
+ await ws.send(json.dumps({"type": "reset"}))
627
+ obs = json.loads(await ws.recv())
628
+ print("Reset →", obs["transaction_id"], "risk:", obs["risk_score"])
629
+
630
+ # Step – approve
631
+ await ws.send(json.dumps({
632
+ "type": "step",
633
+ "action_type": "approve",
634
+ "transaction_id": obs["transaction_id"]
635
+ }))
636
+ obs2 = json.loads(await ws.recv())
637
+ print("Step →", "reward:", obs2["reward"], "next:", obs2["transaction_id"])
638
+
639
+ # State
640
+ await ws.send(json.dumps({"type": "state"}))
641
+ state = json.loads(await ws.recv())
642
+ print("State →", "steps:", state["step_count"], "txns:", state["transactions_processed"])
643
+
644
+ asyncio.run(test_ws())
645
+ EOF
646
+ ```
647
+
648
+ **Expected output**
649
+ ```
650
+ Reset → TXN-E001 risk: 0.05
651
+ Step → reward: 1.0 next: TXN-E002
652
+ State → steps: 1 txns: 1
653
+ ```
654
+
655
+ ---
656
+
657
+ ## T-21 Baseline Agent Script (Standalone)
658
+
659
+ **Goal:** Run the standalone Python baseline script independently of the server.
660
+
661
+ ```bash
662
+ cd /Users/padmapriya
663
+ PYTHONPATH=/Users/padmapriya python3 payops_env/scripts/baseline_agent.py
664
+ ```
665
+
666
+ **Expected output (last few lines)**
667
+ ```
668
+ ============================================================
669
+ Episode Summary
670
+ ============================================================
671
+ Steps : 12
672
+ Total reward : +8.75
673
+ Max possible : 12.00
674
+ Normalised score : 0.7292
675
+ Passed (≥0.5) : YES ✓
676
+ ============================================================
677
+ ```
678
+
679
+ ---
680
+
681
+ ## T-22 All Actions Are Valid on Each Task
682
+
683
+ **Goal:** Confirm every action type is accepted without error (even if penalised).
684
+
685
+ ```bash
686
+ python3 - <<'EOF'
687
+ import httpx
688
+
689
+ BASE = "http://localhost:8000"
690
+ ACTIONS = ["approve", "reject", "flag", "escalate", "inspect", "hold"]
691
+
692
+ c = httpx.Client()
693
+ for action in ACTIONS:
694
+ c.post(f"{BASE}/reset")
695
+ resp = c.post(f"{BASE}/step", json={
696
+ "action_type": action,
697
+ "transaction_id": "TXN-E001"
698
+ })
699
+ print(f"action={action:10} HTTP={resp.status_code} reward={resp.json()['reward']:+.2f}")
700
+ c.close()
701
+ EOF
702
+ ```
703
+
704
+ **Expected output**
705
+ ```
706
+ action=approve HTTP=200 reward=+1.00
707
+ action=reject HTTP=200 reward=-0.50
708
+ action=flag HTTP=200 reward=+0.30
709
+ action=escalate HTTP=200 reward=-0.25
710
+ action=inspect HTTP=200 reward=+0.15
711
+ action=hold HTTP=200 reward=-0.25
712
+ ```
713
+
714
+ ---
715
+
716
+ ## T-23 Full Perfect Episode (Score = 1.0)
717
+
718
+ **Goal:** Submit all 12 correct actions and confirm normalised_score = 1.0.
719
+
720
+ ```bash
721
+ python3 - <<'EOF'
722
+ import httpx
723
+
724
+ BASE = "http://localhost:8000"
725
+ c = httpx.Client()
726
+
727
+ tasks = c.get(f"{BASE}/tasks").json()["tasks"]
728
+ c.post(f"{BASE}/reset")
729
+
730
+ for t in tasks:
731
+ resp = c.post(f"{BASE}/step", json={
732
+ "action_type": t["correct_action"],
733
+ "transaction_id": t["transaction_id"]
734
+ }).json()
735
+ mark = "✓" if resp["reward"] == 1.0 else "✗"
736
+ print(f"{mark} {t['task_id']:12} action={t['correct_action']:10} reward={resp['reward']:+.2f}")
737
+
738
+ score = c.get(f"{BASE}/grader").json()
739
+ print()
740
+ print("Normalised score:", score["normalised_score"])
741
+ print("Passed :", score["passed"])
742
+ c.close()
743
+ EOF
744
+ ```
745
+
746
+ **Expected output**
747
+ ```
748
+ ✓ EASY-001 action=approve reward=+1.00
749
+ ✓ EASY-002 action=reject reward=+1.00
750
+ ✓ EASY-003 action=approve reward=+1.00
751
+ ✓ EASY-004 action=flag reward=+1.00
752
+ ✓ MED-001 action=escalate reward=+1.00
753
+ ✓ MED-002 action=hold reward=+1.00
754
+ ✓ MED-003 action=flag reward=+1.00
755
+ ✓ MED-004 action=flag reward=+1.00
756
+ ✓ HARD-001 action=escalate reward=+1.00
757
+ ✓ HARD-002 action=reject reward=+1.00
758
+ ✓ HARD-003 action=reject reward=+1.00
759
+ ✓ HARD-004 action=approve reward=+1.00
760
+
761
+ Normalised score: 1.0
762
+ Passed : True
763
+ ```
764
+
765
+ ---
766
+
767
+ ## T-24 Worst-Case Episode (Approve Everything)
768
+
769
+ **Goal:** Approve all 12 transactions (maximally wrong) and confirm very low score.
770
+
771
+ ```bash
772
+ python3 - <<'EOF'
773
+ import httpx
774
+
775
+ BASE = "http://localhost:8000"
776
+ c = httpx.Client()
777
+
778
+ tasks = c.get(f"{BASE}/tasks").json()["tasks"]
779
+ c.post(f"{BASE}/reset")
780
+
781
+ total = 0
782
+ for t in tasks:
783
+ resp = c.post(f"{BASE}/step", json={
784
+ "action_type": "approve",
785
+ "transaction_id": t["transaction_id"]
786
+ }).json()
787
+ total += resp["reward"]
788
+ print(f"{t['task_id']:12} correct={t['correct_action']:10} reward={resp['reward']:+.2f}")
789
+
790
+ score = c.get(f"{BASE}/grader").json()
791
+ print()
792
+ print(f"Total reward : {total:+.2f}")
793
+ print(f"Normalised score : {score['normalised_score']}")
794
+ print(f"Passed : {score['passed']}")
795
+ c.close()
796
+ EOF
797
+ ```
798
+
799
+ **Expected outcome:** Several `-1.0` and `-0.5` penalties. Normalised score near or equal to `0.0`. `passed=False`.
800
+
801
+ ---
802
+
803
+ ## Quick Reference — Expected Rewards Per Action
804
+
805
+ | Scenario | Action | Reward |
806
+ |----------|--------|--------|
807
+ | Correct decision | any | `+1.0` |
808
+ | Inspect (first time) | `inspect` | `+0.15` |
809
+ | Inspect (already inspected) | `inspect` | `0.0` |
810
+ | Partial credit (task-specific) | adjacent | `+0.2` – `+0.6` |
811
+ | Approve fraud/escalation | `approve` | `-1.0` |
812
+ | Approve flagged/held | `approve` | `-0.5` |
813
+ | Reject legitimate tx | `reject` | `-0.5` |
814
+ | Any other wrong action | any | `-0.25` |
815
+
816
+ ---
817
+
818
+ ## Quick Reference — Correct Actions per Task
819
+
820
+ | Task ID | Difficulty | Correct Action | Key Signal |
821
+ |---------|-----------|----------------|------------|
822
+ | EASY-001 | easy | `approve` | risk=0.05, no flags, verified KYC |
823
+ | EASY-002 | easy | `reject` | sanctioned country, unknown sender, risk=0.97 |
824
+ | EASY-003 | easy | `approve` | small refund, risk=0.03, verified |
825
+ | EASY-004 | easy | `flag` | velocity_1h=15 (ATM burst) |
826
+ | MED-001 | medium | `escalate` | large B2B, cross-border, medium-risk country |
827
+ | MED-002 | medium | `hold` | KYC pending, large internal transfer |
828
+ | MED-003 | medium | `flag` | amount 3× historical average |
829
+ | MED-004 | medium | `flag` | crypto exchange, moderate risk |
830
+ | HARD-001 | hard | `escalate` | risk_score=0.18 is poisoned — manual flags say escalate |
831
+ | HARD-002 | hard | `reject` | APP scam, mule account pattern |
832
+ | HARD-003 | hard | `reject` | structuring/smurfing, KYC failed |
833
+ | HARD-004 | hard | `approve` | legitimate FX settlement — looks scary, is fine |
834
+
835
+ ---
836
+
837
+ ## Running All Tests in One Script
838
+
839
+ Save the following as `run_tests.sh` and execute from `/Users/padmapriya`:
840
+
841
+ ```bash
842
+ #!/usr/bin/env bash
843
+ # run_tests.sh — smoke-test all PayOps endpoints
844
+
845
+ set -e
846
+ BASE="http://localhost:8000"
847
+ PASS=0
848
+ FAIL=0
849
+
850
+ check() {
851
+ local name="$1"
852
+ local got="$2"
853
+ local want="$3"
854
+ if echo "$got" | grep -q "$want"; then
855
+ echo " ✓ $name"
856
+ ((PASS++))
857
+ else
858
+ echo " ✗ $name (expected '$want', got '$got')"
859
+ ((FAIL++))
860
+ fi
861
+ }
862
+
863
+ echo "=== PayOps Test Suite ==="
864
+
865
+ check "T-01 health" "$(curl -s $BASE/health)" '"status":"ok"'
866
+ check "T-02 schema" "$(curl -s $BASE/schema)" '"PayOpsAction"'
867
+ check "T-03 tasks count" "$(curl -s $BASE/tasks)" '"count":12'
868
+ check "T-04 reset" "$(curl -s -X POST $BASE/reset)" '"task_id":"EASY-001"'
869
+ check "T-05 correct step" "$(curl -s -X POST $BASE/step -H 'Content-Type: application/json' -d '{"action_type":"approve","transaction_id":"TXN-E001"}')" '"reward":1.0'
870
+ check "T-10 invalid action" "$(curl -s -X POST $BASE/step -H 'Content-Type: application/json' -d '{"action_type":"delete","transaction_id":"TXN-E001"}')" "Invalid action_type"
871
+ check "T-16 baseline" "$(curl -s -X POST $BASE/baseline)" '"normalised_score"'
872
+
873
+ echo ""
874
+ echo "Results: $PASS passed, $FAIL failed"
875
+ ```
876
+
877
+ ```bash
878
+ cd /Users/padmapriya
879
+ bash payops_env/run_tests.sh
880
+ ```
881
+
882
+ **Expected output**
883
+ ```
884
+ === PayOps Test Suite ===
885
+ ✓ T-01 health
886
+ ✓ T-02 schema
887
+ ✓ T-03 tasks count
888
+ ✓ T-04 reset
889
+ ✓ T-05 correct step
890
+ ✓ T-10 invalid action
891
+ ✓ T-16 baseline
892
+
893
+ Results: 7 passed, 0 failed
894
+ ```
895
+
896
+ ---
897
+
898
+ ## Interactive API Explorer
899
+
900
+ FastAPI serves auto-generated interactive docs. Open in a browser while the server is running:
901
+
902
+ ```
903
+ http://localhost:8000/docs ← Swagger UI (try endpoints in-browser)
904
+ http://localhost:8000/redoc ← ReDoc documentation
905
+ ```
__init__.py ADDED
File without changes
environment.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PayOps Environment — core simulation.
3
+
4
+ Supports all 10 action types:
5
+ Terminal decisions: approve | reject | flag | escalate | hold
6
+ Investigation sub-actions: inspect | request_docs | verify_kyc |
7
+ contact_sender | file_sar
8
+
9
+ Investigation sub-actions do NOT advance the task pointer; they reveal
10
+ additional context and consume budget. A file_sar sub-action is required
11
+ for full credit on regulatory tasks.
12
+
13
+ Multi-step chain tasks (chain_total > 1) are presented as a single task
14
+ entry; the grader handles them as one decision unit.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import uuid
20
+ from collections import deque
21
+ from typing import Deque, List, Optional
22
+
23
+ from payops_env.grader import INVESTIGATION_BONUS, grade
24
+ from payops_env.models import PayOpsAction, PayOpsObservation, PayOpsState
25
+ from payops_env.tasks import ACTION_COSTS, TASKS, PayOpsTask
26
+
27
+
28
+ TERMINAL_ACTIONS = {"approve", "reject", "flag", "escalate", "hold"}
29
+ INVESTIGATION_ACTIONS = {"inspect", "request_docs", "verify_kyc", "contact_sender", "file_sar"}
30
+ VALID_ACTIONS = TERMINAL_ACTIONS | INVESTIGATION_ACTIONS
31
+
32
+ _RECENT_WINDOW = 4 # how many past (task_id, action) pairs to surface in obs
33
+
34
+
35
+ class PayOpsEnvironment:
36
+ """
37
+ OpenEnv-compatible environment for Payment Operations triage.
38
+
39
+ An episode proceeds through every task in TASKS. For each task the
40
+ agent may issue any number of investigation sub-actions before a
41
+ terminal decision closes the task.
42
+
43
+ Budget
44
+ ------
45
+ The agent starts with ``budget_limit`` points. Each investigation
46
+ sub-action deducts its cost (see tasks.ACTION_COSTS). Budget overspend
47
+ is penalised in grade_episode; within the environment it is tracked but
48
+ does not terminate the episode.
49
+ """
50
+
51
+ BUDGET_LIMIT = 5.0
52
+
53
+ def __init__(self):
54
+ self._tasks: List[PayOpsTask] = []
55
+ self._current_task: Optional[PayOpsTask] = None
56
+ self._state: PayOpsState = PayOpsState()
57
+ self._used_inv: dict = {} # task_id → set of investigation actions used
58
+ self._sar_filed: set = set() # task_ids where file_sar was issued
59
+ self._recent_decisions: Deque = deque(maxlen=_RECENT_WINDOW)
60
+
61
+ # ------------------------------------------------------------------
62
+ # OpenEnv API
63
+ # ------------------------------------------------------------------
64
+
65
+ async def reset_async(self) -> PayOpsObservation:
66
+ self._tasks = list(TASKS)
67
+ self._current_task = self._tasks[0]
68
+ self._used_inv = {}
69
+ self._sar_filed = set()
70
+ self._recent_decisions = deque(maxlen=_RECENT_WINDOW)
71
+ self._state = PayOpsState(
72
+ episode_id=str(uuid.uuid4()),
73
+ step_count=0,
74
+ current_task_id=self._current_task.task_id,
75
+ transactions_processed=0,
76
+ total_tasks=len(self._tasks),
77
+ cumulative_reward=0.0,
78
+ actions_taken=[],
79
+ last_action=None,
80
+ done=False,
81
+ budget_spent=0.0,
82
+ budget_limit=self.BUDGET_LIMIT,
83
+ investigation_actions_used=[],
84
+ correct_decisions=0,
85
+ wrong_high_cost=0,
86
+ recent_decisions=[],
87
+ )
88
+ return self._make_observation(reward=0.0, done=False, info={"event": "reset"})
89
+
90
+ async def step_async(self, action: PayOpsAction) -> PayOpsObservation:
91
+ if self._current_task is None:
92
+ raise RuntimeError("Environment must be reset before stepping.")
93
+
94
+ action_type = action.action_type.lower()
95
+ if action_type not in VALID_ACTIONS:
96
+ raise ValueError(
97
+ f"Invalid action '{action_type}'. "
98
+ f"Valid actions: {sorted(VALID_ACTIONS)}"
99
+ )
100
+
101
+ task = self._current_task
102
+ task_id = task.task_id
103
+ cost = ACTION_COSTS.get(action_type, 0.0)
104
+
105
+ # Deduct cost
106
+ self._state.budget_spent = round(self._state.budget_spent + cost, 4)
107
+ self._state.step_count += 1
108
+ self._state.actions_taken.append(action_type)
109
+ self._state.last_action = action_type
110
+
111
+ # ── INVESTIGATION SUB-ACTIONS ────────────────────────────────────
112
+ if action_type in INVESTIGATION_ACTIONS:
113
+ used = self._used_inv.setdefault(task_id, set())
114
+ already = action_type in used
115
+ reward = 0.0 if already else INVESTIGATION_BONUS
116
+ used.add(action_type)
117
+ self._state.investigation_actions_used.append(action_type)
118
+ self._state.cumulative_reward = round(
119
+ self._state.cumulative_reward + reward, 4
120
+ )
121
+
122
+ # Determine reveal text
123
+ reveal_text: Optional[str] = None
124
+ reveal_field: Optional[str] = None
125
+ if action_type == "inspect":
126
+ reveal_text = task.inspect_reveal or "No additional information available."
127
+ reveal_field = "inspection_notes"
128
+ elif action_type == "request_docs":
129
+ reveal_text = task.docs_reveal or "No documents on record for this transaction."
130
+ reveal_field = "docs_notes"
131
+ elif action_type == "verify_kyc":
132
+ reveal_text = task.kyc_reveal or "KYC records could not be retrieved."
133
+ reveal_field = "kyc_notes"
134
+ elif action_type == "contact_sender":
135
+ reveal_text = task.contact_reveal or "Sender did not respond to contact attempt."
136
+ reveal_field = "contact_notes"
137
+ elif action_type == "file_sar":
138
+ self._sar_filed.add(task_id)
139
+ reveal_text = (
140
+ "SAR filed with FinCEN. Reference number will be generated within 24 h."
141
+ if task.regulatory_action
142
+ else "SAR filed. Note: this transaction may not meet SAR-filing threshold."
143
+ )
144
+ reveal_field = "docs_notes"
145
+
146
+ info = {
147
+ "event": action_type,
148
+ "already_used": already,
149
+ reveal_field: reveal_text,
150
+ "budget_remaining": round(
151
+ self.BUDGET_LIMIT - self._state.budget_spent, 4
152
+ ),
153
+ }
154
+ return self._make_observation(
155
+ reward=reward, done=False, info=info, reveal_field=reveal_field, reveal_text=reveal_text
156
+ )
157
+
158
+ # ── TERMINAL DECISION ────────────────────────────────────────────
159
+ used_inv = list(self._used_inv.get(task_id, set()))
160
+ sar_used = task_id in self._sar_filed
161
+ inspected_already = "inspect" in self._used_inv.get(task_id, set())
162
+
163
+ reward = grade(action_type, task, inspected_already=inspected_already)
164
+ self._state.cumulative_reward = round(
165
+ self._state.cumulative_reward + reward, 4
166
+ )
167
+
168
+ is_correct = action_type == task.correct_action
169
+ if is_correct:
170
+ self._state.correct_decisions += 1
171
+ elif action_type == "approve" and task.correct_action in ("reject", "escalate"):
172
+ self._state.wrong_high_cost += 1
173
+
174
+ self._recent_decisions.append(
175
+ {"task_id": task_id, "action": action_type, "correct": is_correct}
176
+ )
177
+ self._state.recent_decisions = list(self._recent_decisions)
178
+ self._state.transactions_processed += 1
179
+
180
+ # Advance task pointer
181
+ task_idx = self._tasks.index(task)
182
+ remaining = self._tasks[task_idx + 1:]
183
+ done = len(remaining) == 0
184
+
185
+ if not done:
186
+ self._current_task = remaining[0]
187
+ self._state.current_task_id = self._current_task.task_id
188
+ else:
189
+ self._state.done = True
190
+
191
+ return self._make_observation(
192
+ reward=reward,
193
+ done=done,
194
+ info={
195
+ "event": "step",
196
+ "action_taken": action_type,
197
+ "correct_action": task.correct_action,
198
+ "task_id": task_id,
199
+ "difficulty": task.difficulty,
200
+ "is_correct": is_correct,
201
+ "investigation_used": used_inv,
202
+ "budget_remaining": round(
203
+ self.BUDGET_LIMIT - self._state.budget_spent, 4
204
+ ),
205
+ },
206
+ )
207
+
208
+ def state(self) -> PayOpsState:
209
+ return self._state
210
+
211
+ def close(self):
212
+ pass
213
+
214
+ # ------------------------------------------------------------------
215
+ # Internal helpers
216
+ # ------------------------------------------------------------------
217
+
218
+ def _make_observation(
219
+ self,
220
+ reward: float,
221
+ done: bool,
222
+ info: dict,
223
+ reveal_field: Optional[str] = None,
224
+ reveal_text: Optional[str] = None,
225
+ ) -> PayOpsObservation:
226
+ task = self._current_task
227
+ steps_remaining = self._state.total_tasks - self._state.transactions_processed
228
+
229
+ return PayOpsObservation(
230
+ # ── transaction core ──
231
+ transaction_id=task.transaction_id,
232
+ amount=task.amount,
233
+ currency=task.currency,
234
+ sender=task.sender,
235
+ receiver=task.receiver,
236
+ transaction_type=task.transaction_type,
237
+ status=(
238
+ "inspected"
239
+ if info.get("event") in INVESTIGATION_ACTIONS
240
+ else ("done" if done else "pending")
241
+ ),
242
+ # ── risk signals ──
243
+ risk_score=task.risk_score,
244
+ ml_confidence=getattr(task, "ml_confidence", 0.90),
245
+ flags=list(task.flags),
246
+ velocity_1h=task.velocity_1h,
247
+ velocity_24h=getattr(task, "velocity_24h", None),
248
+ avg_transaction_amount=getattr(task, "avg_transaction_amount", None),
249
+ account_age_days=getattr(task, "account_age_days", None),
250
+ country_risk=task.country_risk,
251
+ kyc_status=task.kyc_status,
252
+ kyc_expiry_days=getattr(task, "kyc_expiry_days", None),
253
+ previous_violations=task.previous_violations,
254
+ previous_sars=getattr(task, "previous_sars", None),
255
+ counterparty_risk=getattr(task, "counterparty_risk", None),
256
+ # ── chain context ──
257
+ chain_total=getattr(task, "chain_total", 1),
258
+ chain_step=self._state.step_count,
259
+ chain_context=task.description,
260
+ # ── investigation reveals ──
261
+ inspection_notes=(
262
+ reveal_text if reveal_field == "inspection_notes" else info.get("inspection_notes")
263
+ ),
264
+ docs_notes=(
265
+ reveal_text if reveal_field == "docs_notes" else None
266
+ ),
267
+ kyc_notes=(
268
+ reveal_text if reveal_field == "kyc_notes" else None
269
+ ),
270
+ contact_notes=(
271
+ reveal_text if reveal_field == "contact_notes" else None
272
+ ),
273
+ # ── episode meta ──
274
+ task_id=task.task_id,
275
+ task_difficulty=task.difficulty,
276
+ step_in_episode=self._state.step_count,
277
+ steps_remaining=steps_remaining,
278
+ action_cost=ACTION_COSTS.get(info.get("event", ""), 0.0),
279
+ budget_remaining=round(self.BUDGET_LIMIT - self._state.budget_spent, 4),
280
+ recent_decisions=list(self._recent_decisions),
281
+ reward=reward,
282
+ cumulative_reward=self._state.cumulative_reward,
283
+ done=done,
284
+ info=info,
285
+ )
286
+
287
+
grader.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grader for the PayOps environment.
3
+
4
+ Reward design
5
+ -------------
6
+ The grader provides a *partial-credit* reward signal so agents receive
7
+ feedback for cautious-but-not-perfect decisions.
8
+
9
+ Base rewards
10
+ ~~~~~~~~~~~~
11
+ Correct action → +1.0 (full credit)
12
+ Partial-credit adjacent action → per-task fraction of +1.0
13
+ approve when should be reject/escalate→ -1.0 (worst mistake — approving fraud)
14
+ approve when should be flag/hold → -0.5
15
+ reject when should be approve → -0.5 (over-rejection)
16
+ any other wrong terminal action → -0.25
17
+
18
+ Investigation sub-actions (non-terminal):
19
+ inspect / request_docs / verify_kyc / contact_sender used BEFORE the
20
+ terminal decision when the task requests them → +0.15 bonus each
21
+ Same investigation action used twice on same task → +0.0 (no double-dip)
22
+
23
+ Modifiers
24
+ ~~~~~~~~~
25
+ Difficulty weight — multiplies the base reward:
26
+ easy=1.0, medium=1.2, hard=1.5, critical=2.0
27
+
28
+ Confidence bonus/penalty — applied when the agent provides confidence:
29
+ high-confidence (≥0.8) AND correct → +0.10
30
+ high-confidence (≥0.8) AND wrong → -0.10
31
+
32
+ Cost penalty — investigation actions have per-action budget costs
33
+ (see tasks.ACTION_COSTS). Spending beyond budget_limit reduces episode
34
+ score by budget_overspend × 0.1.
35
+
36
+ Time penalty — excessive investigation steps on a single task:
37
+ each investigation sub-action beyond the first on the same task → -0.05
38
+
39
+ Regulatory bonus — for tasks requiring a SAR filing:
40
+ agent called file_sar before terminal action → +0.20 bonus
41
+
42
+ Episode score (0–1)
43
+ ~~~~~~~~~~~~~~~~~~~
44
+ Computed by summing difficulty-weighted reward across all tasks and
45
+ normalising against the maximum theoretically achievable reward.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from dataclasses import dataclass, field
51
+ from typing import Dict, List, Optional, Set, Tuple
52
+
53
+ from payops_env.tasks import ACTION_COSTS, PayOpsTask
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Constants
57
+ # ---------------------------------------------------------------------------
58
+
59
+ FULL_CREDIT = 1.0
60
+
61
+ WRONG_APPROVE_FRAUD = -1.0
62
+ WRONG_APPROVE_CAUTION = -0.5
63
+ WRONG_REJECT_GOOD = -0.5
64
+ WRONG_DEFAULT = -0.25
65
+
66
+ INVESTIGATION_BONUS = 0.15 # per eligible sub-action used before terminal
67
+ TIME_PENALTY_PER_EXTRA_STEP = 0.05 # per duplicate investigation on same task
68
+ CONFIDENCE_CORRECT_BONUS = 0.10
69
+ CONFIDENCE_WRONG_PENALTY = -0.10
70
+ REGULATORY_BONUS = 0.20 # filing SAR when required
71
+ BUDGET_OVERSPEND_PENALTY = 0.10 # per unit of budget exceeded
72
+
73
+ DIFFICULTY_WEIGHT: Dict[str, float] = {
74
+ "easy": 1.0,
75
+ "medium": 1.2,
76
+ "hard": 1.5,
77
+ "critical": 2.0,
78
+ }
79
+
80
+ INVESTIGATION_ACTIONS: Set[str] = {
81
+ "inspect", "request_docs", "verify_kyc", "contact_sender", "file_sar"
82
+ }
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Per-step helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def _is_investigation(action_type: str) -> bool:
90
+ return action_type in INVESTIGATION_ACTIONS
91
+
92
+
93
+ def _base_terminal_reward(action_type: str, task: PayOpsTask) -> float:
94
+ """Return the base reward for a terminal action against a task."""
95
+ if action_type == task.correct_action:
96
+ return FULL_CREDIT
97
+
98
+ if action_type in task.partial_credit_actions:
99
+ return FULL_CREDIT * task.partial_credit_actions[action_type]
100
+
101
+ if action_type == "approve" and task.correct_action in ("reject", "escalate"):
102
+ return WRONG_APPROVE_FRAUD
103
+
104
+ if action_type == "approve" and task.correct_action in ("flag", "hold"):
105
+ return WRONG_APPROVE_CAUTION
106
+
107
+ if action_type == "reject" and task.correct_action == "approve":
108
+ return WRONG_REJECT_GOOD
109
+
110
+ return WRONG_DEFAULT
111
+
112
+
113
+ def step_reward(
114
+ action_type: str,
115
+ task: PayOpsTask,
116
+ inspected_already: bool = False,
117
+ ) -> float:
118
+ """
119
+ Backward-compatible single-step reward used by the real-time environment.
120
+
121
+ Investigation actions return 0 (already handled inside environment.step_async
122
+ through the richer grader logic). Terminal actions use the base reward table.
123
+ """
124
+ if _is_investigation(action_type):
125
+ return 0.0 if inspected_already else INVESTIGATION_BONUS
126
+
127
+ return _base_terminal_reward(action_type, task)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Extended per-task grader (used by grade_episode)
132
+ # ---------------------------------------------------------------------------
133
+
134
+ @dataclass
135
+ class TaskGradeDetail:
136
+ task_id: str
137
+ difficulty: str
138
+ weight: float
139
+ correct_action: str
140
+ terminal_action: str
141
+ investigation_actions_used: List[str]
142
+ base_reward: float
143
+ investigation_bonus: float
144
+ time_penalty: float
145
+ confidence_modifier: float
146
+ regulatory_bonus: float
147
+ total_reward: float
148
+ correct: bool
149
+ reward_breakdown: Dict[str, float] = field(default_factory=dict)
150
+
151
+
152
+ def _grade_single_task(
153
+ terminal_action: str,
154
+ investigation_actions: List[str], # sub-actions used BEFORE terminal
155
+ task: PayOpsTask,
156
+ agent_confidence: Optional[float] = None,
157
+ ) -> TaskGradeDetail:
158
+ weight = DIFFICULTY_WEIGHT.get(task.difficulty, 1.0)
159
+ base = _base_terminal_reward(terminal_action, task)
160
+ correct = terminal_action == task.correct_action
161
+
162
+ # ── investigation bonus and time penalty ────────────────────────────────
163
+ inv_bonus = 0.0
164
+ time_pen = 0.0
165
+ eligible = task.requires_investigation # set of actions agent should use
166
+ seen_counts: Dict[str, int] = {}
167
+ for inv_action in investigation_actions:
168
+ seen_counts[inv_action] = seen_counts.get(inv_action, 0) + 1
169
+ if inv_action in eligible and seen_counts[inv_action] == 1:
170
+ inv_bonus += INVESTIGATION_BONUS
171
+ elif seen_counts[inv_action] > 1:
172
+ time_pen += TIME_PENALTY_PER_EXTRA_STEP
173
+
174
+ # ── confidence modifier ─────────────────────────────────────────────────
175
+ conf_mod = 0.0
176
+ if agent_confidence is not None and agent_confidence >= 0.8:
177
+ conf_mod = CONFIDENCE_CORRECT_BONUS if correct else CONFIDENCE_WRONG_PENALTY
178
+
179
+ # ── regulatory bonus ────────────────────────────────────────────────────
180
+ reg_bonus = 0.0
181
+ if task.regulatory_action and "file_sar" in investigation_actions:
182
+ reg_bonus = REGULATORY_BONUS
183
+
184
+ total = weight * (base + inv_bonus - time_pen + conf_mod + reg_bonus)
185
+
186
+ return TaskGradeDetail(
187
+ task_id=task.task_id,
188
+ difficulty=task.difficulty,
189
+ weight=weight,
190
+ correct_action=task.correct_action,
191
+ terminal_action=terminal_action,
192
+ investigation_actions_used=investigation_actions,
193
+ base_reward=round(base, 4),
194
+ investigation_bonus=round(inv_bonus, 4),
195
+ time_penalty=round(time_pen, 4),
196
+ confidence_modifier=round(conf_mod, 4),
197
+ regulatory_bonus=round(reg_bonus, 4),
198
+ total_reward=round(total, 4),
199
+ correct=correct,
200
+ reward_breakdown={
201
+ "base": round(base, 4),
202
+ "weight": weight,
203
+ "investigation": round(inv_bonus, 4),
204
+ "time_penalty": round(-time_pen, 4),
205
+ "confidence": round(conf_mod, 4),
206
+ "regulatory": round(reg_bonus, 4),
207
+ "weighted_total": round(total, 4),
208
+ },
209
+ )
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Episode grader
214
+ # ---------------------------------------------------------------------------
215
+
216
+ @dataclass
217
+ class EpisodeResult:
218
+ total_reward: float
219
+ max_possible_reward: float
220
+ normalised_score: float # 0.0 – 1.0
221
+ per_task_rewards: List[dict]
222
+ budget_spent: float
223
+ budget_overspend: float
224
+ budget_penalty: float
225
+ passed: bool # normalised_score >= 0.5
226
+
227
+
228
+ def grade_episode(
229
+ actions: List[str],
230
+ tasks: List[PayOpsTask],
231
+ confidences: Optional[List[Optional[float]]] = None,
232
+ budget_limit: float = 5.0,
233
+ ) -> EpisodeResult:
234
+ """
235
+ Grade a complete episode.
236
+
237
+ ``actions`` is the flat list of all actions taken (including investigation
238
+ sub-actions interspersed between terminal decisions).
239
+
240
+ The grader separates them by treating any action in INVESTIGATION_ACTIONS as
241
+ a sub-action that accumulates per-task until a terminal action closes the task.
242
+
243
+ Args:
244
+ actions: Ordered list of action_type strings.
245
+ tasks: Ordered list of PayOpsTask objects.
246
+ confidences: Optional parallel list of agent confidence values (same
247
+ length as ``actions``). Use None for missing entries.
248
+ budget_limit: Maximum investigation budget. Overspend is penalised.
249
+
250
+ Returns:
251
+ EpisodeResult with comprehensive score breakdown.
252
+ """
253
+ if confidences is None:
254
+ confidences = [None] * len(actions)
255
+
256
+ per_task_details: List[TaskGradeDetail] = []
257
+ budget_spent = 0.0
258
+
259
+ task_idx = 0
260
+ pending_inv: List[str] = [] # sub-actions for current task
261
+ pending_conf: List[Optional[float]] = []
262
+
263
+ for i, (action, conf) in enumerate(zip(actions, confidences)):
264
+ budget_spent += ACTION_COSTS.get(action, 0.0)
265
+
266
+ if _is_investigation(action):
267
+ pending_inv.append(action)
268
+ pending_conf.append(conf)
269
+ else:
270
+ # terminal action → grade and advance task pointer
271
+ if task_idx >= len(tasks):
272
+ break
273
+ task = tasks[task_idx]
274
+ # Use the confidence from the terminal action step
275
+ detail = _grade_single_task(action, pending_inv, task, agent_confidence=conf)
276
+ per_task_details.append(detail)
277
+ pending_inv = []
278
+ pending_conf = []
279
+ task_idx += 1
280
+
281
+ # Handle any tasks with no actions (agent ran out of steps)
282
+ while task_idx < len(tasks):
283
+ task = tasks[task_idx]
284
+ detail = _grade_single_task("approve", [], task, agent_confidence=None)
285
+ detail.base_reward = WRONG_DEFAULT
286
+ detail.total_reward = DIFFICULTY_WEIGHT.get(task.difficulty, 1.0) * WRONG_DEFAULT
287
+ per_task_details.append(detail)
288
+ task_idx += 1
289
+
290
+ # ── budget overspend penalty ─────────────────────────────────────────────
291
+ budget_overspend = max(0.0, budget_spent - budget_limit)
292
+ budget_penalty = round(budget_overspend * BUDGET_OVERSPEND_PENALTY, 4)
293
+
294
+ total = sum(d.total_reward for d in per_task_details) - budget_penalty
295
+ max_possible = sum(
296
+ DIFFICULTY_WEIGHT.get(t.difficulty, 1.0) * FULL_CREDIT for t in tasks
297
+ )
298
+ normalised = max(0.0, min(1.0, total / max_possible)) if max_possible > 0 else 0.0
299
+
300
+ return EpisodeResult(
301
+ total_reward=round(total, 4),
302
+ max_possible_reward=round(max_possible, 4),
303
+ normalised_score=round(normalised, 4),
304
+ per_task_rewards=[
305
+ {
306
+ "task_id": d.task_id,
307
+ "difficulty": d.difficulty,
308
+ "weight": d.weight,
309
+ "terminal_action": d.terminal_action,
310
+ "correct_action": d.correct_action,
311
+ "investigation_used": d.investigation_actions_used,
312
+ "correct": d.correct,
313
+ "reward_breakdown": d.reward_breakdown,
314
+ "weighted_reward": d.total_reward,
315
+ }
316
+ for d in per_task_details
317
+ ],
318
+ budget_spent=round(budget_spent, 4),
319
+ budget_overspend=round(budget_overspend, 4),
320
+ budget_penalty=budget_penalty,
321
+ passed=normalised >= 0.5,
322
+ )
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Convenience wrapper used by the environment
327
+ # ---------------------------------------------------------------------------
328
+
329
+ def grade(action_type: str, task: PayOpsTask, inspected_already: bool = False) -> float:
330
+ """Single-step reward used inside environment.step_async."""
331
+ return step_reward(action_type, task, inspected_already=inspected_already)
332
+
models.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Action space
10
+ # ---------------------------------------------------------------------------
11
+
12
+ class PayOpsAction(BaseModel):
13
+ """
14
+ Action submitted by the agent for a single transaction.
15
+
16
+ action_type choices
17
+ -------------------
18
+ approve – mark transaction as legitimate and allow it through
19
+ reject – block the transaction outright
20
+ flag – mark for manual review with a soft hold
21
+ escalate – route to senior compliance officer / fraud team
22
+ inspect – pull additional signals (logs, KYC data, velocity)
23
+ hold – temporary hold pending more information
24
+ request_docs – ask sender for supporting documents (e.g. invoice, contract)
25
+ verify_kyc – trigger an active KYC re-verification check
26
+ contact_sender – contact the sender directly to confirm intent
27
+ file_sar – file a Suspicious Activity Report to regulator
28
+ """
29
+
30
+ action_type: str = Field(
31
+ ...,
32
+ description=(
33
+ "One of: approve | reject | flag | escalate | inspect | hold "
34
+ "| request_docs | verify_kyc | contact_sender | file_sar"
35
+ ),
36
+ )
37
+ transaction_id: str = Field(..., description="ID of the transaction being acted on")
38
+ reason: Optional[str] = Field(
39
+ default=None, description="Free-text rationale from the agent"
40
+ )
41
+ confidence: Optional[float] = Field(
42
+ default=None,
43
+ ge=0.0,
44
+ le=1.0,
45
+ description="Agent self-reported confidence [0, 1]. Used in reward shaping.",
46
+ )
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Observation space
51
+ # ---------------------------------------------------------------------------
52
+
53
+ class PayOpsObservation(BaseModel):
54
+ """
55
+ Structured observation returned after each step (and on reset).
56
+ Describes the current transaction visible to the agent.
57
+ """
58
+
59
+ # --- transaction identity ---
60
+ transaction_id: str
61
+ amount: float = Field(..., description="Transaction amount in the stated currency")
62
+ currency: str = Field(..., description="ISO-4217 currency code, e.g. USD, EUR")
63
+ sender: str = Field(..., description="Sender identifier (email / account / alias)")
64
+ receiver: str = Field(..., description="Receiver identifier")
65
+ transaction_type: str = Field(
66
+ default="transfer",
67
+ description="Type: transfer | payment | withdrawal | refund | internal | loan_repayment | payroll",
68
+ )
69
+
70
+ # --- risk signals ---
71
+ status: str = Field(
72
+ default="pending",
73
+ description=(
74
+ "Current status: pending | approved | rejected | flagged | escalated "
75
+ "| held | inspected | docs_requested | kyc_triggered | sender_contacted | sar_filed"
76
+ ),
77
+ )
78
+ risk_score: float = Field(
79
+ ..., ge=0.0, le=1.0, description="Composite ML risk score [0=low, 1=high]"
80
+ )
81
+ ml_confidence: float = Field(
82
+ default=0.9,
83
+ ge=0.0,
84
+ le=1.0,
85
+ description="Model's self-reported confidence in its own risk_score. Low = possibly poisoned.",
86
+ )
87
+ flags: List[str] = Field(
88
+ default_factory=list,
89
+ description="Active risk flags e.g. high_value, unknown_sender, velocity_breach",
90
+ )
91
+
92
+ # --- sender behaviour signals ---
93
+ velocity_1h: Optional[int] = Field(
94
+ default=None,
95
+ description="Number of transactions from this sender in the past hour",
96
+ )
97
+ velocity_24h: Optional[int] = Field(
98
+ default=None,
99
+ description="Number of transactions from this sender in the past 24 hours",
100
+ )
101
+ avg_transaction_amount: Optional[float] = Field(
102
+ default=None,
103
+ description="Sender's historical average transaction amount",
104
+ )
105
+ account_age_days: Optional[int] = Field(
106
+ default=None,
107
+ description="Age of the sender account in days",
108
+ )
109
+
110
+ # --- counterparty / geography ---
111
+ country_risk: Optional[str] = Field(
112
+ default=None,
113
+ description="Receiver country risk tier: low | medium | high | sanctioned",
114
+ )
115
+ kyc_status: Optional[str] = Field(
116
+ default=None,
117
+ description="KYC verification status: verified | pending | failed | none | expired",
118
+ )
119
+ kyc_expiry_days: Optional[int] = Field(
120
+ default=None,
121
+ description="Days until KYC expires (negative = already expired)",
122
+ )
123
+ previous_violations: Optional[int] = Field(
124
+ default=None,
125
+ description="Number of prior compliance violations for this sender",
126
+ )
127
+ previous_sars: Optional[int] = Field(
128
+ default=None,
129
+ description="Number of Suspicious Activity Reports previously filed for this sender",
130
+ )
131
+ counterparty_risk: Optional[str] = Field(
132
+ default=None,
133
+ description="Known risk profile of the receiver: clean | unknown | watchlist | blacklist",
134
+ )
135
+
136
+ # --- chain context (multi-hop investigation) ---
137
+ chain_step: int = Field(
138
+ default=1,
139
+ description="Which step within a multi-hop investigation chain (1=initial presentation)",
140
+ )
141
+ chain_total: int = Field(
142
+ default=1,
143
+ description="Total number of chained investigation steps for this task",
144
+ )
145
+ chain_context: Optional[str] = Field(
146
+ default=None,
147
+ description="Summary of findings from earlier chain steps",
148
+ )
149
+
150
+ # --- resource tracking ---
151
+ steps_remaining: Optional[int] = Field(
152
+ default=None,
153
+ description="How many investigation sub-steps remain before a terminal decision is required",
154
+ )
155
+ action_cost: float = Field(
156
+ default=0.0,
157
+ description="Operational cost penalty incurred by the last action",
158
+ )
159
+ budget_remaining: float = Field(
160
+ default=1.0,
161
+ description="Fraction of investigation budget remaining (1.0=full, 0.0=exhausted)",
162
+ )
163
+
164
+ # --- context from prior investigation actions ---
165
+ inspection_notes: Optional[str] = Field(
166
+ default=None,
167
+ description="Additional details revealed after an 'inspect' action",
168
+ )
169
+ docs_notes: Optional[str] = Field(
170
+ default=None,
171
+ description="Document review findings after a 'request_docs' action",
172
+ )
173
+ kyc_notes: Optional[str] = Field(
174
+ default=None,
175
+ description="KYC re-verification outcome after a 'verify_kyc' action",
176
+ )
177
+ contact_notes: Optional[str] = Field(
178
+ default=None,
179
+ description="Outcome of contacting the sender via 'contact_sender' action",
180
+ )
181
+
182
+ # --- recent decision context (last 3 decisions in this episode) ---
183
+ recent_decisions: List[Dict[str, Any]] = Field(
184
+ default_factory=list,
185
+ description="Last up to 3 completed decisions in this episode for pattern context",
186
+ )
187
+
188
+ # --- episode bookkeeping ---
189
+ task_id: str = Field(default="", description="Identifier of the active task")
190
+ task_difficulty: str = Field(
191
+ default="easy", description="Difficulty tier: easy | medium | hard | critical"
192
+ )
193
+ step_in_episode: int = Field(
194
+ default=0, description="How many steps have elapsed in this episode"
195
+ )
196
+ reward: float = Field(default=0.0, description="Reward from the last action")
197
+ reward_breakdown: Dict[str, float] = Field(
198
+ default_factory=dict,
199
+ description="Itemised reward components: base, time_penalty, confidence_bonus, cost_penalty",
200
+ )
201
+ cumulative_reward: float = Field(
202
+ default=0.0, description="Total reward accumulated so far in this episode"
203
+ )
204
+ done: bool = Field(default=False, description="Whether the episode has ended")
205
+ info: Dict[str, Any] = Field(
206
+ default_factory=dict,
207
+ description="Extra diagnostic information (action taken, correct action, etc.)",
208
+ )
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Internal state (used by the server's /state endpoint)
213
+ # ---------------------------------------------------------------------------
214
+
215
+ class PayOpsState(BaseModel):
216
+ episode_id: Optional[str] = None
217
+ step_count: int = 0
218
+ current_task_id: str = ""
219
+ transactions_processed: int = 0
220
+ total_tasks: int = 0
221
+ cumulative_reward: float = 0.0
222
+ budget_spent: float = Field(default=0.0, description="Total action costs accumulated")
223
+ budget_limit: float = Field(default=5.0, description="Max investigation budget per episode")
224
+ actions_taken: List[str] = Field(default_factory=list)
225
+ last_action: Optional[str] = None
226
+ investigation_actions_used: List[str] = Field(
227
+ default_factory=list,
228
+ description="All investigation sub-actions used this episode (inspect, request_docs, etc.)",
229
+ )
230
+ correct_decisions: int = Field(default=0, description="Terminal decisions that matched ground truth")
231
+ wrong_high_cost: int = Field(
232
+ default=0, description="Count of approve-on-fraud type mistakes"
233
+ )
234
+ recent_decisions: List[Dict[str, Any]] = Field(
235
+ default_factory=list,
236
+ description="Recent completed task outcomes for analytics",
237
+ )
238
+ done: bool = False
239
+
openenv.yaml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: payops_env
3
+ display_name: "PayOps — Payment Operations Incident Response"
4
+ description: >
5
+ An AI agent acts as a Payment Operations analyst, reviewing financial
6
+ transactions and deciding how to handle each one: approve, reject, flag,
7
+ escalate, inspect, or hold. Tasks range from clear-cut fraud to subtle
8
+ adversarial patterns like model poisoning and APP scams.
9
+ type: space
10
+ runtime: fastapi
11
+ app: payops_env.server.app:app
12
+ port: 7860
13
+ version: "1.0.0"
14
+ author: "PayOps Team"
15
+ tags:
16
+ - finance
17
+ - fraud-detection
18
+ - compliance
19
+ - real-world
20
+ - payment-operations
21
+ action_space:
22
+ type: discrete
23
+ actions:
24
+ - approve
25
+ - reject
26
+ - flag
27
+ - escalate
28
+ - inspect
29
+ - hold
30
+ observation_space:
31
+ type: structured
32
+ fields:
33
+ - transaction_id
34
+ - amount
35
+ - currency
36
+ - sender
37
+ - receiver
38
+ - risk_score
39
+ - flags
40
+ - velocity_1h
41
+ - country_risk
42
+ - kyc_status
43
+ tasks:
44
+ count: 12
45
+ difficulties:
46
+ - easy
47
+ - medium
48
+ - hard
49
+ reward:
50
+ min: -1.0
51
+ max: 1.0
52
+ partial_credit: true
53
+
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ openenv-core>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+ pydantic>=2.0.0
run_tests.sh ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # run_tests.sh — PayOps v2 full test suite
3
+ # Usage: bash payops_env/run_tests.sh [BASE_URL]
4
+ # Requires the server to be running. Default: http://localhost:8000
5
+
6
+ BASE="${1:-http://localhost:8000}"
7
+ PASS=0
8
+ FAIL=0
9
+ SKIP=0
10
+
11
+ # ── helpers ──────────────────────────────────────────────────────────────────
12
+
13
+ check() {
14
+ local name="$1"
15
+ local got="$2"
16
+ local want="$3"
17
+ if echo "$got" | grep -qE "$want"; then
18
+ printf " \033[32m✓\033[0m %s\n" "$name"
19
+ PASS=$((PASS+1))
20
+ else
21
+ printf " \033[31m✗\033[0m %s\n" "$name"
22
+ printf " expected pattern : %s\n" "$want"
23
+ printf " got : %s\n" "$(echo "$got" | head -c 200)"
24
+ FAIL=$((FAIL+1))
25
+ fi
26
+ }
27
+
28
+ check_absent() {
29
+ local name="$1"
30
+ local got="$2"
31
+ local absent="$3"
32
+ if echo "$got" | grep -qE "$absent"; then
33
+ printf " \033[31m✗\033[0m %s (unexpected: %s)\n" "$name" "$absent"
34
+ FAIL=$((FAIL+1))
35
+ else
36
+ printf " \033[32m✓\033[0m %s\n" "$name"
37
+ PASS=$((PASS+1))
38
+ fi
39
+ }
40
+
41
+ step() {
42
+ curl -s -X POST "$BASE/step" \
43
+ -H "Content-Type: application/json" \
44
+ -d "$1"
45
+ }
46
+
47
+ reset() {
48
+ curl -s -X POST "$BASE/reset" > /dev/null
49
+ }
50
+
51
+ section() {
52
+ echo ""
53
+ echo "── $1 ──"
54
+ }
55
+
56
+ echo ""
57
+ echo "╔══════════════════════════════════════════════════════╗"
58
+ echo "║ PayOps v2 — Full Post-Main Test Suite ║"
59
+ echo "║ Target: $BASE"
60
+ echo "╚══════════════════════════════════════════════════════╝"
61
+ echo ""
62
+
63
+ # ═══════════════════════════════════════════════════════════
64
+ # GROUP A — Server / Infrastructure
65
+ # ═══════════════════════════════════════════════════════════
66
+ section "GROUP A — Server / Infrastructure"
67
+
68
+ # A-01 Health check
69
+ check "A-01 GET /health → status ok" \
70
+ "$(curl -s $BASE/health)" \
71
+ '"status":"ok"'
72
+
73
+ # A-02 Version is v2
74
+ check "A-02 GET /health → version 2.0.0" \
75
+ "$(curl -s $BASE/health)" \
76
+ '"version":"2.0.0"'
77
+
78
+ # A-03 Schema action model present
79
+ check "A-03 GET /schema → PayOpsAction schema" \
80
+ "$(curl -s $BASE/schema)" \
81
+ '"PayOpsAction"'
82
+
83
+ # A-04 Schema observation model present
84
+ check "A-04 GET /schema → PayOpsObservation schema" \
85
+ "$(curl -s $BASE/schema)" \
86
+ '"PayOpsObservation"'
87
+
88
+ # A-05 Schema state model present
89
+ check "A-05 GET /schema → PayOpsState schema" \
90
+ "$(curl -s $BASE/schema)" \
91
+ '"PayOpsState"'
92
+
93
+ # ═══════════════════════════════════════════════════════════
94
+ # GROUP B — Tasks endpoint
95
+ # ═══════════════════════════════════════════════════════════
96
+ section "GROUP B — Tasks endpoint"
97
+
98
+ TASKS_RESP="$(curl -s $BASE/tasks)"
99
+
100
+ # B-01 20 tasks
101
+ check "B-01 GET /tasks → count=20" \
102
+ "$TASKS_RESP" '"count":20'
103
+
104
+ # B-02 All 4 difficulty tiers present
105
+ check "B-02 Tasks include 'easy' tier" "$TASKS_RESP" '"difficulty":"easy"'
106
+ check "B-03 Tasks include 'medium' tier" "$TASKS_RESP" '"difficulty":"medium"'
107
+ check "B-04 Tasks include 'hard' tier" "$TASKS_RESP" '"difficulty":"hard"'
108
+ check "B-05 Tasks include 'critical' tier" "$TASKS_RESP" '"difficulty":"critical"'
109
+
110
+ # B-06 Regulatory tasks present
111
+ check "B-06 Tasks include regulatory_action flag" \
112
+ "$TASKS_RESP" '"regulatory_action":true'
113
+
114
+ # B-07 Multi-step chain tasks present
115
+ check "B-07 Tasks include chain_total > 1" \
116
+ "$TASKS_RESP" '"chain_total":3'
117
+
118
+ # B-08 requires_investigation populated
119
+ check "B-08 Tasks include requires_investigation" \
120
+ "$TASKS_RESP" '"requires_investigation":\['
121
+
122
+ # ═══════════════════════════════════════════════════════════
123
+ # GROUP C — Reset
124
+ # ═══════════════════════════════════════════════════════════
125
+ section "GROUP C — Reset"
126
+
127
+ RESET_RESP="$(curl -s -X POST $BASE/reset)"
128
+ check "C-01 POST /reset → returns EASY-001" "$RESET_RESP" '"task_id":"EASY-001"'
129
+ check "C-02 POST /reset → done=false" "$RESET_RESP" '"done":false'
130
+ check "C-03 POST /reset → budget_remaining=5.0" "$RESET_RESP" '"budget_remaining":5.0'
131
+ check "C-04 POST /reset → risk_score present" "$RESET_RESP" '"risk_score":'
132
+ check "C-05 POST /reset → ml_confidence present" "$RESET_RESP" '"ml_confidence":'
133
+ check "C-06 POST /reset → chain_total present" "$RESET_RESP" '"chain_total":'
134
+
135
+ # ═══════════════════════════════════════════════════════════
136
+ # GROUP D — Terminal Actions (correct decisions)
137
+ # ═══════════════════════════════════════════════════════════
138
+ section "GROUP D — Terminal Actions (correct decisions)"
139
+
140
+ reset
141
+
142
+ # D-01 approve correct → reward=1.0
143
+ check "D-01 EASY-001 approve → reward=1.0" \
144
+ "$(step '{"action_type":"approve","transaction_id":"TXN-E001"}')" \
145
+ '"reward":1.0'
146
+
147
+ # D-02 reject correct → reward=1.0
148
+ check "D-02 EASY-002 reject → reward=1.0" \
149
+ "$(step '{"action_type":"reject","transaction_id":"TXN-E002"}')" \
150
+ '"reward":1.0'
151
+
152
+ # D-03 approve correct (refund) → reward=1.0
153
+ check "D-03 EASY-003 approve → reward=1.0" \
154
+ "$(step '{"action_type":"approve","transaction_id":"TXN-E003"}')" \
155
+ '"reward":1.0'
156
+
157
+ # D-04 flag correct → reward=1.0
158
+ check "D-04 EASY-004 flag → reward=1.0" \
159
+ "$(step '{"action_type":"flag","transaction_id":"TXN-E004"}')" \
160
+ '"reward":1.0'
161
+
162
+ # D-05 escalate correct → reward=1.0
163
+ check "D-05 MED-001 escalate → reward=1.0" \
164
+ "$(step '{"action_type":"escalate","transaction_id":"TXN-M001"}')" \
165
+ '"reward":1.0'
166
+
167
+ # D-06 hold correct → reward=1.0
168
+ check "D-06 MED-002 hold → reward=1.0" \
169
+ "$(step '{"action_type":"hold","transaction_id":"TXN-M002"}')" \
170
+ '"reward":1.0'
171
+
172
+ # D-07 task advances after terminal
173
+ R=$(step '{"action_type":"flag","transaction_id":"TXN-M003"}')
174
+ check "D-07 After flag on MED-003, next task is MED-004" "$R" '"task_id":"MED-004"'
175
+
176
+ # ═══════════════════════════════════════════════════════════
177
+ # GROUP E — Terminal Actions (wrong decisions / partial credit)
178
+ # ═══════════════════════════════════════════════════════════
179
+ section "GROUP E — Wrong actions & partial credit"
180
+
181
+ reset
182
+
183
+ # E-01 approve when should reject → -1.0 (fraud approval)
184
+ step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null # advance past E001
185
+ check "E-01 Approve fraud (EASY-002) → reward=-1.0" \
186
+ "$(step '{"action_type":"approve","transaction_id":"TXN-E002"}')" \
187
+ '"reward":-1.0'
188
+
189
+ reset
190
+ step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
191
+ step '{"action_type":"reject","transaction_id":"TXN-E002"}' > /dev/null
192
+
193
+ # E-02 reject correct → approve → -0.5
194
+ check "E-02 Reject legit (EASY-003) → reward=-0.5" \
195
+ "$(step '{"action_type":"reject","transaction_id":"TXN-E003"}')" \
196
+ '"reward":-0.5'
197
+
198
+ reset
199
+ # E-03 partial credit — escalate instead of escalate on MED-001 is correct
200
+ # flag instead of escalate earns partial
201
+ step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
202
+ step '{"action_type":"reject","transaction_id":"TXN-E002"}' > /dev/null
203
+ step '{"action_type":"approve","transaction_id":"TXN-E003"}' > /dev/null
204
+ step '{"action_type":"flag","transaction_id":"TXN-E004"}' > /dev/null
205
+ R=$(step '{"action_type":"flag","transaction_id":"TXN-M001"}')
206
+ check "E-03 Partial credit: flag on MED-001 (correct=escalate) → reward > 0" \
207
+ "$R" '"reward":0\.[0-9]'
208
+
209
+ # ═══════════════════════════════════════════════════════════
210
+ # GROUP F — Investigation Sub-Actions
211
+ # ═══════════════════════════════════════════════════════════
212
+ section "GROUP F — Investigation sub-actions"
213
+
214
+ reset
215
+
216
+ # F-01 inspect → reward=0.15, stays on same task
217
+ R=$(step '{"action_type":"inspect","transaction_id":"TXN-E001"}')
218
+ check "F-01 inspect → reward=0.15" "$R" '"reward":0.15'
219
+ check "F-02 inspect → does NOT advance task (still EASY-001)" "$R" '"task_id":"EASY-001"'
220
+ check "F-03 inspect → inspection_notes populated" "$R" '"inspection_notes":"'
221
+ check "F-04 inspect → budget_remaining=4.9" "$R" '"budget_remaining":4.9'
222
+
223
+ # F-05 inspect again on same task → reward=0.0 (no double-dip)
224
+ check "F-05 second inspect → reward=0.0" \
225
+ "$(step '{"action_type":"inspect","transaction_id":"TXN-E001"}')" \
226
+ '"reward":0.0'
227
+
228
+ # Advance to EASY-002
229
+ step '{"action_type":"approve","transaction_id":"TXN-E001"}' > /dev/null
230
+
231
+ # F-06 request_docs → reward=0.15, docs_notes populated
232
+ R=$(step '{"action_type":"request_docs","transaction_id":"TXN-E002"}')
233
+ check "F-06 request_docs → reward=0.15" "$R" '"reward":0.15'
234
+ check "F-07 request_docs → docs_notes populated" "$R" '"docs_notes":"'
235
+ check "F-08 request_docs → budget_remaining=4.6 (cost=0.2)" "$R" '"budget_remaining":4.6'
236
+
237
+ # F-09 request_docs again → reward=0.0
238
+ check "F-09 second request_docs → reward=0.0" \
239
+ "$(step '{"action_type":"request_docs","transaction_id":"TXN-E002"}')" \
240
+ '"reward":0.0'
241
+
242
+ step '{"action_type":"reject","transaction_id":"TXN-E002"}' > /dev/null # advance
243
+
244
+ # F-10 verify_kyc → reward=0.15, kyc_notes populated
245
+ R=$(step '{"action_type":"verify_kyc","transaction_id":"TXN-E003"}')
246
+ check "F-10 verify_kyc → reward=0.15" "$R" '"reward":0.15'
247
+ check "F-11 verify_kyc → kyc_notes populated" "$R" '"kyc_notes":"'
248
+ check "F-12 verify_kyc → budget cost=0.2 deducted" "$R" '"budget_remaining":4.[0-9]'
249
+
250
+ step '{"action_type":"approve","transaction_id":"TXN-E003"}' > /dev/null
251
+
252
+ # F-13 contact_sender → reward=0.15, contact_notes populated
253
+ R=$(step '{"action_type":"contact_sender","transaction_id":"TXN-E004"}')
254
+ check "F-13 contact_sender → reward=0.15" "$R" '"reward":0.15'
255
+ check "F-14 contact_sender → contact_notes populated" "$R" '"contact_notes":"'
256
+ check "F-15 contact_sender → budget cost=0.3 deducted" "$R" '"budget_remaining":3\.[0-9]'
257
+
258
+ step '{"action_type":"flag","transaction_id":"TXN-E004"}' > /dev/null
259
+
260
+ # F-16 file_sar → reward=0.15, docs_notes mentions SAR
261
+ R=$(step '{"action_type":"file_sar","transaction_id":"TXN-M001"}')
262
+ check "F-16 file_sar → reward=0.15" "$R" '"reward":0.15'
263
+ check "F-17 file_sar → docs_notes mentions SAR" "$R" '"docs_notes":"SAR'
264
+ check "F-18 file_sar → budget cost=0.05 deducted" "$R" '"budget_remaining":3\.[0-9]'
265
+
266
+ # ═══════════════════════════════════════════════════════════
267
+ # GROUP G — /state endpoint
268
+ # ═══════════════════════════════════════════════════════════
269
+ section "GROUP G — State endpoint"
270
+
271
+ STATE="$(curl -s $BASE/state)"
272
+ check "G-01 GET /state → episode_id set" "$STATE" '"episode_id":"'
273
+ check "G-02 GET /state → step_count > 0" "$STATE" '"step_count":[1-9]'
274
+ check "G-03 GET /state → budget_spent > 0" "$STATE" '"budget_spent":[0-9]'
275
+ check "G-04 GET /state → investigation_actions_used list" "$STATE" '"investigation_actions_used":\['
276
+ check "G-05 GET /state → recent_decisions list" "$STATE" '"recent_decisions":\['
277
+ check "G-06 GET /state → correct_decisions >= 0" "$STATE" '"correct_decisions":[0-9]'
278
+
279
+ # ═══════════════════════════════════════════════════════════
280
+ # GROUP H — /grader endpoint
281
+ # ═══════════════════════════════════════════════════════════
282
+ section "GROUP H — Grader endpoint"
283
+
284
+ GRADER="$(curl -s $BASE/grader)"
285
+ check "H-01 GET /grader → normalised_score present" "$GRADER" '"normalised_score":'
286
+ check "H-02 GET /grader → total_reward present" "$GRADER" '"total_reward":'
287
+ check "H-03 GET /grader → budget_spent present" "$GRADER" '"budget_spent":'
288
+ check "H-04 GET /grader → budget_penalty present" "$GRADER" '"budget_penalty":'
289
+ check "H-05 GET /grader → per_task array" "$GRADER" '"per_task":\['
290
+ check "H-06 GET /grader → reward_breakdown in per_task" "$GRADER" '"reward_breakdown":'
291
+ check "H-07 GET /grader → passed field present" "$GRADER" '"passed":'
292
+
293
+ # ═══════════════════════════════════════════════════════════
294
+ # GROUP I — /replay endpoint
295
+ # ═══════════════════════════════════════════════════════════
296
+ section "GROUP I — /replay endpoint (offline eval)"
297
+
298
+ # I-01 Replay with all correct actions → score=1.0
299
+ REPLAY_PERFECT='{"actions":["approve","reject","approve","flag",
300
+ "escalate","hold","flag","flag","hold","escalate",
301
+ "escalate","reject","reject","approve","escalate","flag",
302
+ "approve","reject","escalate","reject"]}'
303
+ R=$(curl -s -X POST $BASE/replay \
304
+ -H "Content-Type: application/json" \
305
+ -d "$REPLAY_PERFECT")
306
+ check "I-01 Replay 20 correct actions → score=1.0" "$R" '"normalised_score":1.0'
307
+ check "I-02 Replay → passed=true" "$R" '"passed":true'
308
+ check "I-03 Replay → budget_spent=0.0 (no inv actions)" "$R" '"budget_spent":0.0'
309
+
310
+ # I-04 Replay with investigation actions included
311
+ REPLAY_WITH_INV='{"actions":["inspect","approve","reject","approve","flag",
312
+ "escalate","hold","flag","flag","hold","escalate",
313
+ "escalate","reject","reject","approve","escalate","flag",
314
+ "approve","reject","escalate","reject"]}'
315
+ R=$(curl -s -X POST $BASE/replay \
316
+ -H "Content-Type: application/json" \
317
+ -d "$REPLAY_WITH_INV")
318
+ check "I-04 Replay with inspect → budget_spent=0.1" "$R" '"budget_spent":0.1'
319
+
320
+ # I-05 Replay with invalid action → 422
321
+ check "I-05 Replay invalid action → 422 error" \
322
+ "$(curl -s -X POST $BASE/replay \
323
+ -H 'Content-Type: application/json' \
324
+ -d '{"actions":["delete","approve"]}')" \
325
+ "Invalid action"
326
+
327
+ # I-06 Replay result has per_task breakdown
328
+ check "I-06 Replay → per_task array present" "$R" '"per_task":\['
329
+ check "I-07 Replay → reward_breakdown in each task" "$R" '"reward_breakdown":'
330
+
331
+ # I-08 Replay with confidences
332
+ REPLAY_CONF='{"actions":["approve","reject"],"confidences":[0.95,0.90]}'
333
+ check "I-08 Replay with confidences → ok" \
334
+ "$(curl -s -X POST $BASE/replay \
335
+ -H 'Content-Type: application/json' \
336
+ -d "$REPLAY_CONF")" \
337
+ '"normalised_score":'
338
+
339
+ # ═══════════════════════════════════════════════════════════
340
+ # GROUP J — /baseline endpoint
341
+ # ═══════════════════════════════════════════════════════════
342
+ section "GROUP J — Baseline agent"
343
+
344
+ BASELINE="$(curl -s -X POST $BASE/baseline)"
345
+ check "J-01 POST /baseline → normalised_score present" "$BASELINE" '"normalised_score":'
346
+ check "J-02 POST /baseline → total_reward present" "$BASELINE" '"total_reward":'
347
+ check "J-03 POST /baseline → steps > 0" "$BASELINE" '"steps":[1-9]'
348
+ check "J-04 POST /baseline → per_task scores present" "$BASELINE" '"scores":\['
349
+ check "J-05 POST /baseline → score >= 0.5 (passes)" "$BASELINE" '"normalised_score":0\.[5-9]'
350
+
351
+ # ═══════════════════════════════════════════════════════════
352
+ # GROUP K — /analytics endpoint
353
+ # ═══════════════════════════════════════════════════════════
354
+ section "GROUP K — Analytics endpoint"
355
+
356
+ # Run a full episode first so analytics has data
357
+ reset
358
+ python3 - <<'PYEOF'
359
+ import urllib.request, json, sys
360
+ BASE = "http://localhost:8000"
361
+ def post(path, body=None):
362
+ req = urllib.request.Request(f"{BASE}{path}",
363
+ data=json.dumps(body).encode() if body else None,
364
+ headers={"Content-Type": "application/json"}, method="POST")
365
+ with urllib.request.urlopen(req) as r: return json.loads(r.read())
366
+ def get(path):
367
+ with urllib.request.urlopen(f"{BASE}{path}") as r: return json.loads(r.read())
368
+
369
+ post("/reset")
370
+ actions = [
371
+ ("approve","TXN-E001"),("reject","TXN-E002"),("approve","TXN-E003"),("flag","TXN-E004"),
372
+ ("escalate","TXN-M001"),("hold","TXN-M002"),("flag","TXN-M003"),("flag","TXN-M004"),
373
+ ("hold","TXN-M005"),("escalate","TXN-M006"),
374
+ ("escalate","TXN-H001"),("reject","TXN-H002"),("reject","TXN-H003"),("approve","TXN-H004"),
375
+ ("escalate","TXN-H005"),("flag","TXN-H006"),
376
+ ("approve","TXN-C001"),("reject","TXN-C002"),("escalate","TXN-C003"),("reject","TXN-C004"),
377
+ ]
378
+ for action, txn in actions:
379
+ post("/step", {"action_type": action, "transaction_id": txn})
380
+ PYEOF
381
+
382
+ ANA="$(curl -s $BASE/analytics)"
383
+ check "K-01 GET /analytics → episodes_completed >= 1" "$ANA" '"episodes_completed":[1-9]'
384
+ check "K-02 GET /analytics → best_score present" "$ANA" '"best_score":'
385
+ check "K-03 GET /analytics → avg_score present" "$ANA" '"avg_score":'
386
+ check "K-04 GET /analytics → avg_budget_spent present" "$ANA" '"avg_budget_spent":'
387
+ check "K-05 GET /analytics → current_episode present" "$ANA" '"current_episode":'
388
+ check "K-06 GET /analytics → by_difficulty present" "$ANA" '"by_difficulty":'
389
+ check "K-07 GET /analytics → easy accuracy" "$ANA" '"easy":'
390
+ check "K-08 GET /analytics → critical accuracy" "$ANA" '"critical":'
391
+
392
+ # ═══════════════════════════════════════════════════════════
393
+ # GROUP L — /leaderboard endpoint
394
+ # ═══════════════════════════════════════════════════════════
395
+ section "GROUP L — Leaderboard endpoint"
396
+
397
+ LB="$(curl -s $BASE/leaderboard)"
398
+ check "L-01 GET /leaderboard → count >= 1 (episode recorded)" "$LB" '"count":[1-9]'
399
+ check "L-02 GET /leaderboard → entries array present" "$LB" '"entries":\['
400
+ check "L-03 GET /leaderboard → episode_id in entry" "$LB" '"episode_id":"'
401
+ check "L-04 GET /leaderboard → normalised_score in entry" "$LB" '"normalised_score":'
402
+ check "L-05 GET /leaderboard → timestamp in entry" "$LB" '"timestamp":"'
403
+ check "L-06 GET /leaderboard → budget_spent in entry" "$LB" '"budget_spent":'
404
+
405
+ # ══════════════════════════════════════════════���════════════
406
+ # GROUP M — Perfect episode score
407
+ # ═══════════════════════════════════════════════════════════
408
+ section "GROUP M — Perfect episode (all 20 correct, no investigation cost)"
409
+
410
+ reset
411
+ python3 - <<'PYEOF'
412
+ import urllib.request, json
413
+ BASE = "http://localhost:8000"
414
+ def post(path, body=None):
415
+ req = urllib.request.Request(f"{BASE}{path}",
416
+ data=json.dumps(body).encode() if body else None,
417
+ headers={"Content-Type": "application/json"}, method="POST")
418
+ with urllib.request.urlopen(req) as r: return json.loads(r.read())
419
+
420
+ post("/reset")
421
+ actions = [
422
+ ("approve","TXN-E001"),("reject","TXN-E002"),("approve","TXN-E003"),("flag","TXN-E004"),
423
+ ("escalate","TXN-M001"),("hold","TXN-M002"),("flag","TXN-M003"),("flag","TXN-M004"),
424
+ ("hold","TXN-M005"),("escalate","TXN-M006"),
425
+ ("escalate","TXN-H001"),("reject","TXN-H002"),("reject","TXN-H003"),("approve","TXN-H004"),
426
+ ("escalate","TXN-H005"),("flag","TXN-H006"),
427
+ ("approve","TXN-C001"),("reject","TXN-C002"),("escalate","TXN-C003"),("reject","TXN-C004"),
428
+ ]
429
+ for action, txn in actions:
430
+ post("/step", {"action_type": action, "transaction_id": txn})
431
+ PYEOF
432
+
433
+ GRADER="$(curl -s $BASE/grader)"
434
+ check "M-01 Perfect episode → normalised_score=1.0" "$GRADER" '"normalised_score":1.0'
435
+ check "M-02 Perfect episode → passed=true" "$GRADER" '"passed":true'
436
+ check "M-03 Perfect episode → budget_penalty=0.0" "$GRADER" '"budget_penalty":0.0'
437
+
438
+ # ═══════════════════════════════════════════════════════════
439
+ # GROUP N — Difficulty weighting
440
+ # ═══════════════════════════════════════════════════════════
441
+ section "GROUP N — Difficulty weighting in grader"
442
+
443
+ # A critical task correct should contribute more weight than easy
444
+ REPLAY_CRIT='{"actions":["approve","reject","approve","flag",
445
+ "escalate","hold","flag","flag","hold","escalate",
446
+ "escalate","reject","reject","approve","escalate","flag",
447
+ "approve","reject","escalate","reject"]}'
448
+
449
+ FULL=$(curl -s -X POST $BASE/replay \
450
+ -H "Content-Type: application/json" \
451
+ -d "$REPLAY_CRIT")
452
+ check "N-01 Full correct replay → max_possible_reward includes weights" \
453
+ "$FULL" '"max_possible_reward":2[0-9]\.'
454
+ check "N-02 Full correct → total_reward equals max_possible" \
455
+ "$(echo "$FULL" | python3 -c "
456
+ import sys,json,re
457
+ d=json.load(sys.stdin)
458
+ print('EQUAL' if abs(d['total_reward']-d['max_possible_reward'])<0.01 else 'DIFF')
459
+ ")" "EQUAL"
460
+
461
+ # ═══════════════════════════════════════════════════════════
462
+ # GROUP O — Budget mechanics
463
+ # ═══════════════════════════════════════════════════════════
464
+ section "GROUP O — Budget mechanics"
465
+
466
+ reset
467
+
468
+ # Burn through budget with contact_sender (cost=0.3) × 5 = 1.5
469
+ # then request_docs × 5 = 1.0, then verify_kyc × 5 = 1.0 (total = 3.5 so far ≤5)
470
+ # Total exceeding: won't exceed in these few calls, test budget tracking
471
+ step '{"action_type":"contact_sender","transaction_id":"TXN-E001"}' > /dev/null # -0.30
472
+ step '{"action_type":"contact_sender","transaction_id":"TXN-E001"}' > /dev/null # dup, but cost still deducted
473
+ step '{"action_type":"request_docs","transaction_id":"TXN-E001"}' > /dev/null # -0.20
474
+ step '{"action_type":"verify_kyc","transaction_id":"TXN-E001"}' > /dev/null # -0.20
475
+ step '{"action_type":"file_sar","transaction_id":"TXN-E001"}' > /dev/null # -0.05
476
+ R=$(step '{"action_type":"approve","transaction_id":"TXN-E001"}')
477
+ # budget_remaining = 5.0 - 0.30 - 0.30 - 0.20 - 0.20 - 0.05 = 3.95
478
+ check "O-01 Budget correctly deducted after multiple inv actions" \
479
+ "$R" '"budget_remaining":3\.[0-9]'
480
+
481
+ # grader shows budget_spent
482
+ GRADER="$(curl -s $BASE/grader)"
483
+ check "O-02 Grader → budget_spent > 0" "$GRADER" '"budget_spent":0\.[1-9]'
484
+
485
+ # replay that overshoots budget → budget_penalty > 0
486
+ HEAVY='{"actions":["contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","contact_sender","approve","reject","approve","flag","escalate","hold","flag","flag","hold","escalate","escalate","reject","reject","approve","escalate","flag","approve","reject","escalate","reject"]}'
487
+ R=$(curl -s -X POST $BASE/replay \
488
+ -H "Content-Type: application/json" \
489
+ -d "$HEAVY")
490
+ check "O-03 Over-budget replay → budget_penalty > 0" "$R" '"budget_penalty":0\.[1-9]'
491
+ check "O-04 Over-budget replay → budget_overspend > 0" "$R" '"budget_overspend":0\.[1-9]'
492
+
493
+ # ═══════════════════════════════════════════════════════════
494
+ # GROUP P — Edge cases & errors
495
+ # ═══════════════════════════════════════════════════════════
496
+ section "GROUP P — Edge cases & error handling"
497
+
498
+ # P-01 Step before reset → 400
499
+ check "P-01 Step on fresh env (reset then done) → handled" \
500
+ "$(curl -s -X POST $BASE/reset > /dev/null && curl -s -X POST $BASE/step \
501
+ -H 'Content-Type: application/json' \
502
+ -d '{"action_type":"approve","transaction_id":"TXN-NONE"}')" \
503
+ '"reward"'
504
+
505
+ # P-02 Unknown action_type → 422
506
+ check "P-02 Unknown action_type → 422" \
507
+ "$(curl -s -X POST $BASE/step \
508
+ -H 'Content-Type: application/json' \
509
+ -d '{"action_type":"nuke","transaction_id":"TXN-E001"}')" \
510
+ "Invalid action_type"
511
+
512
+ # P-03 Missing action_type field → 422
513
+ check "P-03 Missing action_type field → error" \
514
+ "$(curl -s -X POST $BASE/step \
515
+ -H 'Content-Type: application/json' \
516
+ -d '{"transaction_id":"TXN-E001"}')" \
517
+ '"detail"'
518
+
519
+ # P-04 Empty replay actions list
520
+ check "P-04 Replay empty actions → returns score" \
521
+ "$(curl -s -X POST $BASE/replay \
522
+ -H 'Content-Type: application/json' \
523
+ -d '{"actions":[]}')" \
524
+ '"normalised_score"'
525
+
526
+ # P-05 Replay with confidences shorter than actions → still works
527
+ check "P-05 Replay with partial confidences → score present" \
528
+ "$(curl -s -X POST $BASE/replay \
529
+ -H 'Content-Type: application/json' \
530
+ -d '{"actions":["approve","reject"],"confidences":[0.9]}')" \
531
+ '"normalised_score"'
532
+
533
+ # P-06 Grader before any reset → 400 or returns score
534
+ check "P-06 Grader before actions → handled gracefully" \
535
+ "$(curl -s $BASE/grader)" \
536
+ '"normalised_score"\|"error"'
537
+
538
+ # P-07 Analytics before any completed episode → handled
539
+ reset
540
+ check "P-07 Analytics after fresh reset → message or data" \
541
+ "$(curl -s $BASE/analytics)" \
542
+ '"message"\|"episodes_completed"'
543
+
544
+ # ═══════════════════════════════════════════════════════════
545
+ # GROUP Q — WebSocket
546
+ # ═══════════════════════════════════════════════════════════
547
+ section "GROUP Q — WebSocket (requires wscat or python)"
548
+
549
+ if command -v python3 &>/dev/null; then
550
+ WS_RESULT=$(python3 - <<'PYEOF'
551
+ import urllib.request, json
552
+ try:
553
+ # ws upgrade check via HTTP (will get 426 Upgrade Required — proves endpoint exists)
554
+ req = urllib.request.Request("http://localhost:8000/ws")
555
+ try:
556
+ urllib.request.urlopen(req)
557
+ except Exception as e:
558
+ msg = str(e)
559
+ if "426" in msg or "101" in msg or "Switching" in msg or "upgrade" in msg.lower():
560
+ print("WS_OK")
561
+ else:
562
+ print(f"WS_UNKNOWN:{msg[:60]}")
563
+ except Exception as e:
564
+ print(f"WS_ERR:{e}")
565
+ PYEOF
566
+ )
567
+ check "Q-01 WS /ws endpoint exists (426 upgrade = correct)" \
568
+ "$WS_RESULT" "WS_OK"
569
+ else
570
+ printf " \033[33m-\033[0m Q-01 WebSocket check skipped (no python3)\n"
571
+ SKIP=$((SKIP+1))
572
+ fi
573
+
574
+ # ═══════════════════════════════════════════════════════════
575
+ # SUMMARY
576
+ # ═══════════════════════════════════════════════════════════
577
+ echo ""
578
+ echo "╔══════════════════════════════════════════════════════╗"
579
+ TOTAL=$((PASS+FAIL))
580
+ if [ "$FAIL" -eq 0 ]; then
581
+ printf "║ ✓ All %d tests passed" "$TOTAL"
582
+ else
583
+ printf "║ Results: %d/%d passed, %d failed" "$PASS" "$TOTAL" "$FAIL"
584
+ fi
585
+ [ "$SKIP" -gt 0 ] && printf " (%d skipped)" "$SKIP"
586
+ echo ""
587
+ echo "╚══════════════════════════════════════════════════════╝"
588
+ echo ""
589
+
590
+ # Exit with failure code if any tests failed
591
+ [ "$FAIL" -eq 0 ]
scripts/baseline_agent.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Baseline agent for the PayOps OpenEnv environment.
3
+
4
+ A rule-based agent that decides an action based on observable risk signals.
5
+ Use this as a sanity-check to prove the environment works end-to-end and
6
+ as a lower-bound performance reference before training a learned agent.
7
+
8
+ Usage
9
+ -----
10
+ # Run directly
11
+ python scripts/baseline_agent.py
12
+
13
+ # Or via the /baseline API endpoint
14
+ curl -X POST http://localhost:8000/baseline
15
+
16
+ Policy
17
+ ------
18
+ 1. If any sanctioned / high-risk flag → reject
19
+ 2. velocity_1h >= 10 (velocity burst) → reject
20
+ 3. kyc_status in (failed, none) → escalate
21
+ 4. risk_score >= 0.80 → reject
22
+ 5. risk_score >= 0.60 → escalate
23
+ 6. risk_score >= 0.35 OR any flag present → flag
24
+ 7. Otherwise → approve
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import sys
31
+ import os
32
+
33
+ # Allow running from the project root or the scripts/ directory
34
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
35
+
36
+ from payops_env.environment import PayOpsEnvironment
37
+ from payops_env.grader import grade_episode
38
+ from payops_env.models import PayOpsAction
39
+ from payops_env.tasks import TASKS
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Policy
44
+ # ---------------------------------------------------------------------------
45
+
46
+ DANGER_FLAGS = {
47
+ "sanctioned_country",
48
+ "app_scam_indicator",
49
+ "mule_account_pattern",
50
+ "structuring_pattern",
51
+ "ctr_threshold_avoidance",
52
+ }
53
+
54
+
55
+ def rule_based_policy(obs) -> str:
56
+ """
57
+ Deterministic rule-based policy.
58
+
59
+ Priority order ensures the most dangerous patterns are caught first,
60
+ even when the ML risk score is low (adversarial/poisoned score inputs).
61
+ """
62
+ # Priority 1: known fraud or sanctioned patterns regardless of risk score
63
+ if any(flag in DANGER_FLAGS for flag in obs.flags):
64
+ return "reject"
65
+
66
+ # Priority 2: transaction velocity burst (potential account takeover)
67
+ if obs.velocity_1h is not None and obs.velocity_1h >= 10:
68
+ return "reject"
69
+
70
+ # Priority 3: identity not confirmed
71
+ if obs.kyc_status in ("failed", "none"):
72
+ return "escalate"
73
+
74
+ # Priority 4–6: risk score tiers
75
+ if obs.risk_score >= 0.80:
76
+ return "reject"
77
+ elif obs.risk_score >= 0.60:
78
+ return "escalate"
79
+ elif obs.risk_score >= 0.35 or obs.flags:
80
+ return "flag"
81
+ else:
82
+ return "approve"
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Runner
87
+ # ---------------------------------------------------------------------------
88
+
89
+ async def run():
90
+ env = PayOpsEnvironment()
91
+ obs = await env.reset_async()
92
+ total_reward = 0.0
93
+ step = 0
94
+ actions_taken = []
95
+
96
+ print("=" * 60)
97
+ print(" PayOps Baseline Agent — Rule-Based Policy")
98
+ print("=" * 60)
99
+ print(f" Tasks in episode : {len(TASKS)}")
100
+ print("=" * 60)
101
+
102
+ while not obs.done:
103
+ action_type = rule_based_policy(obs)
104
+ action = PayOpsAction(
105
+ action_type=action_type,
106
+ transaction_id=obs.transaction_id,
107
+ )
108
+
109
+ print(f"\nStep {step + 1} [{obs.task_difficulty.upper()}] {obs.task_id}")
110
+ print(f" TXN : {obs.transaction_id}")
111
+ print(f" Amount : {obs.amount:,.2f} {obs.currency}")
112
+ print(f" Sender : {obs.sender}")
113
+ print(f" Receiver : {obs.receiver}")
114
+ print(f" Risk score : {obs.risk_score:.2f}")
115
+ print(f" KYC : {obs.kyc_status or 'n/a'} | "
116
+ f"Country risk: {obs.country_risk or 'n/a'} | "
117
+ f"Velocity 1h: {obs.velocity_1h or 'n/a'}")
118
+ print(f" Flags : {obs.flags or '[]'}")
119
+ print(f" → Agent : {action_type}")
120
+
121
+ obs = await env.step_async(action)
122
+ actions_taken.append(action_type)
123
+ total_reward += obs.reward
124
+ step += 1
125
+
126
+ print(f" ✓ Reward : {obs.reward:+.2f} "
127
+ f"(correct: {obs.info.get('correct_action', '?')})")
128
+
129
+ env.close()
130
+
131
+ # Grade the episode
132
+ result = grade_episode(actions_taken, list(TASKS))
133
+
134
+ print("\n" + "=" * 60)
135
+ print(" Episode Summary")
136
+ print("=" * 60)
137
+ print(f" Steps : {step}")
138
+ print(f" Total reward : {result.total_reward:+.2f}")
139
+ print(f" Max possible : {result.max_possible_reward:.2f}")
140
+ print(f" Normalised score : {result.normalised_score:.4f}")
141
+ print(f" Passed (≥0.5) : {'YES ✓' if result.passed else 'NO ✗'}")
142
+
143
+ print("\n Per-task breakdown:")
144
+ for t in result.per_task_rewards:
145
+ mark = "✓" if t["correct"] else "✗"
146
+ print(
147
+ f" {mark} {t['task_id']:12s} [{t['difficulty']:6s}] "
148
+ f"action={t['action_taken']:10s} "
149
+ f"correct={t['correct_action']:10s} "
150
+ f"reward={t['reward']:+.2f}"
151
+ )
152
+
153
+ print("=" * 60)
154
+ return result
155
+
156
+
157
+ if __name__ == "__main__":
158
+ asyncio.run(run())
159
+
scripts_util.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utilities for running the baseline agent programmatically (used by /baseline endpoint).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from payops_env.environment import PayOpsEnvironment
9
+ from payops_env.grader import grade_episode
10
+ from payops_env.models import PayOpsAction
11
+ from payops_env.tasks import TASKS
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Adaptive rule-based policy
16
+ # ---------------------------------------------------------------------------
17
+
18
+ _DANGER_FLAGS = {
19
+ "sanctioned_country", "app_scam_indicator", "mule_account_pattern",
20
+ "structuring_pattern", "ctr_threshold_avoidance", "fraud_ring_indicator",
21
+ "geo_impossible_login", "account_takeover_indicator",
22
+ }
23
+
24
+ _WATCHLIST_FLAGS = {
25
+ "new_account_7d", "large_first_transfer", "solicitor_mule_pattern",
26
+ "dormant_receiver", "sudden_activity", "insider_threat", "internal_to_personal",
27
+ "invoice_mismatch", "trade_finance",
28
+ }
29
+
30
+
31
+ def _should_investigate(obs) -> Optional[str]:
32
+ """
33
+ Decide whether to issue an investigation sub-action first.
34
+
35
+ Returns the sub-action name, or None if we should decide directly.
36
+
37
+ Priority:
38
+ 1. KYC expired / pending → verify_kyc (once)
39
+ 2. Watchlist flags AND high amount AND docs not yet requested → request_docs
40
+ 3. Low ml_confidence AND medium risk → inspect (once)
41
+ 4. contact_sender: APP scam pattern or insider threat
42
+ 5. file_sar: if structuring/fraud-ring flags and not yet filed
43
+ """
44
+ already = set(obs.info.get("investigation_used", []) if isinstance(obs.info, dict) else [])
45
+
46
+ # file_sar if structuring / fraud ring and SAR not yet filed
47
+ sar_flags = {"structuring_pattern", "ctr_threshold_avoidance",
48
+ "fraud_ring_indicator", "coordinated_transfers"}
49
+ if sar_flags & set(obs.flags) and "file_sar" not in already:
50
+ return "file_sar"
51
+
52
+ # contact_sender for APP scam or insider
53
+ contact_flags = {"app_scam_indicator", "internal_to_personal", "account_takeover_indicator"}
54
+ if contact_flags & set(obs.flags) and "contact_sender" not in already:
55
+ return "contact_sender"
56
+
57
+ # verify_kyc if expired or pending
58
+ if obs.kyc_status in ("expired", "pending") and "verify_kyc" not in already:
59
+ return "verify_kyc"
60
+
61
+ # request_docs for first-time payees with high value
62
+ doc_flags = {"first_time_payee", "large_first_transfer", "invoice_mismatch", "trade_finance"}
63
+ if (doc_flags & set(obs.flags)
64
+ and obs.amount >= 50_000
65
+ and "request_docs" not in already):
66
+ return "request_docs"
67
+
68
+ # inspect when ml_confidence is low or watchlist flags are present
69
+ ml_conf = getattr(obs, "ml_confidence", 0.9) or 0.9
70
+ watchlist_hit = bool(_WATCHLIST_FLAGS & set(obs.flags))
71
+ if (ml_conf < 0.60 or watchlist_hit) and "inspect" not in already:
72
+ return "inspect"
73
+
74
+ return None
75
+
76
+
77
+ def _terminal_decision(obs) -> str:
78
+ """
79
+ Make a terminal decision. Uses enriched observation fields where available
80
+ (inspect_reveal, docs_notes, kyc_notes, contact_notes already in obs.info).
81
+ """
82
+ # Definitive danger signals
83
+ if any(f in _DANGER_FLAGS for f in obs.flags):
84
+ # Exception: if we confirmed via contact that it is genuine, approve
85
+ contact = getattr(obs, "contact_notes", None) or ""
86
+ if "CEO confirms they did NOT" in contact or "Classic APP scam" in contact:
87
+ return "reject"
88
+ return "reject"
89
+
90
+ vel = obs.velocity_1h
91
+ if vel is not None and vel >= 10:
92
+ return "reject"
93
+
94
+ if obs.kyc_status in ("failed", "none"):
95
+ return "escalate"
96
+
97
+ # KYC expired → hold (wait for renewal)
98
+ if obs.kyc_status == "expired":
99
+ return "hold"
100
+
101
+ # Very high risk
102
+ if obs.risk_score >= 0.85:
103
+ return "reject"
104
+
105
+ # Watchlist flags with high value
106
+ if _WATCHLIST_FLAGS & set(obs.flags) and obs.amount >= 20_000:
107
+ return "escalate"
108
+
109
+ # FX / correspondent banking settlement
110
+ if "fx_settlement" in obs.flags:
111
+ return "approve"
112
+
113
+ # After inspection revealed legitimacy (corp-level, FX)
114
+ inspection = getattr(obs, "inspection_notes", None) or ""
115
+ if "correspondent banking" in inspection.lower() or "on file" in inspection.lower():
116
+ if obs.risk_score < 0.75:
117
+ return "approve"
118
+
119
+ if obs.risk_score >= 0.65:
120
+ return "escalate"
121
+ elif obs.risk_score >= 0.40 or obs.flags:
122
+ return "flag"
123
+ else:
124
+ return "approve"
125
+
126
+
127
+ def _rule_based_policy(obs) -> str:
128
+ """
129
+ Adaptive policy. First checks whether investigation is needed; if so
130
+ returns a sub-action. Otherwise returns a terminal decision.
131
+ """
132
+ sub = _should_investigate(obs)
133
+ if sub is not None:
134
+ return sub
135
+ return _terminal_decision(obs)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Episode runner
140
+ # ---------------------------------------------------------------------------
141
+
142
+ async def run_baseline() -> Tuple[List[Dict[str, Any]], float, float, int]:
143
+ """
144
+ Run the adaptive rule-based baseline over the full task set.
145
+
146
+ Returns:
147
+ (per_task_rewards, total_reward, normalised_score, steps)
148
+ """
149
+ env = PayOpsEnvironment()
150
+ obs = await env.reset_async()
151
+ actions_taken: List[str] = []
152
+ confs: List[Optional[float]] = []
153
+ step = 0
154
+
155
+ while not obs.done:
156
+ action_type = _rule_based_policy(obs)
157
+ action = PayOpsAction(
158
+ action_type=action_type,
159
+ transaction_id=obs.transaction_id,
160
+ )
161
+ obs = await env.step_async(action)
162
+ actions_taken.append(action_type)
163
+ confs.append(None)
164
+ step += 1
165
+
166
+ env.close()
167
+
168
+ result = grade_episode(actions_taken, list(TASKS), confs)
169
+ return result.per_task_rewards, result.total_reward, result.normalised_score, step
170
+
server/__init__.py ADDED
File without changes
server/app.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI server for the PayOps OpenEnv environment.
3
+
4
+ Endpoints
5
+ ---------
6
+ POST /reset Reset environment, return initial observation
7
+ POST /step Execute an action, return observation + reward
8
+ GET /state Current internal environment state
9
+ GET /schema Action / observation JSON schemas
10
+ GET /tasks List all tasks with metadata
11
+ GET /grader Grade the current episode
12
+ POST /baseline Run the rule-based baseline agent
13
+ GET /analytics Aggregate performance analytics for this session
14
+ POST /replay Grade a supplied action sequence without modifying state
15
+ GET /leaderboard All scored episodes this session
16
+ GET /health Health check
17
+ WS /ws WebSocket for persistent sessions
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import time
25
+ from collections import defaultdict
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
29
+ from fastapi.responses import JSONResponse
30
+ from pydantic import BaseModel
31
+
32
+ from payops_env.environment import PayOpsEnvironment, VALID_ACTIONS
33
+ from payops_env.grader import grade_episode
34
+ from payops_env.models import PayOpsAction, PayOpsObservation, PayOpsState
35
+ from payops_env.tasks import TASKS, TASKS_BY_ID
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # App setup
40
+ # ---------------------------------------------------------------------------
41
+
42
+ app = FastAPI(
43
+ title="PayOps OpenEnv",
44
+ description=(
45
+ "Payment Operations Incident Response environment. "
46
+ "An AI agent reviews financial transactions and decides how to handle them."
47
+ ),
48
+ version="2.0.0",
49
+ )
50
+
51
+ # Single shared environment instance (suitable for single-user / HF Space use)
52
+ _env = PayOpsEnvironment()
53
+ _episode_actions: List[str] = [] # ALL actions including investigation
54
+ _episode_confs: List[Optional[float]] = []
55
+ _episode_tasks: List[Any] = []
56
+
57
+ # Leaderboard persists for the process lifetime
58
+ _leaderboard: List[Dict[str, Any]] = []
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Request / response helpers
63
+ # ---------------------------------------------------------------------------
64
+
65
+ class StepRequest(BaseModel):
66
+ action_type: str
67
+ transaction_id: str
68
+ reason: Optional[str] = None
69
+ confidence: Optional[float] = None
70
+
71
+
72
+ class BaselineResult(BaseModel):
73
+ scores: List[Dict[str, Any]]
74
+ total_reward: float
75
+ normalised_score: float
76
+ steps: int
77
+
78
+
79
+ class ReplayRequest(BaseModel):
80
+ actions: List[str]
81
+ confidences: Optional[List[Optional[float]]] = None
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Endpoints
86
+ # ---------------------------------------------------------------------------
87
+
88
+ @app.post("/reset", response_model=PayOpsObservation, summary="Reset the environment")
89
+ async def reset():
90
+ """Reset the environment and return the first transaction observation."""
91
+ global _episode_actions, _episode_tasks, _episode_confs
92
+ _episode_actions = []
93
+ _episode_confs = []
94
+ _episode_tasks = list(TASKS)
95
+ obs = await _env.reset_async()
96
+ return obs
97
+
98
+
99
+ @app.post("/step", response_model=PayOpsObservation, summary="Execute an action")
100
+ async def step(request: StepRequest):
101
+ """
102
+ Submit an action for the current transaction.
103
+
104
+ Valid action_type values:
105
+ Terminal: approve | reject | flag | escalate | hold
106
+ Investigation: inspect | request_docs | verify_kyc | contact_sender | file_sar
107
+ """
108
+ if request.action_type.lower() not in VALID_ACTIONS:
109
+ raise HTTPException(
110
+ status_code=422,
111
+ detail=f"Invalid action_type '{request.action_type}'. "
112
+ f"Valid values: {sorted(VALID_ACTIONS)}",
113
+ )
114
+ action = PayOpsAction(
115
+ action_type=request.action_type,
116
+ transaction_id=request.transaction_id,
117
+ reason=request.reason,
118
+ confidence=request.confidence,
119
+ )
120
+ try:
121
+ obs = await _env.step_async(action)
122
+ except RuntimeError as exc:
123
+ raise HTTPException(status_code=400, detail=str(exc))
124
+
125
+ _episode_actions.append(request.action_type.lower())
126
+ _episode_confs.append(request.confidence)
127
+
128
+ # Auto-save completed episode to leaderboard
129
+ if obs.done:
130
+ result = grade_episode(
131
+ _episode_actions, _episode_tasks, _episode_confs
132
+ )
133
+ _leaderboard.append(
134
+ {
135
+ "episode_id": _env.state().episode_id,
136
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
137
+ "normalised_score": result.normalised_score,
138
+ "total_reward": result.total_reward,
139
+ "budget_spent": result.budget_spent,
140
+ "budget_overspend": result.budget_overspend,
141
+ "passed": result.passed,
142
+ "steps": len(_episode_actions),
143
+ }
144
+ )
145
+
146
+ return obs
147
+
148
+
149
+ @app.get("/state", response_model=PayOpsState, summary="Get internal environment state")
150
+ async def state():
151
+ """Return the current internal state of the environment."""
152
+ return _env.state()
153
+
154
+
155
+ @app.get("/schema", summary="Get action and observation schemas")
156
+ async def schema():
157
+ """Return the JSON schemas for PayOpsAction and PayOpsObservation."""
158
+ return {
159
+ "action": PayOpsAction.model_json_schema(),
160
+ "observation": PayOpsObservation.model_json_schema(),
161
+ "state": PayOpsState.model_json_schema(),
162
+ }
163
+
164
+
165
+ @app.get("/tasks", summary="List all available tasks")
166
+ async def tasks():
167
+ """Return metadata for all tasks grouped by difficulty."""
168
+ result = []
169
+ for t in TASKS:
170
+ result.append(
171
+ {
172
+ "task_id": t.task_id,
173
+ "difficulty": t.difficulty,
174
+ "description": t.description,
175
+ "transaction_id": t.transaction_id,
176
+ "amount": t.amount,
177
+ "currency": t.currency,
178
+ "transaction_type":t.transaction_type,
179
+ "risk_score": t.risk_score,
180
+ "ml_confidence": getattr(t, "ml_confidence", None),
181
+ "flags": t.flags,
182
+ "correct_action": t.correct_action,
183
+ "requires_investigation": list(getattr(t, "requires_investigation", [])),
184
+ "regulatory_action": getattr(t, "regulatory_action", False),
185
+ "chain_total": getattr(t, "chain_total", 1),
186
+ }
187
+ )
188
+ return {"count": len(result), "tasks": result}
189
+
190
+
191
+ @app.get("/grader", summary="Grade the current episode")
192
+ async def grader():
193
+ """
194
+ Grade the episode using all actions taken since the last /reset.
195
+ """
196
+ if not _episode_actions:
197
+ return JSONResponse(
198
+ status_code=400,
199
+ content={"error": "No actions recorded. Run /reset then /step first."},
200
+ )
201
+ result = grade_episode(_episode_actions, _episode_tasks, _episode_confs)
202
+ return {
203
+ "total_reward": result.total_reward,
204
+ "max_possible_reward":result.max_possible_reward,
205
+ "normalised_score": result.normalised_score,
206
+ "budget_spent": result.budget_spent,
207
+ "budget_overspend": result.budget_overspend,
208
+ "budget_penalty": result.budget_penalty,
209
+ "passed": result.passed,
210
+ "per_task": result.per_task_rewards,
211
+ }
212
+
213
+
214
+ @app.post("/baseline", response_model=BaselineResult, summary="Run the baseline agent")
215
+ async def baseline():
216
+ """
217
+ Run the built-in rule-based baseline agent against the full task set
218
+ and return its scores. Useful for sanity-checking the environment.
219
+ """
220
+ from payops_env.scripts_util import run_baseline
221
+ scores, total, normalised, steps = await run_baseline()
222
+ return BaselineResult(
223
+ scores=scores,
224
+ total_reward=total,
225
+ normalised_score=normalised,
226
+ steps=steps,
227
+ )
228
+
229
+
230
+ @app.get("/analytics", summary="Session performance analytics")
231
+ async def analytics():
232
+ """
233
+ Return aggregate analytics across all completed episodes this session.
234
+ Includes accuracy by difficulty, average budget spend, and common mistakes.
235
+ """
236
+ if not _leaderboard:
237
+ return {"message": "No completed episodes yet. Run a full episode first."}
238
+
239
+ # Per-difficulty accuracy from the last episode's per_task breakdown
240
+ result = grade_episode(_episode_actions, _episode_tasks, _episode_confs)
241
+ by_diff: Dict[str, Dict] = defaultdict(lambda: {"total": 0, "correct": 0, "rewards": []})
242
+ for pt in result.per_task_rewards:
243
+ d = pt["difficulty"]
244
+ by_diff[d]["total"] += 1
245
+ by_diff[d]["correct"] += int(pt["correct"])
246
+ by_diff[d]["rewards"].append(pt["weighted_reward"])
247
+
248
+ diff_summary = {
249
+ diff: {
250
+ "accuracy": round(v["correct"] / v["total"], 3) if v["total"] else 0,
251
+ "avg_reward": round(sum(v["rewards"]) / len(v["rewards"]), 3) if v["rewards"] else 0,
252
+ "count": v["total"],
253
+ }
254
+ for diff, v in by_diff.items()
255
+ }
256
+
257
+ return {
258
+ "episodes_completed": len(_leaderboard),
259
+ "best_score": max(e["normalised_score"] for e in _leaderboard),
260
+ "avg_score": round(sum(e["normalised_score"] for e in _leaderboard) / len(_leaderboard), 4),
261
+ "avg_budget_spent": round(sum(e["budget_spent"] for e in _leaderboard) / len(_leaderboard), 4),
262
+ "current_episode": {
263
+ "normalised_score": result.normalised_score,
264
+ "budget_spent": result.budget_spent,
265
+ "budget_penalty": result.budget_penalty,
266
+ "by_difficulty": diff_summary,
267
+ },
268
+ }
269
+
270
+
271
+ @app.post("/replay", summary="Grade a supplied action sequence")
272
+ async def replay(request: ReplayRequest):
273
+ """
274
+ Grade a supplied list of actions against the full task bank without
275
+ modifying the current environment state.
276
+
277
+ Useful for offline evaluation and leaderboard submissions.
278
+ """
279
+ actions = [a.lower() for a in request.actions]
280
+ invalid = [a for a in actions if a not in VALID_ACTIONS]
281
+ if invalid:
282
+ raise HTTPException(
283
+ status_code=422,
284
+ detail=f"Invalid action(s): {invalid}. Valid: {sorted(VALID_ACTIONS)}",
285
+ )
286
+
287
+ confs = request.confidences or [None] * len(actions)
288
+ result = grade_episode(actions, list(TASKS), confs)
289
+ return {
290
+ "total_reward": result.total_reward,
291
+ "max_possible_reward": result.max_possible_reward,
292
+ "normalised_score": result.normalised_score,
293
+ "budget_spent": result.budget_spent,
294
+ "budget_overspend": result.budget_overspend,
295
+ "budget_penalty": result.budget_penalty,
296
+ "passed": result.passed,
297
+ "per_task": result.per_task_rewards,
298
+ }
299
+
300
+
301
+ @app.get("/leaderboard", summary="Session leaderboard")
302
+ async def leaderboard():
303
+ """
304
+ Return all scored episodes from this server session, sorted by score.
305
+ """
306
+ sorted_board = sorted(_leaderboard, key=lambda e: e["normalised_score"], reverse=True)
307
+ return {"count": len(sorted_board), "entries": sorted_board}
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # WebSocket endpoint for persistent sessions
312
+ # ---------------------------------------------------------------------------
313
+
314
+ @app.websocket("/ws")
315
+ async def websocket_endpoint(websocket: WebSocket):
316
+ """
317
+ WebSocket interface.
318
+
319
+ Client sends JSON:
320
+ {"type": "reset"}
321
+ {"type": "step", "action_type": "...", "transaction_id": "..."}
322
+ {"type": "state"}
323
+
324
+ Server responds with observation JSON.
325
+ """
326
+ await websocket.accept()
327
+ ws_env = PayOpsEnvironment()
328
+
329
+ try:
330
+ while True:
331
+ raw = await websocket.receive_text()
332
+ try:
333
+ msg = json.loads(raw)
334
+ except json.JSONDecodeError:
335
+ await websocket.send_json({"error": "Invalid JSON"})
336
+ continue
337
+
338
+ msg_type = msg.get("type", "")
339
+
340
+ if msg_type == "reset":
341
+ obs = await ws_env.reset_async()
342
+ await websocket.send_json(obs.model_dump())
343
+
344
+ elif msg_type == "step":
345
+ action_type = msg.get("action_type", "")
346
+ if action_type.lower() not in VALID_ACTIONS:
347
+ await websocket.send_json(
348
+ {"error": f"Invalid action_type '{action_type}'"}
349
+ )
350
+ continue
351
+ action = PayOpsAction(
352
+ action_type=action_type,
353
+ transaction_id=msg.get("transaction_id", ""),
354
+ reason=msg.get("reason"),
355
+ confidence=msg.get("confidence"),
356
+ )
357
+ try:
358
+ obs = await ws_env.step_async(action)
359
+ await websocket.send_json(obs.model_dump())
360
+ except Exception as exc:
361
+ await websocket.send_json({"error": str(exc)})
362
+
363
+ elif msg_type == "state":
364
+ await websocket.send_json(ws_env.state().model_dump())
365
+
366
+ else:
367
+ await websocket.send_json(
368
+ {"error": f"Unknown message type '{msg_type}'"}
369
+ )
370
+
371
+ except WebSocketDisconnect:
372
+ ws_env.close()
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Health check
377
+ # ---------------------------------------------------------------------------
378
+
379
+ @app.get("/health", summary="Health check")
380
+ async def health():
381
+ return {"status": "ok", "environment": "payops_env", "version": "2.0.0"}
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Entry point
386
+ # ---------------------------------------------------------------------------
387
+
388
+ def main(host: str = "0.0.0.0", port: int = 8000):
389
+ import uvicorn
390
+ uvicorn.run(app, host=host, port=port)
391
+
392
+
393
+ if __name__ == "__main__":
394
+ import argparse
395
+ parser = argparse.ArgumentParser()
396
+ parser.add_argument("--port", type=int, default=8000)
397
+ args = parser.parse_args()
398
+ main(port=args.port)
399
+
tasks.py ADDED
@@ -0,0 +1,668 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task bank for the PayOps environment — 20 tasks across 4 difficulty tiers.
3
+
4
+ Difficulty tiers
5
+ ----------------
6
+ easy – single clear signal; one-hop decision
7
+ medium – ambiguous or competing signals; reasoning required
8
+ hard – adversarial, conflicting, or edge-case patterns
9
+ critical – multi-step investigation chains; regulatory compliance stakes
10
+
11
+ Multi-step chains
12
+ -----------------
13
+ Tasks with chain_total > 1 require the agent to issue investigation sub-actions
14
+ (inspect / request_docs / verify_kyc / contact_sender) before a terminal decision.
15
+ Each chain step reveals progressively more context via the appropriate _reveal field.
16
+ The terminal decision is only scored on the final chain step.
17
+
18
+ Action costs
19
+ ------------
20
+ Each investigation sub-action incurs a budget cost. If the agent exhausts the
21
+ budget (spend > budget_limit), a cumulative cost penalty is applied to the reward.
22
+
23
+ inspect → cost 0.1
24
+ request_docs → cost 0.2
25
+ verify_kyc → cost 0.2
26
+ contact_sender → cost 0.3
27
+ file_sar → cost 0.1 (required for structuring/AML — free if correct)
28
+
29
+ Reward structure per task
30
+ -------------------------
31
+ Each task defines:
32
+ correct_action – ground-truth terminal decision
33
+ partial_credit_actions – {action: fraction_of_full_credit}
34
+ requires_investigation – set of sub-actions ideally used before deciding
35
+ regulatory_action – if True, file_sar is a required prerequisite for full credit
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from dataclasses import dataclass, field
41
+ from typing import Dict, List, Optional, Set
42
+
43
+
44
+ @dataclass
45
+ class PayOpsTask:
46
+ # --- identity ---
47
+ task_id: str
48
+ difficulty: str # easy | medium | hard | critical
49
+ description: str
50
+
51
+ # --- transaction fields ---
52
+ transaction_id: str
53
+ amount: float
54
+ currency: str
55
+ sender: str
56
+ receiver: str
57
+ transaction_type: str # transfer | payment | withdrawal | refund | internal | loan_repayment | payroll
58
+
59
+ # --- primary risk signals ---
60
+ risk_score: float # 0.0–1.0
61
+ ml_confidence: float = 0.90 # model's confidence in its own risk_score
62
+ flags: List[str] = field(default_factory=list)
63
+
64
+ # --- sender behaviour ---
65
+ velocity_1h: Optional[int] = None
66
+ velocity_24h: Optional[int] = None
67
+ avg_transaction_amount: Optional[float] = None
68
+ account_age_days: Optional[int] = None
69
+
70
+ # --- counterparty / geo ---
71
+ country_risk: Optional[str] = None # low | medium | high | sanctioned
72
+ kyc_status: Optional[str] = None # verified | pending | failed | none | expired
73
+ kyc_expiry_days: Optional[int] = None
74
+ previous_violations: Optional[int] = None
75
+ previous_sars: Optional[int] = None
76
+ counterparty_risk: Optional[str] = None # clean | unknown | watchlist | blacklist
77
+
78
+ # --- grading ---
79
+ correct_action: str = "approve"
80
+ partial_credit_actions: Dict[str, float] = field(default_factory=dict)
81
+ requires_investigation: Set[str] = field(default_factory=set)
82
+ # Set of sub-actions the agent SHOULD use for best score.
83
+ # Using them before the terminal decision grants a bonus (applied in grader).
84
+ regulatory_action: bool = False
85
+ # If True, filing a SAR is required to get full credit.
86
+
87
+ # --- investigation reveals (each unlocked by the matching sub-action) ---
88
+ inspect_reveal: Optional[str] = None
89
+ docs_reveal: Optional[str] = None
90
+ kyc_reveal: Optional[str] = None
91
+ contact_reveal: Optional[str] = None
92
+
93
+ # --- multi-step chain metadata ---
94
+ chain_total: int = 1
95
+ # chain_total > 1 means the task spans multiple investigation steps.
96
+ # The grader only scores the terminal decision on the last chain step.
97
+
98
+
99
+ # =============================================================================
100
+ # EASY (4 tasks — single clear signal)
101
+ # =============================================================================
102
+
103
+ EASY_TASKS: List[PayOpsTask] = [
104
+ PayOpsTask(
105
+ task_id="EASY-001",
106
+ difficulty="easy",
107
+ description="Low-value domestic transfer between two long-standing verified users. No flags.",
108
+ transaction_id="TXN-E001",
109
+ amount=47.50,
110
+ currency="USD",
111
+ sender="alice@example.com",
112
+ receiver="bob@example.com",
113
+ transaction_type="transfer",
114
+ risk_score=0.05,
115
+ ml_confidence=0.97,
116
+ flags=[],
117
+ velocity_1h=1, velocity_24h=3,
118
+ avg_transaction_amount=55.0,
119
+ account_age_days=1_095,
120
+ country_risk="low", kyc_status="verified", kyc_expiry_days=320,
121
+ previous_violations=0, previous_sars=0, counterparty_risk="clean",
122
+ correct_action="approve",
123
+ partial_credit_actions={"flag": 0.3},
124
+ inspect_reveal="Sender account 3 yrs old. Consistent transaction history. KYC fully verified.",
125
+ ),
126
+ PayOpsTask(
127
+ task_id="EASY-002",
128
+ difficulty="easy",
129
+ description="Textbook fraud: unknown sender, offshore, sanctioned country, risk=0.97.",
130
+ transaction_id="TXN-E002",
131
+ amount=9_800.00,
132
+ currency="USD",
133
+ sender="unknown_user_x99",
134
+ receiver="offshore_acct_442",
135
+ transaction_type="transfer",
136
+ risk_score=0.97,
137
+ ml_confidence=0.95,
138
+ flags=["high_value", "unknown_sender", "offshore_receiver", "sanctioned_country"],
139
+ velocity_1h=12, velocity_24h=28,
140
+ avg_transaction_amount=None,
141
+ account_age_days=2,
142
+ country_risk="sanctioned", kyc_status="none", kyc_expiry_days=None,
143
+ previous_violations=3, previous_sars=1, counterparty_risk="blacklist",
144
+ correct_action="reject",
145
+ partial_credit_actions={"escalate": 0.4, "flag": 0.2},
146
+ inspect_reveal="Receiver linked to 3 prior rejected transactions. IP on threat-intel blocklist.",
147
+ ),
148
+ PayOpsTask(
149
+ task_id="EASY-003",
150
+ difficulty="easy",
151
+ description="Standard refund to a verified customer. Tiny amount, no flags.",
152
+ transaction_id="TXN-E003",
153
+ amount=12.99,
154
+ currency="USD",
155
+ sender="shop@retailer.com",
156
+ receiver="carol@customer.com",
157
+ transaction_type="refund",
158
+ risk_score=0.03,
159
+ ml_confidence=0.98,
160
+ flags=[],
161
+ velocity_1h=1, velocity_24h=5,
162
+ avg_transaction_amount=14.0,
163
+ account_age_days=730,
164
+ country_risk="low", kyc_status="verified", kyc_expiry_days=180,
165
+ previous_violations=0, previous_sars=0, counterparty_risk="clean",
166
+ correct_action="approve",
167
+ partial_credit_actions={"flag": 0.3},
168
+ inspect_reveal="Standard return via web checkout. Matching order ID on file. No anomalies.",
169
+ ),
170
+ PayOpsTask(
171
+ task_id="EASY-004",
172
+ difficulty="easy",
173
+ description="ATM withdrawal burst — 15 withdrawals in 58 minutes across 4 ATMs. Velocity violation.",
174
+ transaction_id="TXN-E004",
175
+ amount=200.00,
176
+ currency="USD",
177
+ sender="david@accounts.com",
178
+ receiver="atm_node_77",
179
+ transaction_type="withdrawal",
180
+ risk_score=0.78,
181
+ ml_confidence=0.89,
182
+ flags=["velocity_breach", "atm_burst"],
183
+ velocity_1h=15, velocity_24h=17,
184
+ avg_transaction_amount=80.0,
185
+ account_age_days=540,
186
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=90,
187
+ previous_violations=0, previous_sars=0, counterparty_risk="unknown",
188
+ correct_action="flag",
189
+ partial_credit_actions={"escalate": 0.6, "hold": 0.5},
190
+ inspect_reveal="15 ATM withdrawals in 58 min across 4 ATMs. Pattern consistent with card clone.",
191
+ ),
192
+ ]
193
+
194
+
195
+ # =============================================================================
196
+ # MEDIUM (6 tasks — ambiguous, multi-signal)
197
+ # =============================================================================
198
+
199
+ MEDIUM_TASKS: List[PayOpsTask] = [
200
+ PayOpsTask(
201
+ task_id="MED-001",
202
+ difficulty="medium",
203
+ description="Large B2B wire. Verified CFO. Cross-border to medium-risk EU jurisdiction.",
204
+ transaction_id="TXN-M001",
205
+ amount=85_000.00,
206
+ currency="EUR",
207
+ sender="cfo@globalcorp.com",
208
+ receiver="vendor@eu-supplier.de",
209
+ transaction_type="transfer",
210
+ risk_score=0.52,
211
+ ml_confidence=0.72,
212
+ flags=["high_value", "cross_border"],
213
+ velocity_1h=2, velocity_24h=4,
214
+ avg_transaction_amount=45_000.0,
215
+ account_age_days=2_190,
216
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=200,
217
+ previous_violations=0, previous_sars=0, counterparty_risk="clean",
218
+ correct_action="escalate",
219
+ partial_credit_actions={"flag": 0.5, "hold": 0.4},
220
+ requires_investigation={"inspect"},
221
+ inspect_reveal="Contract on file for €85k milestone. Receiver is licensed EU entity. Quarterly vendor pattern.",
222
+ ),
223
+ PayOpsTask(
224
+ task_id="MED-002",
225
+ difficulty="medium",
226
+ description="Internal treasury transfer. Large amount. KYC pending renewal — risk is procedural, not fraud.",
227
+ transaction_id="TXN-M002",
228
+ amount=250_000.00,
229
+ currency="USD",
230
+ sender="treasury@holdco.com",
231
+ receiver="subsidiary@holdco-us.com",
232
+ transaction_type="internal",
233
+ risk_score=0.41,
234
+ ml_confidence=0.78,
235
+ flags=["high_value", "kyc_expiry_90d"],
236
+ velocity_1h=1, velocity_24h=2,
237
+ avg_transaction_amount=200_000.0,
238
+ account_age_days=3_650,
239
+ country_risk="low", kyc_status="pending", kyc_expiry_days=5,
240
+ previous_violations=0, previous_sars=0, counterparty_risk="clean",
241
+ correct_action="hold",
242
+ partial_credit_actions={"escalate": 0.6, "flag": 0.4},
243
+ requires_investigation={"verify_kyc"},
244
+ kyc_reveal="KYC renewal submitted 10 days ago. Both accounts share same UBO. Transfer aligns with Q1 plan.",
245
+ ),
246
+ PayOpsTask(
247
+ task_id="MED-003",
248
+ difficulty="medium",
249
+ description="Subscription payment 3× historical average. Possible upgrade billing or card compromise.",
250
+ transaction_id="TXN-M003",
251
+ amount=449.97,
252
+ currency="USD",
253
+ sender="eve@subscriber.com",
254
+ receiver="billing@saas-platform.com",
255
+ transaction_type="payment",
256
+ risk_score=0.44,
257
+ ml_confidence=0.68,
258
+ flags=["amount_spike", "pattern_deviation"],
259
+ velocity_1h=1, velocity_24h=1,
260
+ avg_transaction_amount=149.99,
261
+ account_age_days=820,
262
+ country_risk="low", kyc_status="verified", kyc_expiry_days=150,
263
+ previous_violations=0, previous_sars=0, counterparty_risk="clean",
264
+ correct_action="flag",
265
+ partial_credit_actions={"hold": 0.5, "escalate": 0.3},
266
+ inspect_reveal="Historical monthly charge $149.99. This charge = 3-month annual upgrade. Merchant confirmation pending.",
267
+ ),
268
+ PayOpsTask(
269
+ task_id="MED-004",
270
+ difficulty="medium",
271
+ description="Payment to regulated crypto exchange. Moderate risk. Sender has clean history.",
272
+ transaction_id="TXN-M004",
273
+ amount=5_000.00,
274
+ currency="USD",
275
+ sender="frank@personal.com",
276
+ receiver="exchange@cryptovault.io",
277
+ transaction_type="payment",
278
+ risk_score=0.58,
279
+ ml_confidence=0.74,
280
+ flags=["crypto_exchange", "high_value"],
281
+ velocity_1h=1, velocity_24h=2,
282
+ avg_transaction_amount=2_000.0,
283
+ account_age_days=1_460,
284
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=270,
285
+ previous_violations=0, previous_sars=0, counterparty_risk="unknown",
286
+ correct_action="flag",
287
+ partial_credit_actions={"escalate": 0.5, "hold": 0.4},
288
+ inspect_reveal="Exchange licensed in sender's jurisdiction. Sender made 4 similar payments over 6 months. Within limits.",
289
+ ),
290
+ PayOpsTask(
291
+ task_id="MED-005",
292
+ difficulty="medium",
293
+ description="Expired KYC on a high-frequency corporate account. Routine transactions continue but KYC lapsed 12 days ago.",
294
+ transaction_id="TXN-M005",
295
+ amount=28_000.00,
296
+ currency="GBP",
297
+ sender="payments@logistics-uk.com",
298
+ receiver="driver-pool@payroll.co.uk",
299
+ transaction_type="payroll",
300
+ risk_score=0.38,
301
+ ml_confidence=0.82,
302
+ flags=["kyc_expired", "high_value"],
303
+ velocity_1h=1, velocity_24h=6,
304
+ avg_transaction_amount=26_000.0,
305
+ account_age_days=1_800,
306
+ country_risk="low", kyc_status="expired", kyc_expiry_days=-12,
307
+ previous_violations=0, previous_sars=0, counterparty_risk="clean",
308
+ correct_action="hold",
309
+ partial_credit_actions={"flag": 0.5, "escalate": 0.4},
310
+ requires_investigation={"verify_kyc"},
311
+ kyc_reveal="KYC expired 12 days ago due to administrative oversight. Re-submission in progress. No fraud indicators.",
312
+ ),
313
+ PayOpsTask(
314
+ task_id="MED-006",
315
+ difficulty="medium",
316
+ description="Real estate advance payment. Large amount. First payment to this receiver but contract exists.",
317
+ transaction_id="TXN-M006",
318
+ amount=120_000.00,
319
+ currency="USD",
320
+ sender="buyer@realestate-client.com",
321
+ receiver="escrow@property-agent.co",
322
+ transaction_type="transfer",
323
+ risk_score=0.56,
324
+ ml_confidence=0.65,
325
+ flags=["high_value", "first_time_payee", "large_first_transfer"],
326
+ velocity_1h=1, velocity_24h=1,
327
+ avg_transaction_amount=5_000.0,
328
+ account_age_days=2_555,
329
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=90,
330
+ previous_violations=0, previous_sars=0, counterparty_risk="unknown",
331
+ correct_action="escalate",
332
+ partial_credit_actions={"flag": 0.4, "hold": 0.5},
333
+ requires_investigation={"request_docs"},
334
+ docs_reveal="Signed purchase agreement found. Escrow agent licensed and registered. Standard conveyancing practice.",
335
+ ),
336
+ ]
337
+
338
+
339
+ # =============================================================================
340
+ # HARD (6 tasks — adversarial, conflicting, edge-cases)
341
+ # =============================================================================
342
+
343
+ HARD_TASKS: List[PayOpsTask] = [
344
+ PayOpsTask(
345
+ task_id="HARD-001",
346
+ difficulty="hard",
347
+ description="ML model poisoning: risk_score=0.18 but manual flags scream fraud. Agent must override the model.",
348
+ transaction_id="TXN-H001",
349
+ amount=47_000.00,
350
+ currency="GBP",
351
+ sender="grace@newaccount.co.uk",
352
+ receiver="payee@solicitor-uk.com",
353
+ transaction_type="transfer",
354
+ risk_score=0.18, # poisoned — deliberately underscored
355
+ ml_confidence=0.31, # low confidence signals model uncertainty
356
+ flags=["new_account_7d", "large_first_transfer", "solicitor_mule_pattern"],
357
+ velocity_1h=1, velocity_24h=1,
358
+ avg_transaction_amount=None,
359
+ account_age_days=7,
360
+ country_risk="low", kyc_status="pending", kyc_expiry_days=None,
361
+ previous_violations=0, previous_sars=0, counterparty_risk="watchlist",
362
+ correct_action="escalate",
363
+ partial_credit_actions={"flag": 0.6, "hold": 0.5, "reject": 0.3},
364
+ requires_investigation={"inspect"},
365
+ inspect_reveal=(
366
+ "Account 7 days old. First outbound transfer. Receiver matches solicitor-impersonation "
367
+ "mule pattern from last month's intelligence bulletin. ML underscored — new clean account."
368
+ ),
369
+ ),
370
+ PayOpsTask(
371
+ task_id="HARD-002",
372
+ difficulty="hard",
373
+ description="APP scam: victim willingly sending money but has been social-engineered. Receiver is a mule.",
374
+ transaction_id="TXN-H002",
375
+ amount=12_500.00,
376
+ currency="GBP",
377
+ sender="henry@victim-bank.co.uk",
378
+ receiver="mule_relay_2287@fastpay.io",
379
+ transaction_type="transfer",
380
+ risk_score=0.61,
381
+ ml_confidence=0.77,
382
+ flags=["app_scam_indicator", "mule_account_pattern", "first_time_payee"],
383
+ velocity_1h=1, velocity_24h=1,
384
+ avg_transaction_amount=300.0,
385
+ account_age_days=3_285,
386
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=180,
387
+ previous_violations=0, previous_sars=0, counterparty_risk="blacklist",
388
+ correct_action="reject",
389
+ partial_credit_actions={"escalate": 0.6, "hold": 0.5, "flag": 0.3},
390
+ requires_investigation={"contact_sender"},
391
+ contact_reveal=(
392
+ "Sender says they were called by someone claiming to be their bank. "
393
+ "Instructed to move savings to a 'safe account'. Classic APP scam confirmed."
394
+ ),
395
+ ),
396
+ PayOpsTask(
397
+ task_id="HARD-003",
398
+ difficulty="hard",
399
+ description="Structuring/smurfing: just-below-CTR-threshold payments from same beneficial owner across accounts.",
400
+ transaction_id="TXN-H003",
401
+ amount=9_450.00, # just below $10k CTR threshold
402
+ currency="USD",
403
+ sender="irene_acct_A@shadow.net",
404
+ receiver="irene_acct_B@shadow.net",
405
+ transaction_type="transfer",
406
+ risk_score=0.71,
407
+ ml_confidence=0.83,
408
+ flags=["structuring_pattern", "same_ubo", "ctr_threshold_avoidance", "high_value"],
409
+ velocity_1h=3, velocity_24h=9,
410
+ avg_transaction_amount=9_200.0,
411
+ account_age_days=120,
412
+ country_risk="high", kyc_status="failed", kyc_expiry_days=None,
413
+ previous_violations=2, previous_sars=1, counterparty_risk="watchlist",
414
+ correct_action="reject",
415
+ partial_credit_actions={"escalate": 0.5, "flag": 0.2},
416
+ requires_investigation={"inspect"},
417
+ regulatory_action=True,
418
+ inspect_reveal=(
419
+ "3 transactions in 24h: $9,450 + $9,200 + $9,100 from related accounts. "
420
+ "Same UBO. KYC failed on inconsistent ID docs. Classic CTR structuring."
421
+ ),
422
+ ),
423
+ PayOpsTask(
424
+ task_id="HARD-004",
425
+ difficulty="hard",
426
+ description="Legitimate FX settlement: huge amount, looks alarming — is a standard correspondent banking transfer.",
427
+ transaction_id="TXN-H004",
428
+ amount=4_200_000.00,
429
+ currency="USD",
430
+ sender="nostro@bank-a-swift.com",
431
+ receiver="vostro@bank-b-swift.com",
432
+ transaction_type="internal",
433
+ risk_score=0.67,
434
+ ml_confidence=0.58, # model uncertain on legitimate large transfers
435
+ flags=["high_value", "cross_border", "fx_settlement"],
436
+ velocity_1h=8, velocity_24h=24,
437
+ avg_transaction_amount=3_900_000.0,
438
+ account_age_days=7_300,
439
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=400,
440
+ previous_violations=0, previous_sars=0, counterparty_risk="clean",
441
+ correct_action="approve",
442
+ partial_credit_actions={"escalate": 0.5, "hold": 0.4, "flag": 0.3},
443
+ requires_investigation={"inspect"},
444
+ inspect_reveal=(
445
+ "Both SWIFT BIC-verified. Part of daily USD/EUR FX settlement cycle. "
446
+ "8 similar settlements this month, all cleared. Nostro/vostro agreement on file."
447
+ ),
448
+ ),
449
+ PayOpsTask(
450
+ task_id="HARD-005",
451
+ difficulty="hard",
452
+ description=(
453
+ "Insider threat: employee of the bank initiating an unauthorised wire "
454
+ "to their personal account, disguised as a vendor payment."
455
+ ),
456
+ transaction_id="TXN-H005",
457
+ amount=22_000.00,
458
+ currency="USD",
459
+ sender="staff_ops@bank-internal.com",
460
+ receiver="jake.smith.personal@gmail.com",
461
+ transaction_type="payment",
462
+ risk_score=0.54,
463
+ ml_confidence=0.61,
464
+ flags=["internal_to_personal", "unusual_beneficiary", "after_hours"],
465
+ velocity_1h=1, velocity_24h=1,
466
+ avg_transaction_amount=75_000.0,
467
+ account_age_days=2_000,
468
+ country_risk="low", kyc_status="verified", kyc_expiry_days=240,
469
+ previous_violations=0, previous_sars=0, counterparty_risk="unknown",
470
+ correct_action="escalate",
471
+ partial_credit_actions={"flag": 0.5, "hold": 0.4, "reject": 0.3},
472
+ requires_investigation={"inspect", "contact_sender"},
473
+ inspect_reveal=(
474
+ "Initiation time: 11:47 PM. No vendor contract matches this beneficiary. "
475
+ "Staff member placed on PIP last week. Receiver email matches staff home address."
476
+ ),
477
+ contact_reveal=(
478
+ "Staff member claims it is a legitimate vendor. Unable to provide contract reference. "
479
+ "Story changes on follow-up. Escalate to HR and Fraud immediately."
480
+ ),
481
+ ),
482
+ PayOpsTask(
483
+ task_id="HARD-006",
484
+ difficulty="hard",
485
+ description=(
486
+ "Ghost account: receiver account was dormant for 5 years and suddenly "
487
+ "received 20 inbound transfers this week. Possible account takeover."
488
+ ),
489
+ transaction_id="TXN-H006",
490
+ amount=3_200.00,
491
+ currency="EUR",
492
+ sender="layla@verified-sender.eu",
493
+ receiver="old_dormant_acct_889@bank.eu",
494
+ transaction_type="transfer",
495
+ risk_score=0.63,
496
+ ml_confidence=0.69,
497
+ flags=["dormant_receiver", "sudden_activity", "high_inbound_velocity"],
498
+ velocity_1h=1, velocity_24h=3,
499
+ avg_transaction_amount=800.0,
500
+ account_age_days=890,
501
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=60,
502
+ previous_violations=0, previous_sars=0, counterparty_risk="unknown",
503
+ correct_action="flag",
504
+ partial_credit_actions={"hold": 0.6, "escalate": 0.5},
505
+ requires_investigation={"inspect"},
506
+ inspect_reveal=(
507
+ "Receiver dormant 5 years. 20 inbound transfers this week totalling €64k. "
508
+ "All immediately forwarded offshore. Classic money-mule account reactivation."
509
+ ),
510
+ ),
511
+ ]
512
+
513
+
514
+ # =============================================================================
515
+ # CRITICAL (4 tasks — multi-step chains, regulatory stakes)
516
+ # =============================================================================
517
+
518
+ CRITICAL_TASKS: List[PayOpsTask] = [
519
+ PayOpsTask(
520
+ task_id="CRIT-001",
521
+ difficulty="critical",
522
+ description=(
523
+ "Multi-step investigation: large wire to a new counterparty. "
524
+ "Agent must inspect logs, then request supporting documents, "
525
+ "then make a final decision. Chain of 3 steps."
526
+ ),
527
+ transaction_id="TXN-C001",
528
+ amount=375_000.00,
529
+ currency="USD",
530
+ sender="deal-team@pe-firm.com",
531
+ receiver="newco@acquisition-target.io",
532
+ transaction_type="transfer",
533
+ risk_score=0.59,
534
+ ml_confidence=0.55,
535
+ flags=["high_value", "first_time_payee", "cross_border", "new_counterparty"],
536
+ velocity_1h=1, velocity_24h=2,
537
+ avg_transaction_amount=50_000.0,
538
+ account_age_days=3_000,
539
+ country_risk="medium", kyc_status="verified", kyc_expiry_days=120,
540
+ previous_violations=0, previous_sars=0, counterparty_risk="unknown",
541
+ correct_action="approve",
542
+ partial_credit_actions={"escalate": 0.5, "hold": 0.4, "flag": 0.3},
543
+ requires_investigation={"inspect", "request_docs"},
544
+ chain_total=3,
545
+ inspect_reveal="PE firm confirmed. Series B investment round. Deal announced in press last week.",
546
+ docs_reveal="Signed SPA (Share Purchase Agreement) on file. Notarised. Receiver is the acquisition target.",
547
+ ),
548
+ PayOpsTask(
549
+ task_id="CRIT-002",
550
+ difficulty="critical",
551
+ description=(
552
+ "Fraud ring: three related accounts sending coordinated small payments "
553
+ "that aggregate above the reporting threshold. Requires SAR filing."
554
+ ),
555
+ transaction_id="TXN-C002",
556
+ amount=4_900.00,
557
+ currency="USD",
558
+ sender="ring_node_1@tempmail.com",
559
+ receiver="collector_hub@offshore.net",
560
+ transaction_type="transfer",
561
+ risk_score=0.76,
562
+ ml_confidence=0.80,
563
+ flags=["fraud_ring_indicator", "coordinated_transfers", "threshold_avoidance", "high_value"],
564
+ velocity_1h=5, velocity_24h=18,
565
+ avg_transaction_amount=4_800.0,
566
+ account_age_days=45,
567
+ country_risk="high", kyc_status="failed", kyc_expiry_days=None,
568
+ previous_violations=1, previous_sars=0, counterparty_risk="blacklist",
569
+ correct_action="reject",
570
+ partial_credit_actions={"escalate": 0.4, "flag": 0.2},
571
+ requires_investigation={"inspect"},
572
+ regulatory_action=True,
573
+ chain_total=2,
574
+ inspect_reveal=(
575
+ "3 accounts (ring_node_1/2/3) sending $4,900 / $4,850 / $4,750 simultaneously. "
576
+ "All created same day. Receiver account drained offshore within minutes. "
577
+ "SAR filing required under BSA §5318(g)."
578
+ ),
579
+ ),
580
+ PayOpsTask(
581
+ task_id="CRIT-003",
582
+ difficulty="critical",
583
+ description=(
584
+ "Trade-based money laundering: over- and under-invoiced international trade payments. "
585
+ "Documents don't match transfer amounts. Regulatory escalation required."
586
+ ),
587
+ transaction_id="TXN-C003",
588
+ amount=680_000.00,
589
+ currency="USD",
590
+ sender="importer@trade-co.hk",
591
+ receiver="exporter@goods-supplier.cn",
592
+ transaction_type="payment",
593
+ risk_score=0.72,
594
+ ml_confidence=0.67,
595
+ flags=["trade_finance", "invoice_mismatch", "cross_border", "high_value"],
596
+ velocity_1h=1, velocity_24h=3,
597
+ avg_transaction_amount=120_000.0,
598
+ account_age_days=730,
599
+ country_risk="high", kyc_status="verified", kyc_expiry_days=30,
600
+ previous_violations=1, previous_sars=0, counterparty_risk="watchlist",
601
+ correct_action="escalate",
602
+ partial_credit_actions={"flag": 0.4, "hold": 0.35, "reject": 0.3},
603
+ requires_investigation={"request_docs", "inspect"},
604
+ regulatory_action=True,
605
+ chain_total=3,
606
+ docs_reveal="Invoice declares 500 units @ $1,360 each = $680k. Market price is $320/unit. 4× over-invoiced.",
607
+ inspect_reveal="Shipping records show only 200 units dispatched. Payment/goods ratio 3.4× above market norm.",
608
+ ),
609
+ PayOpsTask(
610
+ task_id="CRIT-004",
611
+ difficulty="critical",
612
+ description=(
613
+ "Compromised corporate account: valid credentials but geo-impossible login. "
614
+ "Someone in Nigeria logged into a US-only account 8 minutes after the CEO logged out in NY."
615
+ ),
616
+ transaction_id="TXN-C004",
617
+ amount=198_000.00,
618
+ currency="USD",
619
+ sender="ceo@target-corp.com",
620
+ receiver="urgent-wire@third-party-finance.com",
621
+ transaction_type="transfer",
622
+ risk_score=0.81,
623
+ ml_confidence=0.85,
624
+ flags=["geo_impossible_login", "account_takeover_indicator", "high_value", "urgency_flag"],
625
+ velocity_1h=1, velocity_24h=1,
626
+ avg_transaction_amount=15_000.0,
627
+ account_age_days=4_380,
628
+ country_risk="high", kyc_status="verified", kyc_expiry_days=365,
629
+ previous_violations=0, previous_sars=0, counterparty_risk="unknown",
630
+ correct_action="reject",
631
+ partial_credit_actions={"escalate": 0.5, "hold": 0.4},
632
+ requires_investigation={"inspect", "contact_sender"},
633
+ chain_total=2,
634
+ inspect_reveal=(
635
+ "Last login: NY (US) at 14:23. This session: Lagos (NG) at 14:31. "
636
+ "Physical travel impossible in 8 minutes. Likely credential compromise."
637
+ ),
638
+ contact_reveal="CEO confirms they did NOT initiate this transfer. Account takeover confirmed.",
639
+ ),
640
+ ]
641
+
642
+
643
+ # =============================================================================
644
+ # Combine all tasks
645
+ # =============================================================================
646
+
647
+ TASKS: List[PayOpsTask] = EASY_TASKS + MEDIUM_TASKS + HARD_TASKS + CRITICAL_TASKS
648
+
649
+ TASKS_BY_ID: Dict[str, PayOpsTask] = {t.task_id: t for t in TASKS}
650
+
651
+ # ---------------------------------------------------------------------------
652
+ # Action cost table (investigation sub-actions consume budget)
653
+ # ---------------------------------------------------------------------------
654
+
655
+ ACTION_COSTS: Dict[str, float] = {
656
+ "approve": 0.0,
657
+ "reject": 0.0,
658
+ "flag": 0.0,
659
+ "escalate": 0.0,
660
+ "hold": 0.0,
661
+ "inspect": 0.10,
662
+ "request_docs": 0.20,
663
+ "verify_kyc": 0.20,
664
+ "contact_sender": 0.30,
665
+ "file_sar": 0.05, # intentionally cheap — incentivise regulatory compliance
666
+ }
667
+
668
+