og-arin commited on
Commit
4352d74
Β·
verified Β·
1 Parent(s): a87dd47

Update grader.py

Browse files
Files changed (1) hide show
  1. grader.py +138 -130
grader.py CHANGED
@@ -2,89 +2,91 @@
2
  grader.py – PhishGuard-Env | Triage Scoring & Task Registry
3
  ==============================================================
4
 
5
- WHAT CHANGED (and why)
6
  -----------------------
7
- The OpenEnv validator's "Not enough tasks with graders" check works at the
8
- TASK-TYPE level, not the scenario level. It counts distinct `task_id`
9
- values that appear in graded step responses, and it verifies upfront (via
10
- GET /metadata) that the environment declares at least 3 task types.
11
-
12
- Previous design used level IDs ("lv1"…"lv10") as task_id, which gave the
13
- validator 10 IDs each appearing once β€” not 5 semantic categories appearing
14
- multiple times. The validator requires the latter.
15
-
16
- NEW DESIGN
17
- ----------
18
- task_id : semantic category string, one of:
19
- "task_spam" – unsolicited bulk mail
20
- "task_phishing" – credential-harvesting / typosquat attacks
21
- "task_safe" – legitimate emails (false-positive test)
22
- "task_malware" – executable / macro payload delivery
23
- "task_bec" – business email compromise / wire-fraud
24
-
25
- scenario_id : individual level identifier ("lv1"…"lv10"), kept for logs.
26
-
27
- TASK_REGISTRY (exported)
28
- ------------------------
29
- A dict consumed by env.py to:
30
- 1. Populate GET /metadata so the validator sees total_tasks β‰₯ 3 before
31
- running any episode.
32
- 2. Drive grade_action() dispatch β€” all grading logic is keyed on task_id,
33
- not on the raw email_type string, so the two are always in sync.
34
-
35
- REWARD SCALE CONTRACT (unchanged)
36
- ----------------------------------
37
- All rewards strictly in the open interval (0.0, 1.0) β€” 0 and 1 never returned.
38
-
39
- Outcome Reward Rationale
40
- ─────────────────────────────────────────────────────────────────────────────
41
- Perfect match (any task type) 0.95 Near-ideal; headroom kept
42
- MALWARE β†’ QUARANTINE (safe containment) 0.75 Correct containment call
43
- PHISH/BEC β†’ QUARANTINE (under-response) 0.60 Held but domain still active
44
- SPAM β†’ BLOCK_DOMAIN (over-escalation) 0.40 Too aggressive, not wrong
45
- SPAM β†’ QUARANTINE (lighter escalation) 0.35 Wastes analyst queue
46
- General wrong procedure (no active harm) 0.10 Wrong but not dangerous
47
- Business Disruption (SAFE email blocked) 0.05 Ops impact, below drain floor
48
- Security Breach (threat β†’ MARK_SAFE) 0.02 Catastrophic; minimum signal
49
-
50
- Health-drain threshold (consumed by env.py)
51
- -------------------------------------------
52
  HEALTH_DRAIN_THRESHOLD = 0.15
