zephO-O commited on
Commit
a0045b9
Β·
verified Β·
1 Parent(s): 2c5abb8

Update grader.py

Browse files
Files changed (1) hide show
  1. grader.py +346 -167
grader.py CHANGED
@@ -1,115 +1,113 @@
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
- # R_PARTIAL: alias for R_MALWARE_QUARANTINE.
39
- # test_grader.py imports this name and asserts R_PARTIAL == R_MALWARE_QUARANTINE.
40
- # Without this line, test_grader.py crashes with ImportError on line 21.
41
- R_PARTIAL = R_MALWARE_QUARANTINE # 0.75
42
-
43
- PASS_THRESHOLD = 0.70 # inference.py imports this β€” a score >= this = success
44
- SUCCESS_THRESHOLD = 0.70 # same value, alternative name
45
-
46
- HEALTH_DRAIN_THRESHOLD = 0.15 # env.py: reward < this β†’ lose one life
47
-
48
-
49
- # ── Task Registry ──────────────────────────────────────────────────────────────
50
- TASK_REGISTRY: dict[str, dict] = {
51
- "task_spam": {
52
- "email_types": {"SPAM"},
53
- "description": (
54
- "Identify unsolicited bulk/commercial mail and route it to spam "
55
- "without over-escalating to a full domain block."
56
- ),
57
- "threat": True,
58
- },
59
- "task_phishing": {
60
- "email_types": {"PHISH"},
61
- "description": (
62
- "Detect credential-harvesting and typosquat phishing attacks; "
63
- "block the offending domain at the perimeter."
64
- ),
65
- "threat": True,
66
- },
67
- "task_safe": {
68
- "email_types": {"SAFE"},
69
- "description": (
70
- "Recognise legitimate internal and partner emails and pass them "
71
- "through without causing business disruption."
72
- ),
73
- "threat": False,
74
- },
75
- "task_malware": {
76
- "email_types": {"MALWARE"},
77
- "description": (
78
- "Identify emails delivering executable payloads or macro-enabled "
79
- "attachments and quarantine them for analysis."
80
- ),
81
- "threat": True,
82
- },
83
- "task_bec": {
84
- "email_types": {"BEC"},
85
- "description": (
86
- "Detect business email compromise and supply-chain attacks, "
87
- "including cases where SPF/DMARC authentication passes."
88
- ),
89
- "threat": True,
90
- },
91
- }
92
-
93
- # Reverse lookup: raw email_type string β†’ task_id
94
- _TYPE_TO_TASK_ID: dict[str, str] = {
95
- etype: task_id
96
- for task_id, meta in TASK_REGISTRY.items()
97
- for etype in meta["email_types"]
98
- }
99
-
100
-
101
- def resolve_task_id(email_type: str) -> str:
102
- """Convert raw email type (e.g. 'PHISH') to task_id (e.g. 'task_phishing')."""
103
- key = email_type.strip().upper()
104
- if key not in _TYPE_TO_TASK_ID:
105
- raise ValueError(
106
- f"Unknown email_type '{email_type}'. "
107
- f"Valid types: {sorted(_TYPE_TO_TASK_ID)}"
108
- )
109
- return _TYPE_TO_TASK_ID[key]
110
 
 
 
111
 
112
- # ── Core grading function ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  def grade_action(
115
  agent_output: str,
@@ -117,108 +115,289 @@ def grade_action(
117
  email_type: str,
118
  ) -> Tuple[float, str]:
119
  """
120
- Grade one SOC triage decision.
121
- Returns (reward, message) where reward is strictly in (0.0, 1.0).
 
 
 
 
 
 
 
 
 
 
 
122
  """
123
  agent_action = agent_output.strip().upper()
124
  expected_action = expected_output.strip().upper()
125
  etype = email_type.strip().upper()
126
 
127
- # Validate action is one of the four known values
128
- valid_actions = {"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"}
129
- if agent_action not in valid_actions:
130
- return R_WRONG_PROCEDURE, f"INVALID_ACTION: '{agent_action}' is not a recognised triage action"
131
-
132
- task_id = resolve_task_id(etype)
133
- is_threat = TASK_REGISTRY[task_id]["threat"]
134
 
135
- # ── 1. Perfect match ───────────────────────────────────────────────────────
136
  if agent_action == expected_action:
137
  return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
138
 
