zephO-O commited on
Commit
45883ee
Β·
verified Β·
1 Parent(s): e7042a3

Update grader.py

Browse files
Files changed (1) hide show
  1. grader.py +397 -403
grader.py CHANGED
@@ -1,403 +1,397 @@
1
- """
2
- grader.py – PhishGuard-Env SOC Triage Scoring Logic
3
- ====================================================
4
-
5
- REWARD CONTRACT β†’ OPEN INTERVAL (0.0, 1.0)
6
- ---------------------------------------------
7
- All rewards are STRICTLY greater than 0 and STRICTLY less than 1.
8
- The endpoints 0 and 1 are NEVER returned. This is a hard invariant
9
- enforced by the constant table below and by calculate_overall_score().
10
-
11
- Why open-interval?
12
- β€’ 1.0 saturates the leaderboard and implies a theoretically perfect agent.
13
- β€’ 0.0 is indistinguishable from a missing data-point in an RL pipeline.
14
- β€’ Every decision carries a non-zero gradient signal so training never dies.
15
-
16
- REWARD TABLE
17
- ────────────────────────────────────────────────────────────────────────────
18
- Constant Value Outcome / Rationale
19
- ─────────────────────────────────────────────────────────────────────────
20
- R_PERFECT 0.95 Exact match β€” near-ideal; headroom for 1.0
21
- R_MALWARE_QUARANTINE 0.75 MALWARE β†’ QUARANTINE (textbook isolation)
22
- R_PHISH_BEC_QUARANTINE 0.60 PHISH/BEC β†’ QUARANTINE (domain still live)
23
- R_SPAM_BLOCK 0.40 SPAM β†’ BLOCK_DOMAIN (over-escalation)
24
- R_SPAM_QUARANTINE 0.35 SPAM β†’ QUARANTINE (lighter over-escalation)
25
- R_WRONG_PROCEDURE 0.10 Wrong action, no direct security/ops harm
26
- R_DISRUPTION 0.05 SAFE email blocked β€” operational cost
27
- R_BREACH 0.02 Threat allowed into inbox β€” catastrophic
28
-
29
- HEALTH-DRAIN THRESHOLD
30
- ────────────────────────────────────────────────────────────────────────────
31
- HEALTH_DRAIN_THRESHOLD = 0.15
32
- reward < 0.15 β†’ agent loses one life.
33
-
34
- PASS_THRESHOLD
35
- ────────────────────────────────────────────────────────────────────────────
36
- PASS_THRESHOLD = 0.50
37
-
38
- LEVEL CONTEXT (from env.py)
39
- ────────────────────────────────────────────────────────────────────────────
40
- easy β†’ lv1 (SPAM), lv2 (PHISH), lv3 (SAFE)
41
- medium β†’ lv4 (MALWARE), lv5 (SAFE), lv6 (BEC), lv7 (PHISH)
42
- hard β†’ lv8 (MALWARE), lv9 (PHISH), lv10 (BEC)
43
-
44
- VALID AGENT ACTIONS
45
- ────────────────────────────────────────────────────────────────────────────
46
- MARK_SAFE – deliver to inbox
47
- MOVE_TO_SPAM – bulk / unsolicited mail
48
- QUARANTINE – hold for analyst review
49
- BLOCK_DOMAIN – perimeter block
50
-
51
- BUG FIX (v1.0.2 β†’ v1.0.3)
52
- ────────────────────────────────────────────────────────────────────────────
53
- SPAM added to _THREAT_TYPES so MARK_SAFE on any threat drains health.
54
- """
55
-
56
- from __future__ import annotations
57
-
58
- import logging
59
- from typing import Tuple
60
-
61
- logger = logging.getLogger(__name__)
62
-
63
- __all__ = [
64
- "R_PERFECT",
65
- "R_MALWARE_QUARANTINE",
66
- "R_PHISH_BEC_QUARANTINE",
67
- "R_SPAM_BLOCK",
68
- "R_SPAM_QUARANTINE",
69
- "R_WRONG_PROCEDURE",
70
- "R_DISRUPTION",
71
- "R_BREACH",
72
- "R_PARTIAL",
73
- "PASS_THRESHOLD",
74
- "HEALTH_DRAIN_THRESHOLD",
75
- "grade_action",
76
- "grade_easy",
77
- "grade_medium",
78
- "grade_hard",
79
- "grade_performance",
80
- "calculate_overall_score",
81
- "GRADERS",
82
- "SCENARIO_LOADERS",
83
- ]
84
-
85
-
86
- # ══════════════════════════════════════════════════════════════════════════════
87
- # REWARD CONSTANTS
88
- # ══════════════════════════════════════════════════════════════════════════════
89
-
90
- R_PERFECT = 0.95
91
- R_MALWARE_QUARANTINE = 0.75
92
- R_PHISH_BEC_QUARANTINE = 0.60
93
- R_SPAM_BLOCK = 0.40
94
- R_SPAM_QUARANTINE = 0.35
95
- R_WRONG_PROCEDURE = 0.10
96
- R_DISRUPTION = 0.05
97
- R_BREACH = 0.02
98
-
99
- R_PARTIAL = R_MALWARE_QUARANTINE
100
- PASS_THRESHOLD = 0.50
101
- HEALTH_DRAIN_THRESHOLD = 0.15
102
-
103
- _THREAT_TYPES = frozenset({"PHISH", "BEC", "MALWARE", "SPAM"})
104
- _BLOCKED_MOVES = frozenset({"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"})
105
- _VALID_ACTIONS = frozenset({"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"})
106
-
107
-
108
- # ══════════════════════════════════════════════════════════════════════════════
109
- # GRADE_ACTION
110
- # ══════════════════════════════════════════════════════════════════════════════
111
-
112
- def grade_action(
113
- agent_output: str,
114
- expected_output: str,
115
- email_type: str,
116
- ) -> Tuple[float, str]:
117
- """
118
- Grade one SOC triage decision and return a reward in (0.0, 1.0).
119
-
120
- Parameters
121
- ----------
122
- agent_output : Raw action string from the LLM agent (case-insensitive).
123
- expected_output : Ground-truth correct action for this scenario.
124
- email_type : Threat category β€” PHISH | BEC | MALWARE | SPAM | SAFE.
125
-
126
- Returns
127
- -------
128
- (reward, message)
129
- reward : float strictly in (0.0, 1.0)
130
- message : short verdict string for logs and agent feedback.
131
- """
132
- agent_action = agent_output.strip().upper()
133
- expected_action = expected_output.strip().upper()
134
- etype = email_type.strip().upper()
135
-
136
- # Step 1: Reject unrecognised tokens
137
- if agent_action not in _VALID_ACTIONS:
138
- return (
139
- R_WRONG_PROCEDURE,
140
- f"INVALID_ACTION: '{agent_action}' is not a recognised triage action β€” "
141
- f"must be one of: {', '.join(sorted(_VALID_ACTIONS))}",
142
- )
143
-
144
- # Step 2: Perfect match
145
- if agent_action == expected_action:
146
- return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
147
-
148
- # Step 3: Security Breach
149
- if etype in _THREAT_TYPES and agent_action == "MARK_SAFE":
150
- return (
151
- R_BREACH,
152
- f"SECURITY_BREACH: {etype} threat delivered to inbox unimpeded β€” "
153
- "catastrophic failure; source remains active",
154
- )
155
-
156
- # Step 4: Business Disruption
157
- if etype == "SAFE" and agent_action in _BLOCKED_MOVES:
158
- return (
159
- R_DISRUPTION,
160
- "BUSINESS_DISRUPTION: Legitimate communication was incorrectly blocked β€” "
161
- "operational impact; sender trust degraded",
162
- )
163
-
164
- # Step 5: Partial credit
165
-
166
- # MALWARE β†’ QUARANTINE
167
- if etype == "MALWARE" and agent_action == "QUARANTINE":
168
- return (
169
- R_MALWARE_QUARANTINE,
170
- "CAUTIOUS: Malware isolated via QUARANTINE β€” strong containment; "
171
- "no further propagation risk detected",
172
- )
173
-
174
- # PHISH/BEC β†’ QUARANTINE
175
- if etype in {"PHISH", "BEC"} and agent_action == "QUARANTINE":
176
- return (
177
- R_PHISH_BEC_QUARANTINE,
178
- f"UNDER_RESPONSE: {etype} quarantined but source domain still active β€” "
179
- "prefer BLOCK_DOMAIN to prevent further phishing delivery attempts",
180
- )
181
-
182
- # SPAM β†’ BLOCK_DOMAIN
183
- if etype == "SPAM" and agent_action == "BLOCK_DOMAIN":
184
- return (
185
- R_SPAM_BLOCK,
186
- "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM β€” "
187
- "prefer MOVE_TO_SPAM to preserve block-list resources",
188
- )
189
-
190
- # SPAM β†’ QUARANTINE
191
- if etype == "SPAM" and agent_action == "QUARANTINE":
192
- return (
193
- R_SPAM_QUARANTINE,
194
- "OVER_ESCALATION: QUARANTINE wastes analyst capacity on SPAM β€” "
195
- "prefer MOVE_TO_SPAM for bulk unsolicited mail",
196
- )
197
-
198
- # Step 6: Catch-all wrong procedure
199
- return (
200
- R_WRONG_PROCEDURE,
201
- f"INCORRECT_PROCEDURE: '{agent_action}' does not match security policy "
202
- f"for {etype} email (expected: {expected_action}) β€” "
203
- "review triage guidelines",
204
- )
205
-
206
-
207
- # ══════════════════════════════════════════════════════════════════════════════
208
- # CALCULATE_OVERALL_SCORE
209
- # ══════════════════════════════════════════════════════════════════════════════
210
-
211
- def calculate_overall_score(task_scores: list) -> float:
212
- """
213
- Compute the final benchmark score from a list of per-step rewards.
214
- Result is clamped to [R_BREACH, R_PERFECT].
215
- Empty list returns R_BREACH.
216
- """
217
- if not task_scores:
218
- return R_BREACH
219
-
220
- raw_avg = sum(task_scores) / len(task_scores)
221
- clamped = max(R_BREACH, min(R_PERFECT, raw_avg))
222
- return round(clamped, 4)
223
-
224
-
225
- # ══════════════════════════════════════════════════════════════════════════════
226
- # SCORE SAFETY HELPERS
227
- # ══════════════════════════════════════════════════════════════════════════════
228
-
229
- def _safe_score(raw: float) -> float:
230
- """
231
- Map any float to the open interval (R_BREACH, R_PERFECT).
232
-
233
- Mirrors Focus-AI's safe_score() pattern:
234
- safe_score(raw) = LOWER + (UPPER - LOWER) * clamp(raw, 0, 1)
235
- where LOWER = R_BREACH (0.02), UPPER = R_PERFECT (0.95).
236
-
237
- Guarantees:
238
- raw = 0.0 -> 0.02 (> 0, never equals 0)
239
- raw = 1.0 -> 0.95 (< 1, never equals 1)
240
- """
241
- raw = float(raw)
242
- if raw < 0.0:
243
- raw = 0.0
244
- elif raw > 1.0:
245
- raw = 1.0
246
- result = R_BREACH + (R_PERFECT - R_BREACH) * raw
247
- result = round(result, 6)
248
- assert 0.0 < result < 1.0, (
249
- f"_safe_score VIOLATION: raw={raw!r} produced result={result!r} "
250
- f"which is not strictly inside (0, 1)"
251
- )
252
- return result
253
-
254
-
255
- def _safe_ratio(num: float, den: float) -> float:
256
- """Safe division clamped to [0, 1]."""
257
- if den <= 0:
258
- return 0.0
259
- return max(0.0, min(1.0, num / den))
260
-
261
-
262
- # ══════════════════════════════════════════════════════════════════════════════
263
- # OPENENV GRADERS
264
- # Called by the OpenEnv validator β€” one function per difficulty level.
265
- # Signature: grade_X(metrics: dict) -> float strictly in (0, 1)
266
- # ══════════════════════════════════════════════════════════════════════════════
267
-
268
- def grade_easy(metrics: dict) -> float:
269
- """
270
- Grader for easy tasks (lv1-lv3): SPAM, PHISH, SAFE.
271
- Scoring: 60% correct action + 40% threat identification accuracy.
272
- """
273
- total = max(1, metrics.get("total_tasks", metrics.get("total", 3)))
274
- correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
275
- on_time = metrics.get("on_time", metrics.get("completed", correct))
276
-
277
- if isinstance(correct, bool):
278
- correct = int(correct)
279
- if isinstance(on_time, bool):
280
- on_time = int(on_time)
281
-
282
- raw = (
283
- 0.60 * _safe_ratio(correct, total)
284
- + 0.40 * _safe_ratio(on_time, total)
285
- )
286
- return _safe_score(raw)
287
-
288
-
289
- def grade_medium(metrics: dict) -> float:
290
- """
291
- Grader for medium tasks (lv4-lv7): MALWARE, SAFE HR, BEC, PHISH.
292
- Scoring: 40% correct + 35% on-time detection + 25% escalation quality.
293
- """
294
- total = max(1, metrics.get("total_tasks", metrics.get("total", 4)))
295
- correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
296
- on_time = metrics.get("on_time", metrics.get("completed", correct))
297
- steps = max(1, metrics.get("total_steps", metrics.get("steps", 4)))
298
- good_esc = metrics.get("good_escalation", metrics.get("reward", correct))
299
-
300
- if isinstance(correct, bool): correct = int(correct)
301
- if isinstance(on_time, bool): on_time = int(on_time)
302
- if isinstance(good_esc, bool): good_esc = int(good_esc)
303
-
304
- raw = (
305
- 0.40 * _safe_ratio(correct, total)
306
- + 0.35 * _safe_ratio(on_time, total)
307
- + 0.25 * _safe_ratio(good_esc, steps)
308
- )
309
- return _safe_score(raw)
310
-
311
-
312
- def grade_hard(metrics: dict) -> float:
313
- """
314
- Grader for hard tasks (lv8-lv10): MALWARE macro, QR phishing, BEC domain.
315
- Scoring: 35% correct + 30% threat accuracy + 20% escalation + 15% priority.
316
- """
317
- total = max(1, metrics.get("total_tasks", metrics.get("total", 3)))
318
- correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
319
- on_time = metrics.get("on_time", metrics.get("completed", correct))
320
- steps = max(1, metrics.get("total_steps", metrics.get("steps", 3)))
321
- good_esc = metrics.get("good_escalation", metrics.get("reward", correct))
322
- hi_pri = metrics.get("high_priority_correct", correct)
323
-
324
- if isinstance(correct, bool): correct = int(correct)
325
- if isinstance(on_time, bool): on_time = int(on_time)
326
- if isinstance(good_esc, bool): good_esc = int(good_esc)
327
- if isinstance(hi_pri, bool): hi_pri = int(hi_pri)
328
-
329
- raw = (
330
- 0.35 * _safe_ratio(correct, total)
331
- + 0.30 * _safe_ratio(on_time, total)
332
- + 0.20 * _safe_ratio(good_esc, steps)
333
- + 0.15 * _safe_ratio(hi_pri, max(1, correct))
334
- )
335
- return _safe_score(raw)
336
-
337
-
338
- def grade_performance(metrics: dict) -> float:
339
- """
340
- Aggregate grader used for cross-difficulty scoring.
341
-
342
- Mirrors Focus-AI's grade_performance() pattern β€” provides a single
343
- unified score across all difficulty levels for leaderboard ranking.
344
-
345
- Scoring: 40% correct actions + 30% on-time + 20% escalation + 10% priority.
346
- """
347
- total = max(1, metrics.get("total_tasks", metrics.get("total", 1)))
348
- correct = metrics.get("correct_actions", metrics.get("is_correct", 0))
349
- on_time = metrics.get("on_time", metrics.get("completed", correct))
350
- steps = max(1, metrics.get("total_steps", metrics.get("steps", 1)))
351
- good_esc = metrics.get("good_escalation", metrics.get("reward", correct))
352
-
353
- if isinstance(correct, bool): correct = int(correct)
354
- if isinstance(on_time, bool): on_time = int(on_time)
355
- if isinstance(good_esc, bool): good_esc = int(good_esc)
356
-
357
- raw = (
358
- 0.40 * _safe_ratio(correct, total)
359
- + 0.30 * _safe_ratio(on_time, total)
360
- + 0.20 * _safe_ratio(good_esc, steps)
361
- + 0.10 * _safe_ratio(correct, steps)
362
- )
363
- return _safe_score(raw)
364
-
365
-
366
- # ══════════════════════════════════════════════════════════════════════════════
367
- # GRADERS DICT (mirrors Focus-AI's GRADERS pattern)
368
- # Maps difficulty level β†’ grader function for easy programmatic lookup.
369
- # ══════════════════════════════════════════════════════════════════════════════
370
-
371
- GRADERS = {
372
- "easy": grade_easy,
373
- "medium": grade_medium,
374
- "hard": grade_hard,
375
- }
376
-
377
-
378
- # ══════════════════════════════════════════════════════════════════════════════
379
- # SCENARIO LOADERS (mirrors Focus-AI's TASK_LOADERS pattern)
380
- # Maps difficulty level β†’ callable that returns the scenario list for that level.
381
- # Used by env.py to load scenarios without hard-coding level names.
382
- # ══════════════════════════════════════════════════════════════════════════════
383
-
384
- def _get_easy_scenarios() -> list:
385
- """Return scenario IDs for easy difficulty."""
386
- return ["lv1", "lv2", "lv3"]
387
-
388
-
389
- def _get_medium_scenarios() -> list:
390
- """Return scenario IDs for medium difficulty."""
391
- return ["lv4", "lv5", "lv6", "lv7"]
392
-
393
-
394
- def _get_hard_scenarios() -> list:
395
- """Return scenario IDs for hard difficulty."""
396
- return ["lv8", "lv9", "lv10"]
397
-
398
-
399
- SCENARIO_LOADERS = {
400
- "easy": _get_easy_scenarios,
401
- "medium": _get_medium_scenarios,
402
- "hard": _get_hard_scenarios,
403
- }
 
