CoolFace
Apppublic

ryosao/Artificial_Life_Simulator_GUI

sourceHugging Facemitupdated 8mo agoView on Hugging Face
0likes
neat_simulation.py1384 linesDownload Raw Back to sim
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)

Showing the first 1,200 of 1384 lines. Download the file for the rest.