# Search Lenia Parameter Space # Book: Cellular Automata From First Principles # Chapter URL: https://programmer.ie/books/cellular-automata-from-first-principles/33-chapter/ # Prose fingerprint (sha256, normalized): 3d7014779de3e6968584fe5354e3a4439a9a7e2e04f5d94ca3e6321b79843556 # Snapshot generated at build time by Hugo. This is the complete rendered prose # of the chapter; it is not a transcript of the source file. A Lenia pattern is not defined by its initial state alone. It also lives inside a particular dynamical world. Change the kernel or growth function slightly and the same seed may: die explode freeze oscillate move split stabilize into a different morphology So discovery has two coupled search spaces: initial condition + world parameters Define parameter ranges PARAM_RANGES = { "radius": (8, 24), "ring_center": (0.25, 0.75), "ring_width": (0.05, 0.25), "mu": (0.05, 0.30), "sigma": (0.01, 0.08), "dt": (0.03, 0.20), } These ranges are experimental choices. They define which region of Lenia space we are willing to explore. (Chan’s maps span the same μ–σ plane among others — including the β-cube this single-ring implementation does not reach, a stated scope limit from Chapter 31.) Each parameter’s local role — and how its failure looks, so the heatmap below reads as diagnosis rather than decoration: Parameter Role Too low Too high mu preferred neighborhood density sparse contexts starve crowding saturates sigma tolerance width selective to the point of death indiscriminate flooding radius kernel scale fine detail, small structures coarse, slow, expensive dt integration step sluggish convergence instability Sample a configuration def sample_config(rng): return LeniaConfig( radius=int(rng.integers(8, 25)), ring_center=float(rng.uniform(0.25, 0.75)), ring_width=float(rng.uniform(0.05, 0.25)), mu=float(rng.uniform(0.05, 0.30)), sigma=float(rng.uniform(0.01, 0.08)), dt=float(rng.uniform(0.03, 0.20)), ) A random search is not sophisticated. But it gives us a baseline. Never build an elaborate optimizer before measuring what random search can already find. Evaluate configurations and seeds together def evaluate_trial(config, seed_value, shape=(128, 128), steps=500): initial = random_seed(shape=shape, seed=seed_value) result = evaluate_seed(initial, config, steps=steps) return { "config": config, "seed": seed_value, "score": candidate_score(result), "result": result, } Then: rng = np.random.default_rng(1234) trials = [] for trial_id in range(200): config = sample_config(rng) seed_value = int(rng.integers(0, 1_000_000)) trials.append(evaluate_trial(config, seed_value)) Separate failure categories A single low score hides useful information. Record explicit outcomes: def classify_outcome(result): final = result["final"] mass = final.sum() fraction = result["active_fraction"] activity = result["activity"] if mass < 1e-3: return "dead" if fraction > 0.8: return "world-filling" if activity < 1e-5: return "static" return "persistent-dynamic" Now we can ask: Which parameters tend to die? Which explode? Where do persistent structures cluster? Failure becomes data. Plot the search landscape import matplotlib.pyplot as plt x = [t["config"].mu for t in trials] y = [t["config"].sigma for t in trials] c = [t["score"] for t in trials] plt.scatter(x, y, c=c) plt.xlabel("mu") plt.ylabel("sigma") plt.colorbar(label="candidate score") plt.show() This is the book’s version of Chan’s μ–σ maps, which chart species distributions across growth-parameter space: a two-dimensional projection of a larger parameter space, where persistent regions — not individual champions — are the finding. But projections can reveal regions worth exploring more densely. Measured here over a 6×5 μ–σ grid (2 seeds per cell, 150 steps, 96×96, full candidate score with real activity): Scores run 0.40 (dead) to ~0.67 with a persistent interior around μ ≈ 0.13–0.17, σ ≥ 0.02 and extinction along the narrow-sigma border. Read it as a map of where to search, not a phase theorem — 150 steps and 2 seeds per cell resolve regimes, not boundaries. Local refinement Once a promising configuration appears, mutate around it: def mutate_config(config, rng): return LeniaConfig( radius=max(3, config.radius + int(rng.integers(-2, 3))), ring_center=float(np.clip(config.ring_center + rng.normal(0, 0.03), 0.05, 0.95)), ring_width=float(np.clip(config.ring_width + rng.normal(0, 0.02), 0.01, 0.40)), mu=float(np.clip(config.mu + rng.normal(0, 0.015), 0.01, 0.50)), sigma=float(np.clip(config.sigma + rng.normal(0, 0.008), 0.005, 0.20)), dt=float(np.clip(config.dt + rng.normal(0, 0.01), 0.01, 0.30)), ) The strategy becomes: explore broadly ↓ find promising region ↓ search locally Robustness matters more than one lucky run A configuration that works for one exact seed may be extremely fragile. Evaluate multiple nearby seeds: def robustness_score(config, seeds, steps=500): scores = [] for seed_value in seeds: initial = random_seed(seed=seed_value) result = evaluate_seed(initial, config, steps=steps) scores.append(candidate_score(result)) return { "mean": float(np.mean(scores)), "minimum": float(np.min(scores)), "std": float(np.std(scores)), } A robust region should not collapse under tiny changes in initialization. The regime test, executed on the reference configuration: shifting μ by ±0.02, widening σ by +0.02, or doubling dt all preserve persistent dynamics (masses ≈ 1785–2237) — quantitative change within qualitative persistence. But narrowing σ by 0.015 kills the pattern outright. That is the honest shape of a parameter region: robust interior, fragile borders, verified rather than asserted. Search for niches, not just champions The highest score may not be the most interesting result. Imagine three candidates: A: stationary pulsing blob B: fast translating crescent C: branching structure that repeatedly repairs itself A single score may rank one highest and discard the others. Instead maintain an archive indexed by behavioral descriptors: speed mass size activity symmetry oscillation period Keep the best candidate in each region of behavior space. This is the quality-diversity idea in practical form. Search produces maps of possibility A good search system does more than return one organism. It can reveal: stable regions fragile borders extinction zones world-filling zones mobile-pattern niches oscillatory niches That changes the scientific question from: What creature did we find? into: What kinds of behavior are possible in this family of worlds? Store the complete experiment table Save every trial, not just winners. A tabular record might contain: trial_id seed radius ring_center ring_width mu sigma dt survival mass activity active_fraction classification score artifact_path Then later analysis does not require rerunning every simulation. The rule itself can become richer So far one kernel produces one perception field and one growth response. But many biological systems operate at more than one spatial scale. We can have: several kernels several channels several growth responses cross-channel influence That is the next major expansion — and the canonical direction: Chan’s full formulation already carries multi-shell β kernels, and multi-kernel/channel systems generalize further. In the next chapter we will build multi-kernel and multi-channel Lenia, where a cell’s state becomes a vector again and different local fields can interact. Research Chan, B. W.-C. — Lenia: Biology of Artificial Life (Complex Systems 28(3), 2019). The mapping precedent for this chapter: μ–σ maps and the β-cube charting where species live in parameter space, niches and landscapes as the unit of discovery, and the stability–motility hypothesis linking persistence to movement. Run the regime test first; read the maps second. https://arxiv.org/html/1812.05433v3 Lehman, J. & Stanley, K. O. — Abandoning Objectives (Evolutionary Computation 19(2), 2011). The reason the archive is indexed by behavior rather than score: novelty in descriptor space finds what champion-chasing discards. The niches section is quality diversity argued from the book’s own candidates. https://dl.acm.org/doi/10.1162/EVCO_a_00025