Topology operators: 7 mutations + area-matched subtree crossover
operators.py (homemaker-py-nyb): divide/undivide/retype/swap/rotate/ level_add/level_delete + Urb-style area-matched base-storey crossover. Operators edit the decoded Node tree; genome.encode absorbs all repair (dangling deltas, storey misalignment) so every child is a valid genome by construction. Geometry moves deliberately absent — the inner loop owns continuous DOF, and 8cs made Lamarckian re-optimisation mandatory. Fixes dom._link to CLEAR stale below-links when a path vanishes from the storey below (undividing a base branch left upper nodes pointing at orphaned quads; oracle scoring unaffected but in-process geometry crashed). Acceptance (experiments/operator_locality.py, flag-on): 115/115 children scored without error; geometry perturbation small for core ops (retype 0.07, divide/undivide 0.14, swap/crossover 0.16-0.17), fitness perturbation large for all (0.68-0.99 rel) — the 0.5^n cliff flags most raw moves, confirming warm-started re-optimisation + penalty reshaping as the load-bearing design choices. 27 tests pass. Closes homemaker-py-nyb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
13f73be771
commit
92cc63348e
5 changed files with 396 additions and 6 deletions
|
|
@ -9,13 +9,13 @@
|
|||
{"id":"homemaker-py-ccw","title":"Scaled topology search on native fitness","description":"DESIGN.md §7 Phase 3 closing step. Once native fitness passes corpus parity, re-run the Phase-2 memetic search at real scale (population/generations comparable to urb-evolve) on the native objective. This is the first point where the §1 scaling question gets a real answer.","acceptance_criteria":"Full-scale run on programme-house beats both urb-evolve and the small-scale Phase-2 result; larger programme attempted","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:59Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:59Z","dependencies":[{"issue_id":"homemaker-py-ccw","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-ccw","depends_on_id":"homemaker-py-way","type":"blocks","created_at":"2026-06-12T00:39:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-way","title":"Benchmark: memetic loop vs urb-evolve at equal oracle-call budget (Phase 2 gate)","description":"DESIGN.md §7 Phase 2 gate. Compare against urb-evolve from the same seeds/programmes at equal oracle-evaluation budget — NOT generations (urb-evolve has diversity injection/culling baked in, so generations are not comparable). Go/no-go: memetic loop must beat equal-budget urb-evolve. Scaling up waits for native fitness.","acceptance_criteria":"Best-fitness and failure-count comparison at \u003e=2 budgets, \u003e=3 seeds; go/no-go decision recorded in DESIGN.md","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:28Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:37:28Z","dependencies":[{"issue_id":"homemaker-py-way","depends_on_id":"homemaker-py-b39","type":"blocks","created_at":"2026-06-12T00:39:39Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-way","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:45Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-b39","title":"Memetic search driver, small-scale (budgets in oracle evaluations)","description":"DESIGN.md §5, §7 Phase 2, §4.6 arithmetic. Memetic EA/SA over topology genomes wrapping the geometry inner loop (warm-started per §5.6); score = best full fitness over the inner loop. Explicitly small-scale on the batched oracle: tens of topologies, budget accounted in oracle evaluations, not generations. Population evaluation batched into single oracle calls.","acceptance_criteria":"End-to-end run on programme-house completes within a stated oracle-call budget and logs evaluations; produces valid .dom output","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:37:27Z","dependencies":[{"issue_id":"homemaker-py-b39","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:37Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-b39","depends_on_id":"homemaker-py-nyb","type":"blocks","created_at":"2026-06-12T00:39:38Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-nyb","title":"High-locality topology operators (mutation + subtree crossover)","description":"DESIGN.md §5, §7 Phase 2, §8.4. Mutation moves: divide/undivide leaf, swap children, rotate cut, retype leaf, per-floor delta edits, storey add/delete (cf. Urb Mutate.pm — but geometry sliding belongs to the inner loop, not the operator set). Crossover: area-matched subtree exchange (a subtree = a contiguous region, so crossover is meaningful — Crossover.pm). Operators must be high-locality: small genome change =\u003e small phenotype change, so warm-started inner loops stay cheap.","acceptance_criteria":"Each operator produces valid genomes (oracle scores them without error); locality measured (mean fitness/geometry perturbation per operator)","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:37:27Z","dependencies":[{"issue_id":"homemaker-py-nyb","depends_on_id":"homemaker-py-k2g","type":"blocks","created_at":"2026-06-12T00:39:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-k2g","title":"Topology genome: base-floor tree + per-floor deltas + type assignment","description":"DESIGN.md §5.2, §7 Phase 2. Genome = base-floor slicing topology (primary) + per-leaf type assignment + per-floor divide/undivide deltas (Below-inheritance as regulariser; cut owned by lowest storey where its path is divided — §10). Must round-trip to/from dom.py Node trees so the oracle and inner loop consume it directly. Includes storey count and per-floor type overrides.","acceptance_criteria":"Genome \u003c-\u003e .dom round-trip on all 35 corpus files preserves fitness; multi-storey wall stacking preserved","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:26Z","created_by":"Bruno Postle","updated_at":"2026-06-12T10:55:21Z","started_at":"2026-06-12T10:55:21Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-nyb","title":"High-locality topology operators (mutation + subtree crossover)","description":"DESIGN.md §5, §7 Phase 2, §8.4. Mutation moves: divide/undivide leaf, swap children, rotate cut, retype leaf, per-floor delta edits, storey add/delete (cf. Urb Mutate.pm — but geometry sliding belongs to the inner loop, not the operator set). Crossover: area-matched subtree exchange (a subtree = a contiguous region, so crossover is meaningful — Crossover.pm). Operators must be high-locality: small genome change =\u003e small phenotype change, so warm-started inner loops stay cheap.","acceptance_criteria":"Each operator produces valid genomes (oracle scores them without error); locality measured (mean fitness/geometry perturbation per operator)","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:27Z","created_by":"Bruno Postle","updated_at":"2026-06-12T12:54:23Z","started_at":"2026-06-12T12:54:23Z","dependencies":[{"issue_id":"homemaker-py-nyb","depends_on_id":"homemaker-py-k2g","type":"blocks","created_at":"2026-06-12T00:39:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-k2g","title":"Topology genome: base-floor tree + per-floor deltas + type assignment","description":"DESIGN.md §5.2, §7 Phase 2. Genome = base-floor slicing topology (primary) + per-leaf type assignment + per-floor divide/undivide deltas (Below-inheritance as regulariser; cut owned by lowest storey where its path is divided — §10). Must round-trip to/from dom.py Node trees so the oracle and inner loop consume it directly. Includes storey count and per-floor type overrides.","acceptance_criteria":"Genome \u003c-\u003e .dom round-trip on all 35 corpus files preserves fitness; multi-storey wall stacking preserved","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:26Z","created_by":"Bruno Postle","updated_at":"2026-06-12T12:52:34Z","started_at":"2026-06-12T10:55:21Z","closed_at":"2026-06-12T12:52:34Z","close_reason":"genome.py encode/decode lands. 35/35 oracle fitness parity after round-trip (flag-on); genome fixed-point + owned-projection tests. Dead-field discovery: corpus upper storeys carry drifted dead divisions (97) and rotations (187) — canonicalised by decode, validated fitness-neutral.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-d0s","title":"Experiment: inner-loop optimiser bake-off at equal oracle budgets","description":"DESIGN.md §7 Phase 1, §8.3. DOF is only ~rooms-1 (6–7 on corpus). Compare Nelder-Mead vs CMA-ES vs batched multi-start pattern search at equal oracle-call budgets, measuring fitness gained per oracle call and wall-clock (batch-friendliness matters — §4.6). Measure, don't commit blind.","acceptance_criteria":"Table of fitness-per-budget across \u003e=3 candidates; one optimiser chosen and recorded in DESIGN.md","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:59Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:36:59Z","dependencies":[{"issue_id":"homemaker-py-d0s","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:35Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:01Z","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:00Z","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-9gp","title":"Canonical slicing encoding (normalized Polish expression) + shape feasibility","description":"DESIGN.md §5.5, §7 Phase 5. Representation upgrade once core lands: normalized Polish expression / skewed slicing tree (Wong–Liu) for redundancy-free, high-locality topology moves (M1/M2/M3); bottom-up shape-feasibility checks to prune infeasible topologies before the inner loop. Goal: scale to larger programmes. Excluded representations stay excluded (§2): no sequence-pair/B*-tree (non-slicing).","acceptance_criteria":"Encoding round-trips with the genome; M1/M2/M3 moves implemented; measured search improvement on a larger-than-house programme","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:02Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:02Z","dependencies":[{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-ccw","type":"blocks","created_at":"2026-06-12T00:39:48Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:27:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
|
||||
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
|
||||
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
|
||||
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
|
||||
|
|
|
|||
103
experiments/operator_locality.py
Normal file
103
experiments/operator_locality.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Operator validity + locality measurement (homemaker-py-nyb acceptance).
|
||||
|
||||
For each operator: apply 5 seeded instances per corpus design, score every
|
||||
child through the oracle (validity = scores without error), and report
|
||||
locality as (a) mean relative fitness perturbation and (b) mean geometry
|
||||
perturbation — the fraction of leaf rooms whose (type, footprint) changed.
|
||||
High-locality operators keep both small, so warm-started inner loops stay
|
||||
cheap (DESIGN.md §5).
|
||||
|
||||
Run under the go-forward fitness:
|
||||
URB_NO_OCCLUSION=1 python3 experiments/operator_locality.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from homemaker import dom, genome, geometry, operators, oracle, programme # noqa: E402
|
||||
|
||||
URB = Path("/home/bruno/src/urb")
|
||||
CORPUS = URB / "examples" / "programme-house"
|
||||
FILES = ["2f45907abd9accac2a124d311732f749.dom", "candidate-002.dom",
|
||||
"c964435454c459f86c3ed9a5a7621132.dom"]
|
||||
SEEDS = range(5)
|
||||
|
||||
|
||||
def leaf_signature(root: dom.Node) -> Counter:
|
||||
sig = Counter()
|
||||
for li, lvl in enumerate(dom.levels(root)):
|
||||
for leaf in lvl.leaves():
|
||||
corners = tuple(tuple(round(c, 6) for c in geometry.coordinate(leaf, i))
|
||||
for i in range(4))
|
||||
sig[(li, leaf.type, corners)] += 1
|
||||
return sig
|
||||
|
||||
|
||||
def geometry_perturbation(parent_sig: Counter, child: dom.Node) -> float:
|
||||
child_sig = leaf_signature(child)
|
||||
common = sum((parent_sig & child_sig).values())
|
||||
return 1.0 - common / max(parent_sig.total(), child_sig.total())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
types = sorted(programme.load_programme(str(CORPUS / "patterns.config"))) + ["c", "o"]
|
||||
roots = {f: genome.decode(genome.encode(dom.load(str(CORPUS / f)))) for f in FILES}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="op_locality_") as tmp:
|
||||
scratch = Path(tmp)
|
||||
shutil.copy(CORPUS / "patterns.config", scratch)
|
||||
|
||||
parents = {}
|
||||
paths = []
|
||||
for f, root in roots.items():
|
||||
p = scratch / f
|
||||
dom.dump(root, str(p))
|
||||
paths.append(p)
|
||||
for f, s in zip(roots, oracle.score_batch(paths, URB)):
|
||||
parents[f] = s
|
||||
|
||||
jobs: list[tuple[str, str, dom.Node]] = [] # (op, desc, child)
|
||||
for f, root in roots.items():
|
||||
for name, op in operators.MUTATIONS.items():
|
||||
for seed in SEEDS:
|
||||
child, desc = op(root, np.random.default_rng(seed), types)
|
||||
jobs.append((name, f, child))
|
||||
for seed in SEEDS:
|
||||
ca, cb, _ = operators.crossover(roots[FILES[0]], roots[FILES[1]],
|
||||
np.random.default_rng(seed))
|
||||
jobs.append(("crossover", FILES[0], ca))
|
||||
jobs.append(("crossover", FILES[1], cb))
|
||||
|
||||
paths = []
|
||||
for i, (_, _, child) in enumerate(jobs):
|
||||
p = scratch / f"child_{i:03d}.dom"
|
||||
dom.dump(child, str(p))
|
||||
paths.append(p)
|
||||
scores = oracle.score_batch(paths, URB) # raises if any child is invalid
|
||||
|
||||
sigs = {f: leaf_signature(root) for f, root in roots.items()}
|
||||
stats: dict[str, list[tuple[float, float]]] = {}
|
||||
for (name, f, child), s in zip(jobs, scores):
|
||||
df = abs(s.fitness - parents[f].fitness) / parents[f].fitness
|
||||
dg = geometry_perturbation(sigs[f], child)
|
||||
stats.setdefault(name, []).append((df, dg))
|
||||
|
||||
print(f"{'operator':14s} {'n':>3s} {'mean |dF|/F':>12s} {'mean geom-pert':>15s}")
|
||||
for name in sorted(stats):
|
||||
dfs, dgs = zip(*stats[name])
|
||||
print(f"{name:14s} {len(dfs):3d} {np.mean(dfs):12.3f} {np.mean(dgs):15.3f}")
|
||||
print(f"\nall {len(jobs)} children scored by the oracle without error")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -126,9 +126,9 @@ def _link(root: Node) -> None:
|
|||
below_root = lvls[i - 1]
|
||||
|
||||
def _set(n: Node, below_root: Node = below_root) -> None:
|
||||
b = below_root.by_id(n.id)
|
||||
if b is not None:
|
||||
n.below = b
|
||||
# always assign: re-linking a structurally mutated tree must CLEAR
|
||||
# below-links whose path no longer exists on the storey below
|
||||
n.below = below_root.by_id(n.id)
|
||||
if n.divided:
|
||||
_set(n.left)
|
||||
_set(n.right)
|
||||
|
|
|
|||
194
src/homemaker/operators.py
Normal file
194
src/homemaker/operators.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""High-locality topology operators: mutation + subtree crossover.
|
||||
|
||||
Operators edit a *decoded* Node tree (the canonical phenotype) and re-link it;
|
||||
``genome.encode`` then re-derives the genome, which makes every operator
|
||||
total: dangling per-storey deltas after an undivide below, or storey
|
||||
misalignment after crossover, are absorbed by encode's parallel walk (cuts
|
||||
that stop existing below simply become owned above). Geometry moves (Urb's
|
||||
``slide``, floor heights) are deliberately absent — the inner loop owns all
|
||||
continuous DOF (DESIGN.md §5), and the warm-vs-cold result (homemaker-py-8cs)
|
||||
makes Lamarckian re-optimisation after every topology move mandatory anyway.
|
||||
|
||||
Each ``mutate_*`` helper applies one random instance to a deep copy and
|
||||
returns ``(child_root, descriptor)``; ``crossover`` returns two children.
|
||||
Candidate selection respects ownership: cuts are swappable/rotatable only
|
||||
where they are live (below is None / below undivided — the free-branch
|
||||
criterion), so operators never edit dead fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import dom
|
||||
|
||||
|
||||
def _finalise(root: dom.Node) -> dom.Node:
|
||||
from . import geometry
|
||||
|
||||
dom._link(root)
|
||||
geometry.clear_cache()
|
||||
return root
|
||||
|
||||
|
||||
def _level_nodes(lvl: dom.Node) -> list[dom.Node]:
|
||||
out = [lvl]
|
||||
if lvl.divided:
|
||||
out += _level_nodes(lvl.left) + _level_nodes(lvl.right)
|
||||
return out
|
||||
|
||||
|
||||
def _pick(rng: np.random.Generator, items: list):
|
||||
return items[int(rng.integers(len(items)))]
|
||||
|
||||
|
||||
def _owned_branches(root: dom.Node) -> list[tuple[int, dom.Node]]:
|
||||
"""(level_index, node) for every divided node whose cut is live here."""
|
||||
out = []
|
||||
for li, lvl in enumerate(dom.levels(root)):
|
||||
for n in _level_nodes(lvl):
|
||||
if n.divided and (n.below is None or not n.below.divided):
|
||||
out.append((li, n))
|
||||
return out
|
||||
|
||||
|
||||
def _leaves(root: dom.Node) -> list[tuple[int, dom.Node]]:
|
||||
return [(li, leaf) for li, lvl in enumerate(dom.levels(root)) for leaf in lvl.leaves()]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mutations
|
||||
# --------------------------------------------------------------------------- #
|
||||
def mutate_divide(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
child = copy.deepcopy(root)
|
||||
li, leaf = _pick(rng, _leaves(child))
|
||||
leaf.division = [0.5, 0.5]
|
||||
leaf.rotation = int(rng.integers(4))
|
||||
leaf.left = dom.Node(type=leaf.type)
|
||||
leaf.right = dom.Node(type=str(_pick(rng, types)))
|
||||
leaf.type = None
|
||||
return _finalise(child), f"divide {li}/{leaf.id or 'root'}"
|
||||
|
||||
|
||||
def mutate_undivide(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
child = copy.deepcopy(root)
|
||||
cands = [(li, n) for li, n in _owned_branches(child)
|
||||
if not n.left.divided and not n.right.divided]
|
||||
if not cands:
|
||||
return _finalise(child), "undivide noop"
|
||||
li, n = _pick(rng, cands)
|
||||
keep = [t for t in (n.left.type, n.right.type) if t and not t.startswith(("c", "o", "s"))]
|
||||
n.type = keep[0] if keep else (n.left.type or str(_pick(rng, types)))
|
||||
n.division = None
|
||||
n.left = n.right = None
|
||||
return _finalise(child), f"undivide {li}/{n.id or 'root'}"
|
||||
|
||||
|
||||
def mutate_retype(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
child = copy.deepcopy(root)
|
||||
li, leaf = _pick(rng, _leaves(child))
|
||||
leaf.type = str(_pick(rng, [t for t in types if t != leaf.type] or types))
|
||||
return _finalise(child), f"retype {li}/{leaf.id or 'root'}->{leaf.type}"
|
||||
|
||||
|
||||
def mutate_swap(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
child = copy.deepcopy(root)
|
||||
li, n = _pick(rng, _owned_branches(child))
|
||||
n.left, n.right = n.right, n.left
|
||||
return _finalise(child), f"swap {li}/{n.id or 'root'}"
|
||||
|
||||
|
||||
def mutate_rotate(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
# re-orient a live cut; live rotation = node without a below link (base
|
||||
# storey or inside an upper-storey divide delta)
|
||||
child = copy.deepcopy(root)
|
||||
cands = [(li, n) for li, n in _owned_branches(child) if n.below is None]
|
||||
if not cands:
|
||||
return _finalise(child), "rotate noop"
|
||||
li, n = _pick(rng, cands)
|
||||
n.rotation = (n.rotation + int(rng.integers(1, 4))) % 4
|
||||
return _finalise(child), f"rotate {li}/{n.id or 'root'}"
|
||||
|
||||
|
||||
def mutate_level_add(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
from . import genome as _g
|
||||
|
||||
child = copy.deepcopy(root)
|
||||
top = dom.levels(child)[-1]
|
||||
dup = _g._copy_storey(top)
|
||||
dup.height = top.height
|
||||
top.above = dup
|
||||
return _finalise(child), f"level_add ({len(dom.levels(child))} storeys)"
|
||||
|
||||
|
||||
def mutate_level_delete(root: dom.Node, rng: np.random.Generator,
|
||||
types: list[str]) -> tuple[dom.Node, str]:
|
||||
child = copy.deepcopy(root)
|
||||
lvls = dom.levels(child)
|
||||
if len(lvls) < 2:
|
||||
return _finalise(child), "level_delete noop"
|
||||
lvls[-2].above = None
|
||||
return _finalise(child), f"level_delete ({len(lvls) - 1} storeys)"
|
||||
|
||||
|
||||
MUTATIONS = {
|
||||
"divide": mutate_divide,
|
||||
"undivide": mutate_undivide,
|
||||
"retype": mutate_retype,
|
||||
"swap": mutate_swap,
|
||||
"rotate": mutate_rotate,
|
||||
"level_add": mutate_level_add,
|
||||
"level_delete": mutate_level_delete,
|
||||
}
|
||||
|
||||
|
||||
def mutate(root: dom.Node, rng: np.random.Generator, types: list[str],
|
||||
weights: dict[str, float] | None = None) -> tuple[dom.Node, str]:
|
||||
"""Apply one random mutation drawn from MUTATIONS."""
|
||||
names = sorted(MUTATIONS)
|
||||
p = np.array([(weights or {}).get(n, 1.0) for n in names], dtype=float)
|
||||
name = rng.choice(names, p=p / p.sum())
|
||||
return MUTATIONS[name](root, rng, types)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Crossover
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _graft(dst: dom.Node, src: dom.Node) -> None:
|
||||
"""Replace dst's subtree content with a copy of src's (cf. Urb Crossover)."""
|
||||
sub = copy.deepcopy(src)
|
||||
dst.type = sub.type
|
||||
dst.rotation = sub.rotation
|
||||
dst.division = sub.division
|
||||
dst.left, dst.right = sub.left, sub.right
|
||||
|
||||
|
||||
def crossover(a: dom.Node, b: dom.Node,
|
||||
rng: np.random.Generator) -> tuple[dom.Node, dom.Node, str]:
|
||||
"""Area-matched base-storey subtree exchange (Urb Crossover.pm style):
|
||||
pick a random subtree of A's base storey, find the area-closest third of
|
||||
B's base subtrees, exchange. A subtree is a contiguous region, so this
|
||||
recombines whole neighbourhoods; storeys above re-anchor via encode."""
|
||||
from . import geometry
|
||||
|
||||
ca, cb = copy.deepcopy(a), copy.deepcopy(b)
|
||||
_finalise(ca)
|
||||
_finalise(cb)
|
||||
base_a, base_b = dom.levels(ca)[0], dom.levels(cb)[0]
|
||||
na = _pick(rng, _level_nodes(base_a))
|
||||
by_area = sorted(_level_nodes(base_b),
|
||||
key=lambda n: abs(geometry.area(n) - geometry.area(na)))
|
||||
nb = by_area[int(rng.integers(max(1, len(by_area) // 3)))]
|
||||
tmp = copy.deepcopy(na)
|
||||
_graft(na, nb)
|
||||
_graft(nb, tmp)
|
||||
desc = f"crossover {na.id or 'root'}<->{nb.id or 'root'}"
|
||||
return _finalise(ca), _finalise(cb), desc
|
||||
93
tests/test_operators.py
Normal file
93
tests/test_operators.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Operator tests (oracle-free): every child is a valid, canonical genome."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from homemaker import dom, genome, operators
|
||||
|
||||
CORPUS = Path("/home/bruno/src/urb/examples/programme-house")
|
||||
FILES = ["2f45907abd9accac2a124d311732f749.dom", "candidate-002.dom",
|
||||
"c964435454c459f86c3ed9a5a7621132.dom"]
|
||||
TYPES = ["k1", "l1", "b1", "b2", "t1", "c", "o"]
|
||||
|
||||
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Urb corpus not available")
|
||||
|
||||
|
||||
def canonical(root: dom.Node) -> None:
|
||||
"""Child must encode to a genome that decode/encode holds fixed."""
|
||||
g1 = genome.encode(root)
|
||||
g2 = genome.encode(genome.decode(g1))
|
||||
assert g2 == g1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(operators.MUTATIONS))
|
||||
def test_mutations_yield_canonical_genomes(name):
|
||||
op = operators.MUTATIONS[name]
|
||||
for f in FILES:
|
||||
root = genome.decode(genome.encode(dom.load(str(CORPUS / f))))
|
||||
for seed in range(5):
|
||||
child, desc = op(root, np.random.default_rng(seed), TYPES)
|
||||
assert desc.startswith(name.split("_")[0]) or "noop" in desc
|
||||
canonical(child)
|
||||
# the parent must never be mutated in place
|
||||
canonical(root)
|
||||
|
||||
|
||||
def test_divide_grows_and_undivide_shrinks():
|
||||
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
||||
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
|
||||
child, _ = operators.mutate_divide(root, np.random.default_rng(0), TYPES)
|
||||
assert sum(len(lvl.leaves()) for lvl in dom.levels(child)) == n_leaves + 1
|
||||
child, desc = operators.mutate_undivide(root, np.random.default_rng(0), TYPES)
|
||||
if "noop" not in desc:
|
||||
assert sum(len(lvl.leaves()) for lvl in dom.levels(child)) < n_leaves
|
||||
|
||||
|
||||
def test_level_add_delete():
|
||||
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
||||
n = len(dom.levels(root))
|
||||
up, _ = operators.mutate_level_add(root, np.random.default_rng(0), TYPES)
|
||||
assert len(dom.levels(up)) == n + 1
|
||||
canonical(up)
|
||||
down, _ = operators.mutate_level_delete(root, np.random.default_rng(0), TYPES)
|
||||
assert len(dom.levels(down)) == n - 1
|
||||
|
||||
|
||||
def test_relink_clears_stale_below_after_base_undivide():
|
||||
# regression: dom._link must clear below-links whose path vanished, or
|
||||
# geometry on the mutated tree dereferences orphaned nodes
|
||||
from homemaker import geometry
|
||||
|
||||
root = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
||||
# force an undivide on the BASE storey specifically
|
||||
base = dom.levels(root)[0]
|
||||
cands = [n for li, n in operators._owned_branches(root)
|
||||
if li == 0 and not n.left.divided and not n.right.divided]
|
||||
assert cands, "corpus design has no base leaf-pair branch"
|
||||
import copy as _copy
|
||||
|
||||
child = _copy.deepcopy(root)
|
||||
target = dom.levels(child)[0].by_id(cands[0].id)
|
||||
target.division = None
|
||||
target.left = target.right = None
|
||||
target.type = "l1"
|
||||
dom._link(child)
|
||||
geometry.clear_cache()
|
||||
for lvl in dom.levels(child):
|
||||
for leaf in lvl.leaves():
|
||||
for i in range(4):
|
||||
geometry.coordinate(leaf, i) # must not raise
|
||||
canonical(child)
|
||||
assert base.by_id(cands[0].id) is not None # parent untouched
|
||||
|
||||
|
||||
def test_crossover_yields_canonical_pair():
|
||||
a = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[0]))))
|
||||
b = genome.decode(genome.encode(dom.load(str(CORPUS / FILES[1]))))
|
||||
for seed in range(5):
|
||||
ca, cb, desc = operators.crossover(a, b, np.random.default_rng(seed))
|
||||
assert desc.startswith("crossover")
|
||||
canonical(ca)
|
||||
canonical(cb)
|
||||
Loading…
Add table
Reference in a new issue