1
+ """
2
+ grader.py – PhishGuard-Env SOC Triage Scoring Logic
3
+ ====================================================
4
+
5
+ SCORE CONTRACT (HIGHEST PRIORITY β€” mirrors FocusAI reward_and_tasks.py)
6
+ -------------------------------------------------------------------------
7
+ Every public grader returns a float STRICTLY inside the open interval (0, 1).
8
+
9
+ safe_score(raw) = LOWER + (UPPER - LOWER) * clamp(raw, 0, 1)
10
+
11
+ where LOWER = 0.01, UPPER = 0.99
12
+
13
+ This guarantees:
14
+ raw = 0.0 β†’ 0.01 (> 0, never equals 0)
15
+ raw = 1.0 β†’ 0.99 (< 1, never equals 1)
16
+ raw = 0.5 β†’ 0.50
17
+
18
+ VALIDATOR COMPLIANCE β€” "not enough tasks with graders"
19
+ -------------------------------------------------------
20
+ The OpenEnv validator requires at least 3 task IDs that each have a
21
+ registered grader function. This is satisfied by the GRADERS dict:
22
+
23
+ GRADERS["easy"] = grade_easy
24
+ GRADERS["medium"] = grade_medium
25
+ GRADERS["hard"] = grade_hard
26
+
27
+ TASK_GRADERS additionally maps every lv1–lv10 scenario ID to its level
28
+ grader for per-task lookups from env.py.
29
+
30
+ PER-STEP REWARD TABLE (grade_action β€” used by /step endpoint)
31
+ ──────────────────────────────────────────────────────────────────────────
32
+ Constant Value Outcome
33
+ ─────────────────────────────────────────────────────────────────────
34
+ R_PERFECT 0.95 Exact triage match
35
+ R_MALWARE_QUARANTINE 0.75 MALWARE β†’ QUARANTINE (good containment)
36
+ R_PHISH_BEC_QUARANTINE 0.60 PHISH/BEC β†’ QUARANTINE (domain still live)
37
+ R_SPAM_BLOCK 0.40 SPAM β†’ BLOCK_DOMAIN (over-escalation)
38
+ R_SPAM_QUARANTINE 0.35 SPAM β†’ QUARANTINE (lighter over-escalation)
39
+ R_WRONG_PROCEDURE 0.10 Wrong; no direct breach or disruption
40
+ R_DISRUPTION 0.05 SAFE email blocked β€” operational cost
41
+ R_BREACH 0.02 Threat allowed into inbox β€” catastrophic
42
+
43
+ HEALTH-DRAIN THRESHOLD
44
+ ──────────────────────────────────────────────────────────────────────────
45
+ reward < 0.15 β†’ agent loses one life.
46
+ Cautious / partial-credit scores (β‰₯ 0.35) NEVER drain health.
47
+
48
+ LEVEL β†’ TASK MAPPING
49
+ ──────────────────────────────────────────────────────────────────────────
50
+ easy β†’ lv1 (SPAM), lv2 (PHISH), lv3 (SAFE)
51
+ medium β†’ lv4 (MALWARE), lv5 (SAFE), lv6 (BEC), lv7 (PHISH)
52
+ hard β†’ lv8 (MALWARE), lv9 (PHISH), lv10 (BEC)
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from typing import Callable, Dict, Tuple
58
+
59
+
60
+ # ══════════════════════════════════════════════════════════════════════════════
61
+ # SCORE SAFETY (mirrors FocusAI safe_score exactly)
62
+ # ══════════════════════════════════════════════════════════════════════════════
63
+
64
+ _SCORE_LOWER = 0.01
65
+ _SCORE_UPPER = 0.99
66
+
67
+
68
+ def safe_score(raw: float) -> float:
69
+ """
70
+ Map any raw float to the open interval (0.01, 0.99).
71
+
72
+ Never returns 0 or 1 β€” satisfies the open-interval contract required
73
+ by the OpenEnv validator and the RL pipeline.
74
+
75
+ safe_score(0.0) = 0.01
76
+ safe_score(1.0) = 0.99
77
+ safe_score(0.5) = 0.50
78
+ """
79
+ raw = float(raw)
80
+ raw = max(0.0, min(1.0, raw))
81
+ result = _SCORE_LOWER + (_SCORE_UPPER - _SCORE_LOWER) * raw
82
+ result = round(result, 6)
83
+ assert 0.0 < result < 1.0, (
84
+ f"safe_score VIOLATION: raw={raw!r} produced result={result!r} "
85
+ f"which is not strictly inside (0, 1)"
86
+ )
87
+ return result
88
+
89
+
90
+ # ══════════════════════════════════════════════════════════════════════════════
91
+ # PER-STEP REWARD CONSTANTS
92
+ # ══════════════════════════════════════════════════════════════════════════════
93
+
94
+ R_PERFECT = 0.95
95
+ R_MALWARE_QUARANTINE = 0.75
96
+ R_PHISH_BEC_QUARANTINE = 0.60
97
+ R_SPAM_BLOCK = 0.40
98
+ R_SPAM_QUARANTINE = 0.35
99
+ R_WRONG_PROCEDURE = 0.10
100
+ R_DISRUPTION = 0.05
101
+ R_BREACH = 0.02
102
+
103
+ # Convenience alias (mid-range cautious signal)
104
+ R_PARTIAL = R_MALWARE_QUARANTINE
105
+
106
+ # Minimum weighted average for a run to be considered passing
107
+ PASS_THRESHOLD = 0.50
108
+
109
+ # env.py: `reward < HEALTH_DRAIN_THRESHOLD` β†’ lose one life
110
+ HEALTH_DRAIN_THRESHOLD = 0.15
111
+
112
+ # Internal lookup sets
113
+ _THREAT_TYPES = frozenset({"PHISH", "BEC", "MALWARE", "SPAM"})
114
+ _BLOCKED_MOVES = frozenset({"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"})
115
+ _VALID_ACTIONS = frozenset({"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"})
116
+
117
+
118
+ # ══════════════════════════════════════════════════════════════════════════════
119
+ # GRADE_ACTION (per-step reward, called on every /step)
120
+ # ══════════════════════════════════════════════════════════════════════════════
121
+
122
+ def grade_action(
123
+ agent_output: str,
124
+ expected_output: str,
125
+ email_type: str,
126
+ ) -> Tuple[float, str]:
127
+ """
128
+ Grade one SOC triage decision and return (reward, verdict_message).
129
+
130
+ reward is a raw float in (0.02, 0.95).
131
+ Callers may wrap with safe_score() for the hard (0.01, 0.99) contract.
132
+
133
+ Decision tree
134
+ -------------
135
+ 1. Unrecognised action β†’ R_WRONG_PROCEDURE
136
+ 2. action == correct β†’ R_PERFECT
137
+ 3. Any threat + MARK_SAFE β†’ R_BREACH
138
+ 4. SAFE + blocking action β†’ R_DISRUPTION
139
+ 5. MALWARE β†’ QUARANTINE β†’ R_MALWARE_QUARANTINE
140
+ 6. PHISH/BEC β†’ QUARANTINE β†’ R_PHISH_BEC_QUARANTINE
141
+ 7. SPAM β†’ BLOCK_DOMAIN β†’ R_SPAM_BLOCK
142
+ 8. SPAM β†’ QUARANTINE β†’ R_SPAM_QUARANTINE
143
+ 9. catch-all β†’ R_WRONG_PROCEDURE
144
+ """
145
+ agent_action = agent_output.strip().upper()
146
+ expected_action = expected_output.strip().upper()
147
+ etype = email_type.strip().upper()
148
+
149
+ if agent_action not in _VALID_ACTIONS:
150
+ return (
151
+ R_WRONG_PROCEDURE,
152
+ f"INVALID_ACTION: '{agent_action}' is not a recognised triage action β€” "
153
+ f"must be one of: {', '.join(sorted(_VALID_ACTIONS))}",
154
+ )
155
+
156
+ if agent_action == expected_action:
157
+ return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
158
+
159
+ if etype in _THREAT_TYPES and agent_action == "MARK_SAFE":
160
+ return (
161
+ R_BREACH,
162
+ f"SECURITY_BREACH: {etype} threat delivered to inbox unimpeded",
163
+ )
164
+
165
+ if etype == "SAFE" and agent_action in _BLOCKED_MOVES:
166
+ return (
167
+ R_DISRUPTION,
168
+ "BUSINESS_DISRUPTION: Legitimate communication was incorrectly blocked",
169
+ )
170
+
171
+ if etype == "MALWARE" and agent_action == "QUARANTINE":
172
+ return (
173
+ R_MALWARE_QUARANTINE,
174
+ "CAUTIOUS: Malware isolated via QUARANTINE β€” strong containment",
175
+ )
176
+
177
+ if etype in {"PHISH", "BEC"} and agent_action == "QUARANTINE":
178
+ return (
179
+ R_PHISH_BEC_QUARANTINE,
180
+ f"UNDER_RESPONSE: {etype} quarantined but source domain still active",
181
+ )
182
+
183
+ if etype == "SPAM" and agent_action == "BLOCK_DOMAIN":
184
+ return (
185
+ R_SPAM_BLOCK,
186
+ "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM",
187
+ )
188
+
189
+ if etype == "SPAM" and agent_action == "QUARANTINE":
190
+ return (
191
+ R_SPAM_QUARANTINE,
192
+ "OVER_ESCALATION: QUARANTINE wastes analyst capacity on SPAM",
193
+ )
194
+
195
+ return (
196
+ R_WRONG_PROCEDURE,
197
+ f"INCORRECT_PROCEDURE: '{agent_action}' does not match policy "
198
+ f"for {etype} (expected: {expected_action})",
199
+ )
200
+
201
+
202
+ # ══════════════════════════════════════════════════════════════════════════════
203
+ # EPISODE GRADERS (end-of-episode β€” required by OpenEnv validator)
204
+ #
205
+ # Each grader accepts a `metrics` dict built by env.py and returns safe_score.
206
+ #
207
+ # metrics keys
208
+ # ────────────
209
+ # total_tasks : int β€” scenarios in this episode
210
+ # completed_tasks : int β€” steps where any action was graded
211
+ # perfect_tasks : int β€” steps where reward >= R_PERFECT
212
+ # on_time : int β€” steps completed without health drain
213
+ # breach_count : int β€” SECURITY_BREACH outcomes (threat + MARK_SAFE)
214
+ # disruption_count : int β€” BUSINESS_DISRUPTION outcomes (SAFE + blocked)
215
+ # total_steps : int β€” total /step calls
216
+ # ══════════════════════════════════════════════════════════════════════════════
217
+
218
+ def _safe_ratio(numerator: float, denominator: float) -> float:
219
+ """Return numerator/denominator clamped to [0, 1]. 0 if denominator ≀ 0."""
220
+ if denominator <= 0:
221
+ return 0.0
222
+ return max(0.0, min(1.0, numerator / denominator))
223
+
224
+
225
+ def grade_easy(metrics: dict) -> float:
226
+ """
227
+ Easy episode grader (lv1–lv3: SPAM, PHISH, SAFE).
228
+
229
+ Weights
230
+ -------
231
+ 60 % β€” perfect triage rate (exact action matches / total tasks)
232
+ 40 % β€” completion rate (any graded step / total tasks)
233
+
234
+ Penalty: βˆ’0.15 Γ— breach_rate (THREAT + MARK_SAFE outcomes)
235
+ """
236
+ total = max(1, metrics.get("total_tasks", 1))
237
+ perfect = metrics.get("perfect_tasks", 0)
238
+ completed = metrics.get("completed_tasks", 0)
239
+ breaches = metrics.get("breach_count", 0)
240
+
241
+ raw = (
242
+ 0.60 * _safe_ratio(perfect, total)
243
+ + 0.40 * _safe_ratio(completed, total)
244
+ - 0.15 * min(1.0, breaches / max(1, total))
245
+ )
246
+ return safe_score(max(0.0, raw))
247
+
248
+
249
+ def grade_medium(metrics: dict) -> float:
250
+ """
251
+ Medium episode grader (lv4–lv7: MALWARE, SAFE, BEC, PHISH).
252
+
253
+ Weights
254
+ -------
255
+ 45 % β€” perfect triage rate
256
+ 35 % β€” on-time rate (health not drained by step)
257
+ 20 % β€” completion rate
258
+
259
+ Penalty: βˆ’0.10 Γ— breach_rate, βˆ’0.05 Γ— disruption_rate
260
+ """
261
+ total = max(1, metrics.get("total_tasks", 1))
262
+ perfect = metrics.get("perfect_tasks", 0)
263
+ on_time = metrics.get("on_time", 0)
264
+ completed = metrics.get("completed_tasks", 0)
265
+ breaches = metrics.get("breach_count", 0)
266
+ disruptions = metrics.get("disruption_count", 0)
267
+
268
+ raw = (
269
+ 0.45 * _safe_ratio(perfect, total)
270
+ + 0.35 * _safe_ratio(on_time, total)
271
+ + 0.20 * _safe_ratio(completed, total)
272
+ - 0.10 * min(1.0, breaches / max(1, total))
273
+ - 0.05 * min(1.0, disruptions / max(1, total))
274
+ )
275
+ return safe_score(max(0.0, raw))
276
+
277
+
278
+ def grade_hard(metrics: dict) -> float:
279
+ """
280
+ Hard episode grader (lv8–lv10: adversarial MALWARE, PHISH, BEC).
281
+
282
+ Weights
283
+ -------
284
+ 40 % β€” perfect triage rate
285
+ 30 % β€” on-time rate
286
+ 20 % β€” completion rate
287
+ 10 % β€” zero-breach bonus (1.0 if no breaches; else 0.0)
288
+
289
+ Penalty: βˆ’0.12 Γ— breach_rate, βˆ’0.06 Γ— disruption_rate
290
+ """
291
+ total = max(1, metrics.get("total_tasks", 1))
292
+ perfect = metrics.get("perfect_tasks", 0)
293
+ on_time = metrics.get("on_time", 0)
294
+ completed = metrics.get("completed_tasks", 0)
295
+ breaches = metrics.get("breach_count", 0)
296
+ disruptions = metrics.get("disruption_count", 0)
297
+
298
+ zero_breach_bonus = 1.0 if breaches == 0 else 0.0
299
+
300
+ raw = (
301
+ 0.40 * _safe_ratio(perfect, total)
302
+ + 0.30 * _safe_ratio(on_time, total)
303
+ + 0.20 * _safe_ratio(completed, total)
304
+ + 0.10 * zero_breach_bonus
305
+ - 0.12 * min(1.0, breaches / max(1, total))
306
+ - 0.06 * min(1.0, disruptions / max(1, total))
307
+ )
308
+ return safe_score(max(0.0, raw))
309
+
310
+
311
+ def grade_performance(metrics: dict) -> float:
312
+ """
313
+ Aggregate grader for cross-level scoring in inference.py.
314
+
315
+ Produces one float for the full run (all levels combined).
316
+ Mirrors FocusAI's grade_performance signature exactly.
317
+
318
+ Weights
319
+ -------
320
+ 40 % β€” perfect triage rate
321
+ 30 % β€” on-time rate
322
+ 20 % β€” completion rate
323
+ 10 % β€” zero-breach bonus
324
+ """
325
+ total = max(1, metrics.get("total_tasks", 1))
326
+ perfect = metrics.get("perfect_tasks", 0)
327
+ on_time = metrics.get("on_time", 0)
328
+ completed = metrics.get("completed_tasks", 0)
329
+ breaches = metrics.get("breach_count", 0)
330
+
331
+ zero_breach_bonus = 1.0 if breaches == 0 else 0.0
332
+
333
+ raw = (
334
+ 0.40 * _safe_ratio(perfect, total)
335
+ + 0.30 * _safe_ratio(on_time, total)
336
+ + 0.20 * _safe_ratio(completed, total)
337
+ + 0.10 * zero_breach_bonus
338
+ )
339
+ return safe_score(max(0.0, raw))
340
+
341
+
342
+ # ══════════════════════════════════════════════════════════════════════════════
343
+ # REGISTRY MAPS (required by OpenEnv validator β€” β‰₯ 3 entries needed)
344
+ # ══════════════════════════════════════════════════════════════════════════════
345
+
346
+ # Primary registry β€” level name β†’ episode grader.
347
+ # The validator scans GRADERS to confirm β‰₯ 3 tasks have graders.
348
+ GRADERS: Dict[str, Callable[[dict], float]] = {
349
+ "easy": grade_easy,
350
+ "medium": grade_medium,
351
+ "hard": grade_hard,
352
+ }
353
+
354
+ # Per-scenario registry β€” each lv1–lv10 ID mapped to its level grader.
355
+ # env.py uses this for per-task score lookups in the /step response.
356
+ TASK_GRADERS: Dict[str, Callable[[dict], float]] = {
357
+ "lv1": grade_easy,
358
+ "lv2": grade_easy,
359
+ "lv3": grade_easy,
360
+ "lv4": grade_medium,
361
+ "lv5": grade_medium,
362
+ "lv6": grade_medium,
363
+ "lv7": grade_medium,
364
+ "lv8": grade_hard,
365
+ "lv9": grade_hard,
366
+ "lv10": grade_hard,
367
+ }
368
+
369
+
370
+ # ══════════════════════════════════════════════════════════════════════════════
371
+ # CALCULATE_OVERALL_SCORE (backward-compat helper for /state endpoint)
372
+ # ══════════════════════════════════════════════════════════════════════════════
373
+
374
+ def calculate_overall_score(task_scores: list) -> float:
375
+ """
376
+ Average a list of per-step grade_action() rewards and return safe_score.
377
+
378
+ Parameters
379
+ ----------
380
+ task_scores : list of raw floats from grade_action() calls.
381
+
382
+ Returns
383
+ -------
384
+ float in (0.01, 0.99) β€” open-interval contract guaranteed.
385
+
386
+ Edge cases
387
+ ----------
388
+ β€’ Empty list β†’ safe_score(0) = 0.01
389
+ β€’ All perfect β†’ safe_score(~0.968) β‰ˆ 0.968
390
+ """
391
+ if not task_scores:
392
+ return safe_score(0.0)
393
+
394
+ raw_avg = sum(task_scores) / len(task_scores)
395
+ # Normalise from the per-step range (R_BREACH … R_PERFECT) β†’ (0, 1)
396
+ normalised = (raw_avg - R_BREACH) / (R_PERFECT - R_BREACH)
397
+ return safe_score(max(0.0, min(1.0, normalised)))