og-arin commited on
Commit
f261596
Β·
verified Β·
1 Parent(s): 9cc8af4

Update grader.py

Browse files
Files changed (1) hide show
  1. grader.py +185 -256
grader.py CHANGED
@@ -1,134 +1,106 @@
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
- R_PARTIAL is exported as an alias for R_MALWARE_QUARANTINE (0.75) so
30
- inference.py and env.py can import a single mid-range signal constant
31
- without hard-coding a numeric literal.
32
-
33
- HEALTH-DRAIN THRESHOLD
34
- ────────────────────────────────────────────────────────────────────────────
35
  HEALTH_DRAIN_THRESHOLD = 0.15
36
-
37
- reward < 0.15 β†’ agent loses one life. Covers:
38
- R_BREACH (0.02), R_DISRUPTION (0.05), R_WRONG_PROCEDURE (0.10)
39
-
40
- Cautious / partial-credit scores (β‰₯ 0.35) NEVER drain health.
41
-
42
- PASS_THRESHOLD
43
- ────────────────────────────────────────────────────────────────────────────
44
- PASS_THRESHOLD = 0.50
45
-
46
- The minimum overall_score an agent must achieve across a full run to be
47
- considered a passing benchmark result. Imported by inference.py so the
48
- bar is defined in exactly one place.
49
-
50
- LEVEL CONTEXT (from env.py)
51
- ────────────────────────────────────────────────────────────────────────────
52
- easy β†’ lv1 (SPAM), lv2 (PHISH), lv3 (SAFE)
53
- medium β†’ lv4 (MALWARE), lv5 (SAFE), lv6 (BEC), lv7 (PHISH)
54
- hard β†’ lv8 (MALWARE), lv9 (PHISH), lv10 (BEC)
55
-
56
- email_type values in play: SPAM | PHISH | BEC | MALWARE | SAFE
57
-
58
- VALID AGENT ACTIONS
59
- ────────────────────────────────────────────────────────────────────────────
60
- MARK_SAFE – deliver to inbox (use ONLY for confirmed-safe)
61
- MOVE_TO_SPAM – bulk / unsolicited mail (no active threat)
62
- QUARANTINE – hold for analyst review
63
- BLOCK_DOMAIN – perimeter block (confirmed phishing / BEC source)
64
-
65
- DECISION TREE (grade_action)
66
- ────────────────────────────────────────────────────────────────────────────
67
- 1. Unrecognised action token β†’ R_WRONG_PROCEDURE
68
- 2. action == correct β†’ R_PERFECT
69
- 3. Threat type (incl. SPAM) + MARK_SAFE β†’ R_BREACH (Security Breach)
70
- 4. SAFE type + blocking move β†’ R_DISRUPTION (Business Disruption)
71
- 5. MALWARE β†’ QUARANTINE β†’ R_MALWARE_QUARANTINE
72
- 6. PHISH/BEC β†’ QUARANTINE β†’ R_PHISH_BEC_QUARANTINE
73
- 7. SPAM β†’ BLOCK_DOMAIN β†’ R_SPAM_BLOCK
74
- 8. SPAM β†’ QUARANTINE β†’ R_SPAM_QUARANTINE
75
- 9. Catch-all wrong procedure β†’ R_WRONG_PROCEDURE
76
-
77
- BUG FIX (v1.0.2 β†’ v1.0.3)
78
- ────────────────────────────────────────────────────────────────────────────
79
- _THREAT_TYPES previously excluded SPAM. This meant lv1 SPAM + MARK_SAFE
80
- returned R_WRONG_PROCEDURE (0.10) instead of R_BREACH (0.02) β€” a
81
- security-critical email type was not penalised as a breach.
82
- Fix: SPAM added to _THREAT_TYPES so MARK_SAFE on any threat drains health.
83
  """
84
 
85
- from __future__ import annotations
86
-
87
  from typing import Tuple
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- # ══════════════════════════════════════════════════════════════════════════════
91
- # REWARD CONSTANTS
92
- # ══════════════════════════════════════════════════════════════════════════════
93
- # All numeric reward values are defined ONCE here.
94
- # env.py and inference.py import these β€” neither file hard-codes numbers.
95
-
96
- R_PERFECT = 0.95 # Exact triage match β€” near-ideal signal
97
- R_MALWARE_QUARANTINE = 0.75 # MALWARE isolated via QUARANTINE
98
- R_PHISH_BEC_QUARANTINE = 0.60 # PHISH/BEC held but source domain still live
99
- R_SPAM_BLOCK = 0.40 # SPAM β†’ BLOCK_DOMAIN (over-escalation)
100
- R_SPAM_QUARANTINE = 0.35 # SPAM β†’ QUARANTINE (lighter over-escalation)
101
- R_WRONG_PROCEDURE = 0.10 # Wrong action; no security / operational harm
102
- R_DISRUPTION = 0.05 # Business Disruption β€” SAFE email blocked
103
- R_BREACH = 0.02 # Security Breach β€” threat allowed through
104
-
105
- # ── Convenience alias ─────────────────────────────────────────────────────────
106
- # R_PARTIAL = mid-range cautious signal used by inference.py / reporting layers.
107
- # Maps to R_MALWARE_QUARANTINE (0.75) β€” the highest partial-credit reward.
108
- R_PARTIAL = R_MALWARE_QUARANTINE
109
-
110
- # ── Benchmark pass threshold ──────────────────────────────────────────────────
111
- # Imported by inference.py so the pass/fail bar is defined in one place.
112
- PASS_THRESHOLD = 0.50
113
-
114
- # ── Health-drain threshold ────────────────────────────────────────────────────
115
- # env.py compares `reward < HEALTH_DRAIN_THRESHOLD` to decide life-loss.
116
- # Must sit above R_WRONG_PROCEDURE (0.10) and below R_SPAM_QUARANTINE (0.35)
117
- # so cautious over-escalations never drain health.
118
- HEALTH_DRAIN_THRESHOLD = 0.15
119
-
120
- # ── Internal lookup sets ──────────────────────────────────────────────────────
121
- # BUG FIX: SPAM is now included in _THREAT_TYPES.
122
- # Previously SPAM was omitted, so SPAM + MARK_SAFE returned R_WRONG_PROCEDURE
123
- # (0.10) instead of the correct R_BREACH (0.02).
124
- _THREAT_TYPES = frozenset({"PHISH", "BEC", "MALWARE", "SPAM"})
125
- _BLOCKED_MOVES = frozenset({"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"})
126
- _VALID_ACTIONS = frozenset({"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"})
127
-
128
-
129
- # ══════════════════════════════════════════════════════════════════════════════
130
- # GRADE_ACTION
131
- # ══════════════════════════════════════════════════════════════════════════════
132
 
133
  def grade_action(
134
  agent_output: str,
@@ -136,162 +108,119 @@ def grade_action(
136
  email_type: str,
137
  ) -> Tuple[float, str]:
138
  """