53
- reward < 0.15 β†’ env.py deducts one life from the agent.
54
- Covers: Security Breach (0.02), Business Disruption (0.05),
55
- Wrong Procedure (0.10).
56
- Cautious / partial-credit scores (β‰₯ 0.35) never drain health.
57
  """
58
 
59
  from typing import Tuple
60
 
61
- # ── Reward constants ───────────────────────────────────────��───────────────────
62
- # Defined once here; env.py imports them β€” no numeric literals elsewhere.
63
- R_PERFECT = 0.95
64
- R_MALWARE_QUARANTINE = 0.75
65
- R_PHISH_BEC_QUARANTINE = 0.60
66
- R_SPAM_BLOCK = 0.40
67
- R_SPAM_QUARANTINE = 0.35
68
- R_WRONG_PROCEDURE = 0.10
69
- R_DISRUPTION = 0.05
70
- R_BREACH = 0.02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  HEALTH_DRAIN_THRESHOLD = 0.15 # env.py: reward < this β†’ lose one life
73
 
74
 
75
  # ── Task Registry ──────────────────────────────────────────────────────────────
76
- # This is the single source of truth for task identity.
77
  #
78
- # Keys : task_id strings β€” what the OpenEnv validator counts.
79
- # Values : metadata dict consumed by GET /metadata and the grading dispatch.
80
- #
81
- # email_types : set of raw email type strings that map to this task_id.
82
- # Used by grade_action() to look up the correct grader.
83
- # description : human-readable summary for the /metadata endpoint.
84
- # threat : True β†’ dangerous email; graded as a threat task.
85
- # False β†’ legitimate email; graded as a false-positive task.
86
  # ─────────────────────────────────────────────────────────────────────────────
87
-
88
  TASK_REGISTRY: dict[str, dict] = {
89
  "task_spam": {
90
  "email_types": {"SPAM"},
@@ -128,8 +130,7 @@ TASK_REGISTRY: dict[str, dict] = {
128
  },
129
  }
130
 
131
- # Reverse lookup: raw email_type string β†’ task_id
132
- # Built once at import time; O(1) per grade_action call.
133
  _TYPE_TO_TASK_ID: dict[str, str] = {
134
  etype: task_id
135
  for task_id, meta in TASK_REGISTRY.items()
@@ -139,11 +140,10 @@ _TYPE_TO_TASK_ID: dict[str, str] = {
139
 
140
  def resolve_task_id(email_type: str) -> str:
141
  """
142
- Convert a raw email type string (e.g. "PHISH") to its task_id
143
- (e.g. "task_phishing").
144
 
145
- Raises ValueError for unrecognised types so misconfigured scenarios
146
- fail loudly at development time rather than silently at benchmark time.
147
  """
148
  key = email_type.strip().upper()
149
  if key not in _TYPE_TO_TASK_ID:
@@ -154,7 +154,51 @@ def resolve_task_id(email_type: str) -> str:
154
  return _TYPE_TO_TASK_ID[key]
155
 
156
 
157
- # ── Core grading function ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
  def grade_action(
160
  agent_output: str,
@@ -162,110 +206,74 @@ def grade_action(
162
  email_type: str,
163
  ) -> Tuple[float, str]:
164
  """
165
- Grade one SOC triage decision and return a reward in the open interval
166
- (0.0, 1.0) β€” the endpoints 0 and 1 are NEVER returned.
167
-
168
- Parameters
169
- ----------
170
- agent_output : Raw action string from the LLM (case-insensitive).
171
- expected_output : Ground-truth correct action for this scenario.
172
- email_type : Raw threat category β€” PHISH | BEC | MALWARE | SPAM | SAFE.
173
- The function resolves this to a task_id internally.
174
 
175
  Returns
176
  -------
177
  (reward, message)
178
- reward : float strictly in (0.0, 1.0).
179
  message : short verdict string for logs and agent feedback.
