ledgershield / server /curriculum.py
king673134's picture
Upload folder using huggingface_hub
007fbdd verified
Raw
History Blame Contribute Delete
9.79 kB
"""
Dynamic difficulty adaptation (curriculum learning) for LedgerShield.
Implements an adaptive curriculum that adjusts case difficulty and attack
complexity based on the agent's running performance. This ensures:
1. **Warm-up**: New agents start with easier cases to learn the tool API.
2. **Progressive challenge**: As performance improves, harder cases and
more sophisticated attacks are introduced.
3. **Plateau-breaking**: If performance stagnates, difficulty is modulated
to expose specific weaknesses.
4. **Mastery gating**: Expert-level cases (Task E, APT scenarios) are only
unlocked after demonstrating competence on prerequisite tasks.
The curriculum tracks a sliding-window ELO-like competence score and maps
it to difficulty tiers:
Tier 0 (Novice): score < 0.30 → task_a, task_b only, no attacks
Tier 1 (Competent): 0.30 ≤ score < 0.55 → task_a–c, basic attacks
Tier 2 (Proficient): 0.55 ≤ score < 0.75 → task_a–d, medium attacks
Tier 3 (Expert): score ≥ 0.75 → all tasks, full attack library
Design Decision:
We use exponential moving average (EMA) rather than raw windowed mean
to give more weight to recent performance while maintaining stability.
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass, field
from typing import Any
from .schema import normalize_text
# --- Difficulty tiers ---
TIER_NOVICE = 0
TIER_COMPETENT = 1
TIER_PROFICIENT = 2
TIER_EXPERT = 3
TIER_NAMES = {
TIER_NOVICE: "novice",
TIER_COMPETENT: "competent",
TIER_PROFICIENT: "proficient",
TIER_EXPERT: "expert",
}
TIER_THRESHOLDS = [0.0, 0.30, 0.55, 0.75]
TIER_ALLOWED_TASKS: dict[int, list[str]] = {
TIER_NOVICE: ["task_a", "task_b"],
TIER_COMPETENT: ["task_a", "task_b", "task_c"],
TIER_PROFICIENT: ["task_a", "task_b", "task_c", "task_d"],
TIER_EXPERT: ["task_a", "task_b", "task_c", "task_d", "task_e"],
}
TIER_ALLOWED_ATTACKS: dict[int, list[str]] = {
TIER_NOVICE: [],
TIER_COMPETENT: ["urgency_spoof_attack", "fake_receipt_attack"],
TIER_PROFICIENT: [
"urgency_spoof_attack",
"fake_receipt_attack",
"near_duplicate_invoice_attack",
"approval_threshold_evasion_attack",
"bank_override_attack",
],
TIER_EXPERT: [], # Empty means ALL attacks are allowed
}
TIER_MAX_STEPS: dict[int, int] = {
TIER_NOVICE: 25,
TIER_COMPETENT: 22,
TIER_PROFICIENT: 20,
TIER_EXPERT: 18,
}
TIER_BUDGET_MULTIPLIER: dict[int, float] = {
TIER_NOVICE: 1.3,
TIER_COMPETENT: 1.15,
TIER_PROFICIENT: 1.0,
TIER_EXPERT: 0.9,
}
@dataclass
class CurriculumState:
"""Tracks the agent's progression through the curriculum.
Attributes:
competence_ema: Exponential moving average of task scores (0.0–1.0).
ema_alpha: Smoothing factor for EMA updates.
episode_count: Total episodes completed.
tier: Current difficulty tier (0–3).
tier_history: List of (episode, tier) transitions.
score_history: Sliding window of recent scores.
window_size: Number of recent scores to retain for diagnostics.
task_scores: Per-task-type running averages {task_type: [scores]}.
weakest_task: Task type with the lowest running average.
stagnation_counter: Episodes since last meaningful improvement.
last_improvement_episode: Episode number of last score improvement.
"""
competence_ema: float = 0.10
ema_alpha: float = 0.15
episode_count: int = 0
tier: int = TIER_NOVICE
tier_history: list[dict[str, Any]] = field(default_factory=list)
score_history: list[float] = field(default_factory=list)
window_size: int = 20
task_scores: dict[str, list[float]] = field(default_factory=dict)
weakest_task: str = ""
stagnation_counter: int = 0
last_improvement_episode: int = 0
def _score_to_tier(score: float) -> int:
"""Map a competence score to a difficulty tier.
Args:
score: Competence EMA value in [0.0, 1.0].
Returns:
Integer tier level (0–3).
"""
tier = TIER_NOVICE
for threshold_tier, threshold in enumerate(TIER_THRESHOLDS):
if score >= threshold:
tier = threshold_tier
return min(tier, TIER_EXPERT)
def update_curriculum(
state: CurriculumState,
task_type: str,
episode_score: float,
) -> CurriculumState:
"""Update the curriculum state after an episode completes.
Applies EMA smoothing to the competence score, updates per-task
statistics, detects stagnation, and adjusts the difficulty tier.
Args:
state: Current curriculum state (mutated in place).
task_type: The task type just completed (e.g. 'task_c').
episode_score: The score achieved (0.0–1.0).
Returns:
The updated CurriculumState.
"""
state.episode_count += 1
clamped_score = max(0.0, min(1.0, float(episode_score)))
# Update EMA
state.competence_ema = (
state.ema_alpha * clamped_score
+ (1.0 - state.ema_alpha) * state.competence_ema
)
# Update score history (sliding window)
state.score_history.append(clamped_score)
if len(state.score_history) > state.window_size:
state.score_history = state.score_history[-state.window_size:]
# Update per-task scores
task_norm = normalize_text(task_type)
state.task_scores.setdefault(task_norm, []).append(clamped_score)
if len(state.task_scores[task_norm]) > state.window_size:
state.task_scores[task_norm] = state.task_scores[task_norm][-state.window_size:]
# Identify weakest task
task_averages: dict[str, float] = {}
for t_type, scores in state.task_scores.items():
if scores:
task_averages[t_type] = sum(scores) / len(scores)
if task_averages:
state.weakest_task = min(task_averages, key=task_averages.get) # type: ignore[arg-type]
# Detect stagnation
if clamped_score > state.competence_ema + 0.02:
state.stagnation_counter = 0
state.last_improvement_episode = state.episode_count
else:
state.stagnation_counter += 1
# Update tier
new_tier = _score_to_tier(state.competence_ema)
if new_tier != state.tier:
state.tier_history.append({
"episode": state.episode_count,
"from_tier": state.tier,
"to_tier": new_tier,
"competence_ema": round(state.competence_ema, 4),
})
state.tier = new_tier
return state
def select_next_case(
state: CurriculumState,
available_cases: list[dict[str, Any]],
seed: int | None = None,
) -> dict[str, Any]:
"""Select the next case from available cases based on curriculum state.
Selection strategy:
1. Filter cases to those allowed by current tier.
2. If stagnating, bias toward the weakest task type.
3. Otherwise, sample uniformly from allowed cases.
Args:
state: Current curriculum state.
available_cases: List of all available case dicts.
seed: Optional random seed for reproducibility.
Returns:
Selected case dictionary.
Raises:
ValueError: If no cases are available for the current tier.
"""
rng = random.Random(seed)
allowed_tasks = set(TIER_ALLOWED_TASKS.get(state.tier, ["task_a"]))
eligible = [
case for case in available_cases
if normalize_text(case.get("task_type", "")) in allowed_tasks
]
if not eligible:
# Fallback: allow all cases
eligible = list(available_cases)
if not eligible:
raise ValueError("No cases available for curriculum selection.")
# Stagnation override: bias toward weakest task
if state.stagnation_counter > 5 and state.weakest_task:
weak_cases = [
case for case in eligible
if normalize_text(case.get("task_type", "")) == state.weakest_task
]
if weak_cases:
eligible = weak_cases
return rng.choice(eligible)
def adjust_case_for_tier(
case: dict[str, Any],
tier: int,
) -> dict[str, Any]:
"""Adjust case parameters based on the current difficulty tier.
Modifies max_steps and budget_total to match the tier's difficulty.
Args:
case: Case dictionary to adjust (not mutated, returns a copy).
tier: Current difficulty tier (0–3).
Returns:
Adjusted case dictionary.
"""
from copy import deepcopy
adjusted = deepcopy(case)
tier_max_steps = TIER_MAX_STEPS.get(tier, 20)
tier_budget_mult = TIER_BUDGET_MULTIPLIER.get(tier, 1.0)
case_max_steps = int(adjusted.get("max_steps", 20) or 20)
adjusted["max_steps"] = max(case_max_steps, tier_max_steps)
case_budget = float(adjusted.get("budget_total", 15.0) or 15.0)
adjusted["budget_total"] = round(case_budget * tier_budget_mult, 2)
return adjusted
def curriculum_summary(state: CurriculumState) -> dict[str, Any]:
"""Generate a human-readable summary of the curriculum state.
Args:
state: Current curriculum state.
Returns:
Dictionary with summary statistics.
"""
recent_avg = (
sum(state.score_history[-5:]) / max(len(state.score_history[-5:]), 1)
if state.score_history else 0.0
)
return {
"tier": state.tier,
"tier_name": TIER_NAMES.get(state.tier, "unknown"),
"competence_ema": round(state.competence_ema, 4),
"episode_count": state.episode_count,
"recent_5_avg": round(recent_avg, 4),
"weakest_task": state.weakest_task,
"stagnation_counter": state.stagnation_counter,
"tier_transitions": len(state.tier_history),
"allowed_tasks": TIER_ALLOWED_TASKS.get(state.tier, []),
}