139
- Grade one SOC triage decision and return a reward in (0.0, 1.0).
140
-
141
- Parameters
142
- ----------
143
- agent_output : Raw action string from the LLM agent (case-insensitive).
144
- expected_output : Ground-truth correct action for this scenario.
145
- email_type : Threat category β€” PHISH | BEC | MALWARE | SPAM | SAFE.
146
-
147
- Returns
148
- -------
149
- (reward, message)
150
- reward : float strictly in (0.0, 1.0) β€” NEVER 0, NEVER 1.
151
- message : short verdict string for logs and agent feedback.
152
-
153
- Examples (aligned with env.py scenarios)
154
- -----------------------------------------
155
- lv1 SPAM / MOVE_TO_SPAM + MOVE_TO_SPAM β†’ 0.95 PERFECT_TRIAGE
156
- lv1 SPAM / MOVE_TO_SPAM + MARK_SAFE β†’ 0.02 SECURITY_BREACH ← BUG FIX
157
- lv1 SPAM / MOVE_TO_SPAM + QUARANTINE β†’ 0.35 OVER_ESCALATION
158
- lv2 PHISH / BLOCK_DOMAIN + BLOCK_DOMAIN β†’ 0.95 PERFECT_TRIAGE
159
- lv2 PHISH / BLOCK_DOMAIN + QUARANTINE β†’ 0.60 UNDER_RESPONSE
160
- lv2 PHISH / BLOCK_DOMAIN + MARK_SAFE β†’ 0.02 SECURITY_BREACH
161
- lv3 SAFE / MARK_SAFE + MARK_SAFE β†’ 0.95 PERFECT_TRIAGE
162
- lv3 SAFE / MARK_SAFE + QUARANTINE β†’ 0.05 BUSINESS_DISRUPTION
163
- lv4 MALWARE/ QUARANTINE + QUARANTINE β†’ 0.95 PERFECT_TRIAGE
164
- lv4 MALWARE/ QUARANTINE + BLOCK_DOMAIN β†’ 0.10 INCORRECT_PROCEDURE
165
- lv6 BEC / QUARANTINE + MARK_SAFE β†’ 0.02 SECURITY_BREACH
166
- lv10 BEC / BLOCK_DOMAIN + QUARANTINE β†’ 0.60 UNDER_RESPONSE
167
  """
168
  agent_action = agent_output.strip().upper()
169
  expected_action = expected_output.strip().upper()
170
  etype = email_type.strip().upper()
171
 
172
- # ── Step 1: Reject unrecognised tokens ───────────────────��───────────────
173
- # Treat unknown output as wrong procedure rather than raising so a single
174
- # malformed response does not crash the entire episode.
175
- if agent_action not in _VALID_ACTIONS:
176
- return (
177
- R_WRONG_PROCEDURE,
178
- f"INVALID_ACTION: '{agent_action}' is not a recognised triage action β€” "
179
- f"must be one of: {', '.join(sorted(_VALID_ACTIONS))}",
180
- )
181
 
182
- # ── Step 2: Perfect match ─────────────────────────────────────────────────
183
  if agent_action == expected_action:
184
  return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
185
 
186
- # ── Step 3: Security Breach β€” most severe outcome ────────────────────────
187
- # Any threat type (PHISH, BEC, MALWARE, SPAM) rubber-stamped as safe.
188
- # Covers: lv1 SPAM→MARK_SAFE, lv2 PHISH→MARK_SAFE, lv6 BEC→MARK_SAFE, etc.
189
- if etype in _THREAT_TYPES and agent_action == "MARK_SAFE":
190
- return (
191
- R_BREACH,
192
- f"SECURITY_BREACH: {etype} threat delivered to inbox unimpeded β€” "
193
- "catastrophic failure; source remains active",
194
- )
195
-
196
- # ── Step 4: Business Disruption β€” severe false-positive ──────────────────
197
- # A clean, legitimate email (SAFE) was blocked, quarantined, or spammed.
198
- # Covers: lv3 SAFE→QUARANTINE, lv5 SAFE→BLOCK_DOMAIN, etc.
199
- if etype == "SAFE" and agent_action in _BLOCKED_MOVES:
200
- return (
201
- R_DISRUPTION,
202
- "BUSINESS_DISRUPTION: Legitimate communication was incorrectly blocked β€” "
203
- "operational impact; sender trust degraded",
204
- )
205
 
206
- # ── Step 5: Partial credit β€” cautious but sub-optimal ────────────────────
 
207
 
208
- # MALWARE β†’ QUARANTINE
209
- # Isolation is textbook containment; BLOCK_DOMAIN is preferred for confirmed
210
- # malware sources but QUARANTINE still prevents propagation.
211
- if etype == "MALWARE" and agent_action == "QUARANTINE":
212
- return (
213
- R_MALWARE_QUARANTINE,
214
- "CAUTIOUS: Malware isolated via QUARANTINE β€” strong containment; "
215
- "no further propagation risk detected",
216
- )
217
 
218
- # PHISH/BEC β†’ QUARANTINE
219
- # Threat is held but the sending domain remains live and can re-deliver.
220
- # Preferred action is BLOCK_DOMAIN to sever the attack vector entirely.
221
- if etype in {"PHISH", "BEC"} and agent_action == "QUARANTINE":
222
- return (
223
- R_PHISH_BEC_QUARANTINE,
224
- f"UNDER_RESPONSE: {etype} quarantined but source domain still active β€” "
225
- "prefer BLOCK_DOMAIN to prevent further phishing delivery attempts",
226
  )
227
 
228
- # SPAM β†’ BLOCK_DOMAIN
229
- # Disproportionate escalation; burns perimeter block-list capacity on a
230
- # low-severity bulk sender. Prefer MOVE_TO_SPAM.
231
- if etype == "SPAM" and agent_action == "BLOCK_DOMAIN":
232
- return (
233
- R_SPAM_BLOCK,
234
  "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM β€” "
235
- "prefer MOVE_TO_SPAM to preserve block-list resources",
236
  )
237
 
238
- # SPAM β†’ QUARANTINE
239
- # Lighter over-escalation; clogs the analyst review queue with bulk mail.
240
- if etype == "SPAM" and agent_action == "QUARANTINE":
241
- return (
242
- R_SPAM_QUARANTINE,
243
- "OVER_ESCALATION: QUARANTINE wastes analyst capacity on SPAM β€” "
244
- "prefer MOVE_TO_SPAM for bulk unsolicited mail",
245
  )
246
 
247
- # ── Step 6: General incorrect procedure (catch-all) ──────────────────────
248
- # Wrong action with no direct security breach or operational disruption.
249
- # e.g. MALWARE β†’ BLOCK_DOMAIN when QUARANTINE is expected (lv4, lv8).
250
- return (
251
- R_WRONG_PROCEDURE,
252
- f"INCORRECT_PROCEDURE: '{agent_action}' does not match security policy "
253
- f"for {etype} email (expected: {expected_action}) β€” "
254
- "review triage guidelines",
255
- )
256
 
257
 
258
- # ══════════════════════════════════════════════════════════════════════════════
259
- # CALCULATE_OVERALL_SCORE
260
- # ══════════════════════════════════════════════════════════════════════════════
261
 
262
  def calculate_overall_score(task_scores: list) -> float:
263
- """
264
- Compute the final benchmark score from a list of per-step rewards.
265
-
266
- The result is clamped to [R_BREACH, R_PERFECT] β€” maintaining the
267
- open-interval contract β€” so downstream consumers always receive a float
268
- strictly greater than 0 and strictly less than 1.
269
-
270
- Parameters
271
- ----------
272
- task_scores : list of floats, each in (0.0, 1.0).
273
-
274
- Returns
275
- -------
276
- float in [R_BREACH, R_PERFECT] β€” always a valid open-interval value.
277
-
278
- Edge cases
279
- ----------
280
- β€’ Empty list β†’ R_BREACH (minimum non-zero signal; not zero)
281
- β€’ Single step β†’ that step's reward, clamped to [R_BREACH, R_PERFECT]
282
- β€’ All perfect β†’ R_PERFECT (0.95), never 1.0
283
-
284
- Level score examples (env.py alignment)
285
- ----------------------------------------
286
- easy (3 tasks): [0.95, 0.95, 0.95] β†’ 0.95
287
- medium (4 tasks): [0.95, 0.02, 0.95, 0.60] β†’ 0.63
288
- hard (3 tasks): [0.02, 0.05, 0.95] β†’ 0.34
289
- """
290
  if not task_scores:
291
- return R_BREACH # No tasks completed: minimum signal, not zero
292
-
293
  raw_avg = sum(task_scores) / len(task_scores)
294
-
295
- # Clamp strictly within the open-interval boundary constants.
296
  clamped = max(R_BREACH, min(R_PERFECT, raw_avg))
297
  return round(clamped, 4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ grader.py – PhishGuard-Env | Triage Scoring & Task Registry
3
+ ==============================================================
4
+
5
+ REWARD SCALE CONTRACT
6
+ ----------------------------------
7
+ All rewards strictly in the open interval (0.0, 1.0) β€” 0 and 1 never returned.
8
+
9
+ Outcome Reward Rationale
10
+ ─────────────────────────────────────────────────────────────────────────────
11
+ Perfect match (any task type) 0.95 Near-ideal; headroom kept
12
+ MALWARE β†’ QUARANTINE (safe containment) 0.75 Correct containment call
13
+ PHISH/BEC β†’ QUARANTINE (under-response) 0.60 Held but domain still active
14
+ SPAM β†’ BLOCK_DOMAIN (over-escalation) 0.40 Too aggressive, not wrong
15
+ SPAM β†’ QUARANTINE (lighter escalation) 0.35 Wastes analyst queue
16
+ General wrong procedure (no active harm) 0.10 Wrong but not dangerous
17
+ Business Disruption (SAFE email blocked) 0.05 Ops impact, below drain floor
18
+ Security Breach (threat β†’ MARK_SAFE) 0.02 Catastrophic; minimum signal
19
+
20
+ Health-drain threshold (consumed by env.py)
21
+ -------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  HEALTH_DRAIN_THRESHOLD = 0.15
23
+ reward < 0.15 β†’ env.py deducts one life from the agent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  """
25
 
 
 