180
  """
181
  agent_action = agent_output.strip().upper()
182
  expected_action = expected_output.strip().upper()
183
- etype = email_type.strip().upper()
184
 
185
- # Resolve task_id for this email type (validates the type in the process).
186
- task_id = resolve_task_id(etype)
187
  is_threat = TASK_REGISTRY[task_id]["threat"]
188
 
189
- # ── 1. PERFECT MATCH ──────────────────────────────────────────────────────
190
  if agent_action == expected_action:
191
  return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
192
 
193
- # ── 2. SECURITY BREACH ────────────────────────────────────────────────────
194
- # Threat email rubber-stamped as safe β€” worst possible outcome.
195
  if is_threat and agent_action == "MARK_SAFE":
196
  return R_BREACH, "SECURITY_BREACH: Threat allowed into the network"
197
 
198
- # ── 3. BUSINESS DISRUPTION ────────────────────────────────────────────────
199
- # Legitimate email blocked or quarantined β€” costly false positive.
200
  if not is_threat and agent_action in {"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"}:
201
  return R_DISRUPTION, "BUSINESS_DISRUPTION: Legitimate communication blocked"
202
 
203
- # ── 4. PARTIAL CREDIT ─────────────────────────────────────────────────────
204
-
205
- # MALWARE quarantined: textbook containment response.
206
- # Branch fires only when scenario's `correct` field differs from QUARANTINE
207
- # (kept for robustness against future scenario expansions).
208
  if task_id == "task_malware" and agent_action == "QUARANTINE":
209
  return R_MALWARE_QUARANTINE, "CAUTIOUS: Malware isolated β€” strong containment"
210
 
211
- # PHISH or BEC quarantined instead of domain-blocked.
212
- # Threat is held, but the malicious domain stays active.
213
  if task_id in {"task_phishing", "task_bec"} and agent_action == "QUARANTINE":
214
  return R_PHISH_BEC_QUARANTINE, (
215
  "UNDER_RESPONSE: Threat quarantined but domain still active β€” "
216
  "consider BLOCK_DOMAIN"
217
  )
218
 
219
- # SPAM domain fully blocked β€” disproportionate but not dangerous.
220
  if task_id == "task_spam" and agent_action == "BLOCK_DOMAIN":
221
  return R_SPAM_BLOCK, (
222
  "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM β€” "
223
  "prefer MOVE_TO_SPAM"
224
  )
225
 
226
- # SPAM quarantined β€” lighter over-escalation, clogs analyst queue.
227
  if task_id == "task_spam" and agent_action == "QUARANTINE":
228
  return R_SPAM_QUARANTINE, (
229
  "OVER_ESCALATION: QUARANTINE wastes analyst capacity for SPAM β€” "
230
  "prefer MOVE_TO_SPAM"
231
  )
232
 
233
- # ── 5. GENERAL INCORRECT PROCEDURE ───────────────────────────────────────
234
  return R_WRONG_PROCEDURE, "INCORRECT_PROCEDURE: Decision does not match security policy"
235
 
236
 
237
- # ── Final score aggregation ────────────────────────────────────────────────────
238
 
239
- def calculate_overall_score(task_scores: list) -> float:
240
  """
241
- Compute the final benchmark score from per-step rewards.
242
-
243
- Result is clamped to (R_BREACH, R_PERFECT) to honour the open-interval
244
- contract at the episode level as well as the step level.
245
-
246
- Parameters
247
- ----------
248
- task_scores : list of floats, each in (0.0, 1.0).
249
-
250
- Returns
251
- -------
252
- float in (0.0, 1.0) β€” never exactly 0 or 1.
253
  """
254
- if not task_scores:
255
- return R_BREACH # Minimum signal value, not zero
256
-
257
- raw_avg = sum(task_scores) / len(task_scores)
258
- clamped = max(R_BREACH, min(R_PERFECT, raw_avg))
259
- return round(clamped, 4)
260
 
261
 
262
  def calculate_per_task_scores(
263
  task_score_map: dict[str, list[float]],
264
  ) -> dict[str, float]:
265
  """
266
- Compute per-task-type average scores from a map of task_id β†’ [rewards].
267
-
268
- Used by GET /state to give the validator per-task breakdowns.
269
 
270
  Parameters
271
  ----------
