Kashyap Khakhkhar commited on
Commit
812a0b6
Β·
verified Β·
1 Parent(s): 39b58c6

Upload self_evolving_model.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. self_evolving_model.py +1512 -0
self_evolving_model.py ADDED
@@ -0,0 +1,1512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ╔══════════════════════════════════════════════════════════════════════════╗
4
+ β•‘ SELF-EVOLVING NEURAL NETWORK β•‘
5
+ β•‘ A file that evolves itself based on a pre-existing model and can be β•‘
6
+ β•‘ trained locally by itself using evolutionary strategies. β•‘
7
+ β•‘ β•‘
8
+ β•‘ β€’ Architecture genome system (layers, units, activations, lr) β•‘
9
+ β•‘ β€’ Self-generates training data if none provided β•‘
10
+ β•‘ β€’ Mutates & selects the best models across generations β•‘
11
+ β•‘ β€’ Saves checkpoints and evolution history to disk β•‘
12
+ β•‘ β€’ Resumes from checkpoints automatically β•‘
13
+ β•‘ β•‘
14
+ β•‘ Usage: β•‘
15
+ β•‘ python3 self_evolving_model.py # quick run β•‘
16
+ β•‘ python3 self_evolving_model.py --generations 100 # more generations β•‘
17
+ β•‘ python3 self_evolving_model.py --pop-size 20 # larger population β•‘
18
+ β•‘ python3 self_evolving_model.py --reset # fresh start β•‘
19
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
20
+ """
21
+
22
+ import os
23
+ import sys
24
+ import json
25
+ import copy
26
+ import random
27
+ import pickle
28
+ import hashlib
29
+ import argparse
30
+ import datetime
31
+ import threading
32
+ import time
33
+ from pathlib import Path
34
+ from typing import List, Dict, Any, Optional, Tuple, Callable
35
+
36
+ import numpy as np
37
+
38
+ # Suppress TensorFlow warnings for cleaner output
39
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
40
+ import tensorflow as tf
41
+ from tensorflow import keras
42
+ from tensorflow.keras import layers
43
+
44
+ # ─────────────────────────────────────────────────────────────────────
45
+ # Constants & Defaults
46
+ # ─────────────────────────────────────────────────────────────────────
47
+ CHECKPOINT_DIR = Path("evo_checkpoints")
48
+
49
+ ACTIVATION_POOL = ["relu", "tanh", "sigmoid", "elu", "selu", "swish", "linear"]
50
+ OPTIMIZER_POOL = ["adam", "sgd", "rmsprop", "adamw"]
51
+
52
+ # Genome pool bounds (tunable via --max-units / --max-layers so Colab/laptop
53
+ # runs can scale parameters without editing code)
54
+ MAX_LAYERS = 6
55
+ BASE_UNIT_POOL = [16, 32, 48, 64, 96, 128, 192, 256]
56
+ UNIT_POOL = list(BASE_UNIT_POOL)
57
+
58
+
59
+ def configure_genome_pool(max_units: int = 256, max_layers: int = 6):
60
+ """
61
+ Widen the genome search space. Unit sizes are generated by repeatedly
62
+ growing the base pool until max_units is covered; layer count is capped.
63
+ Used by --max-units / --max-layers so v2/v3 runs on GPU can scale up.
64
+ """
65
+ global UNIT_POOL, MAX_LAYERS
66
+ # β›” Bound the tunable flags by the hard safety walls (the AI cannot exceed them)
67
+ max_units = min(max(8, int(max_units)), SafetyGates.HARD_MAX_UNITS)
68
+ max_layers = min(max(1, int(max_layers)), SafetyGates.HARD_MAX_LAYERS)
69
+ units = [u for u in BASE_UNIT_POOL if u <= max_units]
70
+ if max_units > BASE_UNIT_POOL[-1]:
71
+ n = BASE_UNIT_POOL[-1]
72
+ while n < max_units:
73
+ n = min(max_units, int(n * 1.5))
74
+ units.append(n)
75
+ units = units or [min(max_units, BASE_UNIT_POOL[-1])] # never empty
76
+ UNIT_POOL = sorted(set(units))
77
+ MAX_LAYERS = max(1, int(max_layers))
78
+ log(f"Genome pool: units={UNIT_POOL}, max_layers={MAX_LAYERS}", "info")
79
+
80
+
81
+ LOSS_FUNCTIONS = {
82
+ "regression": "mse",
83
+ "classification": "categorical_crossentropy",
84
+ }
85
+
86
+ # ═════════════════════════════════════════════════════════════════════
87
+ # β›” SAFETY GATES β€” HARD LIMITS THE AI CANNOT CHANGE
88
+ # These constants live OUTSIDE the genome. They are not stored in
89
+ # checkpoints, not part of any genome config, and the evolutionary
90
+ # operators (mutate/crossover) can never touch them. They are enforced
91
+ # as absolute ceilings at runtime on EVERY evaluation β€” even if a
92
+ # loaded/corrupt checkpoint tries to exceed them.
93
+ # ═════════════════════════════════════════════════════════════════════
94
+ class SafetyGates:
95
+ """
96
+ Immutable safety valves & failure gates. Change these ONLY by editing
97
+ this source file β€” the AI (evolution) has no path to modify them.
98
+ """
99
+
100
+ # ── Absolute architecture ceilings (hard walls) ──
101
+ HARD_MAX_LAYERS = 16 # never more hidden layers than this
102
+ HARD_MAX_UNITS = 2048 # never more units per layer than this
103
+ HARD_MAX_PARAMS = 5_000_000 # never more total model parameters than this
104
+
105
+ # ── Fitness / loss sanity bounds ──
106
+ MIN_FITNESS = 0.0 # fitness below β†’ rejected
107
+ MAX_FITNESS = 1e9 # absurd fitness β†’ clamped
108
+ MAX_VAL_LOSS = 1e8 # val_loss above β†’ treated as failed model
109
+
110
+ # ── Hyperparameter bounds ──
111
+ MIN_LR = 1e-7
112
+ MAX_LR = 1.0
113
+ MAX_DROPOUT = 0.9
114
+ MIN_BATCH = 8
115
+ MAX_BATCH = 1024
116
+
117
+ # ── Runtime failure gates ──
118
+ DEFAULT_MAX_MINUTES = 0 # 0 = unlimited (set via --max-minutes)
119
+ STAGNATION_LIMIT = 12 # generations w/o improvement β†’ halt
120
+
121
+ @staticmethod
122
+ def enforce_genome(genome: "Genome") -> "Genome":
123
+ """
124
+ Clamp any genome (even corrupt/old checkpoints) to the hard limits.
125
+ Called on EVERY evaluation, so the AI cannot bypass the walls.
126
+ """
127
+ cfg = genome.config
128
+ cfg["layers"] = cfg.get("layers", [])[:SafetyGates.HARD_MAX_LAYERS]
129
+ for l in cfg["layers"]:
130
+ l["units"] = min(max(1, int(l.get("units", 16))), SafetyGates.HARD_MAX_UNITS)
131
+ l["dropout"] = max(0.0, min(float(l.get("dropout", 0.0)), SafetyGates.MAX_DROPOUT))
132
+ cfg["num_layers"] = len(cfg["layers"])
133
+ cfg["learning_rate"] = max(SafetyGates.MIN_LR, min(float(cfg.get("learning_rate", 0.001)), SafetyGates.MAX_LR))
134
+ cfg["batch_size"] = max(SafetyGates.MIN_BATCH, min(int(cfg.get("batch_size", 32)), SafetyGates.MAX_BATCH))
135
+ return genome
136
+
137
+
138
+ # ═════════════════════════════════════════════════════════════════════
139
+ # CLASS: TextVectorizer
140
+ # Turns raw English text into integer token ids for the text path.
141
+ # ═════════════════════════════════════════════════════════════════════
142
+ class TextVectorizer:
143
+ """
144
+ Tokenizes English sentences into integer sequences using Keras
145
+ TextVectorization (no external tokenizer dependency). Fit on the
146
+ training texts once, then encode() maps text β†’ int ids.
147
+ """
148
+
149
+ def __init__(self, vocab_size: int = 10000, max_len: int = 128):
150
+ self.vocab_size = vocab_size
151
+ self.max_len = max_len
152
+ self.tv = layers.TextVectorization(
153
+ max_tokens=vocab_size,
154
+ output_mode="int",
155
+ output_sequence_length=max_len,
156
+ name="text_vectorizer",
157
+ )
158
+ self.fitted = False
159
+
160
+ def fit(self, texts: List[str]) -> "TextVectorizer":
161
+ self.tv.adapt(np.array(texts, dtype=object))
162
+ self.fitted = True
163
+ return self
164
+
165
+ def encode(self, texts: List[str]) -> np.ndarray:
166
+ if not self.fitted:
167
+ raise RuntimeError("TextVectorizer.fit() must be called before encode()")
168
+ return self.tv(np.array(texts, dtype=object)).numpy().astype(np.int32)
169
+
170
+ def vocab_used(self) -> int:
171
+ """Actual fitted vocabulary size (includes reserved tokens)."""
172
+ return self.tv.vocabulary_size() if self.fitted else self.vocab_size
173
+
174
+ # ─────────────────────────────────────────────────────────────────────
175
+ # Utility: Pretty printing
176
+ # ─────────────────────────────────────────────────────────────────────
177
+ class Colors:
178
+ """ANSI color codes for terminal output."""
179
+ HEADER = "\033[95m"
180
+ BLUE = "\033[94m"
181
+ CYAN = "\033[96m"
182
+ GREEN = "\033[92m"
183
+ YELLOW = "\033[93m"
184
+ RED = "\033[91m"
185
+ BOLD = "\033[1m"
186
+ DIM = "\033[2m"
187
+ END = "\033[0m"
188
+
189
+
190
+ def banner(text: str, char: str = "═", width: int = 70):
191
+ print(f"\n{Colors.CYAN}{char * width}")
192
+ print(f" {Colors.BOLD}{text}{Colors.END}")
193
+ print(f"{Colors.CYAN}{char * width}{Colors.END}\n")
194
+
195
+
196
+ def log(msg: str, level: str = "info"):
197
+ prefix = {
198
+ "info": f"{Colors.BLUE}[INFO]{Colors.END}",
199
+ "success": f"{Colors.GREEN}[OK]{Colors.END}",
200
+ "warn": f"{Colors.YELLOW}[WARN]{Colors.END}",
201
+ "error": f"{Colors.RED}[ERR]{Colors.END}",
202
+ "evo": f"{Colors.CYAN}[EVO]{Colors.END}",
203
+ }.get(level, "[LOG]")
204
+ timestamp = datetime.datetime.now().strftime("%H:%M:%S")
205
+ print(f"{Colors.DIM}{timestamp}{Colors.END} {prefix} {msg}")
206
+
207
+
208
+ # ═════���═══════════════════════════════════════════════════════════════
209
+ # CLASS: Genome
210
+ # Represents a neural network architecture as a mutable genome.
211
+ # ═════════════════════════════════════════════════════════════════════
212
+ class Genome:
213
+ """
214
+ Encodes a neural network architecture as a 'genome' that can be
215
+ mutated, crossed-over, and evaluated for fitness.
216
+
217
+ Genome structure:
218
+ {
219
+ "num_layers": int, # number of hidden layers
220
+ "layers": [ # per-layer configuration
221
+ {
222
+ "units": int,
223
+ "activation": str,
224
+ "dropout": float,
225
+ "batch_norm": bool,
226
+ },
227
+ ...
228
+ ],
229
+ "learning_rate": float,
230
+ "optimizer": str,
231
+ "batch_size": int,
232
+ "top3_features": list, # indices of the 3 features gating layer 1
233
+ "task_type": str, # "regression" or "classification"
234
+ }
235
+ """
236
+
237
+ def __init__(self, config: Optional[Dict] = None):
238
+ if config is not None:
239
+ self.config = config
240
+ else:
241
+ self.config = self._random_genome()
242
+ self.fitness: float = 0.0
243
+ self.generation_born: int = 0
244
+ self.id = hashlib.md5(
245
+ json.dumps(self.config, sort_keys=True).encode()
246
+ ).hexdigest()[:8]
247
+
248
+ # ── Random genome factory ──────────────────────────────────────
249
+ @staticmethod
250
+ def _random_genome() -> Dict:
251
+ num_layers = random.randint(1, MAX_LAYERS)
252
+ layers_cfg = []
253
+ for i in range(num_layers):
254
+ layers_cfg.append({
255
+ "units": random.choice(UNIT_POOL),
256
+ "activation": random.choice(ACTIVATION_POOL),
257
+ "dropout": round(random.uniform(0.0, 0.5), 2),
258
+ "batch_norm": random.choice([True, False]),
259
+ })
260
+ return {
261
+ "num_layers": num_layers,
262
+ "layers": layers_cfg,
263
+ "learning_rate": round(random.choice([0.0001, 0.0005, 0.001, 0.003, 0.005, 0.01]), 6),
264
+ "optimizer": random.choice(OPTIMIZER_POOL),
265
+ "batch_size": random.choice([16, 32, 64, 128]),
266
+ "top3_features": sorted(random.sample(range(20), 3)),
267
+ "task_type": "regression",
268
+ }
269
+
270
+ # ── Mutation operators ─────────────────────────────────────────
271
+ def mutate(self, mutation_rate: float = 0.3) -> "Genome":
272
+ """Return a mutated clone of this genome."""
273
+ child = self.clone()
274
+ cfg = child.config
275
+
276
+ # Mutate number of layers (add or remove)
277
+ if random.random() < mutation_rate:
278
+ if random.random() < 0.5 and len(cfg["layers"]) > 1:
279
+ # Remove a random layer
280
+ idx = random.randint(0, len(cfg["layers"]) - 1)
281
+ cfg["layers"].pop(idx)
282
+ log(f" Mutation: removed layer {idx}", "evo")
283
+ elif len(cfg["layers"]) < MAX_LAYERS:
284
+ # Add a new layer at a random position
285
+ idx = random.randint(0, len(cfg["layers"]))
286
+ new_layer = {
287
+ "units": random.choice(UNIT_POOL),
288
+ "activation": random.choice(ACTIVATION_POOL),
289
+ "dropout": round(random.uniform(0.0, 0.5), 2),
290
+ "batch_norm": random.choice([True, False]),
291
+ }
292
+ cfg["layers"].insert(idx, new_layer)
293
+ log(f" Mutation: added layer at {idx} ({new_layer['units']} units)", "evo")
294
+ cfg["num_layers"] = len(cfg["layers"])
295
+
296
+ # Mutate individual layers
297
+ for i, layer in enumerate(cfg["layers"]):
298
+ if random.random() < mutation_rate:
299
+ old_units = layer["units"]
300
+ layer["units"] = random.choice(UNIT_POOL)
301
+ log(f" Mutation: layer {i} units {old_units} β†’ {layer['units']}", "evo")
302
+ if random.random() < mutation_rate:
303
+ old_act = layer["activation"]
304
+ layer["activation"] = random.choice(ACTIVATION_POOL)
305
+ log(f" Mutation: layer {i} activation {old_act} β†’ {layer['activation']}", "evo")
306
+ if random.random() < mutation_rate:
307
+ layer["dropout"] = round(max(0, min(0.5, layer["dropout"] + random.uniform(-0.1, 0.1))), 2)
308
+ if random.random() < mutation_rate * 0.5:
309
+ layer["batch_norm"] = not layer["batch_norm"]
310
+
311
+ # Mutate learning rate (log-scale perturbation)
312
+ if random.random() < mutation_rate:
313
+ old_lr = cfg["learning_rate"]
314
+ factor = random.choice([0.5, 0.7, 1.0, 1.3, 1.5, 2.0])
315
+ cfg["learning_rate"] = round(max(1e-6, min(0.1, old_lr * factor)), 6)
316
+ log(f" Mutation: lr {old_lr} β†’ {cfg['learning_rate']}", "evo")
317
+
318
+ # Mutate optimizer
319
+ if random.random() < mutation_rate * 0.5:
320
+ old_opt = cfg["optimizer"]
321
+ cfg["optimizer"] = random.choice(OPTIMIZER_POOL)
322
+ log(f" Mutation: optimizer {old_opt} β†’ {cfg['optimizer']}", "evo")
323
+
324
+ # Mutate batch size
325
+ if random.random() < mutation_rate * 0.5:
326
+ cfg["batch_size"] = random.choice([16, 32, 64, 128])
327
+
328
+ # Mutate top-3 feature gating (which features drive layer 1)
329
+ # NOTE: .setdefault keeps old-format checkpoints (no top3_features) compatible
330
+ cfg.setdefault("top3_features", sorted(random.sample(range(20), 3)))
331
+ if random.random() < mutation_rate * 0.8:
332
+ idx = random.randrange(len(cfg["top3_features"]))
333
+ old = cfg["top3_features"][idx]
334
+ # Pick a new index not already selected (no duplicates)
335
+ pool = [v for v in range(20) if v not in cfg["top3_features"]] or [0]
336
+ new = random.choice(pool)
337
+ cfg["top3_features"][idx] = new
338
+ cfg["top3_features"].sort()
339
+ log(f" Mutation: top-3 feature gate {old} β†’ {new} ({cfg['top3_features']})", "evo")
340
+
341
+ child.id = hashlib.md5(
342
+ json.dumps(cfg, sort_keys=True).encode()
343
+ ).hexdigest()[:8]
344
+ return child
345
+
346
+ # ── Crossover ──────────────────────────────────────────────────
347
+ def crossover(self, other: "Genome") -> "Genome":
348
+ """Produce a child genome by crossing over with another."""
349
+ child_cfg = copy.deepcopy(self.config)
350
+ other_cfg = other.config
351
+
352
+ # Crossover layer-by-layer (take from either parent)
353
+ max_layers = max(len(child_cfg["layers"]), len(other_cfg["layers"]))
354
+ child_layers = []
355
+ for i in range(max_layers):
356
+ if i < len(child_cfg["layers"]) and i < len(other_cfg["layers"]):
357
+ parent = random.choice([child_cfg, other_cfg])
358
+ child_layers.append(copy.deepcopy(parent["layers"][i]))
359
+ elif i < len(child_cfg["layers"]):
360
+ child_layers.append(copy.deepcopy(child_cfg["layers"][i]))
361
+ else:
362
+ child_layers.append(copy.deepcopy(other_cfg["layers"][i]))
363
+
364
+ child_cfg["layers"] = child_layers
365
+ child_cfg["num_layers"] = len(child_layers)
366
+
367
+ # Randomly inherit hyperparams from either parent
368
+ if random.random() < 0.5:
369
+ child_cfg["learning_rate"] = other_cfg["learning_rate"]
370
+ if random.random() < 0.5:
371
+ child_cfg["optimizer"] = other_cfg["optimizer"]
372
+ if random.random() < 0.5:
373
+ child_cfg["batch_size"] = other_cfg["batch_size"]
374
+ if random.random() < 0.5 and "top3_features" in other_cfg:
375
+ # Only inherit when the other parent has an evolved gate (avoid
376
+ # clobbering a good selection with the default on old checkpoints)
377
+ child_cfg["top3_features"] = sorted(other_cfg["top3_features"])
378
+
379
+ child = Genome(child_cfg)
380
+ child.generation_born = self.generation_born
381
+ return child
382
+
383
+ # ── Utility ────────────────────────────────────────────────────
384
+ def clone(self) -> "Genome":
385
+ c = Genome(copy.deepcopy(self.config))
386
+ c.fitness = self.fitness
387
+ c.generation_born = self.generation_born
388
+ return c
389
+
390
+ def to_dict(self) -> Dict:
391
+ return {
392
+ "config": self.config,
393
+ "fitness": self.fitness,
394
+ "generation_born": self.generation_born,
395
+ "id": self.id,
396
+ }
397
+
398
+ @classmethod
399
+ def from_dict(cls, data: Dict) -> "Genome":
400
+ g = cls(data["config"])
401
+ g.fitness = data.get("fitness", 0.0)
402
+ g.generation_born = data.get("generation_born", 0)
403
+ g.id = data.get("id", g.id)
404
+ return g
405
+
406
+ def summary(self) -> str:
407
+ layers_str = ", ".join(
408
+ f"{l['units']}({l['activation'][:3]})" for l in self.config["layers"]
409
+ )
410
+ return (
411
+ f"Genome[{self.id}] layers={self.config['num_layers']} "
412
+ f"[{layers_str}] lr={self.config['learning_rate']} "
413
+ f"opt={self.config['optimizer']} top3={self.config.get('top3_features')} "
414
+ f"fitness={self.fitness:.4f}"
415
+ )
416
+
417
+ def __repr__(self):
418
+ return self.summary()
419
+
420
+
421
+ # ═════════════════════════════════════════════════════════════════════
422
+ # CLASS: DataHandler
423
+ # Manages training data. Generates synthetic data if none provided.
424
+ # ═════════════════════════════════════════════════════════════════════
425
+ class DataHandler:
426
+ """
427
+ Provides training data for the evolutionary process.
428
+ If no external data is given, generates synthetic datasets
429
+ that the models can learn from.
430
+ """
431
+
432
+ def __init__(self, task_type: str = "regression"):
433
+ self.task_type = task_type
434
+
435
+ def get_data(
436
+ self,
437
+ x: Optional[np.ndarray] = None,
438
+ y: Optional[np.ndarray] = None,
439
+ n_samples: int = 2000,
440
+ n_features: int = 10,
441
+ seed: int = 42,
442
+ ) -> Tuple[np.ndarray, np.ndarray]:
443
+ """
444
+ Return (X, Y) arrays. If x/y are provided, use them.
445
+ Otherwise generate synthetic data.
446
+ """
447
+ if x is not None and y is not None:
448
+ log(f"Using provided data: X={x.shape}, Y={y.shape}", "info")
449
+ return x, y
450
+
451
+ log(f"Generating synthetic {self.task_type} data ({n_samples} samples, {n_features} features)...", "info")
452
+ rng = np.random.RandomState(seed)
453
+ X = rng.uniform(-3.0, 3.0, (n_samples, n_features)).astype(np.float32)
454
+
455
+ if self.task_type == "regression":
456
+ # Complex nonlinear target: sum of sin/cos combinations
457
+ Y = (
458
+ np.sin(X[:, 0]) * np.cos(X[:, 1])
459
+ + 0.5 * np.sin(X[:, 2] + X[:, 3])
460
+ + 0.3 * X[:, 4] ** 2
461
+ + rng.normal(0, 0.1, n_samples)
462
+ ).astype(np.float32)
463
+ Y = Y.reshape(-1, 1)
464
+ else:
465
+ # Classification: threshold-based multi-class
466
+ logits = np.sin(X[:, 0]) + np.cos(X[:, 1]) + X[:, 2] * 0.5
467
+ Y = (logits > 0.5).astype(np.float32)
468
+ Y = keras.utils.to_categorical(Y, num_classes=2)
469
+
470
+ log(f"Data ready: X={X.shape}, Y={Y.shape}", "success")
471
+ return X, Y
472
+
473
+ @staticmethod
474
+ def load_fable_dataset(
475
+ n_samples: int = 10000,
476
+ subset: str = "train",
477
+ ) -> Tuple[np.ndarray, np.ndarray]:
478
+ """
479
+ Load and featurize the Crownelius/Complete-FABLE.5-traces-2M dataset
480
+ from HuggingFace. Extracts numerical features from heterogeneous
481
+ JSON coding traces and returns (X, Y) arrays.
482
+
483
+ Features extracted per row (10-dim vector):
484
+ 0: message_content_len - length of user message content
485
+ 1: message_word_count - word count of user message
486
+ 2: code_keyword_freq - frequency of code keywords (def, class, etc.)
487
+ 3: completion_len - length of assistant completion
488
+ 4: cot_len - length of chain-of-thought
489
+ 5: has_tool_use - whether output_type is tool_use
490
+ 6: output_complexity - len(str(output)) if present
491
+ 7: is_user_turn - whether row is a user message
492
+ 8: session_entropy - hash-based session diversity proxy
493
+ 9: text_special_char_ratio - ratio of special chars in text
494
+
495
+ Target Y: seen_count (how many times this trace was seen)
496
+ """
497
+ try:
498
+ from datasets import load_dataset as hf_load_dataset
499
+ except ImportError:
500
+ log("datasets library not installed. Run: pip install datasets", "error")
501
+ raise
502
+
503
+ log(f"Loading FABLE.5-traces-2M dataset ({n_samples} samples)...", "info")
504
+ ds = hf_load_dataset("Crownelius/Complete-FABLE.5-traces-2M", split=subset)
505
+ if len(ds) > n_samples:
506
+ # ⚠️ Same ordering-bias guard as the text path: if this split is sorted
507
+ # by session/type/timestamp, a head-slice can be strongly biased.
508
+ # Draw a deterministic random sample instead.
509
+ rng = np.random.RandomState(42)
510
+ idxs = rng.choice(len(ds), size=n_samples, replace=False)
511
+ ds = ds.select(sorted(idxs))
512
+ log(f"Dataset loaded: {len(ds)} rows", "success")
513
+
514
+ features = []
515
+ targets = []
516
+
517
+ for row in ds:
518
+ try:
519
+ row_json = json.loads(row["row_json"])
520
+ except (json.JSONDecodeError, TypeError):
521
+ continue
522
+
523
+ # Extract message content
524
+ msg = row_json.get("message", {})
525
+ msg_content = ""
526
+ if isinstance(msg, dict):
527
+ c = msg.get("content", "")
528
+ if isinstance(c, str):
529
+ msg_content = c
530
+ elif isinstance(c, list):
531
+ msg_content = " ".join(
532
+ item.get("text", "") if isinstance(item, dict) else str(item)
533
+ for item in c
534
+ )
535
+
536
+ # Extract completion and chain-of-thought
537
+ completion = str(row_json.get("completion", "") or "")
538
+ cot = str(row_json.get("cot", "") or "")
539
+
540
+ # Output info
541
+ output = row_json.get("output", {})
542
+ output_str = str(output) if output else ""
543
+ output_type = str(row_json.get("output_type", "") or "")
544
+
545
+ # Row type
546
+ row_type = str(row_json.get("type", "") or "")
547
+
548
+ # --- Build feature vector (10 dims) ---
549
+ # 0: message_content_len (normalized by log)
550
+ f0 = np.log1p(len(msg_content))
551
+ # 1: message word count
552
+ f1 = np.log1p(len(msg_content.split())) if msg_content else 0.0
553
+ # 2: code keyword frequency in message
554
+ code_keywords = ["def", "class", "import", "function", "return",
555
+ "if", "for", "while", "async", "const"]
556
+ text_lower = msg_content.lower()
557
+ f2 = sum(text_lower.count(kw) for kw in code_keywords)
558
+ f2 = np.log1p(f2)
559
+ # 3: completion length (log-normalized)
560
+ f3 = np.log1p(len(completion))
561
+ # 4: cot length (log-normalized)
562
+ f4 = np.log1p(len(cot))
563
+ # 5: has tool use
564
+ f5 = 1.0 if output_type == "tool_use" else 0.0
565
+ # 6: output complexity
566
+ f6 = np.log1p(len(output_str))
567
+ # 7: is user turn
568
+ f7 = 1.0 if row_type == "user" else 0.0
569
+ # 8: session entropy proxy (deterministic hash)
570
+ session_id = str(row_json.get("sessionId", "") or row_json.get("session", ""))
571
+ f8 = int(hashlib.md5(session_id.encode()).hexdigest(), 16) % 1000 / 1000.0
572
+ # 9: special character ratio in all text
573
+ all_text = msg_content + completion + cot
574
+ if len(all_text) > 0:
575
+ special = sum(1 for c in all_text if not c.isalnum() and not c.isspace())
576
+ f9 = special / len(all_text)
577
+ else:
578
+ f9 = 0.0
579
+
580
+ features.append([f0, f1, f2, f3, f4, f5, f6, f7, f8, f9])
581
+ targets.append(float(row.get("seen_count", 1)))
582
+
583
+ X = np.array(features, dtype=np.float32)
584
+ Y = np.array(targets, dtype=np.float32).reshape(-1, 1)
585
+
586
+ # Normalize features to zero mean, unit variance
587
+ mean = X.mean(axis=0)
588
+ std = X.std(axis=0) + 1e-8
589
+ X = (X - mean) / std
590
+
591
+ # Log-normalize target (seen_count is power-law distributed)
592
+ Y = np.log1p(Y)
593
+
594
+ log(f"Featurized: X={X.shape}, Y={Y.shape}", "success")
595
+ log(f" Feature ranges: min={X.min():.2f}, max={X.max():.2f}", "info")
596
+ log(f" Target range: min={Y.min():.2f}, max={Y.max():.2f}", "info")
597
+ return X, Y
598
+
599
+ @staticmethod
600
+ def load_text_dataset(
601
+ dataset: str = "rotten_tomatoes",
602
+ n_samples: int = 3000,
603
+ max_len: int = 128,
604
+ vocab_size: int = 10000,
605
+ subset: str = "train",
606
+ ):
607
+ """
608
+ Load an English sentiment dataset from HuggingFace and convert it to
609
+ (X_int_ids, Y_onehot, vectorizer, n_classes) for text-understanding
610
+ evolution. Default: rotten_tomatoes (binary pos/neg movie reviews).
611
+ """
612
+ try:
613
+ from datasets import load_dataset as hf_load_dataset
614
+ except ImportError:
615
+ log("datasets library not installed. Run: pip install datasets", "error")
616
+ raise
617
+
618
+ log(f"Loading English sentiment dataset '{dataset}' ({n_samples} samples)...", "info")
619
+ ds = hf_load_dataset(dataset, split=subset)
620
+ if len(ds) > n_samples:
621
+ # ⚠️ Some HF splits are SORTED by label (e.g. rotten_tomatoes is
622
+ # all-pos then all-neg). A head-slice train[:n] would then be a
623
+ # single class and the model would learn "always positive" β€” which
624
+ # scores ~100% on that slice but ~50% (chance) on balanced held-out
625
+ # data. Draw a deterministic random sample instead.
626
+ rng = np.random.RandomState(42)
627
+ idxs = rng.choice(len(ds), size=n_samples, replace=False)
628
+ ds = ds.select(sorted(idxs))
629
+ texts = [str(r["text"]) for r in ds]
630
+ labels = np.array([int(r["label"]) for r in ds])
631
+ n_classes = int(labels.max()) + 1
632
+ Y = keras.utils.to_categorical(labels, num_classes=n_classes).astype(np.float32)
633
+
634
+ vec = TextVectorizer(vocab_size=vocab_size, max_len=max_len)
635
+ vec.fit(texts)
636
+ X = vec.encode(texts)
637
+ log(f"πŸ“– Text featurized: X={X.shape} (token ids), Y={Y.shape}, vocab={vec.vocab_used():,}", "success")
638
+ return X, Y, vec, n_classes
639
+
640
+
641
+ # ═════════════════════════════════════════════════════════════════════
642
+ # CLASS: ModelEvaluator
643
+ # Builds TensorFlow models from genomes and evaluates their fitness.
644
+ # ═════════════════════════════════════════════════════════════════════
645
+ class ModelEvaluator:
646
+ """
647
+ Translates a Genome into a Keras model, trains it, and returns
648
+ a fitness score (inverse of validation loss).
649
+ """
650
+
651
+ def __init__(self, input_dim: int, output_dim: int, task_type: str = "regression",
652
+ vectorizer: Optional[TextVectorizer] = None, embed_dim: int = 128):
653
+ self.input_dim = input_dim
654
+ self.output_dim = output_dim
655
+ self.task_type = task_type
656
+ self.vectorizer = vectorizer
657
+ self.embed_dim = embed_dim
658
+
659
+ def build_model(self, genome: Genome) -> keras.Model:
660
+ """
661
+ Construct a Keras model from a genome's config. Two paths:
662
+ - text_classification: Embedding + pooling + evolved dense stack
663
+ - otherwise: GATED numeric architecture β€” the genome's top-3
664
+ features feed layer 1; the rest concatenate into every layer
665
+ after the first (including the output).
666
+ """
667
+ if self.task_type == "text_classification":
668
+ return self._build_text_model(genome)
669
+ cfg = genome.config
670
+ top3, rest = self._resolve_feature_split(genome)
671
+ has_rest = len(rest) > 0
672
+
673
+ # Gated inputs: top-3 features (layer 1) + the rest (join later)
674
+ inp_top = layers.Input(shape=(len(top3),), name="input_top3")
675
+ h = inp_top
676
+ if has_rest:
677
+ inp_rest = layers.Input(shape=(len(rest),), name="input_rest")
678
+
679
+ # Hidden layers from genome (rest joins every layer after the first)
680
+ for i, layer_cfg in enumerate(cfg["layers"]):
681
+ if i > 0 and has_rest:
682
+ h = layers.Concatenate(name=f"merge_{i}")([h, inp_rest])
683
+ h = layers.Dense(
684
+ units=layer_cfg["units"],
685
+ activation=layer_cfg["activation"],
686
+ name=f"dense_{i}",
687
+ )(h)
688
+ if layer_cfg["batch_norm"]:
689
+ h = layers.BatchNormalization(name=f"bn_{i}")(h)
690
+ if layer_cfg["dropout"] > 0:
691
+ h = layers.Dropout(layer_cfg["dropout"], name=f"drop_{i}")(h)
692
+
693
+ # Remaining features also join the output layer
694
+ if has_rest:
695
+ h = layers.Concatenate(name="merge_output")([h, inp_rest])
696
+
697
+ # Output layer
698
+ if self.task_type == "regression":
699
+ out = layers.Dense(self.output_dim, activation="linear", name="output")(h)
700
+ else:
701
+ out = layers.Dense(self.output_dim, activation="softmax", name="output")(h)
702
+
703
+ model = keras.Model(
704
+ inputs=[inp_top, inp_rest] if has_rest else [inp_top],
705
+ outputs=out,
706
+ name=f"model_{genome.id}",
707
+ )
708
+
709
+ # Compile
710
+ optimizer = self._get_optimizer(cfg["optimizer"], cfg["learning_rate"])
711
+ loss = LOSS_FUNCTIONS[self.task_type]
712
+ model.compile(optimizer=optimizer, loss=loss, metrics=["mae"] if self.task_type == "regression" else ["accuracy"])
713
+
714
+ return model
715
+
716
+ def _build_text_model(self, genome: Genome) -> keras.Model:
717
+ """
718
+ English text-understanding path: token ids β†’ Embedding β†’ global
719
+ average pooling β†’ the genome's evolved dense stack β†’ softmax.
720
+ """
721
+ cfg = genome.config
722
+ vocab = (self.vectorizer.vocab_used() + 2) if self.vectorizer else (self.input_dim + 2)
723
+ inp = layers.Input(shape=(self.input_dim,), dtype="int32", name="text_input")
724
+ h = layers.Embedding(vocab, self.embed_dim, name="embedding")(inp)
725
+ h = layers.GlobalAveragePooling1D(name="text_pool")(h)
726
+
727
+ for i, layer_cfg in enumerate(cfg["layers"]):
728
+ h = layers.Dense(
729
+ units=layer_cfg["units"],
730
+ activation=layer_cfg["activation"],
731
+ name=f"dense_{i}",
732
+ )(h)
733
+ if layer_cfg["batch_norm"]:
734
+ h = layers.BatchNormalization(name=f"bn_{i}")(h)
735
+ if layer_cfg["dropout"] > 0:
736
+ h = layers.Dropout(layer_cfg["dropout"], name=f"drop_{i}")(h)
737
+
738
+ out = layers.Dense(self.output_dim, activation="softmax", name="output")(h)
739
+ model = keras.Model(inputs=inp, outputs=out, name=f"model_{genome.id}")
740
+
741
+ optimizer = self._get_optimizer(cfg["optimizer"], cfg["learning_rate"])
742
+ model.compile(optimizer=optimizer, loss=LOSS_FUNCTIONS["classification"], metrics=["accuracy"])
743
+ return model
744
+
745
+ def _resolve_feature_split(self, genome: Genome) -> Tuple[List[int], List[int]]:
746
+ """
747
+ Resolve the genome's top-3 feature selection against the real input
748
+ dimension (wraps out-of-range indices, dedupes, pads).
749
+ Returns (top3_indices, rest_indices).
750
+ """
751
+ n = self.input_dim
752
+ raw = genome.config.get("top3_features", [0, 1, 2])
753
+ top: List[int] = []
754
+ for idx in raw:
755
+ idx = int(idx) % n
756
+ if idx not in top:
757
+ top.append(idx)
758
+ if len(top) == 3:
759
+ break
760
+ for i in range(n):
761
+ if len(top) >= 3:
762
+ break
763
+ if i not in top:
764
+ top.append(i)
765
+ top = top[:3]
766
+ rest = [i for i in range(n) if i not in top]
767
+ return top, rest
768
+
769
+ def split_features(self, X: np.ndarray, genome: Genome) -> Tuple[np.ndarray, Optional[np.ndarray]]:
770
+ """Split X into (X_top3, X_rest) for the gated architecture.
771
+ Text path has no feature gating β€” returns (X, None)."""
772
+ if self.task_type == "text_classification":
773
+ return X, None
774
+ top3, rest = self._resolve_feature_split(genome)
775
+ X_rest = X[:, rest] if rest else None
776
+ return X[:, top3], X_rest
777
+
778
+ @staticmethod
779
+ def _get_optimizer(name: str, lr: float):
780
+ optimizers = {
781
+ "adam": keras.optimizers.Adam(learning_rate=lr),
782
+ "sgd": keras.optimizers.SGD(learning_rate=lr, momentum=0.9),
783
+ "rmsprop": keras.optimizers.RMSprop(learning_rate=lr),
784
+ "adamw": keras.optimizers.AdamW(learning_rate=lr, weight_decay=1e-4),
785
+ }
786
+ return optimizers.get(name, keras.optimizers.Adam(learning_rate=lr))
787
+
788
+ def train_and_evaluate(
789
+ self,
790
+ genome: Genome,
791
+ X: np.ndarray,
792
+ Y: np.ndarray,
793
+ epochs: int = 15,
794
+ verbose: int = 0,
795
+ ) -> float:
796
+ """
797
+ Build, train, and evaluate a model from the genome.
798
+ Returns a fitness score (higher is better).
799
+ """
800
+ try:
801
+ # β›” SAFETY: clamp genome to hard limits before building
802
+ SafetyGates.enforce_genome(genome)
803
+ model = self.build_model(genome)
804
+ batch_size = genome.config["batch_size"]
805
+
806
+ # Train/val split (gated architecture: split features too)
807
+ split = int(0.8 * len(X))
808
+ X_top, X_rest = self.split_features(X, genome)
809
+ Y_train, Y_val = Y[:split], Y[split:]
810
+ X_top_train, X_top_val = X_top[:split], X_top[split:]
811
+
812
+ if X_rest is not None:
813
+ X_rest_train, X_rest_val = X_rest[:split], X_rest[split:]
814
+ train_inputs = [X_top_train, X_rest_train]
815
+ val_inputs = [X_top_val, X_rest_val]
816
+ else:
817
+ train_inputs = X_top_train
818
+ val_inputs = X_top_val
819
+
820
+ # πŸ“‰ LR scheduling: halve LR when val_loss plateaus (2 epochs patience)
821
+ # so evolution can refine good architectures instead of overshooting.
822
+ lr_schedule = keras.callbacks.ReduceLROnPlateau(
823
+ monitor="val_loss", factor=0.5, patience=2, min_lr=1e-6, verbose=0
824
+ )
825
+ history = model.fit(
826
+ train_inputs, Y_train,
827
+ validation_data=(val_inputs, Y_val),
828
+ epochs=epochs,
829
+ batch_size=batch_size,
830
+ verbose=verbose,
831
+ callbacks=[lr_schedule],
832
+ )
833
+
834
+ # β›” FAILURE GATE: reject NaN/Inf/exploded validation loss
835
+ val_losses = history.history["val_loss"]
836
+ best_val_loss = min(val_losses)
837
+ if not np.isfinite(best_val_loss) or best_val_loss > SafetyGates.MAX_VAL_LOSS:
838
+ log(f" β›” Safety gate: invalid val_loss {best_val_loss} for {genome.id}", "warn")
839
+ del model
840
+ keras.backend.clear_session()
841
+ return 0.0
842
+
843
+ # β›” FAILURE GATE: reject models beyond the hard parameter wall
844
+ num_params = model.count_params()
845
+ if num_params > SafetyGates.HARD_MAX_PARAMS:
846
+ log(f" β›” Safety gate: {num_params:,} params > hard ceiling for {genome.id}", "warn")
847
+ del model
848
+ keras.backend.clear_session()
849
+ return 0.0
850
+
851
+ # Fitness = inverse of best validation loss
852
+ # Also penalize overly complex models slightly (Occam's razor)
853
+ complexity_penalty = 1.0 + 1e-6 * num_params # tiny penalty for huge models
854
+
855
+ # 🎯 Classification: maximize VALIDATION ACCURACY directly. Loss-only
856
+ # fitness rewards memorization (near-zero loss on trivial/constant fits),
857
+ # which is exactly how the first text run "learned" 100% train / 50% test.
858
+ # Accuracy^2 gives a ~0-100 scale and makes generalization the target.
859
+ if self.task_type in ("classification", "text_classification"):
860
+ val_accs = history.history.get("val_accuracy")
861
+ best_val_acc = max(val_accs) if val_accs else 0.0
862
+ fitness = (best_val_acc ** 2) * 100.0 / complexity_penalty
863
+ else:
864
+ fitness = 1.0 / ((best_val_loss + 1e-7) * complexity_penalty)
865
+ fitness = max(SafetyGates.MIN_FITNESS, min(fitness, SafetyGates.MAX_FITNESS)) # clamp
866
+
867
+ # Clean up
868
+ del model
869
+ keras.backend.clear_session()
870
+
871
+ return fitness
872
+
873
+ except Exception as e:
874
+ log(f" Model {genome.id} failed: {e}", "warn")
875
+ return 0.0
876
+
877
+
878
+ # ═════════════════════════════════════════════════════════════════════
879
+ # CLASS: EvolutionEngine
880
+ # The main orchestrator: manages population, selection, mutation,
881
+ # checkpointing, and the evolutionary training loop.
882
+ # ═════════════════════════════════════════════════════════════════════
883
+ class EvolutionEngine:
884
+ """
885
+ Runs the evolutionary loop:
886
+ 1. Initialize population of genomes
887
+ 2. Evaluate fitness of each genome by building & training it
888
+ 3. Select top performers (elitism)
889
+ 4. Reproduce via mutation & crossover
890
+ 5. Repeat for N generations
891
+ 6. Save checkpoints & history throughout
892
+ """
893
+
894
+ def __init__(
895
+ self,
896
+ pop_size: int = 8,
897
+ generations: int = 20,
898
+ elite_ratio: float = 0.3,
899
+ mutation_rate: float = 0.3,
900
+ train_epochs: int = 15,
901
+ checkpoint_dir: Path = CHECKPOINT_DIR,
902
+ task_type: str = "regression",
903
+ vectorizer: Optional[TextVectorizer] = None,
904
+ embed_dim: int = 128,
905
+ max_minutes: int = SafetyGates.DEFAULT_MAX_MINUTES,
906
+ stagnation_limit: int = SafetyGates.STAGNATION_LIMIT,
907
+ ):
908
+ self.pop_size = pop_size
909
+ self.generations = generations
910
+ self.elite_count = min(max(2, int(pop_size * elite_ratio)), pop_size - 1)
911
+ self.mutation_rate = mutation_rate
912
+ self.train_epochs = train_epochs
913
+ self.checkpoint_dir = Path(checkpoint_dir)
914
+ self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
915
+ self.task_type = task_type
916
+ self.vectorizer = vectorizer
917
+ self.embed_dim = embed_dim
918
+ self.max_minutes = int(max_minutes)
919
+ self.stagnation_limit = int(stagnation_limit)
920
+ # Text-path label used when persisting vectorizer metadata (set by CLI)
921
+ self.dataset_label = "cornell-movie-review-data/rotten_tomatoes"
922
+
923
+ self.population: List[Genome] = []
924
+ self.history: List[Dict] = []
925
+ self.best_genome: Optional[Genome] = None
926
+ self.current_generation = 0
927
+
928
+ # Safety-gate tracking (set at run() start)
929
+ self._start_time: float = 0.0
930
+ self._stagnant_gens: int = 0
931
+ self.safety_trips: List[str] = []
932
+
933
+ # Threading support for GUI
934
+ self.stop_event = threading.Event()
935
+ self.on_generation_complete: Optional[Callable] = None
936
+
937
+ # ── Population management ──────────────────────────────────────
938
+ def initialize_population(self):
939
+ """Create an initial random population."""
940
+ log(f"Initializing population of {self.pop_size} genomes...", "info")
941
+ self.population = [Genome() for _ in range(self.pop_size)]
942
+ for g in self.population:
943
+ g.generation_born = 0
944
+ log(f"Population ready. Genome examples:", "success")
945
+ for g in self.population[:3]:
946
+ log(f" {g}", "info")
947
+
948
+ # ── Selection ──────────────────────────────────────────────────
949
+ def selection(self):
950
+ """Keep the top performers (elitism) and discard the rest."""
951
+ self.population.sort(key=lambda g: g.fitness, reverse=True)
952
+ elites = self.population[:self.elite_count]
953
+ log(f" Selected top {self.elite_count} elites:", "evo")
954
+ for g in elites:
955
+ log(f" {g}", "evo")
956
+ return elites
957
+
958
+ # ── Reproduction ───────────────────────────────────────────────
959
+ def reproduce(self, elites: List[Genome]):
960
+ """Create next generation from elites via mutation & crossover."""
961
+ next_gen = [e.clone() for e in elites] # carry elites forward
962
+
963
+ while len(next_gen) < self.pop_size:
964
+ if random.random() < 0.7 and len(elites) >= 2:
965
+ # Crossover + mutation
966
+ p1, p2 = random.sample(elites, 2)
967
+ child = p1.crossover(p2)
968
+ child = child.mutate(self.mutation_rate)
969
+ else:
970
+ # Pure mutation from a random elite
971
+ parent = random.choice(elites)
972
+ child = parent.mutate(self.mutation_rate)
973
+
974
+ child.generation_born = self.current_generation + 1
975
+ child.fitness = 0.0
976
+ next_gen.append(child)
977
+
978
+ self.population = next_gen
979
+
980
+ # ── Checkpointing ──────────────────────────────────────────────
981
+ def save_checkpoint(self):
982
+ """Save full evolution state to disk."""
983
+ ckpt_path = self.checkpoint_dir / "checkpoint.pkl"
984
+ state = {
985
+ "current_generation": self.current_generation,
986
+ "population": [g.to_dict() for g in self.population],
987
+ "history": self.history,
988
+ "best_genome": self.best_genome.to_dict() if self.best_genome else None,
989
+ "params": {
990
+ "pop_size": self.pop_size,
991
+ "generations": self.generations,
992
+ "elite_count": self.elite_count,
993
+ "mutation_rate": self.mutation_rate,
994
+ "train_epochs": self.train_epochs,
995
+ },
996
+ }
997
+ with open(ckpt_path, "wb") as f:
998
+ pickle.dump(state, f)
999
+ log(f" Checkpoint saved (gen {self.current_generation})", "info")
1000
+
1001
+ def load_checkpoint(self) -> bool:
1002
+ """Resume from checkpoint if available. Returns True if loaded."""
1003
+ ckpt_path = self.checkpoint_dir / "checkpoint.pkl"
1004
+ if not ckpt_path.exists():
1005
+ return False
1006
+
1007
+ try:
1008
+ with open(ckpt_path, "rb") as f:
1009
+ state = pickle.load(f)
1010
+
1011
+ self.current_generation = state["current_generation"]
1012
+ self.population = [Genome.from_dict(g) for g in state["population"]]
1013
+ self.history = state["history"]
1014
+ if state["best_genome"]:
1015
+ self.best_genome = Genome.from_dict(state["best_genome"])
1016
+
1017
+ log(f"Resumed from checkpoint: generation {self.current_generation}", "success")
1018
+ return True
1019
+ except Exception as e:
1020
+ log(f"Failed to load checkpoint: {e}", "warn")
1021
+ return False
1022
+
1023
+ def save_best_model(self, evaluator: ModelEvaluator, X: np.ndarray, Y: np.ndarray):
1024
+ """Rebuild, retrain, and save the best genome's model."""
1025
+ if self.best_genome is None:
1026
+ return
1027
+
1028
+ log("Saving best model to disk...", "info")
1029
+ SafetyGates.enforce_genome(self.best_genome)
1030
+ model = evaluator.build_model(self.best_genome)
1031
+ split = int(0.8 * len(X))
1032
+ X_top, X_rest = evaluator.split_features(X, self.best_genome)
1033
+ if X_rest is not None:
1034
+ train_inputs = [X_top[:split], X_rest[:split]]
1035
+ else:
1036
+ train_inputs = X_top[:split]
1037
+ model.fit(train_inputs, Y[:split], epochs=self.train_epochs * 2, batch_size=self.best_genome.config["batch_size"], verbose=0)
1038
+
1039
+ save_path = self.checkpoint_dir / "best_model.keras"
1040
+ saved_ok = False
1041
+ # 🌍 TEXT PATH: bake the fitted TextVectorizer into the saved model so the
1042
+ # artifact accepts RAW English strings end-to-end (no separate tokenization
1043
+ # step at inference time). Numeric path saves the plain model as before.
1044
+ if self.task_type == "text_classification" and self.vectorizer is not None:
1045
+ try:
1046
+ raw_in = layers.Input(shape=(), dtype="string", name="raw_text")
1047
+ tokens = self.vectorizer.tv(raw_in)
1048
+ preds = model(tokens)
1049
+ serving = keras.Model(raw_in, preds, name=f"text_serving_{self.best_genome.id}")
1050
+ serving.save(str(save_path))
1051
+ saved_ok = True
1052
+ log(f"🌍 Text serving model saved (raw English β†’ sentiment): {save_path}", "success")
1053
+ # Also keep the token-id core model (best-effort β€” never clobber the
1054
+ # already-saved serving model if this optional save fails)
1055
+ try:
1056
+ model.save(str(self.checkpoint_dir / "best_model_ids.keras"))
1057
+ except Exception as e2:
1058
+ log(f"Could not save token-id core model ({e2}) β€” serving model already saved", "warn")
1059
+ del serving
1060
+ except Exception as e:
1061
+ if not saved_ok:
1062
+ log(f"Could not bake vectorizer into saved model ({e}); saving core model instead", "warn")
1063
+ try:
1064
+ model.save(str(save_path))
1065
+ saved_ok = True
1066
+ except Exception as e2:
1067
+ log(f"Could not save core model either ({e2}) β€” skipping model save", "error")
1068
+ else:
1069
+ log(f"Serving model saved but a later step failed ({e}); keeping serving model", "warn")
1070
+ else:
1071
+ try:
1072
+ model.save(str(save_path))
1073
+ saved_ok = True
1074
+ except Exception as e:
1075
+ log(f"Could not save model ({e}) β€” skipping model save", "error")
1076
+ del model
1077
+ keras.backend.clear_session()
1078
+ if saved_ok:
1079
+ log(f"Best model saved to {save_path}", "success")
1080
+ else:
1081
+ log("⚠️ No best model was saved to disk", "error")
1082
+
1083
+ # Also save genome config as JSON
1084
+ config_path = self.checkpoint_dir / "best_genome.json"
1085
+ with open(config_path, "w") as f:
1086
+ json.dump(self.best_genome.to_dict(), f, indent=2)
1087
+ log(f"Best genome config saved to {config_path}", "success")
1088
+
1089
+ # 🌍 TEXT PATH: persist vectorizer config so eval/loading can reconstruct it
1090
+ if self.task_type == "text_classification" and self.vectorizer is not None:
1091
+ vcfg = {
1092
+ "vocab_size": self.vectorizer.vocab_size,
1093
+ "max_len": self.vectorizer.max_len,
1094
+ "vocab_used": self.vectorizer.vocab_used(),
1095
+ "dataset": getattr(self, "dataset_label", "cornell-movie-review-data/rotten_tomatoes"),
1096
+ "vocab": list(self.vectorizer.tv.get_vocabulary()), # exact fitted vocab
1097
+ }
1098
+ vpath = self.checkpoint_dir / "vectorizer_config.json"
1099
+ with open(vpath, "w") as f:
1100
+ json.dump(vcfg, f, indent=2)
1101
+ log(f"Vectorizer config saved to {vpath}", "success")
1102
+
1103
+ # ── Diversity pressure ────────────────────────────────────────
1104
+ @staticmethod
1105
+ def _config_distance(cfg_a: Dict, cfg_b: Dict) -> int:
1106
+ """
1107
+ Cheap topology distance: sum of per-layer unit differences. Returns a huge
1108
+ value when layer counts differ (structurally very different networks).
1109
+ """
1110
+ la = [l["units"] for l in cfg_a.get("layers", [])]
1111
+ lb = [l["units"] for l in cfg_b.get("layers", [])]
1112
+ if len(la) != len(lb):
1113
+ return 10_000_000
1114
+ return sum(abs(a - b) for a, b in zip(la, lb))
1115
+
1116
+ def apply_diversity_pressure(self):
1117
+ """
1118
+ 🧬 Penalize (a) exact duplicate configs and (b) genomes whose layer
1119
+ topology is nearly identical to the current best, so the population does
1120
+ not collapse onto a single local optimum. In-place on self.population.
1121
+ """
1122
+ counts = {}
1123
+ for g in self.population:
1124
+ counts[g.id] = counts.get(g.id, 0) + 1
1125
+ best_cfg = self.best_genome.config if self.best_genome else None
1126
+ best_id = self.best_genome.id if self.best_genome else None
1127
+ for g in self.population:
1128
+ # The champion itself is NEVER penalized (it must stay comparable to the
1129
+ # stored best for stagnation tracking and re-selection to work).
1130
+ if best_id is not None and g.id == best_id:
1131
+ continue
1132
+ if counts[g.id] > 1:
1133
+ g.fitness *= 0.5
1134
+ log(f" 🧬 Diversity: duplicate config {g.id} β€” fitness halved", "warn")
1135
+ elif best_cfg is not None and self._config_distance(g.config, best_cfg) < 32:
1136
+ g.fitness *= 0.9
1137
+ log(f" 🧬 Diversity: {g.id} too similar to best β€” fitness x0.9", "warn")
1138
+
1139
+ # ── Logging ────────────────────────────────────────────────────
1140
+ def log_generation(self, gen: int, fitnesses: List[float]):
1141
+ """Log and store generation statistics."""
1142
+ stats = {
1143
+ "generation": gen,
1144
+ "best_fitness": max(fitnesses),
1145
+ "avg_fitness": float(np.mean(fitnesses)),
1146
+ "worst_fitness": min(fitnesses),
1147
+ "std_fitness": float(np.std(fitnesses)),
1148
+ "timestamp": datetime.datetime.now().isoformat(),
1149
+ }
1150
+ self.history.append(stats)
1151
+
1152
+ best = stats["best_fitness"]
1153
+ avg = stats["avg_fitness"]
1154
+ log(
1155
+ f"Gen {gen:3d} β”‚ Best: {best:10.2f} β”‚ Avg: {avg:10.2f} β”‚ "
1156
+ f"Std: {stats['std_fitness']:8.2f} β”‚ Pop: {len(self.population)}",
1157
+ "evo",
1158
+ )
1159
+
1160
+ # Notify GUI callback if set
1161
+ if self.on_generation_complete:
1162
+ try:
1163
+ self.on_generation_complete(stats, self.best_genome)
1164
+ except Exception:
1165
+ pass
1166
+
1167
+ def print_evolution_summary(self):
1168
+ """Print a final summary of the evolution."""
1169
+ banner("EVOLUTION COMPLETE", "━")
1170
+
1171
+ # β›” Make gate halts VISIBLE β€” a safety-stop must never look like a normal finish
1172
+ if self.safety_trips:
1173
+ log(f"β›” Halted by safety gates: {'; '.join(self.safety_trips)}", "error")
1174
+
1175
+ if self.best_genome:
1176
+ log(f"Best genome found:", "success")
1177
+ log(f" {self.best_genome}", "success")
1178
+
1179
+ if self.history:
1180
+ log(f"Generations run: {len(self.history)}", "info")
1181
+ log(f"Initial best fitness: {self.history[0]['best_fitness']:.4f}", "info")
1182
+ log(f"Final best fitness: {self.history[-1]['best_fitness']:.4f}", "info")
1183
+
1184
+ improvement = 0
1185
+ if self.history[0]["best_fitness"] > 0:
1186
+ improvement = (
1187
+ (self.history[-1]["best_fitness"] - self.history[0]["best_fitness"])
1188
+ / self.history[0]["best_fitness"] * 100
1189
+ )
1190
+ log(f"Improvement: {improvement:+.1f}%", "success")
1191
+
1192
+ # Save history as JSON
1193
+ history_path = self.checkpoint_dir / "evolution_history.json"
1194
+ with open(history_path, "w") as f:
1195
+ json.dump(self.history, f, indent=2)
1196
+ log(f"Full history saved to {history_path}", "info")
1197
+
1198
+ # ── Main evolutionary loop ──────��──────────────────────────────
1199
+ def run(
1200
+ self,
1201
+ X: Optional[np.ndarray] = None,
1202
+ Y: Optional[np.ndarray] = None,
1203
+ reset: bool = False,
1204
+ ):
1205
+ """
1206
+ Run the full evolutionary training loop.
1207
+
1208
+ Args:
1209
+ X: Optional input data. If None, synthetic data is generated.
1210
+ Y: Optional target data. If None, synthetic data is generated.
1211
+ reset: If True, ignore checkpoints and start fresh.
1212
+ """
1213
+ banner("SELF-EVOLVING NEURAL NETWORK")
1214
+
1215
+ # ── Safety-gate initialization ──
1216
+ self._start_time = time.monotonic()
1217
+ self._stagnant_gens = 0
1218
+ self.safety_trips = []
1219
+ log(
1220
+ f"β›” Safety gates armed: max_layers<={SafetyGates.HARD_MAX_LAYERS}, "
1221
+ f"max_units<={SafetyGates.HARD_MAX_UNITS}, max_params<={SafetyGates.HARD_MAX_PARAMS:,}, "
1222
+ f"stagnation_limit={self.stagnation_limit}, max_minutes={self.max_minutes}",
1223
+ "info",
1224
+ )
1225
+
1226
+ # ── Resume or initialize ──
1227
+ if not reset and self.load_checkpoint():
1228
+ log(f"Continuing from generation {self.current_generation + 1}...", "info")
1229
+ else:
1230
+ if reset:
1231
+ log("Reset requested. Starting fresh.", "warn")
1232
+ # Only generate a fresh random population if none was pre-seeded
1233
+ # (SelfTrainer.continue_evolution seeds from the best genome BEFORE
1234
+ # calling run, so we must not clobber it here).
1235
+ if not self.population:
1236
+ self.initialize_population()
1237
+ self.current_generation = 0
1238
+
1239
+ # ── Prepare data ──
1240
+ data_handler = DataHandler(task_type=self.task_type)
1241
+ X, Y = data_handler.get_data(x=X, y=Y)
1242
+
1243
+ input_dim = X.shape[1]
1244
+ output_dim = Y.shape[1] if len(Y.shape) > 1 else 1
1245
+ evaluator = ModelEvaluator(
1246
+ input_dim, output_dim, task_type=self.task_type,
1247
+ vectorizer=self.vectorizer, embed_dim=self.embed_dim,
1248
+ )
1249
+
1250
+ # ── Evolutionary loop ──
1251
+ start_gen = self.current_generation
1252
+ for gen in range(start_gen, self.generations):
1253
+ # Check for stop signal from GUI
1254
+ if self.stop_event.is_set():
1255
+ log("Stop signal received. Halting evolution.", "warn")
1256
+ break
1257
+
1258
+ # β›” FAILURE GATE: wall-clock budget exceeded
1259
+ if self.max_minutes > 0 and (time.monotonic() - self._start_time) / 60 >= self.max_minutes:
1260
+ msg = f"max_minutes budget ({self.max_minutes} min) exceeded"
1261
+ log(f"β›” Safety gate: {msg}. Halting evolution.", "warn")
1262
+ self.safety_trips.append(msg)
1263
+ break
1264
+
1265
+ # β›” FAILURE GATE: stagnation (no fitness improvement for N gens)
1266
+ if self.stagnation_limit > 0 and self._stagnant_gens >= self.stagnation_limit:
1267
+ msg = f"no improvement for {self._stagnant_gens} generations (limit {self.stagnation_limit})"
1268
+ log(f"β›” Safety gate: {msg}. Halting evolution.", "warn")
1269
+ self.safety_trips.append(msg)
1270
+ break
1271
+
1272
+ self.current_generation = gen
1273
+ banner(f"GENERATION {gen + 1} / {self.generations}", "─")
1274
+
1275
+ # Evaluate fitness for each genome
1276
+ fitnesses = []
1277
+ for i, genome in enumerate(self.population):
1278
+ log(f"Evaluating genome {i+1}/{len(self.population)}: {genome.id}", "info")
1279
+ fitness = evaluator.train_and_evaluate(
1280
+ genome, X, Y, epochs=self.train_epochs, verbose=0
1281
+ )
1282
+ genome.fitness = fitness
1283
+ fitnesses.append(fitness)
1284
+ log(f" β†’ Fitness: {fitness:.4f}", "success" if fitness > np.median(fitnesses) else "info")
1285
+
1286
+ # 🧬 Keep the population diverse (prevent premature convergence)
1287
+ self.apply_diversity_pressure()
1288
+ fitnesses = [g.fitness for g in self.population]
1289
+
1290
+ # Update best genome + stagnation tracking
1291
+ gen_best = max(self.population, key=lambda g: g.fitness)
1292
+ if self.best_genome is None or gen_best.fitness > self.best_genome.fitness:
1293
+ self.best_genome = gen_best.clone()
1294
+ self._stagnant_gens = 0
1295
+ log(f" β˜… New best genome! {self.best_genome.id} (fitness={self.best_genome.fitness:.4f})", "success")
1296
+ else:
1297
+ self._stagnant_gens += 1
1298
+
1299
+ # Log generation stats
1300
+ self.log_generation(gen, fitnesses)
1301
+
1302
+ # Selection & reproduction (skip for last gen)
1303
+ if gen < self.generations - 1:
1304
+ elites = self.selection()
1305
+ self.reproduce(elites)
1306
+
1307
+ # Save checkpoint
1308
+ self.save_checkpoint()
1309
+
1310
+ # ── Save final best model ──
1311
+ self.save_best_model(evaluator, X, Y)
1312
+ self.print_evolution_summary()
1313
+
1314
+
1315
+ # ═════════════════════════════════════════════════════════════════════
1316
+ # CLASS: SelfTrainer
1317
+ # Wrapper that allows the system to load and continue training
1318
+ # an existing saved model, evolving it further.
1319
+ # ═════════════════════════════════════════════════════════════════════
1320
+ class SelfTrainer:
1321
+ """
1322
+ Loads a previously saved model or genome and continues the
1323
+ evolutionary process from that point, effectively letting the
1324
+ file 'evolve itself' from its own prior state.
1325
+ """
1326
+
1327
+ def __init__(self, checkpoint_dir: Path = CHECKPOINT_DIR):
1328
+ self.checkpoint_dir = Path(checkpoint_dir)
1329
+
1330
+ def load_best_genome(self) -> Optional[Genome]:
1331
+ """Load the best genome from previous evolution."""
1332
+ config_path = self.checkpoint_dir / "best_genome.json"
1333
+ if not config_path.exists():
1334
+ return None
1335
+ with open(config_path, "r") as f:
1336
+ data = json.load(f)
1337
+ genome = Genome.from_dict(data)
1338
+ log(f"Loaded previous best genome: {genome.id}", "success")
1339
+ return genome
1340
+
1341
+ def continue_evolution(
1342
+ self,
1343
+ generations: int = 20,
1344
+ pop_size: int = 8,
1345
+ X: Optional[np.ndarray] = None,
1346
+ Y: Optional[np.ndarray] = None,
1347
+ task_type: str = "regression",
1348
+ vectorizer: Optional[TextVectorizer] = None,
1349
+ embed_dim: int = 128,
1350
+ max_minutes: int = SafetyGates.DEFAULT_MAX_MINUTES,
1351
+ stagnation_limit: int = SafetyGates.STAGNATION_LIMIT,
1352
+ ):
1353
+ """
1354
+ Continue evolving from the best saved genome.
1355
+ Seeds a new population with mutations of the best genome.
1356
+ """
1357
+ banner("CONTINUING EVOLUTION FROM SAVED STATE")
1358
+
1359
+ parent = self.load_best_genome()
1360
+ if parent is None:
1361
+ log("No previous genome found. Starting fresh evolution.", "warn")
1362
+ engine = EvolutionEngine(pop_size=pop_size, generations=generations,
1363
+ task_type=task_type, vectorizer=vectorizer,
1364
+ embed_dim=embed_dim, checkpoint_dir=self.checkpoint_dir,
1365
+ max_minutes=max_minutes, stagnation_limit=stagnation_limit)
1366
+ engine.run(X=X, Y=Y, reset=True)
1367
+ return
1368
+
1369
+ # Seed population with mutations of the best genome
1370
+ log(f"Seeding population with mutations of {parent.id}...", "info")
1371
+ engine = EvolutionEngine(pop_size=pop_size, generations=generations,
1372
+ task_type=task_type, vectorizer=vectorizer,
1373
+ embed_dim=embed_dim, checkpoint_dir=self.checkpoint_dir,
1374
+ max_minutes=max_minutes, stagnation_limit=stagnation_limit)
1375
+ engine.population = [parent.clone()]
1376
+ for _ in range(pop_size - 1):
1377
+ child = parent.mutate(mutation_rate=0.4) # higher mutation for diversity
1378
+ engine.population.append(child)
1379
+
1380
+ engine.current_generation = 0
1381
+ engine.best_genome = parent # keep track of parent's fitness
1382
+ engine.run(X=X, Y=Y, reset=True)
1383
+
1384
+
1385
+ # ═════════════════════════════════════════════════════════════════════
1386
+ # CLI Entry Point
1387
+ # ═════════════════════════════════════════════════════════════════════
1388
+ def parse_args():
1389
+ parser = argparse.ArgumentParser(
1390
+ description="🧬 Self-Evolving Neural Network β€” evolves its own architecture locally",
1391
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1392
+ epilog="""
1393
+ Examples:
1394
+ python3 self_evolving_model.py # Quick run (20 gens, 8 pop)
1395
+ python3 self_evolving_model.py --generations 50 # More generations
1396
+ python3 self_evolving_model.py --pop-size 15 # Larger population
1397
+ python3 self_evolving_model.py --train-epochs 25 # Longer training per eval
1398
+ python3 self_evolving_model.py --reset # Ignore checkpoint, start fresh
1399
+ python3 self_evolving_model.py --continue # Continue from best saved model
1400
+ python3 self_evolving_model.py --max-units 512 --max-layers 8 # Bigger genomes (GPU)
1401
+ """,
1402
+ )
1403
+ parser.add_argument("--pop-size", type=int, default=8, help="Population size (default: 8, min: 2)")
1404
+ parser.add_argument("--generations", type=int, default=20, help="Number of generations (default: 20)")
1405
+ parser.add_argument("--train-epochs", type=int, default=15, help="Training epochs per evaluation (default: 15)")
1406
+ parser.add_argument("--mutation-rate", type=float, default=0.3, help="Mutation rate (default: 0.3)")
1407
+ parser.add_argument("--reset", action="store_true", help="Start fresh, ignoring checkpoints")
1408
+ parser.add_argument("--continue", dest="continue_evo", action="store_true", help="Continue evolving from best saved model")
1409
+ parser.add_argument("--n-samples", type=int, default=2000, help="Number of synthetic data samples (default: 2000)")
1410
+ parser.add_argument("--n-features", type=int, default=10, help="Number of input features (default: 10)")
1411
+ parser.add_argument("--dataset", type=str, default=None, help="HuggingFace dataset to use (e.g. 'fable' for Crownelius/Complete-FABLE.5-traces-2M)")
1412
+ parser.add_argument("--max-units", type=int, default=256, help="Maximum units per layer for genome search (default: 256)")
1413
+ parser.add_argument("--max-layers", type=int, default=6, help="Maximum number of hidden layers for genome search (default: 6)")
1414
+ parser.add_argument("--max-minutes", type=int, default=0, help="β›” Safety: hard wall-clock budget in minutes (0 = unlimited)")
1415
+ parser.add_argument("--stagnation-limit", type=int, default=SafetyGates.STAGNATION_LIMIT, help=f"β›” Safety: halt if no fitness improvement for N generations (default: {SafetyGates.STAGNATION_LIMIT})")
1416
+ parser.add_argument("--text-dataset", type=str, default=None, help="English text dataset for sentiment understanding (e.g. 'rotten_tomatoes')")
1417
+ parser.add_argument("--max-len", type=int, default=128, help="Max token length for text input (default: 128)")
1418
+ parser.add_argument("--vocab-size", type=int, default=10000, help="Vocabulary size for text tokenizer (default: 10000)")
1419
+ parser.add_argument("--embed-dim", type=int, default=128, help="Embedding dimension for text models (default: 128)")
1420
+ parser.add_argument("--checkpoint-dir", type=str, default=None, help="Override checkpoint directory (default: evo_checkpoints)")
1421
+ parser.add_argument("--gui", action="store_true", help="Launch the web GUI instead of CLI")
1422
+ parser.add_argument("--gui-port", type=int, default=5000, help="Port for the web GUI (default: 5000)")
1423
+ return parser.parse_args()
1424
+
1425
+
1426
+ def main():
1427
+ args = parse_args()
1428
+
1429
+ # Seed for reproducibility
1430
+ random.seed(42)
1431
+ np.random.seed(42)
1432
+ tf.random.set_seed(42)
1433
+
1434
+ # Configure the genome search space (allows scaling params on GPU/laptop)
1435
+ configure_genome_pool(args.max_units, args.max_layers)
1436
+
1437
+ # Limit TF GPU memory growth if GPU available
1438
+ gpus = tf.config.experimental.list_physical_devices("GPU")
1439
+ if gpus:
1440
+ for gpu in gpus:
1441
+ tf.config.experimental.set_memory_growth(gpu, True)
1442
+ log(f"Found {len(gpus)} GPU(s). Memory growth enabled.", "info")
1443
+ else:
1444
+ log("No GPU found. Running on CPU.", "info")
1445
+
1446
+ # Validate pop_size
1447
+ if args.pop_size < 2:
1448
+ log("Population size must be at least 2 for evolution to work.", "error")
1449
+ sys.exit(1)
1450
+
1451
+ # Load dataset (numeric FABLE path OR English text path)
1452
+ X_data, Y_data = None, None
1453
+ vectorizer = None
1454
+ task_type = "regression"
1455
+ if args.dataset:
1456
+ n = args.n_samples if args.n_samples else 10000
1457
+ X_data, Y_data = DataHandler.load_fable_dataset(n_samples=n)
1458
+ elif args.text_dataset:
1459
+ X_data, Y_data, vectorizer, n_classes = DataHandler.load_text_dataset(
1460
+ dataset=args.text_dataset, n_samples=args.n_samples,
1461
+ max_len=args.max_len, vocab_size=args.vocab_size,
1462
+ )
1463
+ task_type = "text_classification"
1464
+ log(f"πŸ“– English text mode ready: {args.text_dataset} ({len(X_data):,} samples, {n_classes} classes)", "success")
1465
+
1466
+ ckpt_dir = Path(args.checkpoint_dir) if args.checkpoint_dir else CHECKPOINT_DIR
1467
+
1468
+ if args.gui:
1469
+ from evo_gui import launch_gui
1470
+ launch_gui(
1471
+ pop_size=args.pop_size,
1472
+ generations=args.generations,
1473
+ mutation_rate=args.mutation_rate,
1474
+ train_epochs=args.train_epochs,
1475
+ X=X_data, Y=Y_data,
1476
+ port=args.gui_port,
1477
+ )
1478
+ sys.exit(0)
1479
+
1480
+ if args.continue_evo:
1481
+ # Continue from saved state (text-aware: task type, vectorizer, checkpoint dir)
1482
+ trainer = SelfTrainer(checkpoint_dir=ckpt_dir)
1483
+ trainer.continue_evolution(
1484
+ generations=args.generations,
1485
+ pop_size=args.pop_size,
1486
+ X=X_data, Y=Y_data,
1487
+ task_type=task_type,
1488
+ vectorizer=vectorizer,
1489
+ embed_dim=args.embed_dim,
1490
+ max_minutes=args.max_minutes,
1491
+ stagnation_limit=args.stagnation_limit,
1492
+ )
1493
+ else:
1494
+ # Fresh or resumed evolution (numeric FABLE or English text)
1495
+ engine = EvolutionEngine(
1496
+ pop_size=args.pop_size,
1497
+ generations=args.generations,
1498
+ mutation_rate=args.mutation_rate,
1499
+ train_epochs=args.train_epochs,
1500
+ checkpoint_dir=ckpt_dir,
1501
+ task_type=task_type,
1502
+ vectorizer=vectorizer,
1503
+ embed_dim=args.embed_dim,
1504
+ max_minutes=args.max_minutes,
1505
+ stagnation_limit=args.stagnation_limit,
1506
+ )
1507
+ engine.dataset_label = args.text_dataset or args.dataset or "synthetic"
1508
+ engine.run(X=X_data, Y=Y_data, reset=args.reset)
1509
+
1510
+
1511
+ if __name__ == "__main__":
1512
+ main()