26
  from typing import Tuple
27
 
28
+ # ── Reward constants ───────────────────────────────────────────────────────────
29
+ R_PERFECT = 0.95
30
+ R_MALWARE_QUARANTINE = 0.75
31
+ R_PHISH_BEC_QUARANTINE = 0.60
32
+ R_SPAM_BLOCK = 0.40
33
+ R_SPAM_QUARANTINE = 0.35
34
+ R_WRONG_PROCEDURE = 0.10
35
+ R_DISRUPTION = 0.05
36
+ R_BREACH = 0.02
37
+
38
+ HEALTH_DRAIN_THRESHOLD = 0.15 # env.py: reward < this β†’ lose one life
39
+
40
+ # ── Task Registry ──────────────────────────────────────────────────────────────
41
+ TASK_REGISTRY: dict[str, dict] = {
42
+ "task_spam": {
43
+ "email_types": {"SPAM"},
44
+ "description": (
45
+ "Identify unsolicited bulk/commercial mail and route it to spam "
46
+ "without over-escalating to a full domain block."
47
+ ),
48
+ "threat": True,
49
+ },
50
+ "task_phishing": {
51
+ "email_types": {"PHISH"},
52
+ "description": (
53
+ "Detect credential-harvesting and typosquat phishing attacks; "
54
+ "block the offending domain at the perimeter."
55
+ ),
56
+ "threat": True,
57
+ },
58
+ "task_safe": {
59
+ "email_types": {"SAFE"},
60
+ "description": (
61
+ "Recognise legitimate internal and partner emails and pass them "
62
+ "through without causing business disruption."
63
+ ),
64
+ "threat": False,
65
+ },
66
+ "task_malware": {
67
+ "email_types": {"MALWARE"},
68
+ "description": (
69
+ "Identify emails delivering executable payloads or macro-enabled "
70
+ "attachments and quarantine them for analysis."
71
+ ),
72
+ "threat": True,
73
+ },
74
+ "task_bec": {
75
+ "email_types": {"BEC"},
76
+ "description": (
77
+ "Detect business email compromise and supply-chain attacks, "
78
+ "including cases where SPF/DMARC authentication passes."
79
+ ),
80
+ "threat": True,
81
+ },
82
+ }
83
+
84
+ # Reverse lookup: raw email_type string β†’ task_id
85
+ _TYPE_TO_TASK_ID: dict[str, str] = {
86
+ etype: task_id
87
+ for task_id, meta in TASK_REGISTRY.items()
88
+ for etype in meta["email_types"]
89
+ }
90
+
91
+
92
+ def resolve_task_id(email_type: str) -> str:
93
+ """Convert raw email type (e.g. 'PHISH') to task_id (e.g. 'task_phishing')."""
94
+ key = email_type.strip().upper()
95
+ if key not in _TYPE_TO_TASK_ID:
96
+ raise ValueError(
97
+ f"Unknown email_type '{email_type}'. "
98
+ f"Valid types: {sorted(_TYPE_TO_TASK_ID)}"
99
+ )
100
+ return _TYPE_TO_TASK_ID[key]
101
+
102
 
