File size: 11,335 Bytes
ac8898f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
357
358
359
360
361
362
363
"""
Tabular Q-Learning Agent for ChaosLab.

A classic reinforcement learning agent that maintains a Q-table
(Python dict) mapping (state_hash, action) β†’ expected reward.

NO neural networks. NO GPU. Pure Python + numpy.

The observation (4096-dim ASCII vector) is hashed into a compact
state key using a combination of keyword detection and structural
fingerprinting, keeping the table size manageable.

Implements `.predict(obs)` matching the stable-baselines3 API
so it plugs into the model registry seamlessly.

Training:
    from src.qlearning_agent import QLearningAgent, train_qlearning
    agent = train_qlearning(scenario="full_incident", episodes=500)
    agent.save("models/qlearning_model.json")
"""

from __future__ import annotations

import json
import hashlib
import numpy as np
import os
import random
from typing import Any, Dict, List, Optional, Tuple
from collections import defaultdict

from .rl_env import ACTION_CATALOG, NUM_ACTIONS


# ======================================================================
#  STATE HASHING β€” compress 4096-dim observation into a table key
# ======================================================================

# Keywords to detect in the observation text for state fingerprinting
_STATE_KEYWORDS = [
    "DEAD", "dead", "running", "RUNNING",
    "error", "500", "502",
    "Failed password", "brute",
    "permission", "executable",
    "disk", "full", "90%", "95%", "100%",
    "memory", "low",
    "cron", "FAILED",
    "nginx", "postgres", "app",
    "cleanup.sh", "backup.sh",
    "Score: 0.0", "Score: 0.2", "Score: 0.5", "Score: 0.8", "Score: 1.0",
    "/var/log", "/home/user",
]


def _decode_obs(obs: np.ndarray) -> str:
    """Decode ASCII-encoded observation back to text."""
    chars = []
    for code in obs:
        c = int(code)
        if c == 0:
            break
        if 0 < c < 128:
            chars.append(chr(c))
    return "".join(chars)


def _hash_state(obs: np.ndarray) -> str:
    """
    Hash a 4096-dim ASCII observation into a compact state key.

    Strategy: detect which keywords are present in the text and
    create a binary fingerprint. This maps the huge observation
    space into a manageable number of distinct states (~2^30 max,
    but in practice much fewer).
    """
    text = _decode_obs(obs)
    text_lower = text.lower()

    # Binary fingerprint: which keywords are present?
    bits = []
    for kw in _STATE_KEYWORDS:
        bits.append("1" if kw.lower() in text_lower else "0")

    fingerprint = "".join(bits)

    # Also include a rough hash of the first 200 chars for
    # distinguishing structurally different observations
    prefix_hash = hashlib.md5(text[:200].encode()).hexdigest()[:6]

    return f"{fingerprint}_{prefix_hash}"


# ======================================================================
#  TABULAR Q-LEARNING AGENT
# ======================================================================

