File size: 14,484 Bytes
3ef7678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a75e08e
 
 
3ef7678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a75e08e
 
3ef7678
 
 
 
 
a75e08e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3ef7678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
"""
RepairEnv V2 — Main OpenEnv-compliant environment class.

HARD CONSTRAINTS ENFORCED:
- Hidden state: Observations only contain symptoms (logs + metrics).
- Strict step order: Action → Cascade → Degrade → Recovery Ticks → Symptoms → Reward
- Delayed recovery: repair_root_cause takes 2 steps to fully heal.
- Inspection cost: 3 free, then -0.1 reward per inspect.
"""

from __future__ import annotations

import copy
import random
from typing import Any, Dict, List, Optional, Tuple

from models_v2 import (
    Action,
    ActionType,
    ComponentMetrics,
    ComponentStatus,
    ComponentTarget,
    EpisodeResultV2,
    InspectResult,
    SymptomObservation,
    Reward,
    ACTION_COSTS
)
from symptom_engine import SymptomEngine

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

SEED = 42
STEP_PENALTY = -0.2
INSPECT_PENALTY = -0.1
MAX_FREE_INSPECTIONS = 3

COMPONENTS = ["database", "api", "cache", "queue"]

# ---------------------------------------------------------------------------
# RepairEnvV2
# ---------------------------------------------------------------------------