103
+ # ── Core grading function ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  def grade_action(
106
  agent_output: str,
 
108
  email_type: str,
109
  ) -> Tuple[float, str]:
110
  """
111
+ Grade one SOC triage decision.
112
+ Returns (reward, message) where reward is strictly in (0.0, 1.0).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  """
114
  agent_action = agent_output.strip().upper()
115
  expected_action = expected_output.strip().upper()
116
  etype = email_type.strip().upper()
117
 
118
+ task_id = resolve_task_id(etype)
119
+ is_threat = TASK_REGISTRY[task_id]["threat"]
 
 
 
 
 
 
 
120
 
 
121
  if agent_action == expected_action:
122
  return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
123
 
124
+ if is_threat and agent_action == "MARK_SAFE":
125
+ return R_BREACH, "SECURITY_BREACH: Threat allowed into the network"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
+ if not is_threat and agent_action in {"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"}:
128
+ return R_DISRUPTION, "BUSINESS_DISRUPTION: Legitimate communication blocked"
129
 
130
+ if task_id == "task_malware" and agent_action == "QUARANTINE":
131
+ return R_MALWARE_QUARANTINE, "CAUTIOUS: Malware isolated β€” strong containment"
 
 
 
 
 
 
 
132
 
133
+ if task_id in {"task_phishing", "task_bec"} and agent_action == "QUARANTINE":
134
+ return R_PHISH_BEC_QUARANTINE, (
135
+ "UNDER_RESPONSE: Threat quarantined but domain still active β€” "
136
+ "consider BLOCK_DOMAIN"
 
 
 
 
137
  )
