ryosao/Artificial_Life_Simulator_GUI
0
1# SPDX-License-Identifier: MIT2"""3Headless NEAT-based artificial life simulation used by the GUI backend.4 5This module is derived from the original `old/evo_sim_neat_diverse.py`, with6all pygame viewer code removed so it can run purely as a simulation service.7"""8 9import json10import math11import os12import random13from dataclasses import dataclass, asdict14from pathlib import Path15from typing import Dict, List, Optional, Tuple16 17import numpy as np18 19# ===================== 基本環境パラメータ =====================20W, H = 2000.0, 2000.021N_INIT = 5022DT = 0.1523SPEED_MAX_BASE = 8.024R_SENSE = 180.025VISION_DEG = 180.026R_HIT = 12.027 28FOOD_RATE = 0.05#0.01529FOOD_EN = 50.030# --- 初期フードシード量(起動直後の欠食対策) ---31INITIAL_FOOD_PIECES = 2000#1200 # 起動時にばら撒くフード個数(W=H=2000想定)。密度を上げたい場合は増やす。32INITIAL_FOOD_SCALE = True # True の場合、ワールド面積に応じて自動スケール33DECAY_BODY = 0.99534BODY_INIT_EN = 80.035FOOD_DENSITY_VARIATION = 0.036SEASON_PERIOD = 1200.037SEASON_AMPLITUDE = 0.038HAZARD_STRENGTH = 0.039HAZARD_COVERAGE = 0.040FOOD_TYPE_ENERGIES = [30.0, 40.0, 55.0, 70.0, 95.0]41FOOD_TYPE_WEIGHTS = [0.32, 0.25, 0.2, 0.15, 0.08]42 43MOVE_COST_K = 0.001#0.00244BRAIN_COST_PER_CONN = 0.0006#0.0006 # ← 結合数比例45 46# --- 新規追加(基礎代謝 & 飢餓 & アイドル・ペナルティ) ---47BASE_COST = 0.35 # 毎tickで必ず払う(止まっていても減る)48STARVATION_E = 120.0 # ここを下回ると飢餓モード49STARVATION_COST = 0.55 # 飢餓モード時の追加コスト(毎tick)50IDLE_SPEED_FRAC = 0.10 # vmaxの10%未満を「低速」とみなす51IDLE_THRUST_TH = 0.2 # 出力が弱い=意図的に動いていない/詰まってる52IDLE_TURN_TH = 0.0553IDLE_COST = 0.45 # アイドル・ペナルティ(毎tick)54 55ENABLE_ADVANCED_ACTIONS = False56DASH_VMAX_MULT = 1.557DASH_COST = 0.3558DEFEND_STRENGTH = 0.659DEFEND_COST = 0.2560REST_BASE_COST_MULT = 0.461REST_COST = 0.0562 63E_BIRTH_THRESHOLD = 220.064PARENT_COST = 80.065CHILD_EN = 120.066 67# 分裂(無性)条件68FISSION_ENERGY_TH = 280.069FISSION_FOOD_UNITS_TH = 5.0 # “食べた量”の累積しきい値70FISSION_CHILD_EN = 100.071FISSION_PARENT_COST = 70.072FISSION_RATE_FACTOR = 1.073 74# 構造変異(NEAT風)75P_ADD_CONN = 0.2076P_ADD_NODE = 0.0877P_DEL_CONN = 0.0278WEIGHT_SIGMA = 0.0879WEIGHT_DRIFT_P = 0.8 # 既存重みを摂動 vs 再初期化80 81# 近親/適合度距離(繁殖相性)82ASSORT_ALPHA = 0.583COMP_TH = 1.584 85# 空間分割86CELL = 40.087GRID_W = int(math.ceil(W / CELL))88GRID_H = int(math.ceil(H / CELL))89 90# 保存/復元91LAST10_PATH = str((Path(__file__).resolve().parent.parent.parent / "last10_genomes.json"))92SAVE_TOP_K = 1093 94# 多様性ブートストラップ95KMEANS_DIM = 64 # ハッシュ特徴次元96KMEANS_MAX_K = 5 # last10<=10 のためクラスタ数上限97KMEANS_ITERS = 5098KMEANS_TRIES = 6 # 初期化マルチトライ(ベストSSE)99 100STAGNATION_TICKS_BEFORE_RESET = 600101SINGLETON_TICKS_BEFORE_RESET = 200102 103rng = np.random.default_rng(1)104rand = random.Random(1)105 106NEXT_AGENT_ID = 1107 108def _next_agent_id() -> int:109 global NEXT_AGENT_ID110 aid = NEXT_AGENT_ID111 NEXT_AGENT_ID += 1112 return aid113 114# ===================== ユーティリティ =====================115def wrap(v, L):116 if v < 0: return v + L117 if v >= L: return v - L118 return v119 120def torus_delta(dx, L):121 if dx > L/2: dx -= L122 if dx < -L/2: dx += L123 return dx124 125def hsl_to_rgb(h, s, l):126 import colorsys127 r,g,b = colorsys.hls_to_rgb(h, l, s)128 return int(r*255), int(g*255), int(b*255)129 130# ===================== NEAT 風遺伝子表現 =====================131INPUT, HIDDEN, OUTPUT = 0, 1, 2132 133@dataclass134class NodeGene:135 id: int136 type: int137 bias: float = 0.0138 139@dataclass140class ConnGene:141 innov: int # イノベーション番号142 in_id: int143 out_id: int144 w: float145 enabled: bool = True146 147class InnovationDB:148 """(in_id, out_id) -> unique innovation id"""149 def __init__(self):150 self.next_innov = 1151 self.map: Dict[Tuple[int,int], int] = {}152 153 def get_innov(self, in_id: int, out_id: int) -> int:154 k = (in_id, out_id)155 if k not in self.map:156 self.map[k] = self.next_innov157 self.next_innov += 1158 return self.map[k]159 160INNOV_DB = InnovationDB()161NEXT_NODE_ID = 1000 # 入出力以外はここから採番162 163class Genome:164 def __init__(self, in_ids: List[int], out_ids: List[int], *, fission_trait: Optional[float] = None):165 self.nodes: Dict[int, NodeGene] = {}166 self.conns: Dict[int, ConnGene] = {}167 for nid in in_ids:168 self.nodes[nid] = NodeGene(nid, INPUT, 0.0)169 for nid in out_ids:170 self.nodes[nid] = NodeGene(nid, OUTPUT, 0.0)171 self.topo_cache_valid = False172 self.topo_order: List[int] = []173 self.in_ids = list(in_ids)174 self.out_ids = list(out_ids)175 base_trait = 1.0 if fission_trait is None else fission_trait176 self.fission_trait = float(np.clip(base_trait, 0.2, 3.0))177 178 def clone(self) -> 'Genome':179 g = Genome(self.in_ids, self.out_ids, fission_trait=self.fission_trait)180 g.nodes = {nid: NodeGene(n.id, n.type, n.bias) for nid,n in self.nodes.items()}181 g.conns = {innov: ConnGene(c.innov, c.in_id, c.out_id, c.w, c.enabled) for innov,c in self.conns.items()}182 g.topo_cache_valid = False183 return g184 185 # ---------- 変異 ----------186 def mutate_weights(self):187 for c in self.conns.values():188 if rand.random() < WEIGHT_DRIFT_P:189 c.w += rng.normal(0, WEIGHT_SIGMA)190 else:191 c.w = rng.normal(0, 1.0)192 for n in self.nodes.values():193 n.bias += rng.normal(0, WEIGHT_SIGMA*0.5)194 self.topo_cache_valid = False195 196 def add_connection(self, tries=20):197 self._ensure_topo()198 if len(self.topo_order) < 2: return199 for _ in range(tries):200 a = rand.choice(self.topo_order)201 b = rand.choice(self.topo_order)202 if a == b: continue203 src, dst = (a,b) if self._topo_index(a) < self._topo_index(b) else (b,a)204 if self.nodes[src].type == OUTPUT: continue205 if self.nodes[dst].type == INPUT: continue206 if self._has_edge(src, dst): continue207 innov = INNOV_DB.get_innov(src, dst)208 self.conns[innov] = ConnGene(innov, src, dst, rng.normal(0,1.0), True)209 self.topo_cache_valid = False210 return211 212 def add_node(self):213 enabled = [c for c in self.conns.values() if c.enabled]214 if not enabled: return215 edge = rand.choice(enabled)216 edge.enabled = False217 global NEXT_NODE_ID218 new_id = NEXT_NODE_ID; NEXT_NODE_ID += 1219 self.nodes[new_id] = NodeGene(new_id, HIDDEN, bias=0.0)220 innov1 = INNOV_DB.get_innov(edge.in_id, new_id)221 innov2 = INNOV_DB.get_innov(new_id, edge.out_id)222 self.conns[innov1] = ConnGene(innov1, edge.in_id, new_id, 1.0, True)223 self.conns[innov2] = ConnGene(innov2, new_id, edge.out_id, edge.w, True)224 self.topo_cache_valid = False225 226 def del_connection(self):227 if not self.conns: return228 innov = rand.choice(list(self.conns.keys()))229 del self.conns[innov]230 self.topo_cache_valid = False231 232 def structural_mutation(self):233 if rand.random() < P_ADD_CONN: self.add_connection()234 if rand.random() < P_ADD_NODE: self.add_node()235 if rand.random() < P_DEL_CONN: self.del_connection()236 self.mutate_weights()237 self._mutate_traits(0.08)238 239 # ---------- 軽量(微小)変異 ----------240 def micro_mutate(self,241 weight_sigma: float = 0.03,242 p_add_conn: float = 0.05,243 p_add_node: float = 0.02,244 p_del_conn: float = 0.01) -> 'Genome':245 child = self.clone()246 if rand.random() < p_add_conn: child.add_connection()247 if rand.random() < p_add_node: child.add_node()248 if rand.random() < p_del_conn: child.del_connection()249 for c in child.conns.values():250 c.w += rng.normal(0, weight_sigma)251 for n in child.nodes.values():252 n.bias += rng.normal(0, weight_sigma * 0.5)253 child.topo_cache_valid = False254 child._mutate_traits(0.05)255 return child256 257 def _mutate_traits(self, sigma: float) -> None:258 self.fission_trait = float(np.clip(self.fission_trait + rng.normal(0, sigma), 0.2, 3.0))259 260 # ---------- 交叉 ----------261 @staticmethod262 def crossover(ga:'Genome', gb:'Genome') -> 'Genome':263 child = ga.clone()264 child.conns.clear()265 set_a = set(ga.conns.keys())266 set_b = set(gb.conns.keys())267 all_innov = sorted(set_a | set_b)268 for innov in all_innov:269 gene = ga.conns.get(innov) if rand.random()<0.5 else gb.conns.get(innov)270 if gene is None:271 gene = ga.conns.get(innov) or gb.conns.get(innov)272 child.conns[innov] = ConnGene(273 innov=gene.innov, in_id=gene.in_id, out_id=gene.out_id,274 w=gene.w, enabled=gene.enabled275 )276 if gene.in_id not in child.nodes:277 src = (ga.nodes.get(gene.in_id) or gb.nodes.get(gene.in_id))278 child.nodes[gene.in_id] = NodeGene(src.id, src.type, src.bias)279 if gene.out_id not in child.nodes:280 dst = (ga.nodes.get(gene.out_id) or gb.nodes.get(gene.out_id))281 child.nodes[gene.out_id] = NodeGene(dst.id, dst.type, dst.bias)282 child.topo_cache_valid = False283 trait_parent = ga if rand.random() < 0.5 else gb284 child.fission_trait = float(trait_parent.fission_trait)285 return child286 287 # ---------- 推論 ----------288 def forward(self, x_dict: Dict[int,float]) -> Dict[int,float]:289 self._ensure_topo()290 val: Dict[int,float] = {}291 for nid in self.topo_order:292 node = self.nodes[nid]293 if node.type == INPUT:294 val[nid] = x_dict.get(nid, 0.0)295 continue296 s = node.bias297 for c in self._incoming[nid]:298 if not c.enabled: continue299 s += val.get(c.in_id, 0.0) * c.w300 if node.type == OUTPUT:301 val[nid] = s302 else:303 val[nid] = math.tanh(s)304 return {nid: val[nid] for nid in self.out_ids}305 306 # ---------- 内部構造 ----------307 def _has_edge(self, u, v) -> bool:308 for c in self.conns.values():309 if c.in_id==u and c.out_id==v and c.enabled:310 return True311 return False312 313 def _topo_index(self, nid) -> int:314 if not self.topo_cache_valid: self._ensure_topo()315 return self._topo_pos.get(nid, 0)316 317 def _ensure_topo(self):318 if self.topo_cache_valid: return319 incoming: Dict[int, List[ConnGene]] = {nid: [] for nid in self.nodes.keys()}320 outgoing: Dict[int, List[ConnGene]] = {nid: [] for nid in self.nodes.keys()}321 indeg: Dict[int, int] = {nid: 0 for nid in self.nodes.keys()}322 for c in self.conns.values():323 if c.enabled and c.in_id in self.nodes and c.out_id in self.nodes:324 outgoing[c.in_id].append(c)325 incoming[c.out_id].append(c)326 indeg[c.out_id] += 1327 order: List[int] = []328 S = [nid for nid in self.nodes if self.nodes[nid].type==INPUT]329 S += [nid for nid in self.nodes if indeg[nid]==0 and nid not in S]330 seen = set()331 while S:332 nid = S.pop()333 if nid in seen: continue334 seen.add(nid)335 order.append(nid)336 for c in outgoing[nid]:337 indeg[c.out_id] -= 1338 if indeg[c.out_id]==0:339 S.append(c.out_id)340 if len(order) < len(self.nodes):341 # 循環検知 -> ランダムで数本無効化して回避342 for _ in range(3):343 if not self.conns: break344 rand.choice(list(self.conns.values())).enabled = False345 self.topo_cache_valid = False346 self._ensure_topo()347 return348 self.topo_order = order349 self._incoming = incoming350 self._topo_pos = {nid:i for i,nid in enumerate(order)}351 self.topo_cache_valid = True352 353 # ---------- シリアライズ ----------354 def to_dict(self) -> dict:355 return {356 "nodes": [asdict(n) for n in self.nodes.values()],357 "conns": [asdict(c) for c in self.conns.values()],358 "in_ids": self.in_ids,359 "out_ids": self.out_ids,360 "fission_trait": self.fission_trait,361 }362 363 @staticmethod364 def from_dict(d: dict) -> 'Genome':365 g = Genome(INPUT_IDS, OUTPUT_IDS, fission_trait=d.get("fission_trait", 1.0))366 g.nodes = {n["id"]: NodeGene(**n) for n in d.get("nodes", [])}367 g.conns = {c["innov"]: ConnGene(**c) for c in d.get("conns", [])}368 g.in_ids = list(d.get("in_ids", INPUT_IDS))369 g.out_ids = list(d.get("out_ids", OUTPUT_IDS))370 # Backward compatibility: ensure required input/output nodes exist.371 for nid in g.in_ids:372 if nid not in g.nodes:373 g.nodes[nid] = NodeGene(nid, INPUT, 0.0)374 for nid in g.out_ids:375 if nid not in g.nodes:376 g.nodes[nid] = NodeGene(nid, OUTPUT, 0.0)377 g.topo_cache_valid = False378 return g379 380# ===================== 入出力ノード定義 =====================381N_RAYS = 5382IN_FEATURES = N_RAYS*3 + 6383OUT_FEATURES = 8 if ENABLE_ADVANCED_ACTIONS else 5384INPUT_IDS = list(range(0, IN_FEATURES))385OUTPUT_IDS = list(range(100, 100+OUT_FEATURES))386 387def brain_init_genome() -> Genome:388 g = Genome(INPUT_IDS, OUTPUT_IDS, fission_trait=float(np.clip(rng.normal(1.0, 0.15), 0.2, 3.0)))389 for i in INPUT_IDS:390 for o in OUTPUT_IDS:391 if rand.random() < 0.2:392 innov = INNOV_DB.get_innov(i, o)393 g.conns[innov] = ConnGene(innov, i, o, rng.normal(0,1.0), True)394 if not g.conns:395 i = rand.choice(INPUT_IDS); o = rand.choice(OUTPUT_IDS)396 innov = INNOV_DB.get_innov(i, o)397 g.conns[innov] = ConnGene(innov, i, o, rng.normal(0,1.0), True)398 g.topo_cache_valid = False399 return g400 401# ===================== 個体と世界 =====================402@dataclass403class Food:404 x: float405 y: float406 energy: float407 type_id: int408 max_energy: float409 410@dataclass411class Body:412 x: float413 y: float414 e: float415 416class Agent:417 __slots__ = (418 "id",419 "parent_id",420 "parent2_id",421 "birth_tick",422 "age",423 "x",424 "y",425 "vx",426 "vy",427 "S",428 "E",429 "brain",430 "base_color",431 "eaten_units",432 "fission_trait",433 "fission_heat",434 "last_thrust",435 "last_turn",436 "last_attack",437 "last_mate",438 "last_eat_strength",439 "last_hazard_damage",440 "strategy_tag",441 "food_energy_total",442 "body_energy_total",443 "attack_attempts_total",444 "attack_successes_total",445 "mate_attempts_total",446 "mate_successes_total",447 "fissions_total",448 "last_dash",449 "last_defend",450 "last_rest",451 )452 453 def __init__(454 self,455 genome: Optional[Genome] = None,456 *,457 agent_id: Optional[int] = None,458 birth_tick: int = 0,459 parent_id: Optional[int] = None,460 parent2_id: Optional[int] = None,461 ):462 self.id = int(_next_agent_id() if agent_id is None else agent_id)463 self.parent_id = parent_id464 self.parent2_id = parent2_id465 self.birth_tick = int(birth_tick)466 self.age = 0467 self.x = rng.uniform(0, W)468 self.y = rng.uniform(0, H)469 ang = rng.uniform(0, 2*np.pi)470 spd = rng.uniform(0, 1.0)471 self.vx, self.vy = spd*math.cos(ang), spd*math.sin(ang)472 self.S = rng.uniform(0.8, 1.2)473 self.E = 200.0474 self.brain: Genome = genome if genome is not None else brain_init_genome()475 self.base_color = self._color_from_genome()476 self.eaten_units = 0.0477 self.fission_trait = float(np.clip(getattr(self.brain, "fission_trait", 1.0), 0.2, 3.0))478 self.fission_heat = 0.0479 self.last_thrust = 0.0480 self.last_turn = 0.0481 self.last_attack = False482 self.last_mate = False483 self.last_eat_strength = 0.0484 self.last_hazard_damage = 0.0485 self.strategy_tag = "Generalist"486 self.food_energy_total = 0.0487 self.body_energy_total = 0.0488 self.attack_attempts_total = 0489 self.attack_successes_total = 0490 self.mate_attempts_total = 0491 self.mate_successes_total = 0492 self.fissions_total = 0493 self.last_dash = False494 self.last_defend = False495 self.last_rest = False496 497 def _color_from_genome(self):498 key = 0499 for k in sorted(self.brain.conns.keys()):500 c = self.brain.conns[k]501 key = (key*1315423911 + k + int(abs(c.w)*1000)) & 0xFFFFFFFF502 h = (key % 360) / 360.0503 return hsl_to_rgb(h, 0.65, 0.5)504 505 def sense(self, neighbors: List['Agent'], food_hint: Optional[Tuple[float, float]] = None) -> Dict[int,float]:506 angles = np.linspace(-math.radians(60), math.radians(60), N_RAYS)507 theta = math.atan2(self.vy, self.vx + 1e-9)508 feats: List[float] = []509 for a in angles:510 dirx, diry = math.cos(theta+a), math.sin(theta+a)511 best_d = R_SENSE512 best_S = 0.0513 best_vproj = 0.0514 for other in neighbors:515 if other is self: continue516 dx = torus_delta(other.x - self.x, W)517 dy = torus_delta(other.y - self.y, H)518 d = math.hypot(dx, dy)519 if d < best_d and d > 1e-6:520 if (dx*dirx + dy*diry)/max(d,1e-6) > math.cos(math.radians(90)):521 best_d = d522 best_S = other.S / self.S523 relv = ((other.vx - self.vx)*dirx + (other.vy - self.vy)*diry)524 best_vproj = relv525 feats += [best_d/R_SENSE, best_S, math.tanh(best_vproj/5.0)]526 spd = math.hypot(self.vx, self.vy)/max(1e-6, SPEED_MAX_BASE)527 if food_hint is None:528 food_type_feat = 0.0529 food_dist_feat = 1.0530 else:531 food_type_feat, food_dist_feat = food_hint532 feats += [self.E/400.0, self.S/1.5, spd, 0.0, food_type_feat, food_dist_feat]533 x = {nid:0.0 for nid in INPUT_IDS}534 for i,v in enumerate(feats[:len(INPUT_IDS)]):535 x[INPUT_IDS[i]] = v536 return x537 538 def step(self, neighbors: List['Agent'], food_hint: Optional[Tuple[float, float]] = None) -> Tuple[bool, bool, float, float, float, float, float]:539 x = self.sense(neighbors, food_hint)540 o = self.brain.forward(x)541 thrust = math.tanh(o[OUTPUT_IDS[0]])542 turn = math.tanh(o[OUTPUT_IDS[1]]) * 0.3543 attack = o[OUTPUT_IDS[2]] > 0.5544 mate = o[OUTPUT_IDS[3]] > 0.5545 eat_strength = float(max(0.0, min(1.0, o[OUTPUT_IDS[4]])))546 dash = False547 defend = False548 rest = False549 if ENABLE_ADVANCED_ACTIONS and len(OUTPUT_IDS) >= 8:550 dash = o[OUTPUT_IDS[5]] > 0.5551 defend = o[OUTPUT_IDS[6]] > 0.5552 rest = o[OUTPUT_IDS[7]] > 0.5553 self.last_dash = bool(dash)554 self.last_defend = bool(defend)555 self.last_rest = bool(rest)556 self.last_thrust = float(thrust)557 self.last_turn = float(turn)558 self.last_attack = bool(attack)559 self.last_mate = bool(mate)560 self.last_eat_strength = float(eat_strength)561 562 theta = math.atan2(self.vy, self.vx + 1e-9) + turn563 vmax = SPEED_MAX_BASE*(1.2 - 0.2*self.S)564 if dash:565 vmax *= float(max(1.0, DASH_VMAX_MULT))566 spd = np.clip(math.hypot(self.vx, self.vy) + thrust, 0, vmax)567 if rest:568 spd = min(spd, 0.25 * vmax)569 self.vx, self.vy = spd*math.cos(theta), spd*math.sin(theta)570 self.x = wrap(self.x + self.vx*DT, W)571 self.y = wrap(self.y + self.vy*DT, H)572 573 # move_cost = MOVE_COST_K * spd*spd574 # brain_cost = BRAIN_COST_PER_CONN * len(self.brain.conns)575 # self.E -= (move_cost + brain_cost)576 # ---- 新しい代謝:基礎代謝 + 飢餓 + アイドル・ペナルティ ----577 move_cost = MOVE_COST_K * spd * spd578 brain_cost = BRAIN_COST_PER_CONN * len(self.brain.conns)579 580 base_cost = BASE_COST581 if rest:582 base_cost *= float(np.clip(REST_BASE_COST_MULT, 0.0, 1.0))583 584 # 飢餓域での追加ドレイン(Eが閾値を下回ると常時発生)585 starvation_cost = STARVATION_COST if self.E < STARVATION_E else 0.0586 # さらに「低速&出力小」のときはアイドル・ペナルティ587 is_idle_speed = (spd < IDLE_SPEED_FRAC * vmax)588 is_idle_cmd = (abs(thrust) < IDLE_THRUST_TH and abs(turn) < IDLE_TURN_TH)589 idle_cost = IDLE_COST if (is_idle_speed and is_idle_cmd) else 0.0590 591 if is_idle_speed:592 noise_theta = rng.uniform(-math.pi, math.pi)593 noise_mag = rng.uniform(0.1, 0.25) * vmax594 self.vx += noise_mag * math.cos(noise_theta)595 self.vy += noise_mag * math.sin(noise_theta)596 noise_spd = math.hypot(self.vx, self.vy)597 if noise_spd > vmax:598 scale = vmax / max(1e-6, noise_spd)599 self.vx *= scale600 self.vy *= scale601 self.x = wrap(self.x + self.vx * DT, W)602 self.y = wrap(self.y + self.vy * DT, H)603 604 extra_action_cost = 0.0605 if dash:606 extra_action_cost += float(max(0.0, DASH_COST))607 if defend:608 extra_action_cost += float(max(0.0, DEFEND_COST))609 if rest:610 extra_action_cost += float(max(0.0, REST_COST))611 self.E -= (move_cost + brain_cost + base_cost + starvation_cost + idle_cost + extra_action_cost)612 613 return attack, mate, eat_strength, spd, vmax, thrust, turn614 615class World:616 def __init__(self, from_last10: bool=False):617 self.t = 0618 self.agents: List[Agent] = []619 self.foods: List[Food] = []620 self.bodies: List[Body] = []621 self.births = 0622 self.deaths = 0623 self.grid: List[List[List[int]]] = [[[] for _ in range(GRID_H)] for __ in range(GRID_W)]624 self._food_density_cdf: Optional[np.ndarray] = None625 self._food_density_weights: Optional[np.ndarray] = None626 self._init_food_types()627 self._init_food_density_field()628 self._hazard_field: Optional[np.ndarray] = None629 self._hazard_damage_scale: float = 0.0630 self._init_hazard_field()631 self._stagnation_ticks = 0632 self._singleton_ticks = 0633 self.telemetry: Dict[str, float] = {}634 635 # 初期フードのばら撒き(足りない初期餌問題の対策)636 self._seed_initial_food()637 638 if from_last10 and os.path.exists(LAST10_PATH):639 data = self._load_last10()640 if data:641 self._bootstrap_from_last10_diverse(data, N_INIT)642 643 if not self.agents:644 self.agents = [Agent(birth_tick=self.t) for _ in range(N_INIT)]645 646 # ---------- グリッド ----------647 def reset_grid(self):648 for x in range(GRID_W):649 for y in range(GRID_H):650 self.grid[x][y].clear()651 652 def insert_grid(self):653 for i,a in enumerate(self.agents):654 gx = int(a.x // CELL) % GRID_W655 gy = int(a.y // CELL) % GRID_H656 self.grid[gx][gy].append(i)657 658 def neighbors(self, a: Agent, radius=R_SENSE) -> List[Agent]:659 cx = int(a.x // CELL)660 cy = int(a.y // CELL)661 rcell = max(1, int(math.ceil(radius / CELL)))662 res_idx = []663 for dx in range(-rcell, rcell+1):664 for dy in range(-rcell, rcell+1):665 gx = (cx + dx) % GRID_W666 gy = (cy + dy) % GRID_H667 res_idx.extend(self.grid[gx][gy])668 out = []669 for idx in res_idx:670 b = self.agents[idx]671 dx = torus_delta(b.x - a.x, W)672 dy = torus_delta(b.y - a.y, H)673 if dx*dx + dy*dy <= radius*radius:674 out.append(b)675 return out676 677 # ---------- 生態 ----------678 def spawn_food(self, season_mult: Optional[float] = None):679 # Note: ランタイムのフード出現密度は FOOD_RATE で制御します。増減で継続的な餌の量を調整可能。680 rate = FOOD_RATE * (season_mult if season_mult is not None else self._season_multiplier())681 if rand.random() < rate:682 x, y = self._random_food_position()683 type_id, energy = self._choose_food_type()684 self.foods.append(Food(x, y, energy, type_id, energy))685 686 def _seed_food(self, n: int):687 """起動直後にフードを一括投入して初期飢餓を防ぐ。"""688 for _ in range(max(0, int(n))):689 x, y = self._random_food_position()690 type_id, energy = self._choose_food_type()691 self.foods.append(Food(x, y, energy, type_id, energy))692 693 def _seed_initial_food(self) -> None:694 if INITIAL_FOOD_SCALE:695 # 面積 2000x2000 を基準にスケール696 area_scale = (W * H) / (2000.0 * 2000.0)697 n_seed = int(INITIAL_FOOD_PIECES * area_scale)698 else:699 n_seed = int(INITIAL_FOOD_PIECES)700 self._seed_food(n_seed)701 702 def reset_food_supply(self) -> None:703 """個体が途絶えたときに初期フード密度へ戻す。"""704 self.foods.clear()705 self._seed_initial_food()706 707 def _reseed_population(self, reason: str) -> None:708 data = self._load_last10()709 if data:710 print(f"[{reason}] reseed from last10 (diverse) -> {N_INIT}")711 self._bootstrap_from_last10_diverse(data, N_INIT)712 else:713 print(f"[{reason}] random reinit -> {N_INIT}")714 self.agents = [Agent(birth_tick=self.t) for _ in range(N_INIT)]715 for agent in self.agents:716 agent.x = rng.uniform(0, W)717 agent.y = rng.uniform(0, H)718 ang = rng.uniform(0, math.tau)719 spd = rng.uniform(0, SPEED_MAX_BASE * 0.4)720 agent.vx = spd * math.cos(ang)721 agent.vy = spd * math.sin(ang)722 agent.eaten_units = 0.0723 agent.fission_heat = 0.0724 agent.E = max(agent.E, 200.0)725 726 def _init_food_types(self) -> None:727 energies = np.array(FOOD_TYPE_ENERGIES, dtype=np.float32)728 if energies.size == 0:729 energies = np.array([float(FOOD_EN)], dtype=np.float32)730 weights = np.array(FOOD_TYPE_WEIGHTS, dtype=np.float64)731 if weights.size != energies.size or weights.sum() <= 0.0:732 weights = np.ones_like(energies, dtype=np.float64)733 weights = np.clip(weights, 1e-6, None)734 weights = weights / weights.sum()735 self._food_type_energies = energies736 self._food_type_cdf = np.cumsum(weights)737 self._food_type_count = energies.size738 739 def _choose_food_type(self) -> Tuple[int, float]:740 if getattr(self, "_food_type_count", 0) <= 0:741 return 0, float(FOOD_EN)742 r = rand.random()743 idx = int(np.searchsorted(self._food_type_cdf, r, side="right"))744 idx = min(idx, self._food_type_count - 1)745 return idx, float(self._food_type_energies[idx])746 747 def _init_food_density_field(self) -> None:748 variation = max(0.0, float(FOOD_DENSITY_VARIATION))749 if variation <= 1e-6:750 self._food_density_weights = None751 self._food_density_cdf = None752 return753 754 tiles_x = max(1, min(GRID_W, 8))755 tiles_y = max(1, min(GRID_H, 8))756 sigma = variation757 coarse = np.exp(758 rng.normal(loc=-0.5 * sigma * sigma, scale=sigma, size=(tiles_y, tiles_x))759 )760 761 repeat_y = int(math.ceil(GRID_H / tiles_y))762 repeat_x = int(math.ceil(GRID_W / tiles_x))763 weights = np.repeat(np.repeat(coarse, repeat_y, axis=0), repeat_x, axis=1)764 weights = weights[:GRID_H, :GRID_W]765 weights = weights / weights.mean() # 正規化して平均 1 に766 767 flat = weights.astype(np.float64).reshape(-1)768 cdf = np.cumsum(flat)769 cdf /= cdf[-1]770 self._food_density_weights = flat771 self._food_density_cdf = cdf772 773 def _sample_food_cell(self) -> Tuple[int, int]:774 if self._food_density_cdf is None:775 return rand.randrange(GRID_W), rand.randrange(GRID_H)776 r = rand.random()777 idx = int(np.searchsorted(self._food_density_cdf, r, side="right"))778 idx = min(idx, self._food_density_cdf.size - 1)779 gx = idx % GRID_W780 gy = idx // GRID_W781 return gx, gy782 783 def _random_food_position(self) -> Tuple[float, float]:784 gx, gy = self._sample_food_cell()785 x = (gx + rand.random()) * CELL786 y = (gy + rand.random()) * CELL787 return x % W, y % H788 789 def _food_hint(self, agent: Agent) -> Optional[Tuple[float, float]]:790 if not self.foods:791 return None792 best_food: Optional[Food] = None793 best_dist = R_SENSE794 for f in self.foods:795 dx = torus_delta(f.x - agent.x, W)796 dy = torus_delta(f.y - agent.y, H)797 dist = math.hypot(dx, dy)798 if dist < best_dist:799 best_dist = dist800 best_food = f801 if best_food is None:802 return None803 if self._food_type_count <= 1:804 type_feat = 0.0805 else:806 type_feat = best_food.type_id / (self._food_type_count - 1)807 dist_feat = min(1.0, best_dist / R_SENSE)808 return (type_feat, dist_feat)809 810 def _season_multiplier(self) -> float:811 if SEASON_AMPLITUDE <= 1e-6 or SEASON_PERIOD <= 1e-6:812 return 1.0813 phase = (self.t % SEASON_PERIOD) / SEASON_PERIOD814 return max(0.1, 1.0 + SEASON_AMPLITUDE * math.sin(2.0 * math.pi * phase))815 816 def _init_hazard_field(self) -> None:817 strength = max(0.0, min(1.0, float(HAZARD_STRENGTH)))818 coverage = max(0.0, min(1.0, float(HAZARD_COVERAGE)))819 if strength <= 1e-6 or coverage <= 1e-6:820 self._hazard_field = None821 self._hazard_damage_scale = 0.0822 return823 824 tiles_x = max(4, min(32, max(4, GRID_W // 2)))825 tiles_y = max(4, min(32, max(4, GRID_H // 2)))826 noise = rng.random((tiles_y, tiles_x))827 threshold = np.quantile(noise, 1.0 - coverage)828 mask = (noise >= threshold).astype(np.float32)829 base = rng.random((tiles_y, tiles_x)).astype(np.float32)830 coarse = base * mask831 for _ in range(2):832 coarse = (833 coarse834 + np.roll(coarse, 1, axis=0)835 + np.roll(coarse, -1, axis=0)836 + np.roll(coarse, 1, axis=1)837 + np.roll(coarse, -1, axis=1)838 ) / 5.0839 coarse = np.clip(coarse, 0.0, 1.0)840 coarse *= strength841 repeat_y = int(math.ceil(GRID_H / tiles_y))842 repeat_x = int(math.ceil(GRID_W / tiles_x))843 field = np.repeat(np.repeat(coarse, repeat_y, axis=0), repeat_x, axis=1)844 field = field[:GRID_H, :GRID_W]845 self._hazard_field = field.astype(np.float32)846 self._hazard_damage_scale = max(0.5, 2.0 * strength)847 848 def is_compatible(self, a: Agent, b: Agent) -> bool:849 d_beh = ( (a.vx-b.vx)**2 + (a.vy-b.vy)**2 )**0.5 / SPEED_MAX_BASE850 sa = set(a.brain.conns.keys()); sb = set(b.brain.conns.keys())851 d_gen = len(sa ^ sb) / (len(sa | sb)+1e-6)852 return ASSORT_ALPHA*d_gen + (1-ASSORT_ALPHA)*d_beh < COMP_TH853 854 def tick(self, substeps=1):855 births_this = 0; deaths_this = 0856 deaths_attack = 0857 deaths_hazard = 0858 deaths_energy = 0859 attack_attempts = 0860 attack_successes = 0861 mate_attempts = 0862 mate_successes = 0863 fission_count = 0864 eat_energy_food = 0.0865 eat_energy_body = 0.0866 eat_energy_by_type: Dict[int, float] = {}867 mean_thrust = 0.0868 mean_turn = 0.0869 mean_eat_strength = 0.0870 sum_speed = 0.0871 count_alive_for_means = 0872 dash_active = 0873 defend_active = 0874 rest_active = 0875 for _ in range(substeps):876 self.t += 1877 season_mult = self._season_multiplier()878 self.spawn_food(season_mult)879 self.reset_grid()880 self.insert_grid()881 882 intents = [a.step(self.neighbors(a, R_SENSE), self._food_hint(a)) for a in self.agents]883 for a in self.agents:884 if a.fission_heat > 0.0:885 a.fission_heat = max(0.0, a.fission_heat - 0.05)886 a.age = max(0, self.t - int(getattr(a, "birth_tick", 0)))887 a.last_hazard_damage = 0.0888 889 # 食餌・死体スカベンジ890 for i,a in enumerate(self.agents):891 if a.E <= 0: continue892 attack, mate, eat_str, spd, vmax, thrust, turn = intents[i]893 count_alive_for_means += 1894 mean_thrust += float(thrust)895 mean_turn += float(turn)896 mean_eat_strength += float(eat_str)897 sum_speed += float(spd) / max(1e-6, float(vmax))898 dash_active += 1 if getattr(a, "last_dash", False) else 0899 defend_active += 1 if getattr(a, "last_defend", False) else 0900 rest_active += 1 if getattr(a, "last_rest", False) else 0901 # 食餌902 for f in self.foods:903 dx = torus_delta(f.x - a.x, W); dy = torus_delta(f.y - a.y, H)904 if dx*dx + dy*dy < R_HIT*R_HIT and f.energy > 0:905 base = self._food_type_energies[f.type_id] if self._food_type_count > 0 else FOOD_EN906 take_base = min(f.energy, base)907 take = take_base * (0.3 + 0.7*eat_str)908 a.E += take909 f.energy -= take910 a.eaten_units += take / max(1.0, base)911 eat_energy_food += float(take)912 eat_energy_by_type[int(f.type_id)] = eat_energy_by_type.get(int(f.type_id), 0.0) + float(take)913 a.food_energy_total += float(take)914 # 死体915 for bdy in self.bodies:916 dx = torus_delta(bdy.x - a.x, W); dy = torus_delta(bdy.y - a.y, H)917 if dx*dx + dy*dy < R_HIT*R_HIT and bdy.e > 0:918 take = min(bdy.e, 10.0*(0.3 + 0.7*eat_str))919 a.E += take; bdy.e -= take920 a.eaten_units += take / FOOD_EN921 eat_energy_body += float(take)922 a.body_energy_total += float(take)923 924 # 近接相互作用(交配/攻撃/分裂)925 newborns: List[Agent] = []926 removed: set[int] = set()927 928 # 交配・攻撃929 for i,a in enumerate(self.agents):930 if i in removed or a.E <= 0: continue931 attack_i, mate_i, eat_i, _, _, _, _ = intents[i]932 neigh = self.neighbors(a, R_HIT*1.2)933 for b in neigh:934 if b is a: continue935 j = self.agents.index(b)936 if j in removed or b.E <= 0: continue937 dx = torus_delta(b.x - a.x, W); dy = torus_delta(b.y - a.y, H)938 if dx*dx + dy*dy < R_HIT*R_HIT:939 # 交配940 if mate_i and intents[j][1]:941 mate_attempts += 1942 a.mate_attempts_total += 1943 b.mate_attempts_total += 1944 if a.E > E_BIRTH_THRESHOLD and b.E > E_BIRTH_THRESHOLD and self.is_compatible(a,b):945 child_g = Genome.crossover(a.brain, b.brain)946 child_g.structural_mutation()947 child = Agent(948 child_g,949 birth_tick=self.t,950 parent_id=int(getattr(a, "id", -1)),951 parent2_id=int(getattr(b, "id", -1)),952 )953 child.x, child.y = a.x, a.y; child.E = CHILD_EN954 a.E -= PARENT_COST; b.E -= PARENT_COST955 newborns.append(child); births_this += 1956 mate_successes += 1957 a.mate_successes_total += 1958 b.mate_successes_total += 1959 # 攻撃960 if attack_i:961 attack_attempts += 1962 a.attack_attempts_total += 1963 atk = 8.0*a.S; dfn = 5.0*b.S964 if getattr(b, "last_defend", False):965 dfn *= (1.0 + float(max(0.0, DEFEND_STRENGTH)))966 if atk > dfn:967 a.E += 60.0; removed.add(j)968 attack_successes += 1969 a.attack_successes_total += 1970 971 # 分裂(無性)972 base_fission_bias = max(0.1, FISSION_RATE_FACTOR)973 974 for i,a in enumerate(self.agents):975 if i in removed or a.E <= 0:976 continue977 indiv_bias = max(0.1, base_fission_bias * a.fission_trait)978 heat_factor = 1.0 + 0.35 * a.fission_heat979 energy_th = (FISSION_ENERGY_TH / indiv_bias) * heat_factor980 food_th = (FISSION_FOOD_UNITS_TH / indiv_bias) * heat_factor981 parent_cost = max(5.0, (FISSION_PARENT_COST / indiv_bias) * (1.0 + 0.15 * a.fission_heat))982 child_energy = max(40.0, (FISSION_CHILD_EN * min(2.0, indiv_bias)) / (1.0 + 0.1 * a.fission_heat))983 if a.E > energy_th and a.eaten_units >= food_th:984 child_g = a.brain.clone().micro_mutate(985 weight_sigma=0.03, p_add_conn=0.04, p_add_node=0.015, p_del_conn=0.008986 )987 child = Agent(988 child_g,989 birth_tick=self.t,990 parent_id=int(getattr(a, "id", -1)),991 parent2_id=None,992 )993 child.x, child.y = a.x, a.y994 child.E = child_energy995 a.E -= parent_cost996 a.eaten_units = 0.0997 a.fission_heat = min(2.0, a.fission_heat + 0.8)998 newborns.append(child)999 births_this += 11000 fission_count += 11001 a.fissions_total += 11002 1003 # 危険ゾーンによるダメージ1004 if self._hazard_field is not None and HAZARD_STRENGTH > 0.0:1005 damage_scale = self._hazard_damage_scale * max(0.5, season_mult)1006 field = self._hazard_field1007 for a in self.agents:1008 gx = int(a.x // CELL) % GRID_W1009 gy = int(a.y // CELL) % GRID_H1010 hazard = field[gy, gx]1011 if hazard > 0.0:1012 dmg = float(hazard * damage_scale)1013 if getattr(a, "last_defend", False):1014 dmg *= float(np.clip(1.0 - DEFEND_STRENGTH, 0.0, 1.0))1015 a.last_hazard_damage = dmg1016 a.E -= dmg1017 1018 # 片付け1019 keep = []1020 for k,ag in enumerate(self.agents):1021 dead = (k in removed) or (ag.E <= 0)1022 if dead:1023 deaths_this += 11024 if k in removed:1025 deaths_attack += 11026 self.bodies.append(Body(ag.x, ag.y, BODY_INIT_EN))1027 else:1028 if float(getattr(ag, "last_hazard_damage", 0.0)) > 0.0:1029 deaths_hazard += 11030 else:1031 deaths_energy += 11032 self.bodies.append(Body(ag.x, ag.y, BODY_INIT_EN * 0.5))1033 else:1034 keep.append(ag)1035 self.agents = keep1036 1037 # 死体減衰・餌整理1038 for b in self.bodies: b.e *= DECAY_BODY1039 self.bodies = [b for b in self.bodies if b.e > 1.0]1040 self.foods = [f for f in self.foods if f.energy > 1.0]1041 1042 if newborns:1043 self.agents.extend(newborns)1044 1045 # 絶滅時:last10 からクラスタ均等サンプルで再播種1046 if not self.agents:1047 self.reset_food_supply()1048 data = self._load_last10()1049 if data:1050 print(f"[extinct] reseed from last10 (diverse) -> {N_INIT}")1051 self._bootstrap_from_last10_diverse(data, N_INIT)1052 else:1053 print("[extinct] no last10 -> random reinit")1054 self.agents = [Agent(birth_tick=self.t) for _ in range(N_INIT)]1055 1056 self.births += births_this; self.deaths += deaths_this1057 if count_alive_for_means > 0:1058 mean_thrust /= count_alive_for_means1059 mean_turn /= count_alive_for_means1060 mean_eat_strength /= count_alive_for_means1061 sum_speed /= count_alive_for_means1062 hazard_mean = 0.01063 hazard_coverage = 0.01064 if self._hazard_field is not None:1065 try:1066 hazard_mean = float(np.mean(self._hazard_field))1067 hazard_coverage = float(np.mean(self._hazard_field > 1e-6))1068 except Exception:1069 hazard_mean = 0.01070 hazard_coverage = 0.01071 self.telemetry = {1072 "season_mult": float(season_mult),1073 "attack_attempts": float(attack_attempts),1074 "attack_successes": float(attack_successes),1075 "mate_attempts": float(mate_attempts),1076 "mate_successes": float(mate_successes),1077 "fissions": float(fission_count),1078 "eat_energy_food": float(eat_energy_food),1079 "eat_energy_body": float(eat_energy_body),1080 "deaths_attack": float(deaths_attack),1081 "deaths_hazard": float(deaths_hazard),1082 "deaths_energy": float(deaths_energy),1083 "mean_thrust": float(mean_thrust),1084 "mean_turn": float(mean_turn),1085 "mean_eat_strength": float(mean_eat_strength),1086 "mean_speed_frac": float(sum_speed),1087 "dash_active": float(dash_active),1088 "defend_active": float(defend_active),1089 "rest_active": float(rest_active),1090 "hazard_mean": float(hazard_mean),1091 "hazard_coverage": float(hazard_coverage),1092 }1093 for type_id, energy in sorted(eat_energy_by_type.items()):1094 self.telemetry[f"eat_energy_food_t{int(type_id)}"] = float(energy)1095 if births_this == 0 and deaths_this == 0:1096 self._stagnation_ticks += 11097 else:1098 self._stagnation_ticks = 01099 1100 if len(self.agents) <= 1:1101 self._singleton_ticks += 11102 else:1103 self._singleton_ticks = 01104 1105 if self._singleton_ticks >= SINGLETON_TICKS_BEFORE_RESET:1106 self._singleton_ticks = 01107 self._stagnation_ticks = 01108 self.reset_food_supply()1109 self._reseed_population("singleton reset")1110 return1111 1112 if self._stagnation_ticks >= STAGNATION_TICKS_BEFORE_RESET:1113 self._stagnation_ticks = 01114 self.reset_food_supply()1115 self._reseed_population("stagnation reset")1116 return1117 1118 self._update_strategy_tags()1119 1120 def _update_strategy_tags(self) -> None:1121 for a in self.agents:1122 attack_attempts = int(getattr(a, "attack_attempts_total", 0))1123 attack_successes = int(getattr(a, "attack_successes_total", 0))1124 mate_successes = int(getattr(a, "mate_successes_total", 0))1125 fissions = int(getattr(a, "fissions_total", 0))1126 food_energy = float(getattr(a, "food_energy_total", 0.0))1127 body_energy = float(getattr(a, "body_energy_total", 0.0))1128 1129 tags: list[str] = []1130 if fissions >= 3 and fissions >= mate_successes:1131 tags.append("Fissioner")1132 if attack_successes >= 3 and (attack_successes / max(1, attack_attempts)) >= 0.25:1133 tags.append("Predator")1134 if body_energy >= max(50.0, 1.5 * food_energy):1135 tags.append("Scavenger")1136 if food_energy >= max(50.0, 1.5 * body_energy):1137 tags.append("Forager")1138 if not tags:1139 tags.append("Generalist")1140 a.strategy_tag = "+".join(tags)1141 1142 # ---------- 最後の10個体 保存/復元 ----------1143 def snapshot_topK(self) -> List[dict]:1144 top = sorted(self.agents, key=lambda a: a.E, reverse=True)[:SAVE_TOP_K]1145 pack = []1146 for a in top:1147 pack.append({"genome": a.brain.to_dict(), "S": a.S, "fission_trait": a.fission_trait})1148 return pack1149 1150 def save_last10(self):1151 data = self.snapshot_topK()1152 if data:1153 with open(LAST10_PATH, "w") as f:1154 json.dump(data, f)1155 print(f"[saved last {len(data)}] -> {LAST10_PATH}")1156 1157 def _load_last10(self) -> list:1158 try:1159 with open(LAST10_PATH, "r") as f:1160 data = json.load(f)1161 except Exception:1162 data = []1163 return data1164 1165 # ---------- 多様性ブートストラップ ----------1166 def _bootstrap_from_last10_diverse(self, data: list, n_target: int) -> None:1167 if not data:1168 self.agents = []1169 return1170 1171 # --- 特徴ベクトル化 ---1172 X = []1173 for item in data:1174 g = Genome.from_dict(item["genome"])1175 X.append(self._genome_features(g))1176 X = np.stack(X, axis=0) # shape: (n, d)1177 n = X.shape[0]1178 1179 # すべて同一(SSE ~ 0)ならクラスタリングはスキップし、均等ローテで増殖1180 all_same = np.allclose(X, X[0], atol=1e-8)1181 1182 # --- k の安定化 ---1183 if n == 1:1184 # クラスタリング不要。1個体ベースで n_target まで微小変異増殖1185 self.agents = []1186 base_item = data[0]1187 for _ in range(n_target):1188 g_child = Genome.from_dict(base_item["genome"]).micro_mutate(1189 weight_sigma=0.03, p_add_conn=0.04, p_add_node=0.015, p_del_conn=0.0081190 )1191 a = Agent(g_child, birth_tick=self.t)1192 a.S = float(base_item.get("S", a.S)) * float(np.clip(rng.normal(1.0, 0.03), 0.9, 1.1))1193 a.E = 220.01194 self.agents.append(a)1195 print("[diverse bootstrap] n=1 -> simple replicate")1196 return1197 1198 # 通常は 2..min(KMEANS_MAX_K, n) の範囲1199 k_max = min(KMEANS_MAX_K, n)1200 k = max(2, k_max) if n >= 4 else min(2, k_max) # n=2→k=2, n=3→k=2, n>=4→k=min(KMAX,n)