class RepairEnvV2:
    def __init__(self, seed: int = SEED) -> None:
        """OpenEnv-compatible constructor. Accepts optional seed (default 42)."""
        self._init_seed = seed
        self._task_config: Optional[Dict[str, Any]] = None
        
        # Hidden State
        self._true_state: Dict[str, Dict] = {}
        self._root_causes: List[str] = []
        self._recovery_cooldowns: Dict[str, int] = {}
        
        self._step_count: int = 0
        self._max_steps: int = 0
        self._task_id: str = ""
        self._done: bool = False
        
        self._total_reward: float = 0.0
        self._inspections_used: int = 0
        self._fixed_symptom_before_root: bool = False
        self._root_cause_fixed: bool = False
        
        self._rng: random.Random = random.Random(seed)
        self._symptom_engine: SymptomEngine = SymptomEngine(seed)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def reset(
        self,
        task_config: Optional[Dict[str, Any]] = None,
        task_id: str = "easy",
    ) -> SymptomObservation:
        """
        Reset the environment.

        OpenEnv calls this with no arguments → defaults to 'easy' task.
        server_v2.py calls it with (task_config=config, task_id=task_id) → still works.
        """
        # If no task_config provided, load from tasks_v2 by task_id
        if task_config is None:
            from tasks_v2 import TASKS
            if task_id not in TASKS:
                task_id = "easy"
            task_config = TASKS[task_id]

        self._task_config = copy.deepcopy(task_config)
        self._task_id = task_id
        
        self._max_steps = self._task_config["max_steps"]
        self._step_count = 0
        self._done = False
        self._total_reward = 0.0
        self._inspections_used = 0
        self._fixed_symptom_before_root = False
        self._root_causes_fixed: Dict[str, bool] = {}
        
        # Initialize Hidden State
        root = self._task_config["root_cause"]
        self._root_causes = root if isinstance(root, list) else [root]
        self._root_causes_fixed = {r: False for r in self._root_causes}
        self._true_state = copy.deepcopy(self._task_config["initial_state"])
        self._recovery_cooldowns = {c: 0 for c in COMPONENTS}
        
        # Ensures rng is deterministic per run
        seed = self._task_config.get("seed", SEED)
        self._rng.seed(seed)
        self._symptom_engine = SymptomEngine(seed)

        return self._generate_symptoms()

    def step(self, action: Action) -> Tuple[SymptomObservation, Reward, bool, Dict[str, Any]]:
        """
        Hard Rule #4: Strict Step Order
        1. Action
        2. Cascading
        3. Degradation
        4. Recovery Ticks
        5. Symptoms (Metrics + Logs)
        6. Reward computation
        """
        if self._done:
            raise RuntimeError("Episode is done. Call reset().")

        # Snapshot for reward computation
        old_health = sum(1 for c in self._true_state.values() if c["status"] == "healthy")
        
        # 1. Action
        action_reward_modifier, inspect_info = self._apply_action(action)

        # 2. Cascading (Side effects driven by root cause)
        self._apply_cascading_effects()

        # 3. Degradation (Passive worsening)
        self._apply_degradation()

        # 4. Recovery Ticks
        self._tick_recovery_cooldowns(action)

        # 5. Generate Symptoms
        obs = self._generate_symptoms()
        self._step_count += 1

        # Check terminal condition
        all_healthy = all(c["status"] == "healthy" for c in self._true_state.values())
        if all_healthy or self._step_count >= self._max_steps:
            self._done = True

        # 6. Compute Reward
        new_health = sum(1 for c in self._true_state.values() if c["status"] == "healthy")
        
        reward = self._compute_reward(
            action, old_health, new_health, 
            all_healthy, action_reward_modifier
        )
        self._total_reward = round(self._total_reward + reward.value, 4)

        # Info dict (transparency for grading, completely hidden from agent observation)
        info = {
            "step": self._step_count,
            "true_state_snapshot": copy.deepcopy(self._true_state),
            "root_causes": self._root_causes,
            "cooldowns": self._recovery_cooldowns.copy(),
            "inspect_result": inspect_info.model_dump() if inspect_info else None,
            "reward_reason": reward.reason
        }

        return obs, reward, self._done, info

    def get_episode_result(self) -> EpisodeResultV2:
        final_health = sum(1 for c in self._true_state.values() if c["status"] == "healthy")
        health_ratio = final_health / 4.0
        
        steps = self._step_count
        optimal = self._task_config.get("optimal_steps", 5)
        efficiency = max(0.0, min(1.0, 1.0 - (steps - optimal) * 0.05))
        
        all_roots_fixed = all(self._root_causes_fixed.values())
        correct_root_fix_ratio = 1.0 if (all_roots_fixed and not self._fixed_symptom_before_root) else 0.0
        
        score = (0.5 * health_ratio) + (0.3 * efficiency) + (0.2 * correct_root_fix_ratio)
        
        return EpisodeResultV2(
            task_id=self._task_id,
            steps_taken=steps,
            total_reward=self._total_reward,
            score=round(score, 4),
            success=(health_ratio == 1.0),
            final_health_ratio=health_ratio,
            efficiency=efficiency,
            correct_root_fix_ratio=correct_root_fix_ratio
        )

    # ------------------------------------------------------------------
    # Private Implementation 
    # ------------------------------------------------------------------

    def _apply_action(self, action: Action) -> Tuple[float, Optional[InspectResult]]:
        """Handles both repair and inspect actions. Returns (reward_mod, inspect_result)."""
        target = action.target
        atype = action.action_type
        
        # Apply strict action costs
        reward_modifier = ACTION_COSTS.get(atype, 0.0)
        inspect_res = None
        
        if atype.startswith("inspect_"):
            self._inspections_used += 1
            if self._inspections_used > MAX_FREE_INSPECTIONS:
                reward_modifier += INSPECT_PENALTY
                
            investigating_root = (target in self._root_causes)
            
            # If it's the root cause, high confidence.
            if investigating_root:
                conf = round(self._rng.uniform(0.75, 1.0), 2)
                hint = f"Critical failures traced back to {target}. High correlation with active symptoms."
                is_likely = True
            else:
                # Symptom. Low confidence, but might randomly say True (false positive).
                conf = round(self._rng.uniform(0.2, 0.55), 2)
                is_likely = self._rng.random() < 0.2 # 20% false positive
                hint = f"Anomalies detected in {target}, but could be upstream cascade."
                
            if self._true_state[target]["status"] == "healthy":
                conf = round(self._rng.uniform(0.8, 1.0), 2)
                is_likely = False
                hint = f"{target} appears healthy. No immediate faults found."
                
            inspect_res = InspectResult(
                component=target,
                hint=hint,
                confidence=conf,
                is_likely_root_cause=is_likely
            )
            
        elif atype == ActionType.repair_database and target == "database":
            # Delayed recovery logic.
            if self._true_state["database"]["status"] != "healthy":
                if "database" in self._root_causes:
                    self._recovery_cooldowns["database"] = 2
                else:
                    self._recovery_cooldowns["database"] = 1
                    
        elif atype == ActionType.restart_service:
            # Short term symptom fix. Will be overwritten by cascade if root still broken.
            if self._true_state[target]["status"] == "down":
                self._true_state[target]["status"] = "degraded"
            elif self._true_state[target]["status"] == "degraded":
                self._true_state[target]["status"] = "healthy"
                
            active_roots = [r for r in self._root_causes if self._true_state[r]["status"] != "healthy"]
            if target not in self._root_causes and active_roots:
                # They are fixing a symptom before the root cause
                self._fixed_symptom_before_root = True
                
            if target in self._root_causes:
                self._recovery_cooldowns[target] = 1 # Restarts are slightly faster but less reliable than dedicated tools
                
        # Other actions omitted for brevity, mapping easily to V1 equivalents where needed.
        elif atype == ActionType.clear_cache and target == "cache":
            if self._true_state["cache"]["status"] != "healthy":
                self._true_state["cache"]["status"] = "healthy"
                
        return reward_modifier, inspect_res

    def _apply_cascading_effects(self):
        """Root-cause driven cascades. Overwrites temporary symptom fixes."""
        if "database" in self._root_causes and self._true_state["database"]["status"] != "healthy":
            # DB brings down the API
            self._true_state["api"]["status"] = "degraded"
            self._true_state["queue"]["status"] = "degraded"
            
        if "cache" in self._root_causes and self._true_state["cache"]["status"] != "healthy":
            # Cache pressure ruins queue
            self._true_state["queue"]["status"] = "degraded"
            
    def _apply_degradation(self):
        """Passive worsening."""
        pass # Currently handled implicitly by SymptomEngine metric generation, but placeholder for hard state drops

    def _tick_recovery_cooldowns(self, action: Action):
        for c in COMPONENTS:
            if self._recovery_cooldowns[c] > 0:
                # Don't tick the cooldown if we *just* started it this exact step.
                if action.target == c and action.action_type in [ActionType.repair_database, ActionType.restart_service]:
                    continue
                self._recovery_cooldowns[c] -= 1
                if self._recovery_cooldowns[c] == 0:
                    self._true_state[c]["status"] = "healthy"
                    if c in self._root_causes:
                        self._root_causes_fixed[c] = True

    def _generate_symptoms(self) -> SymptomObservation:
        metrics = self._symptom_engine.generate_metrics(self._true_state)
        logs = self._symptom_engine.generate_logs(self._true_state, self._step_count)
        inspections = max(0, MAX_FREE_INSPECTIONS - self._inspections_used)
        
        return SymptomObservation(
            metrics=metrics,
            logs=logs,
            step_count=self._step_count,
            max_steps=self._max_steps,
            inspections_remaining=inspections
        )

    def _compute_reward(self, 
                        action: Action, 
                        old_health: int, new_health: int,
                        all_healthy: bool,
                        modifier: float) -> Reward:
        
        base = STEP_PENALTY + modifier
        reason = []
        
        if modifier < 0:
            reason.append(f"Inspection cost ({modifier})")
            
        if action.action_type == ActionType.no_op:
            return Reward(value=base - 0.5, reason="Useless actions: no_op")
            
        # Root cause fix detection is delayed by cooldown, so we check if cooldown was just set 
        # (meaning the correct action was taken this turn)
        root_cooldown_started = (
            action.target in self._root_causes 
            and self._recovery_cooldowns[action.target] > 0
            and action.action_type in [ActionType.repair_database, ActionType.restart_service, ActionType.clear_cache]
        )
            
        if root_cooldown_started:
            base += 1.5
            reason.append(f"Root cause addressed ({action.target})")
        elif new_health > old_health:
            base += 0.5
            reason.append("Symptom improved")
        elif action.target not in self._root_causes and not action.action_type.startswith("inspect"):
            # They tried to fix a symptom that is immediately cascaded away (or just wrong)
            active_roots = [r for r in self._root_causes if self._true_state[r]["status"] != "healthy"]
            if active_roots:
                base -= 1.0
                reason.append("Wrong component targeted (root cause still active)")
            else:
                base -= 0.5
                reason.append("Component action had no net effect")
                
        if all_healthy:
            base += 5.0
            reason.append("Terminal Bonus: All healthy")
            
        return Reward(value=round(base, 4), reason=" | ".join(reason) if reason else "Step tick")