138
 
139
+ if task_id == "task_spam" and agent_action == "BLOCK_DOMAIN":
140
+ return R_SPAM_BLOCK, (
 
 
 
 
141
  "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM β€” "
142
+ "prefer MOVE_TO_SPAM"
143
  )
144
 
145
+ if task_id == "task_spam" and agent_action == "QUARANTINE":
146
+ return R_SPAM_QUARANTINE, (
147
+ "OVER_ESCALATION: QUARANTINE wastes analyst capacity for SPAM β€” "
148
+ "prefer MOVE_TO_SPAM"
 
 
 
149
  )
150
 
151
+ return R_WRONG_PROCEDURE, "INCORRECT_PROCEDURE: Decision does not match security policy"
 
 
 
 
 
 
 
 
152
 
153
 
154
+ # ── Episode-level score aggregation ───────────────────────────────────────────
 
 
155
 
156
  def calculate_overall_score(task_scores: list) -> float:
157
+ """Compute final benchmark score from per-step rewards. Returns float in (0,1)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  if not task_scores:
159
+ return R_BREACH
 
160
  raw_avg = sum(task_scores) / len(task_scores)
 
 
161
  clamped = max(R_BREACH, min(R_PERFECT, raw_avg))
162
  return round(clamped, 4)
163
+
164
+
165
+ def calculate_per_task_scores(
166
+ task_score_map: dict[str, list[float]],
167
+ ) -> dict[str, float]:
168
+ """Compute per-task-type average scores. Returns {task_id: score}."""
169
+ return {
170
+ task_id: calculate_overall_score(scores)
171
+ for task_id, scores in task_score_map.items()
172
+ if scores
173
+ }
174
+
175
+
176
+ # ── Named graders β€” ONE PER DIFFICULTY LEVEL ──────────────────────────────────
177
+ #
178
+ # WHY THESE EXIST:
179
+ # openenv.yaml declares 3 tasks: easy, medium, hard.
180
+ # Each task's `grader` field must point to a real callable in this file.
181
+ # The validator does: import grader; callable(grader.grade_easy) β†’ True
182
+ # Without these functions, it finds nothing β†’ "Not enough tasks with graders"
183
+ #
184
+ # WHAT THEY DO:
185
+ # Each receives the list of per-step rewards collected during that level's
186
+ # scenarios and returns a final aggregated score in (0.0, 1.0).
187
+ #
188
+ # LINE NUMBERS WHERE OPENENV.YAML REFERENCES THESE:
189
+ # openenv.yaml line 7 β†’ grader: "grader.grade_easy"
190
+ # openenv.yaml line 11 β†’ grader: "grader.grade_medium"
191
+ # openenv.yaml line 15 β†’ grader: "grader.grade_hard"
192
+ # ─────────────────────────────────────────────────────────────────────────────
193
+
194
+ def grade_easy(task_scores: list) -> float:
195
+ """
196
+ Grader for easy-level scenarios (lv1–lv3: SPAM, PHISH, SAFE).
197
+ Referenced by openenv.yaml task id 'easy'.
198
+ Returns float strictly in (0.0, 1.0).
199
+ """
200
+ return calculate_overall_score(task_scores)
201
+
202
+
203
+ def grade_medium(task_scores: list) -> float:
204
+ """
205
+ Grader for medium-level scenarios (lv4–lv7: MALWARE, SAFE, BEC, PHISH).
206
+ Referenced by openenv.yaml task id 'medium'.
207
+ Returns float strictly in (0.0, 1.0).
208
+ """
209
+ return calculate_overall_score(task_scores)
210
+
211
+
212
+ def grade_hard(task_scores: list) -> float:
213
+ """
214
+ Grader for hard-level scenarios (lv8–lv10: MALWARE, PHISH, BEC).
215
+ Referenced by openenv.yaml task id 'hard'.
216
+ Returns float strictly in (0.0, 1.0).
217
+ """
218
+ return calculate_overall_score(task_scores)
219
+
220
+
221
+ # Registry map β€” consumed by env.py for the /grade endpoint
222
+ GRADERS = {
223
+ "easy": grade_easy,
224
+ "medium": grade_medium,
225
+ "hard": grade_hard,
226
+ }