class QLearningAgent:
    """
    Tabular Q-Learning with epsilon-greedy exploration.

    Q-table is a Python dict: { (state_hash, action_idx): q_value }

    Hyperparameters:
        alpha:   Learning rate (how fast Q-values update)
        gamma:   Discount factor (importance of future rewards)
        epsilon: Exploration rate (probability of random action)
    """

    def __init__(
        self,
        alpha: float = 0.1,
        gamma: float = 0.95,
        epsilon: float = 0.1,
    ):
        self.alpha = alpha
        self.gamma = gamma
        self.epsilon = epsilon

        # Q-table: (state_hash, action) β†’ q_value
        self.q_table: Dict[str, float] = defaultdict(float)

        # Tracking
        self._prev_state: Optional[str] = None
        self._prev_action: Optional[int] = None
        self._episode_count: int = 0
        self._total_updates: int = 0

    def predict(
        self,
        obs: np.ndarray,
        deterministic: bool = True,
    ) -> Tuple[np.ndarray, None]:
        """
        Pick an action given observation (SB3-compatible interface).

        When deterministic=True (inference), always pick the best action.
        When deterministic=False (training), use epsilon-greedy.
        """
        state = _hash_state(obs)

        if not deterministic and random.random() < self.epsilon:
            # Explore: random action
            action = random.randint(0, NUM_ACTIONS - 1)
        else:
            # Exploit: pick action with highest Q-value
            action = self._best_action(state)

        return np.array(action), None

    def update(
        self,
        state_obs: np.ndarray,
        action: int,
        reward: float,
        next_obs: np.ndarray,
        done: bool,
    ):
        """
        Q-Learning update rule:

        Q(s, a) ← Q(s, a) + Ξ± * [r + Ξ³ * max_a' Q(s', a') - Q(s, a)]
        """
        state = _hash_state(state_obs)
        next_state = _hash_state(next_obs)

        old_q = self.q_table[f"{state}_{action}"]

        if done:
            target = reward
        else:
            # max Q-value for next state across all actions
            best_next = max(
                self.q_table.get(f"{next_state}_{a}", 0.0)
                for a in range(NUM_ACTIONS)
            )
            target = reward + self.gamma * best_next

        # Q-Learning update
        self.q_table[f"{state}_{action}"] = old_q + self.alpha * (target - old_q)
        self._total_updates += 1

    def _best_action(self, state: str) -> int:
        """Return action with highest Q-value for given state."""
        best_val = float("-inf")
        best_actions = [0]

        for a in range(NUM_ACTIONS):
            q = self.q_table.get(f"{state}_{a}", 0.0)
            if q > best_val:
                best_val = q
                best_actions = [a]
            elif q == best_val:
                best_actions.append(a)

        # Break ties randomly
        return random.choice(best_actions)

    def save(self, path: str):
        """Save Q-table + metadata to JSON file."""
        data = {
            "q_table": dict(self.q_table),
            "alpha": self.alpha,
            "gamma": self.gamma,
            "epsilon": self.epsilon,
            "episode_count": self._episode_count,
            "total_updates": self._total_updates,
            "num_states": len(set(
                k.rsplit("_", 1)[0] for k in self.q_table.keys()
            )),
            "table_size": len(self.q_table),
        }
        with open(path, "w") as f:
            json.dump(data, f)

    @classmethod
    def load(cls, path: str) -> "QLearningAgent":
        """Load a trained Q-table from JSON file."""
        with open(path, "r") as f:
            data = json.load(f)

        agent = cls(
            alpha=data.get("alpha", 0.1),
            gamma=data.get("gamma", 0.95),
            epsilon=data.get("epsilon", 0.1),
        )
        agent.q_table = defaultdict(float, data["q_table"])
        agent._episode_count = data.get("episode_count", 0)
        agent._total_updates = data.get("total_updates", 0)
        return agent

    def stats(self) -> Dict[str, Any]:
        """Return training statistics."""
        unique_states = len(set(
            "_".join(k.split("_")[:-1]) for k in self.q_table.keys()
        ))
        return {
            "table_entries": len(self.q_table),
            "unique_states": unique_states,
            "episodes_trained": self._episode_count,
            "total_updates": self._total_updates,
        }

    @staticmethod
    def get_reasoning(obs: np.ndarray) -> str:
        """Return human-readable explanation of what the agent sees."""
        text = _decode_obs(obs)
        state = _hash_state(obs)
        detections = []

        checks = [
            ("dead", "⚠ Dead service"),
            ("error", "πŸ“‹ Error in logs"),
            ("500", "πŸ”΄ HTTP 500"),
            ("502", "πŸ”΄ HTTP 502"),
            ("failed password", "πŸ”’ SSH brute-force"),
            ("permission", "πŸ”‘ Permission issue"),
            ("90%", "πŸ’Ύ Disk >90%"),
            ("cron", "⏰ Cron issue"),
            ("memory", "🧠 Memory pressure"),
        ]

        for keyword, label in checks:
            if keyword in text.lower():
                detections.append(label)

        prefix = f"[Q-Table lookup: state={state[:20]}...]"
        if detections:
            return f"{prefix} {' | '.join(detections)}"
        return f"{prefix} πŸ” Scanning..."


# ======================================================================
#  TRAINING FUNCTION
# ======================================================================

def train_qlearning(
    scenario: str = "full_incident",
    episodes: int = 200,
    alpha: float = 0.15,
    gamma: float = 0.95,
    epsilon_start: float = 1.0,
    epsilon_end: float = 0.05,
    epsilon_decay: float = 0.995,
    verbose: bool = True,
) -> QLearningAgent:
    """
    Train a Q-Learning agent on the ChaosLab environment.

    Uses epsilon-greedy with decay: starts exploring randomly
    and gradually shifts to exploiting learned Q-values.

    Args:
        scenario: Which scenario to train on
        episodes: Number of training episodes
        alpha: Learning rate
        gamma: Discount factor
        epsilon_start: Initial exploration rate
        epsilon_end: Minimum exploration rate
        epsilon_decay: Multiplicative decay per episode

    Returns:
        Trained QLearningAgent
    """
    import gymnasium as gym
    import src.rl_env  # registers ChaosLab-v0

    env = gym.make("ChaosLab-v0", scenario=scenario)
    agent = QLearningAgent(alpha=alpha, gamma=gamma, epsilon=epsilon_start)

    total_rewards = []
    best_score = 0.0

    for ep in range(episodes):
        obs, info = env.reset()
        episode_reward = 0.0
        done = False

        while not done:
            # Epsilon-greedy action selection
            action_arr, _ = agent.predict(obs, deterministic=False)
            action = int(action_arr)

            next_obs, reward, terminated, truncated, info = env.step(action)
            done = terminated or truncated

            # Q-Learning update
            agent.update(obs, action, reward, next_obs, done)

            obs = next_obs
            episode_reward += reward

        agent._episode_count += 1
        total_rewards.append(episode_reward)

        # Decay epsilon
        agent.epsilon = max(epsilon_end, agent.epsilon * epsilon_decay)

        score = info.get("task_score", 0.0)
        if score > best_score:
            best_score = score

        if verbose and (ep + 1) % 25 == 0:
            avg_reward = sum(total_rewards[-25:]) / min(25, len(total_rewards))
            stats = agent.stats()
            print(
                f"  Episode {ep+1:>4d}/{episodes} | "
                f"Avg Reward: {avg_reward:>7.3f} | "
                f"Best Score: {best_score:.2f} | "
                f"Ξ΅: {agent.epsilon:.3f} | "
                f"Q-entries: {stats['table_entries']:,} | "
                f"States: {stats['unique_states']:,}"
            )

    env.close()

    if verbose:
        stats = agent.stats()
        print(f"\n  Training complete!")
        print(f"  Q-table: {stats['table_entries']:,} entries across {stats['unique_states']:,} states")
        print(f"  Best score achieved: {best_score:.2f}")

    return agent