@@ -273,10 +281,10 @@ def calculate_per_task_scores(
273
 
274
  Returns
275
  -------
276
- { task_id: averaged_score } β€” each value in (0.0, 1.0).
277
  """
278
  return {
279
- task_id: calculate_overall_score(scores)
280
  for task_id, scores in task_score_map.items()
281
  if scores
282
  }
 
2
  grader.py – PhishGuard-Env | Triage Scoring & Task Registry
3
  ==============================================================
4
 
5
+ TASK IDENTITY CONTRACT
6
  -----------------------
7
+ The OpenEnv validator counts "tasks with graders" by matching the
8
+ `task_id` values declared in openenv.yaml against `task_id` values
9
+ returned in /step responses at runtime.
10
+
11
+ Both sides must use the SAME five semantic IDs:
12
+
13
+ task_spam task_phishing task_safe
14
+ task_malware task_bec
15
+
16
+ These are defined once in TASK_REGISTRY below and imported everywhere
17
+ else β€” env.py, openenv.yaml is written to match them exactly.
18
+
19
+ REWARD SCALE CONTRACT
20
+ ---------------------
21
+ All rewards strictly in the open interval (0.0, 1.0).
22
+ 0 and 1 are NEVER returned β€” guaranteed by safe_score().
23
+
24
+ safe_score(raw) = 0.01 + 0.98 * raw, raw in [0.0, 1.0]
25
+
26
+ Full reward table
27
+ -----------------
28
+ Outcome raw safe Rationale
29
+ ─────────────────────────────────────────────────────────────────────
30
+ Perfect match 1.00 0.99 Near-ideal
31
+ MALWARE β†’ QUARANTINE (containment) 0.75 0.745 Correct call
32
+ PHISH/BEC β†’ QUARANTINE (under-response) 0.60 0.598 Domain still active
33
+ SPAM β†’ BLOCK_DOMAIN (over-escalation) 0.40 0.402 Disproportionate
34
+ SPAM β†’ QUARANTINE (lighter escalation) 0.35 0.353 Wastes analyst queue
35
+ General wrong procedure 0.10 0.108 Wrong but no harm
36
+ Business Disruption (SAFE β†’ blocked) 0.05 0.059 Ops impact
37
+ Security Breach (threat β†’ MARK_SAFE) 0.02 0.0296 Catastrophic
38
+
39
+ Health-drain threshold
40
+ ----------------------
 
 
 
 
 
 
 
 
 
 
 
41
  HEALTH_DRAIN_THRESHOLD = 0.15
42
+ env.py deducts one life when safe_score result < 0.15.
43
+ Covers: Breach (0.030), Disruption (0.059), Wrong (0.108).
44
+ Cautious/partial scores (β‰₯ 0.35 raw β†’ β‰₯ 0.353 safe) never drain health.
 
45
  """
46
 
47
  from typing import Tuple
48
 
49
+ # ── safe_score ─────────────────────────────────────────────────────────────────
50
+ # Linear bijection [0,1] β†’ (0.01, 0.99).
51
+ # Guarantees the open-interval contract at every call site.
52
+ def safe_score(raw: float) -> float:
53
+ """Map a raw score in [0.0, 1.0] to a safe score strictly in (0.0, 1.0)."""
54
+ clamped = max(0.0, min(1.0, raw))
55
+ result = 0.01 + 0.98 * clamped
56
+ assert 0.0 < result < 1.0, f"safe_score contract violated: {result}"
57
+ return round(result, 6)
58
+
59
+
60
+ # ── Reward constants (raw values, passed through safe_score before use) ────────
61
+ _R_PERFECT_RAW = 1.00
62
+ _R_MALWARE_QUARANTINE_RAW = 0.75
63
+ _R_PHISH_BEC_QUARANTINE_RAW = 0.60
64
+ _R_SPAM_BLOCK_RAW = 0.40
65
+ _R_SPAM_QUARANTINE_RAW = 0.35
66
+ _R_WRONG_PROCEDURE_RAW = 0.10
67
+ _R_DISRUPTION_RAW = 0.05
68
+ _R_BREACH_RAW = 0.02
69
+
70
+ # Public safe-score constants (what env.py, inference.py actually use)
71
+ R_PERFECT = safe_score(_R_PERFECT_RAW) # 0.99
72
+ R_MALWARE_QUARANTINE = safe_score(_R_MALWARE_QUARANTINE_RAW) # 0.745
73
+ R_PHISH_BEC_QUARANTINE = safe_score(_R_PHISH_BEC_QUARANTINE_RAW) # 0.598
74
+ R_SPAM_BLOCK = safe_score(_R_SPAM_BLOCK_RAW) # 0.402
75
+ R_SPAM_QUARANTINE = safe_score(_R_SPAM_QUARANTINE_RAW) # 0.353
76
+ R_WRONG_PROCEDURE = safe_score(_R_WRONG_PROCEDURE_RAW) # 0.108
77
+ R_DISRUPTION = safe_score(_R_DISRUPTION_RAW) # 0.059
78
+ R_BREACH = safe_score(_R_BREACH_RAW) # 0.0296
79
 
80
  HEALTH_DRAIN_THRESHOLD = 0.15 # env.py: reward < this β†’ lose one life
81
 
82
 
83
  # ── Task Registry ──────────────────────────────────────────────────────────────
84
+ # SINGLE SOURCE OF TRUTH for task identity.
85
  #
86
+ # Keys must exactly match the task `id` values in openenv.yaml.
87
+ # The `email_types` set drives grade_action() dispatch.
88
+ # `threat` determines Breach vs Disruption penalty direction.
 
 
 
 
 
89
  # ─────────────────────────────────────────────────────────────────────────────
 
90
  TASK_REGISTRY: dict[str, dict] = {
91
  "task_spam": {
92
  "email_types": {"SPAM"},
 
130
  },
131
  }
132
 
133
+ # Reverse lookup: raw email_type string β†’ task_id (built once at import time)
 
134
  _TYPE_TO_TASK_ID: dict[str, str] = {
135
  etype: task_id
136
  for task_id, meta in TASK_REGISTRY.items()
 
140
 
141
  def resolve_task_id(email_type: str) -> str:
142
  """
143
+ Convert a raw email type (e.g. "PHISH") β†’ task_id (e.g. "task_phishing").
 
144
 
145
+ Raises ValueError for unknown types so misconfigured scenarios fail loudly
146
+ at startup rather than silently corrupting benchmark results.
147
  """
148
  key = email_type.strip().upper()
149
  if key not in _TYPE_TO_TASK_ID:
 
154
  return _TYPE_TO_TASK_ID[key]
155
 
156
 
157
+ # ── Per-task graders (one function per task_id) ────────────────────────────────
158
+ # Each grader takes a list of per-step rewards for that task type and returns
159
+ # a final safe_score. This is what openenv.yaml's grader field points to.
160
+
161
+ def grade_task_spam(rewards: list[float]) -> float:
162
+ """Final score for all task_spam steps in an episode."""
163
+ return _aggregate(rewards)
164
+
165
+ def grade_task_phishing(rewards: list[float]) -> float:
166
+ """Final score for all task_phishing steps in an episode."""
167
+ return _aggregate(rewards)
168
+
169
+ def grade_task_safe(rewards: list[float]) -> float:
170
+ """Final score for all task_safe steps in an episode."""
171
+ return _aggregate(rewards)
172
+
173
+ def grade_task_malware(rewards: list[float]) -> float:
174
+ """Final score for all task_malware steps in an episode."""
175
+ return _aggregate(rewards)
176
+
177
+ def grade_task_bec(rewards: list[float]) -> float:
178
+ """Final score for all task_bec steps in an episode."""
179
+ return _aggregate(rewards)
180
+
181
+
182
+ # Map task_id β†’ its grader function (consumed by env.py /grade endpoint)
183
+ TASK_GRADERS: dict[str, callable] = {
184
+ "task_spam": grade_task_spam,
185
+ "task_phishing": grade_task_phishing,
186
+ "task_safe": grade_task_safe,
187
+ "task_malware": grade_task_malware,
188
+ "task_bec": grade_task_bec,
189
+ }
190
+
191
+
192
+ def _aggregate(rewards: list[float]) -> float:
193
+ """Average a list of per-step rewards, return as a safe_score."""
194
+ if not rewards:
195
+ return R_BREACH
196
+ raw_avg = sum(rewards) / len(rewards)
197
+ # rewards are already safe_scores; average is still in (0,1) but clamp defensively
198
+ return round(max(R_BREACH, min(R_PERFECT, raw_avg)), 6)
199
+
200
+
201
+ # ── Core step grading function ─────────────────────────────────────────────────
202
 
203
  def grade_action(
204
  agent_output: str,
 
206
  email_type: str,
207
  ) -> Tuple[float, str]:
208
  """
209
+ Grade one SOC triage decision.
 
 
 
 
 
 
 
 
210
 
211
  Returns
212
  -------
213
  (reward, message)
214
+ reward : float strictly in (0.0, 1.0) via safe_score().
215
  message : short verdict string for logs and agent feedback.
216
  """
217
  agent_action = agent_output.strip().upper()
218
  expected_action = expected_output.strip().upper()
 
219
 
220
+ # resolve_task_id validates the type β€” raises ValueError on bad input
221
+ task_id = resolve_task_id(email_type)
222
  is_threat = TASK_REGISTRY[task_id]["threat"]
223
 
224
+ # ── 1. Perfect match ───────────────────────────────────────────────────
225
  if agent_action == expected_action:
226
  return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
227
 
228
+ # ── 2. Security Breach β€” threat marked safe ────────────────────────────
 
229
  if is_threat and agent_action == "MARK_SAFE":
230
  return R_BREACH, "SECURITY_BREACH: Threat allowed into the network"
231
 
232
+ # ── 3. Business Disruption β€” safe email blocked ────────────────────────
 
233
  if not is_threat and agent_action in {"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"}:
234
  return R_DISRUPTION, "BUSINESS_DISRUPTION: Legitimate communication blocked"
235
 
236
+ # ── 4. Partial credit ───────────────────────────��──────────────────────
 
 
 
 
237
  if task_id == "task_malware" and agent_action == "QUARANTINE":
238
  return R_MALWARE_QUARANTINE, "CAUTIOUS: Malware isolated β€” strong containment"
239
 
 
 
240
  if task_id in {"task_phishing", "task_bec"} and agent_action == "QUARANTINE":
241
  return R_PHISH_BEC_QUARANTINE, (
242
  "UNDER_RESPONSE: Threat quarantined but domain still active β€” "
243
  "consider BLOCK_DOMAIN"
244
  )
245
 
 
246
  if task_id == "task_spam" and agent_action == "BLOCK_DOMAIN":
247
  return R_SPAM_BLOCK, (
248
  "OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM β€” "
249
  "prefer MOVE_TO_SPAM"
250
  )
251
 
 
252
  if task_id == "task_spam" and agent_action == "QUARANTINE":
253
  return R_SPAM_QUARANTINE, (
254
  "OVER_ESCALATION: QUARANTINE wastes analyst capacity for SPAM β€” "
255
  "prefer MOVE_TO_SPAM"
256
  )
257
 
258
+ # ── 5. General wrong procedure ─────────────────────────────────────────
259
  return R_WRONG_PROCEDURE, "INCORRECT_PROCEDURE: Decision does not match security policy"
260
 
261
 
262
+ # ── Episode-level aggregation ──────────────────────────────────────────────────
263
 
264
+ def calculate_overall_score(task_scores: list[float]) -> float:
265
  """
266
+ Compute a final benchmark score from all per-step rewards.
267
+ Returns a float strictly in (0.0, 1.0).
 
 
 
 
 
 
 
 
 
 
268
  """
269
+ return _aggregate(task_scores)
 
 
 
 
 
270
 
271
 
272
  def calculate_per_task_scores(
273
  task_score_map: dict[str, list[float]],
274
  ) -> dict[str, float]:
275
  """
276
+ Compute per-task-type average scores.
 
 
277
 
278
  Parameters
279
  ----------
 
281
 
282
  Returns
283
  -------
284
+ { task_id: score } β€” each value strictly in (0.0, 1.0).
285
  """
286
  return {
287
+ task_id: _aggregate(scores)
288
  for task_id, scores in task_score_map.items()
289
  if scores
290
  }