direct-evolution-together 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,431 @@
1
+ import itertools
2
+ from pathlib import Path
3
+ import re
4
+ import tempfile
5
+ from typing import Iterable
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+ from torch_einops_utils.save_load import save_load
11
+
12
+ LAYER_DIMS = (4, 12, 12, 12, 3)
13
+ NUM_HIDDEN = sum(LAYER_DIMS[1:-1]) # 36
14
+ NUM_ACTIVATIONS = 5 # 0=tanh, 1=sin, 2=gauss, 3=abs, 4=leaky_relu
15
+ POPULATION_SIZE = 30
16
+ DEFAULT_MUT_SIGMA = 0.35
17
+ DEFAULT_ELITE_FRAC = 0.10
18
+ DEFAULT_CROSSOVER_RATE = 0.75
19
+
20
+ _genome_counter = itertools.count(1)
21
+
22
+
23
+ def _apply_mixed_activations(z: torch.Tensor, act_codes: torch.Tensor) -> torch.Tensor:
24
+ """Apply per-neuron Picbreeder activation functions along the last dimension."""
25
+ out = torch.empty_like(z)
26
+ mask0 = act_codes == 0
27
+ mask1 = act_codes == 1
28
+ mask2 = act_codes == 2
29
+ mask3 = act_codes == 3
30
+ mask4 = act_codes == 4
31
+
32
+ if mask0.any():
33
+ out[..., mask0] = torch.tanh(z[..., mask0])
34
+ if mask1.any():
35
+ out[..., mask1] = torch.sin(z[..., mask1] * 1.5)
36
+ if mask2.any():
37
+ out[..., mask2] = torch.exp(-(z[..., mask2] ** 2))
38
+ if mask3.any():
39
+ out[..., mask3] = torch.abs(z[..., mask3]) - 0.5
40
+ if mask4.any():
41
+ out[..., mask4] = F.leaky_relu(z[..., mask4], negative_slope=0.1)
42
+ return out
43
+
44
+
45
+ @save_load
46
+ class CPPN(nn.Module):
47
+ """Fixed-topology PyTorch Compositional Pattern Producing Network (CPPN)."""
48
+
49
+ def __init__(
50
+ self,
51
+ genome_id: int | None = None,
52
+ parents: list[int] | None = None,
53
+ op: str = "random",
54
+ ) -> None:
55
+ super().__init__()
56
+ self.genome_id = genome_id if genome_id is not None else next(_genome_counter)
57
+ self.parents: list[int] = list(parents) if parents else []
58
+ self.op: str = op
59
+ self.layers = nn.ModuleList(
60
+ [
61
+ nn.Linear(in_f, out_f)
62
+ for in_f, out_f in zip(LAYER_DIMS[:-1], LAYER_DIMS[1:])
63
+ ]
64
+ )
65
+ self.register_buffer(
66
+ "activations",
67
+ torch.zeros(NUM_HIDDEN, dtype=torch.long),
68
+ )
69
+
70
+ @classmethod
71
+ @torch.no_grad()
72
+ def random(cls, genome_id: int | None = None) -> "CPPN":
73
+ net = cls(genome_id=genome_id, parents=[], op="random")
74
+ for layer_idx, layer in enumerate(net.layers):
75
+ in_f, out_f = layer.weight.shape[1], layer.weight.shape[0]
76
+ # Start sparse like early NEAT graphs so gen 0 has crisp geometric & symmetric forms.
77
+ keep_prob = 0.55 if layer_idx == 0 else (0.35 if layer_idx < len(net.layers) - 1 else 0.45)
78
+ std = 1.6 if layer_idx == 0 else (1.1 if layer_idx < len(net.layers) - 1 else 1.0)
79
+ mask = (torch.rand(out_f, in_f) < keep_prob).float()
80
+ # Ensure every output unit receives at least one active input connection.
81
+ for row in range(out_f):
82
+ if mask[row].sum() == 0:
83
+ mask[row, int(torch.randint(0, in_f, (1,)).item())] = 1.0
84
+ layer.weight.normal_(0.0, std).mul_(mask)
85
+ layer.bias.normal_(0.0, 0.4 if layer_idx < len(net.layers) - 1 else 0.25)
86
+ net.activations.copy_(
87
+ torch.randint(0, NUM_ACTIVATIONS, (NUM_HIDDEN,), dtype=torch.long)
88
+ )
89
+ return net
90
+
91
+ @classmethod
92
+ @torch.no_grad()
93
+ def crossover_and_mutate(
94
+ cls,
95
+ parent_a: "CPPN",
96
+ parent_b: "CPPN",
97
+ *,
98
+ use_crossover: bool = True,
99
+ mut_sigma: float = 0.35,
100
+ mut_rate: float = 0.35,
101
+ act_flip_rate: float = 0.08,
102
+ ) -> "CPPN":
103
+ """Create a child CPPN via neuron-unit crossover and sparse Gaussian mutation.
104
+
105
+ Each hidden neuron's incoming weight row, bias, and activation function are
106
+ inherited together as a single functional unit from either parent, avoiding
107
+ mixed/inconsistent neuron representations.
108
+ """
109
+ child = cls(
110
+ parents=(
111
+ [parent_a.genome_id]
112
+ if not use_crossover or parent_a.genome_id == parent_b.genome_id
113
+ else [parent_a.genome_id, parent_b.genome_id]
114
+ ),
115
+ op="crossover" if use_crossover else "mutation",
116
+ )
117
+ acts = parent_a.activations.clone()
118
+ act_offset = 0
119
+
120
+ for layer_idx, (c_layer, a_layer, b_layer) in enumerate(
121
+ zip(child.layers, parent_a.layers, parent_b.layers)
122
+ ):
123
+ out_dim = c_layer.weight.shape[0]
124
+ if use_crossover:
125
+ unit_mask = torch.rand(out_dim) < 0.5
126
+ w = torch.where(unit_mask.unsqueeze(-1), b_layer.weight, a_layer.weight)
127
+ b = torch.where(unit_mask, b_layer.bias, a_layer.bias)
128
+ if layer_idx < len(child.layers) - 1:
129
+ acts[act_offset : act_offset + out_dim] = torch.where(
130
+ unit_mask,
131
+ parent_b.activations[act_offset : act_offset + out_dim],
132
+ parent_a.activations[act_offset : act_offset + out_dim],
133
+ )
134
+ else:
135
+ w = a_layer.weight.clone()
136
+ b = a_layer.bias.clone()
137
+
138
+ if layer_idx < len(child.layers) - 1:
139
+ act_offset += out_dim
140
+
141
+ if mut_sigma > 0.0 and mut_rate > 0.0:
142
+ w_mut = (torch.rand_like(w) < mut_rate).float() * torch.randn_like(w) * mut_sigma
143
+ b_mut = (
144
+ (torch.rand_like(b) < mut_rate).float()
145
+ * torch.randn_like(b)
146
+ * (mut_sigma * 0.6)
147
+ )
148
+ w = (w + w_mut).clamp(-6.0, 6.0)
149
+ b = (b + b_mut).clamp(-4.0, 4.0)
150
+ c_layer.weight.copy_(w)
151
+ c_layer.bias.copy_(b)
152
+
153
+ if act_flip_rate > 0.0:
154
+ flip_mask = torch.rand(NUM_HIDDEN) < act_flip_rate
155
+ if flip_mask.any():
156
+ acts[flip_mask] = torch.randint(
157
+ 0, NUM_ACTIVATIONS, (int(flip_mask.sum().item()),), dtype=torch.long
158
+ )
159
+ child.activations.copy_(acts)
160
+ return child
161
+
162
+ def forward(self, coords: torch.Tensor) -> torch.Tensor:
163
+ """Map (..., 4) coordinates [x, y, r, 1] to (..., 3) RGB values in [0, 1]."""
164
+ h = coords
165
+ act_offset = 0
166
+ for layer in self.layers[:-1]:
167
+ z = layer(h)
168
+ width = z.shape[-1]
169
+ h = _apply_mixed_activations(z, self.activations[act_offset : act_offset + width])
170
+ act_offset += width
171
+ out = self.layers[-1](h)
172
+ return 0.5 + 0.5 * torch.tanh(out)
173
+
174
+ @torch.no_grad()
175
+ def render(self, size: int = 64) -> torch.Tensor:
176
+ """Render the CPPN into a (size, size, 3) uint8 RGB tensor."""
177
+ lin = torch.linspace(-1.0, 1.0, size)
178
+ yy, xx = torch.meshgrid(lin, lin, indexing="ij")
179
+ rr = torch.sqrt(xx * xx + yy * yy)
180
+ ones = torch.ones_like(xx)
181
+ coords = torch.stack([xx, yy, rr, ones], dim=-1)
182
+ rgb = self(coords)
183
+ return (rgb * 255.0).round().clamp(0, 255).to(torch.uint8)
184
+
185
+ def to_dict(self) -> dict:
186
+ weights: list[float] = []
187
+ biases: list[float] = []
188
+ for layer in self.layers:
189
+ weights.extend(round(v, 4) for v in layer.weight.detach().flatten().tolist())
190
+ biases.extend(round(v, 4) for v in layer.bias.detach().flatten().tolist())
191
+ return {
192
+ "id": self.genome_id,
193
+ "parents": list(self.parents),
194
+ "op": self.op,
195
+ "weights": weights,
196
+ "biases": biases,
197
+ "activations": [int(a) for a in self.activations.tolist()],
198
+ }
199
+
200
+ @classmethod
201
+ @torch.no_grad()
202
+ def from_dict(cls, data: dict) -> "CPPN":
203
+ net = cls(
204
+ genome_id=int(data["id"]),
205
+ parents=[int(p) for p in data.get("parents", [])],
206
+ op=str(data.get("op", "random")),
207
+ )
208
+ w_flat = data["weights"]
209
+ b_flat = data["biases"]
210
+ w_offset = 0
211
+ b_offset = 0
212
+ for layer in net.layers:
213
+ w_num = layer.weight.numel()
214
+ b_num = layer.bias.numel()
215
+ w_slice = torch.tensor(
216
+ w_flat[w_offset : w_offset + w_num], dtype=torch.float32
217
+ ).view_as(layer.weight)
218
+ b_slice = torch.tensor(
219
+ b_flat[b_offset : b_offset + b_num], dtype=torch.float32
220
+ ).view_as(layer.bias)
221
+ layer.weight.copy_(w_slice)
222
+ layer.bias.copy_(b_slice)
223
+ w_offset += w_num
224
+ b_offset += b_num
225
+ net.activations.copy_(torch.tensor(data["activations"], dtype=torch.long))
226
+ return net
227
+
228
+
229
+ def create_random_population(pop_size: int = POPULATION_SIZE) -> list[dict]:
230
+ """Generate `pop_size` fresh random PyTorch CPPN genomes."""
231
+ return [CPPN.random().to_dict() for _ in range(pop_size)]
232
+
233
+
234
+ def _sample_parents(
235
+ pool: list[int],
236
+ scores: list[int],
237
+ crossover_rate: float = DEFAULT_CROSSOVER_RATE,
238
+ ) -> tuple[int, int, bool]:
239
+ """Sample fitness-weighted parent indices, pairing distinct parents when possible."""
240
+ if len(pool) == 1:
241
+ return pool[0], pool[0], False
242
+ min_s = min(scores[i] for i in pool)
243
+ weights = torch.tensor([float(scores[i] - min_s + 1) for i in pool])
244
+ picks = torch.multinomial(weights, 2, replacement=False)
245
+ use_cross = bool(torch.rand(1).item() < max(0.0, min(1.0, float(crossover_rate))))
246
+ return pool[int(picks[0].item())], pool[int(picks[1].item())], use_cross
247
+
248
+
249
+ def evolve_population(
250
+ genome_dicts: Iterable[dict],
251
+ scores: list[int],
252
+ up_counts: list[int],
253
+ mut_sigma: float = DEFAULT_MUT_SIGMA,
254
+ pop_size: int = POPULATION_SIZE,
255
+ elite_frac: float = DEFAULT_ELITE_FRAC,
256
+ crossover_rate: float = DEFAULT_CROSSOVER_RATE,
257
+ ) -> tuple[list[dict], int]:
258
+ """Evolve a PyTorch CPPN population using thumbs-up/down scores.
259
+
260
+ Preserves a configurable fraction of top liked elites, blends fine local refinement
261
+ with medium crossover exploration, and injects a small number of neutral/random
262
+ immigrants so larger crowds don't prematurely collapse diversity.
263
+ """
264
+ parents = [CPPN.from_dict(g) for g in genome_dicts]
265
+ ranked = sorted(
266
+ range(len(parents)),
267
+ key=lambda i: (-scores[i], -up_counts[i], i),
268
+ )
269
+
270
+ liked = [i for i in ranked if scores[i] > 0]
271
+ neutral_or_better = [i for i in ranked if scores[i] >= 0]
272
+ clamped_elite_frac = max(0.0, min(0.5, float(elite_frac)))
273
+ clamped_crossover = max(0.0, min(1.0, float(crossover_rate)))
274
+
275
+ next_gen: list[CPPN] = []
276
+ if liked:
277
+ # Preserve configurable fraction of liked top individuals as elites
278
+ if clamped_elite_frac <= 0.0 or pop_size <= 1:
279
+ target_elites = 0
280
+ else:
281
+ target_elites = min(max(1, round(pop_size * clamped_elite_frac)), pop_size - 1)
282
+ num_elites = min(len(liked), target_elites)
283
+ for e_idx in range(num_elites):
284
+ elite_src = parents[liked[e_idx]]
285
+ elite = CPPN.from_dict(elite_src.to_dict())
286
+ elite.parents = [elite_src.genome_id]
287
+ elite.op = "elite"
288
+ next_gen.append(elite)
289
+
290
+ num_immigrants = min(2, max(0, (pop_size - num_elites) // 10))
291
+ num_breed = max(0, pop_size - num_elites - num_immigrants)
292
+
293
+ for slot in range(num_breed):
294
+ idx_a, idx_b, use_cross = _sample_parents(liked, scores, crossover_rate=clamped_crossover)
295
+ # Alternate between fine local polishers (keep silhouette) and broader explorers
296
+ is_fine = (slot % 2 == 0)
297
+ eff_sigma = mut_sigma * (0.42 if is_fine else 0.9)
298
+ eff_rate = 0.14 if is_fine else 0.28
299
+ eff_flip = 0.02 if is_fine else 0.06
300
+ next_gen.append(
301
+ CPPN.crossover_and_mutate(
302
+ parents[idx_a],
303
+ parents[idx_b],
304
+ use_crossover=use_cross,
305
+ mut_sigma=eff_sigma,
306
+ mut_rate=eff_rate,
307
+ act_flip_rate=eff_flip,
308
+ )
309
+ )
310
+
311
+ neutral_only = [i for i in ranked if scores[i] == 0]
312
+ for imm in range(num_immigrants):
313
+ if imm == 0 and neutral_only:
314
+ pick = int(torch.randint(0, len(neutral_only), (1,)).item())
315
+ seed_parent = parents[neutral_only[pick]]
316
+ next_gen.append(
317
+ CPPN.crossover_and_mutate(
318
+ seed_parent,
319
+ seed_parent,
320
+ use_crossover=False,
321
+ mut_sigma=mut_sigma * 0.75,
322
+ mut_rate=0.22,
323
+ act_flip_rate=0.05,
324
+ )
325
+ )
326
+ else:
327
+ next_gen.append(CPPN.random())
328
+ elif neutral_or_better:
329
+ # Only dislikes were cast: steer away by breeding neutral survivors + fresh explorers
330
+ num_immigrants = min(3, max(1, pop_size // 8))
331
+ for _ in range(max(0, pop_size - num_immigrants)):
332
+ idx_a, idx_b, use_cross = _sample_parents(
333
+ neutral_or_better,
334
+ scores,
335
+ crossover_rate=clamped_crossover,
336
+ )
337
+ next_gen.append(
338
+ CPPN.crossover_and_mutate(
339
+ parents[idx_a],
340
+ parents[idx_b],
341
+ use_crossover=use_cross,
342
+ mut_sigma=mut_sigma,
343
+ mut_rate=0.26,
344
+ )
345
+ )
346
+ while len(next_gen) < pop_size:
347
+ next_gen.append(CPPN.random())
348
+ else:
349
+ next_gen = [CPPN.random() for _ in range(pop_size)]
350
+
351
+ top_score = scores[ranked[0]] if ranked else 0
352
+ return [g.to_dict() for g in next_gen[:pop_size]], top_score
353
+
354
+
355
+ def resize_population(
356
+ genome_dicts: Iterable[dict],
357
+ target_size: int,
358
+ scores: list[int],
359
+ up_counts: list[int],
360
+ mut_sigma: float = 0.35,
361
+ ) -> list[dict]:
362
+ """Resize a population while keeping existing genomes (growing via breeding or shrinking by rank)."""
363
+ existing = list(genome_dicts)
364
+ if target_size <= len(existing):
365
+ return existing[:target_size]
366
+
367
+ parents = [CPPN.from_dict(g) for g in existing]
368
+ ranked = sorted(
369
+ range(len(parents)),
370
+ key=lambda i: (-scores[i], -up_counts[i], i),
371
+ )
372
+ liked = [i for i in ranked if scores[i] > 0]
373
+ pool = liked or [i for i in ranked if scores[i] >= 0]
374
+
375
+ extra: list[dict] = []
376
+ for _ in range(target_size - len(existing)):
377
+ if pool:
378
+ idx_a, idx_b, use_cross = _sample_parents(pool, scores)
379
+ child = CPPN.crossover_and_mutate(
380
+ parents[idx_a],
381
+ parents[idx_b],
382
+ use_crossover=use_cross,
383
+ mut_sigma=mut_sigma,
384
+ )
385
+ extra.append(child.to_dict())
386
+ else:
387
+ extra.append(CPPN.random().to_dict())
388
+ return existing + extra
389
+
390
+
391
+ def slugify_policy_name(goal: str, generation: int, index: int) -> str:
392
+ """Build a safe filesystem stem for an exported policy checkpoint."""
393
+ clean = re.sub(r"[^a-zA-Z0-9_-]+", "_", (goal or "policy").strip()).strip("_").lower()
394
+ return f"{clean or 'policy'}_g{generation}_{index}"
395
+
396
+
397
+ def save_cppn_bytes(data: dict) -> bytes:
398
+ """Serialize a CPPN genome dict using `torch_einops_utils.save_load`."""
399
+ net = CPPN.from_dict(data)
400
+ with tempfile.NamedTemporaryFile(suffix=".pt") as tmp:
401
+ net.save(tmp.name)
402
+ return Path(tmp.name).read_bytes()
403
+
404
+
405
+ def build_runner_script(weights_filename: str = "weights.pt") -> str:
406
+ """Return a standalone <=10 LOC uv script that loads and renders a CPPN."""
407
+ repo_root = Path(__file__).resolve().parent.parent
408
+ pkg_spec = (
409
+ f"direct-evolution-together @ {repo_root.as_uri()}"
410
+ if (repo_root / "pyproject.toml").exists()
411
+ else "direct-evolution-together"
412
+ )
413
+ deps = (
414
+ '["torch-einops-utils", "numpy", "packaging", "pillow", '
415
+ f'"{pkg_spec}"]'
416
+ )
417
+ return "\n".join(
418
+ [
419
+ "# /// script",
420
+ f"# dependencies = {deps}",
421
+ "# ///",
422
+ "import sys",
423
+ "from PIL import Image",
424
+ "from direct_evolution_together.cppn import CPPN",
425
+ "",
426
+ f'net = CPPN.init_and_load(sys.argv[1] if len(sys.argv) > 1 else "{weights_filename}")',
427
+ 'Image.fromarray(net.render(256).numpy()).save("out.png")',
428
+ 'print("Voila! Saved out.png")',
429
+ "",
430
+ ]
431
+ )
@@ -0,0 +1,10 @@
1
+ # Package
2
+ version = "0.0.1"
3
+ author = "Phil Wang"
4
+ description = "Mummy WebSocket server for direct-evolution-together"
5
+ license = "MIT"
6
+
7
+ # Dependencies
8
+ requires "nim >= 2.0.0"
9
+ requires "nimpy >= 0.2.0"
10
+ requires "mummy >= 0.4.8"
@@ -0,0 +1,27 @@
1
+ # Generated by Django 5.2.19 on 2026-11-14 14:45
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ initial = True
9
+
10
+ dependencies = [
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='ChatMessage',
16
+ fields=[
17
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+ ('room', models.CharField(db_index=True, max_length=64)),
19
+ ('username', models.CharField(max_length=24)),
20
+ ('message', models.TextField(max_length=2000)),
21
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
22
+ ],
23
+ options={
24
+ 'ordering': ['created_at', 'id'],
25
+ },
26
+ ),
27
+ ]
File without changes
@@ -0,0 +1,25 @@
1
+ from datetime import timezone
2
+
3
+ from django.db import models
4
+
5
+
6
+ class ChatMessage(models.Model):
7
+ """Persisted room chat message."""
8
+
9
+ room = models.CharField(max_length=64, db_index=True)
10
+ username = models.CharField(max_length=24)
11
+ message = models.TextField(max_length=2000)
12
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
13
+
14
+ class Meta:
15
+ ordering = ["created_at", "id"]
16
+
17
+ def to_payload(self) -> dict[str, str]:
18
+ return {
19
+ "username": self.username,
20
+ "message": self.message,
21
+ "timestamp": self.created_at.astimezone(timezone.utc).strftime("%H:%M:%S"),
22
+ }
23
+
24
+ def __str__(self) -> str:
25
+ return f"[{self.room}] {self.username}: {self.message[:40]}"