139
- # ── 2. Security Breach ─────────────────────────────────────────────────────
140
- if is_threat and agent_action == "MARK_SAFE":
141
- return R_BREACH, "SECURITY_BREACH: Threat allowed into the network"
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- # ── 3. Business Disruption ─────────────────────────────────────────────────
144
- if not is_threat and agent_action in {"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"}:
145
- return R_DISRUPTION, "BUSINESS_DISRUPTION: Legitimate communication blocked"
146
 
147
- # ── 4. Partial credit ──────────────────────────────────────────────────────
148
- if task_id == "task_malware" and agent_action == "QUARANTINE":
149
- return R_MALWARE_QUARANTINE, "CAUTIOUS: Malware isolated β€” strong containment"
 
 
 
 
150
 
151
- if task_id in {"task_phishing", "task_bec"} and agent_action == "QUARANTINE":
152
- return R_PHISH_BEC_QUARANTINE, (
153
- "UNDER_RESPONSE: Threat quarantined but domain still active β€” "
154
- "consider BLOCK_DOMAIN"
 
 
155
  )
156
 
157
- if task_id == "task_spam" and agent_action == "BLOCK_DOMAIN":
158
- return R_SPAM_BLOCK, (
 
 
159
  "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM β€” "
160
- "prefer MOVE_TO_SPAM"
161
  )
162
 
163
- if task_id == "task_spam" and agent_action == "QUARANTINE":
164
- return R_SPAM_QUARANTINE, (
165
- "OVER_ESCALATION: QUARANTINE wastes analyst capacity for SPAM β€” "
166
- "prefer MOVE_TO_SPAM"
 
 
167
  )
168
 
169
- # ── 5. General wrong procedure ─────────────────────────────────────────────
170
- return R_WRONG_PROCEDURE, "INCORRECT_PROCEDURE: Decision does not match security policy"
 
 
 
 
 
171
 
172
 
173
- # ── Episode-level score aggregation ───────────────────────────────────────────
 
 
174
 
175
  def calculate_overall_score(task_scores: list) -> float:
176
- """Compute final benchmark score from per-step rewards. Returns float in (0,1)."""
 
 
 
 
177
  if not task_scores:
178
  return R_BREACH
 
179
  raw_avg = sum(task_scores) / len(task_scores)
180
  clamped = max(R_BREACH, min(R_PERFECT, raw_avg))
181
  return round(clamped, 4)
182
 
183
 
184
- def calculate_per_task_scores(
185
- task_score_map: dict[str, list[float]],
186
- ) -> dict[str, float]:
187
- """Compute per-task-type average scores. Returns {task_id: score}."""
188
- return {
189
- task_id: calculate_overall_score(scores)
190
- for task_id, scores in task_score_map.items()
191
- if scores
192
- }
193
 
 
 
 
194
 
195
- # ── Named graders β€” one per difficulty level ──────────────────────────────────
196
- # Referenced by openenv.yaml:
197
- # task id "easy" β†’ grader: "grader.grade_easy"
198
- # task id "medium" β†’ grader: "grader.grade_medium"
199
- # task id "hard" β†’ grader: "grader.grade_hard"
200
- #
201
- # The validator does: import grader; callable(grader.grade_easy) β†’ True
202
- # Without these, it counts 0 graded tasks β†’ "Not enough tasks with graders"
203
 
204
- def grade_easy(task_scores: list) -> float:
205
- """Grader for easy-level scenarios (lv1–lv3: SPAM, PHISH, SAFE). Returns float in (0, 1)."""
206
- return calculate_overall_score(task_scores)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
 
 
 
 
208
 
209
- def grade_medium(task_scores: list) -> float:
210
- """Grader for medium-level scenarios (lv4–lv7: MALWARE, SAFE, BEC, PHISH). Returns float in (0, 1)."""
211
- return calculate_overall_score(task_scores)
 
 
212
 
213
 
214
- def grade_hard(task_scores: list) -> float:
215
- """Grader for hard-level scenarios (lv8–lv10: MALWARE, PHISH, BEC). Returns float in (0, 1)."""
216
- return calculate_overall_score(task_scores)
 
 
 
 
 
 
 
 
 
 
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
- # Registry map β€” consumed by env.py and server/app.py
220
  GRADERS = {
221
  "easy": grade_easy,
222
  "medium": grade_medium,
223
  "hard": grade_hard,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
 
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,
 
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
  }