Spaces:
Sleeping
Sleeping
| """ | |
| 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, | |
| } | |
| 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, []), | |
| } | |