genome.signature: ratio-invariant structural topology hash (per-storey tree shape + cut orientation + leaf types), the cheap stand-in for the 9gp canonical encoding. driver gains niche_by_signature (one individual per topology, replaces the fitness-scalar dedup) and restart_patience (soft restart: keep elites, refill with fresh seeds); SearchResult gains n_distinct_signatures / diversity_history / n_restarts. Diversity criterion MET (final-pop distinct ~5/16 -> 16/16). Gate NOT met: blank-slate programme-house mean fails 12.3(legacy)/12.7(niche)/13.0(restart) over 3 seeds at 20000 evals; harbor staged 95/94/108. Niching is a tie within seed noise, restarts strictly worse — falsifies the premise that the fitness-scalar dedup causes premature convergence. Both flags default-off, kept for reuse. Epic c4c complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
3.5 KiB
Python
103 lines
3.5 KiB
Python
"""Genome encode/decode tests (oracle-free; corpus-backed)."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from homemaker_layout import dom, genome, solver
|
|
|
|
CORPUS = Path(__file__).parent.parent / "examples" / "programme-house"
|
|
|
|
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Corpus not available")
|
|
|
|
|
|
def corpus():
|
|
return sorted(CORPUS.glob("*.dom"))
|
|
|
|
|
|
def owned_projection(root: dom.Node):
|
|
"""Everything fitness can see: per-level shape+leaf types, owned cut
|
|
divisions, heights, base metadata. Dead fields excluded by construction."""
|
|
def shape(n: dom.Node):
|
|
if not n.divided:
|
|
return n.type
|
|
return (shape(n.left), shape(n.right))
|
|
|
|
lvls = dom.levels(root)
|
|
owned = {}
|
|
for li, lvl in enumerate(lvls):
|
|
for b in solver._branches(lvl):
|
|
if b.below is None or not b.below.divided:
|
|
owned[(li, b.id)] = tuple(b.division)
|
|
return {
|
|
"shapes": [shape(lvl) for lvl in lvls],
|
|
"owned_cuts": owned,
|
|
"heights": [lvl.height for lvl in lvls],
|
|
"base_meta": {k: getattr(lvls[0], k) for k in
|
|
("node", "node_file", "perimeter", "elevation",
|
|
"wall_inner", "wall_outer")},
|
|
}
|
|
|
|
|
|
def test_roundtrip_preserves_owned_projection():
|
|
for f in corpus():
|
|
root = dom.load(str(f))
|
|
root2 = genome.decode(genome.encode(root))
|
|
assert owned_projection(root2) == owned_projection(root), f.name
|
|
|
|
|
|
def test_genome_is_a_fixed_point():
|
|
# encode(decode(g)) == g: nothing fitness-relevant is lost or invented
|
|
for f in corpus():
|
|
g1 = genome.encode(dom.load(str(f)))
|
|
g2 = genome.encode(genome.decode(g1))
|
|
assert g2 == g1, f.name
|
|
|
|
|
|
def test_decoded_tree_dumps_and_reloads():
|
|
for f in corpus():
|
|
root2 = genome.decode(genome.encode(dom.load(str(f))))
|
|
dom.dump(root2, "/tmp/genome_rt.dom")
|
|
root3 = dom.load("/tmp/genome_rt.dom")
|
|
assert owned_projection(root3) == owned_projection(root2), f.name
|
|
|
|
|
|
def test_storey_counts():
|
|
for f in corpus():
|
|
root = dom.load(str(f))
|
|
assert genome.encode(root).n_storeys == len(dom.levels(root)), f.name
|
|
|
|
|
|
# --- signature (§11.5 structural niching) --------------------------------- #
|
|
|
|
def test_signature_invariant_under_roundtrip():
|
|
# genome-equal trees (decode canonicalises dead fields) share a signature
|
|
for f in corpus():
|
|
root = dom.load(str(f))
|
|
root2 = genome.decode(genome.encode(root))
|
|
assert genome.signature(root) == genome.signature(root2), f.name
|
|
|
|
|
|
def test_signature_ignores_division_ratios():
|
|
# same topology, different geometry -> same signature (the niching premise)
|
|
import copy
|
|
for f in corpus():
|
|
root = dom.load(str(f))
|
|
sig0 = genome.signature(root)
|
|
perturbed = copy.deepcopy(root)
|
|
for lvl in dom.levels(perturbed):
|
|
for b in solver._branches(lvl):
|
|
b.division = [0.37, 0.63] # arbitrary ratio change, same shape
|
|
assert genome.signature(perturbed) == sig0, f.name
|
|
|
|
|
|
def test_signature_changes_with_topology():
|
|
# a divide mutation changes the structure -> changes the signature
|
|
import numpy as np
|
|
|
|
from homemaker_layout import operators
|
|
for f in corpus():
|
|
root = genome.decode(genome.encode(dom.load(str(f))))
|
|
child, _ = operators.mutate_divide(root, np.random.default_rng(0),
|
|
["k1", "l1", "C", "O"])
|
|
assert genome.signature(child) != genome.signature(root), f.name
|