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.
- direct_evolution_together/__init__.py +1 -0
- direct_evolution_together/admin.py +11 -0
- direct_evolution_together/apps.py +39 -0
- direct_evolution_together/asgi.py +9 -0
- direct_evolution_together/cli.py +200 -0
- direct_evolution_together/config.nims +4 -0
- direct_evolution_together/cppn.py +431 -0
- direct_evolution_together/direct_evolution_together.nimble +10 -0
- direct_evolution_together/migrations/0001_initial.py +27 -0
- direct_evolution_together/migrations/__init__.py +0 -0
- direct_evolution_together/models.py +25 -0
- direct_evolution_together/mummy_ws.nim +1360 -0
- direct_evolution_together/settings.py +134 -0
- direct_evolution_together/templates/direct_evolution_together/admin_reset.html +162 -0
- direct_evolution_together/templates/direct_evolution_together/index.html +3145 -0
- direct_evolution_together/urls.py +16 -0
- direct_evolution_together/views.py +305 -0
- direct_evolution_together/ws_bridge.py +201 -0
- direct_evolution_together/ws_server.py +46 -0
- direct_evolution_together-0.0.1.dist-info/METADATA +57 -0
- direct_evolution_together-0.0.1.dist-info/RECORD +24 -0
- direct_evolution_together-0.0.1.dist-info/WHEEL +4 -0
- direct_evolution_together-0.0.1.dist-info/entry_points.txt +2 -0
- direct_evolution_together-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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]}"
|