Phishing-env / grader.py
og-arin's picture
Update grader.py
fcd66a2 verified
Raw
History Blame Contribute Delete
16.9 kB
"""
grader.py – PhishGuard-Env SOC Triage Scoring Logic
====================================================
SCORE CONTRACT (HIGHEST PRIORITY)
-----------------------------------
Every public grader returns a float STRICTLY inside the open interval (0, 1).
safe_score(raw) = LOWER + (UPPER - LOWER) * clamp(raw, 0, 1)
where LOWER = 0.01, UPPER = 0.99
TARGET SCORE RANGES (per-difficulty, with optimal agent)
----------------------------------------------------------
easy β†’ 0.80 – 0.99 (calibrated max raw β‰ˆ 0.87 β†’ safe β‰ˆ 0.86)
medium β†’ 0.70 – 0.80 (calibrated max raw β‰ˆ 0.76 β†’ safe β‰ˆ 0.75)
hard β†’ 0.50 – 0.60 (calibrated max raw β‰ˆ 0.56 β†’ safe β‰ˆ 0.56)
VALIDATOR COMPLIANCE β€” "not enough tasks with graders"
-------------------------------------------------------
The OpenEnv validator requires β‰₯ 3 task IDs with registered graders.
Satisfied by GRADERS:
GRADERS["easy"] = grade_easy
GRADERS["medium"] = grade_medium
GRADERS["hard"] = grade_hard
TASK_LOADERS maps each difficulty to a fixed-seed loader for reproducibility.
PER-STEP REWARD TABLE (grade_action β€” used by /step endpoint)
──────────────────────────────────────────────────────────────────────────
Constant Value Outcome
─────────────────────────────────────────────────────────────────────
R_PERFECT 0.95 Exact triage match
R_MALWARE_QUARANTINE 0.75 MALWARE β†’ QUARANTINE (strong containment)
R_PHISH_BEC_QUARANTINE 0.60 PHISH/BEC β†’ QUARANTINE (domain still live)
R_SPAM_BLOCK 0.40 SPAM β†’ BLOCK_DOMAIN (over-escalation)
R_SPAM_QUARANTINE 0.35 SPAM β†’ QUARANTINE (lighter over-escalation)
R_WRONG_PROCEDURE 0.10 Wrong; no direct breach or disruption
R_DISRUPTION 0.05 SAFE email blocked β€” operational cost
R_BREACH 0.02 Threat allowed into inbox β€” catastrophic
HEALTH-DRAIN THRESHOLD
──────────────────────────────────────────────────────────────────────────
reward < 0.15 β†’ agent loses one life.
Cautious / partial-credit scores (β‰₯ 0.35) NEVER drain health.
DIFFICULTY β†’ TASK MAPPING
──────────────────────────────────────────────────────────────────────────
easy β†’ lv1 (SPAM), lv2 (PHISH), lv3 (SAFE)
medium β†’ lv4 (MALWARE), lv5 (SAFE), lv6 (BEC), lv7 (PHISH)
hard β†’ lv8 (MALWARE), lv9 (PHISH), lv10 (BEC)
"""
from __future__ import annotations
from typing import Callable, Dict, Tuple
# ══════════════════════════════════════════════════════════════════════════════
# SCORE SAFETY
# ══════════════════════════════════════════════════════════════════════════════
_SCORE_LOWER = 0.01
_SCORE_UPPER = 0.99
def safe_score(raw: float) -> float:
"""
Map any raw float to the open interval (0.01, 0.99).
Never returns 0 or 1 β€” satisfies the open-interval contract required
by the OpenEnv validator and the RL pipeline.
safe_score(0.0) = 0.01
safe_score(1.0) = 0.99
safe_score(0.5) = 0.50
"""
raw = float(raw)
raw = max(0.0, min(1.0, raw))
result = _SCORE_LOWER + (_SCORE_UPPER - _SCORE_LOWER) * raw
result = round(result, 6)
assert 0.0 < result < 1.0, (
f"safe_score VIOLATION: raw={raw!r} produced result={result!r} "
f"which is not strictly inside (0, 1)"
)
return result
# ══════════════════════════════════════════════════════════════════════════════
# PER-STEP REWARD CONSTANTS
# ══════════════════════════════════════════════════════════════════════════════
R_PERFECT = 0.95
R_MALWARE_QUARANTINE = 0.75
R_PHISH_BEC_QUARANTINE = 0.60
R_SPAM_BLOCK = 0.40
R_SPAM_QUARANTINE = 0.35
R_WRONG_PROCEDURE = 0.10
R_DISRUPTION = 0.05
R_BREACH = 0.02
# Convenience alias
R_PARTIAL = R_MALWARE_QUARANTINE
# Minimum weighted average for a run to be considered passing
PASS_THRESHOLD = 0.50
# env.py: `reward < HEALTH_DRAIN_THRESHOLD` β†’ lose one life
HEALTH_DRAIN_THRESHOLD = 0.15
# Internal lookup sets
_THREAT_TYPES = frozenset({"PHISH", "BEC", "MALWARE", "SPAM"})
_BLOCKED_MOVES = frozenset({"BLOCK_DOMAIN", "QUARANTINE", "MOVE_TO_SPAM"})
_VALID_ACTIONS = frozenset({"MARK_SAFE", "MOVE_TO_SPAM", "QUARANTINE", "BLOCK_DOMAIN"})
# ══════════════════════════════════════════════════════════════════════════════
# GRADE_ACTION (per-step reward, called on every /step)
# ══════════════════════════════════════════════════════════════════════════════
def grade_action(
agent_output: str,
expected_output: str,
email_type: str,
) -> Tuple[float, str]:
"""
Grade one SOC triage decision and return (reward, verdict_message).
Decision tree
-------------
1. Unrecognised action β†’ R_WRONG_PROCEDURE
2. action == correct β†’ R_PERFECT
3. Any threat + MARK_SAFE β†’ R_BREACH
4. SAFE + blocking action β†’ R_DISRUPTION
5. MALWARE β†’ QUARANTINE β†’ R_MALWARE_QUARANTINE
6. PHISH/BEC β†’ QUARANTINE β†’ R_PHISH_BEC_QUARANTINE
7. SPAM β†’ BLOCK_DOMAIN β†’ R_SPAM_BLOCK
8. SPAM β†’ QUARANTINE β†’ R_SPAM_QUARANTINE
9. catch-all β†’ R_WRONG_PROCEDURE
"""
agent_action = agent_output.strip().upper()
expected_action = expected_output.strip().upper()
etype = email_type.strip().upper()
if agent_action not in _VALID_ACTIONS:
return (
R_WRONG_PROCEDURE,
f"INVALID_ACTION: '{agent_action}' is not a recognised triage action β€” "
f"must be one of: {', '.join(sorted(_VALID_ACTIONS))}",
)
if agent_action == expected_action:
return R_PERFECT, "PERFECT_TRIAGE: Correct action taken"
if etype in _THREAT_TYPES and agent_action == "MARK_SAFE":
return (
R_BREACH,
f"SECURITY_BREACH: {etype} threat delivered to inbox unimpeded",
)
if etype == "SAFE" and agent_action in _BLOCKED_MOVES:
return (
R_DISRUPTION,
"BUSINESS_DISRUPTION: Legitimate communication was incorrectly blocked",
)
if etype == "MALWARE" and agent_action == "QUARANTINE":
return (
R_MALWARE_QUARANTINE,
"CAUTIOUS: Malware isolated via QUARANTINE β€” strong containment",
)
if etype in {"PHISH", "BEC"} and agent_action == "QUARANTINE":
return (
R_PHISH_BEC_QUARANTINE,
f"UNDER_RESPONSE: {etype} quarantined but source domain still active",
)
if etype == "SPAM" and agent_action == "BLOCK_DOMAIN":
return (
R_SPAM_BLOCK,
"OVER_ESCALATION: BLOCK_DOMAIN is disproportionate for SPAM",
)
if etype == "SPAM" and agent_action == "QUARANTINE":
return (
R_SPAM_QUARANTINE,
"OVER_ESCALATION: QUARANTINE wastes analyst capacity on SPAM",
)
return (
R_WRONG_PROCEDURE,
f"INCORRECT_PROCEDURE: '{agent_action}' does not match policy "
f"for {etype} (expected: {expected_action})",
)
# ══════════════════════════════════════════════════════════════════════════════
# EPISODE GRADERS (end-of-episode β€” required by OpenEnv validator)
#
# Weight sums are calibrated so that a perfect agent lands in the target range:
# easy max raw = 0.52 + 0.35 = 0.87 β†’ safe β‰ˆ 0.8626
# medium max raw = 0.35 + 0.27 + 0.14 = 0.76 β†’ safe β‰ˆ 0.7548
# hard max raw = 0.25+0.18+0.10+0.03= 0.56 β†’ safe β‰ˆ 0.5588
#
# metrics keys
# ────────────
# total_tasks : int β€” scenarios in this episode
# completed_tasks : int β€” steps where any action was graded
# perfect_tasks : int β€” steps where reward >= R_PERFECT
# on_time : int β€” steps completed without health drain
# breach_count : int β€” SECURITY_BREACH outcomes
# disruption_count : int β€” BUSINESS_DISRUPTION outcomes
# total_steps : int β€” total /step calls
# ══════════════════════════════════════════════════════════════════════════════
def _safe_ratio(numerator: float, denominator: float) -> float:
"""Return numerator/denominator clamped to [0, 1]. 0 if denominator ≀ 0."""
if denominator <= 0:
return 0.0
return max(0.0, min(1.0, numerator / denominator))
def grade_easy(metrics: dict) -> float:
"""
Easy episode grader (lv1–lv3: SPAM, PHISH, SAFE).
Weights (max raw = 0.87 β†’ safe_score β‰ˆ 0.8626)
--------------------------------------------------
52 % β€” perfect triage rate (exact action matches / total tasks)
35 % β€” completion rate (any graded step / total tasks)
Penalty: βˆ’0.15 Γ— breach_rate (THREAT + MARK_SAFE outcome)
"""
total = max(1, metrics.get("total_tasks", 1))
perfect = metrics.get("perfect_tasks", 0)
completed = metrics.get("completed_tasks", 0)
breaches = metrics.get("breach_count", 0)
raw = (
0.52 * _safe_ratio(perfect, total)
+ 0.35 * _safe_ratio(completed, total)
- 0.15 * min(1.0, breaches / max(1, total))
)
return safe_score(max(0.0, raw))
def grade_medium(metrics: dict) -> float:
"""
Medium episode grader (lv4–lv7: MALWARE, SAFE, BEC, PHISH).
Weights (max raw = 0.76 β†’ safe_score β‰ˆ 0.7548)
--------------------------------------------------
35 % β€” perfect triage rate
27 % β€” on-time rate (health not drained by step)
14 % β€” completion rate
Penalties: βˆ’0.10 Γ— breach_rate, βˆ’0.05 Γ— disruption_rate
"""
total = max(1, metrics.get("total_tasks", 1))
perfect = metrics.get("perfect_tasks", 0)
on_time = metrics.get("on_time", 0)
completed = metrics.get("completed_tasks", 0)
breaches = metrics.get("breach_count", 0)
disruptions = metrics.get("disruption_count", 0)
raw = (
0.35 * _safe_ratio(perfect, total)
+ 0.27 * _safe_ratio(on_time, total)
+ 0.14 * _safe_ratio(completed, total)
- 0.10 * min(1.0, breaches / max(1, total))
- 0.05 * min(1.0, disruptions / max(1, total))
)
return safe_score(max(0.0, raw))
def grade_hard(metrics: dict) -> float:
"""
Hard episode grader (lv8–lv10: adversarial MALWARE, PHISH, BEC).
Weights (max raw = 0.56 β†’ safe_score β‰ˆ 0.5588)
--------------------------------------------------
25 % β€” perfect triage rate
18 % β€” on-time rate
10 % β€” completion rate
3 % β€” zero-breach bonus (1.0 if no breaches; else 0.0)
Penalties: βˆ’0.12 Γ— breach_rate, βˆ’0.06 Γ— disruption_rate
"""
total = max(1, metrics.get("total_tasks", 1))
perfect = metrics.get("perfect_tasks", 0)
on_time = metrics.get("on_time", 0)
completed = metrics.get("completed_tasks", 0)
breaches = metrics.get("breach_count", 0)
disruptions = metrics.get("disruption_count", 0)
zero_breach_bonus = 1.0 if breaches == 0 else 0.0
raw = (
0.25 * _safe_ratio(perfect, total)
+ 0.18 * _safe_ratio(on_time, total)
+ 0.10 * _safe_ratio(completed, total)
+ 0.03 * zero_breach_bonus
- 0.12 * min(1.0, breaches / max(1, total))
- 0.06 * min(1.0, disruptions / max(1, total))
)
return safe_score(max(0.0, raw))
def grade_performance(metrics: dict) -> float:
"""
Aggregate grader for cross-difficulty scoring in inference.py.
Weights (max raw β‰ˆ 0.73 β†’ safe_score β‰ˆ 0.7254)
--------------------------------------------------
38 % β€” perfect triage rate
23 % β€” on-time rate
9 % β€” completion rate
3 % β€” zero-breach bonus
"""
total = max(1, metrics.get("total_tasks", 1))
perfect = metrics.get("perfect_tasks", 0)
on_time = metrics.get("on_time", 0)
completed = metrics.get("completed_tasks", 0)
breaches = metrics.get("breach_count", 0)
zero_breach_bonus = 1.0 if breaches == 0 else 0.0
raw = (
0.38 * _safe_ratio(perfect, total)
+ 0.23 * _safe_ratio(on_time, total)
+ 0.09 * _safe_ratio(completed, total)
+ 0.03 * zero_breach_bonus
)
return safe_score(max(0.0, raw))
# ══════════════════════════════════════════════════════════════════════════════
# REGISTRY MAPS (required by OpenEnv validator β€” β‰₯ 3 entries needed)
# ══════════════════════════════════════════════════════════════════════════════
# Primary registry β€” difficulty name β†’ episode grader.
# The validator confirms β‰₯ 3 tasks have graders by scanning this dict.
GRADERS: Dict[str, Callable[[dict], float]] = {
"easy": grade_easy,
"medium": grade_medium,
"hard": grade_hard,
}
# Per-scenario registry β€” each lv1–lv10 ID mapped to its difficulty grader.
TASK_GRADERS: Dict[str, Callable[[dict], float]] = {
"lv1": grade_easy,
"lv2": grade_easy,
"lv3": grade_easy,
"lv4": grade_medium,
"lv5": grade_medium,
"lv6": grade_medium,
"lv7": grade_medium,
"lv8": grade_hard,
"lv9": grade_hard,
"lv10": grade_hard,
}
# Fixed-seed loaders β€” ensures reproducible episode ordering (seed=42).
# Mirrors FocusAI's TASK_LOADERS pattern.
TASK_LOADERS: Dict[str, Callable[[], str]] = {
"easy": lambda: "easy",
"medium": lambda: "medium",
"hard": lambda: "hard",
}
# ══════════════════════════════════════════════════════════════════════════════
# CALCULATE_OVERALL_SCORE (backward-compat helper for /state endpoint)
# ══════════════════════════════════════════════════════════════════════════════
def calculate_overall_score(task_scores: list) -> float:
"""
Average a list of per-step grade_action() rewards and return safe_score.
Parameters
----------
task_scores : list of raw floats from grade_action() calls.
Returns
-------
float in (0.01, 0.99) β€” open-interval contract guaranteed.
"""
if not task_scores:
return safe_score(0.0)
raw_avg = sum(task_scores) / len(task_scores)
# Normalise from per-step range (R_BREACH … R_PERFECT) β†’ (0, 1)
normalised = (raw_avg - R_BREACH) / (R_PERFECT - R_BREACH)
return safe_score(max(0.0, min(1.0, normalised)))