Geometry inner loop: batched full-objective ratio optimiser (CMA-ES)

innerloop.py: optimise(root, programme_dir, x0=None, budget, method) ->
Result, optimising equal-offset free-branch ratios (midpoint projection of
legacy unequal cuts) against full oracle fitness. OracleEvaluator scores
each population in one batched perl call. Methods: cma (default) — multi-
start sigma ladder (0.05 local, 0.15 exploratory) with IPOP-style popsize
doubling and deterministic seeding (pycma treats seed 0 as clock!) — and
compass with Hooke-Jeeves pattern moves, kept for the d0s bake-off.

Acceptance (experiments/accept_innerloop.py, §4.5 bars vs unprojected
originals, within-noise tolerance 1%): x1.65 / x1.66 / x1.58 against bars
x1.24 / x1.67 / x1.59, no new failures, 46 oracle calls vs Nelder-Mead's
200. The two near-bar results are statistically indistinguishable from the
single-NM-draw bars (measured draw spread brackets them); decision approved
by Bruno 2026-06-12.

Also: tests/ scaffold (12 oracle-free unit tests, pytest pythonpath=src),
rebaseline_no_occlusion.py for homemaker-py-gp2, cma>=3.0 dependency
(installed via dnf), dead-variable cleanup in solver.py.

Closes homemaker-py-1p0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-12 09:42:24 +01:00
parent d4266f46dc
commit 0dcdf1f29f
10 changed files with 636 additions and 2 deletions

View file

@ -17,5 +17,5 @@
{"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 (WongLiu) 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-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 (WongLiu) 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} {"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":"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":"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":"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":"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":"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)."}

View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Acceptance run for the geometry inner loop (homemaker-py-1p0).
Gate (DESIGN.md §4.5, issue acceptance criteria): reproduce or exceed the
Nelder-Mead diagnostic gains x1.24 / x1.67 / x1.59, no new failures on
2f45907, candidate-002 and c964435, using the batched compass search.
Usage: accept_innerloop.py [budget] [method] (default: 200 oracle evals, cma)
"""
from __future__ import annotations
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker import dom, innerloop, oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house"
# name -> the x-gain the §4.5 Nelder-Mead diagnostic achieved (the bar)
GATE = {
"2f45907abd9accac2a124d311732f749.dom": 1.24,
"candidate-002.dom": 1.67,
"c964435454c459f86c3ed9a5a7621132.dom": 1.59,
}
# The §4.5 bars are single Nelder-Mead draws and the inner loop's per-run
# variance brackets them (candidate-002 drew 0.0117-0.0160 against a 0.0123
# bar). Reproduction within 1% counts as met — decision approved 2026-06-12
# (homemaker-py-1p0); chasing the last fraction with seed rolls would be
# cherry-picking.
NOISE_TOL = 0.99
def main() -> int:
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 200
method = sys.argv[2] if len(sys.argv) > 2 else "cma"
print(f"method={method} budget={budget}")
all_ok = True
with tempfile.TemporaryDirectory(prefix="accept_innerloop_") as tmp:
scratch = Path(tmp)
shutil.copy(EX / "patterns.config", scratch)
for name, bar in GATE.items():
# Baseline = the UNMODIFIED original file, exactly as §4.5 measured
# it. The inner loop's own x0 score is the equal-offset *projection*
# of the original (a==b forced, clipped), which is lower for legacy
# designs with unequal cuts — gains must not be measured from there.
s_orig = oracle.score(shutil.copy(EX / name, scratch), URB)
root = dom.load(str(EX / name))
t0 = time.perf_counter()
r = innerloop.optimise(root, EX, budget=budget, method=method, urb_root=URB)
dt = time.perf_counter() - t0
gain = r.fitness / s_orig.fitness if s_orig.fitness else float("inf")
ok = gain >= bar * NOISE_TOL and r.n_fails <= s_orig.n_fails
all_ok &= ok
print(
f"{name:42s} dof={len(r.x):2d} "
f"orig={s_orig.fitness:.6g}(fails {s_orig.n_fails}) "
f"projected x0={r.x0_fitness:.6g}(fails {r.x0_n_fails}) "
f"opt={r.fitness:.6g}(fails {r.n_fails}) "
f"x{gain:.2f} (bar x{bar:.2f}) "
f"{r.n_evals} evals / {r.n_oracle_calls} oracle calls / {dt:.0f}s "
f"{'PASS' if ok else 'FAIL'}",
flush=True,
)
print("\nGATE " + ("PASSED" if all_ok else "FAILED"))
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Why does 2f45907 resist equal-offset recovery? (homemaker-py-1p0)
Its equal-offset projection adds a failure (2 -> 3) and batched searches stall
near the projected start, yet DESIGN.md §4.5 reports Nelder-Mead reached
0.015684 from the same projection. This script checks, for 2f45907 only:
1. fitness of the three projections (midpoint, a-end, b-end)
2. scipy Nelder-Mead from the midpoint, maxfev=200 (the §4.5 setup)
3. CMA-ES with sigma0=0.05 (tighter than the 0.15 default)
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from scipy.optimize import minimize
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker import dom, innerloop # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house"
NAME = "2f45907abd9accac2a124d311732f749.dom"
def main() -> None:
root = dom.load(str(EX / NAME))
with innerloop.OracleEvaluator(root, EX, URB) as ev:
a = np.array([b.division[0] for b in ev.free])
b_ = np.array([b.division[1] for b in ev.free])
mid = (a + b_) / 2
for label, x in [("mid", mid), ("a-end", a), ("b-end", b_)]:
s = ev.evaluate([x])[0]
print(f"projection {label:6s}: {s.fitness:.6g} fails {s.n_fails}", flush=True)
# §4.5 reproduction: sequential Nelder-Mead on the same objective
best = {"f": -1.0, "fails": -1}
def neg(x: np.ndarray) -> float:
s = ev.evaluate([np.clip(x, 0.02, 0.98)])[0]
if s.fitness > best["f"]:
best.update(f=s.fitness, fails=s.n_fails)
return -s.fitness
n0 = ev.n_evals
minimize(neg, mid, method="Nelder-Mead",
options={"maxfev": 200, "xatol": 1e-3, "fatol": 1e-12})
print(f"NM from mid: {best['f']:.6g} fails {best['fails']} "
f"({ev.n_evals - n0} evals)", flush=True)
root = dom.load(str(EX / NAME))
r = innerloop.optimise(root, EX, budget=200, method="cma", sigmas=(0.05,), urb_root=URB)
print(f"CMA sigma 0.05: {r.fitness:.6g} fails {r.n_fails} ({r.n_evals} evals)")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Re-baseline the corpus under URB_NO_OCCLUSION=1 (homemaker-py-gp2).
Occlusion/daylight is disabled in Urb behind the URB_NO_OCCLUSION env flag
(daylight -> 1 everywhere, CIEsky illumination factor pinned to 1, i.e.
simple crinkliness). Flipping the flag changes every score, so this script
records the new baseline in one pass:
- per-file flag-on vs flag-off score and failure-set deltas (35 corpus files)
- batched oracle throughput under the flag
Run the inner-loop reference gains separately:
URB_NO_OCCLUSION=1 python3 experiments/accept_innerloop.py 400 cma
"""
from __future__ import annotations
import math
import os
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker import oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
CORPUS = URB / "examples" / "programme-house"
def score_corpus(paths: list[Path], flag: bool) -> tuple[list[oracle.Score], float]:
os.environ.pop("URB_NO_OCCLUSION", None)
if flag:
os.environ["URB_NO_OCCLUSION"] = "1"
t0 = time.perf_counter()
scores = oracle.score_batch(paths, URB)
return scores, time.perf_counter() - t0
def main() -> int:
with tempfile.TemporaryDirectory(prefix="rebaseline_") as tmp:
scratch = Path(tmp)
shutil.copy(CORPUS / "patterns.config", scratch)
paths = [Path(shutil.copy(d, scratch)) for d in sorted(CORPUS.glob("*.dom"))]
off, t_off = score_corpus(paths, flag=False)
on, t_on = score_corpus(paths, flag=True)
n_changed_score = n_changed_fails = 0
print(f"{'file':42s} {'flag-off':>12s} {'flag-on':>12s} {'ratio':>7s} fails off->on")
for p, a, b in zip(paths, off, on):
score_changed = not math.isclose(a.fitness, b.fitness, rel_tol=1e-9)
fails_changed = a.fail_lines != b.fail_lines
n_changed_score += score_changed
n_changed_fails += fails_changed
ratio = b.fitness / a.fitness if a.fitness else float("inf")
mark = "*" if fails_changed else " "
print(f"{p.name:42s} {a.fitness:12.6g} {b.fitness:12.6g} {ratio:7.3f} "
f"{a.n_fails}->{b.n_fails}{mark}")
n = len(paths)
print(f"\nscore changed: {n_changed_score}/{n} failure set changed: {n_changed_fails}/{n}")
print(f"batched s/dom: flag-off {t_off / n:.3f}, flag-on {t_on / n:.3f} "
f"(x{t_off / t_on:.2f})")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -9,6 +9,7 @@ dependencies = [
"scipy>=1.11", "scipy>=1.11",
"shapely>=2.0", "shapely>=2.0",
"networkx>=3.0", "networkx>=3.0",
"cma>=3.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@ -21,5 +22,8 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100

271
src/homemaker/innerloop.py Normal file
View file

@ -0,0 +1,271 @@
"""Geometry inner loop: full-objective equal-offset ratio optimisation.
The memetic architecture (DESIGN.md §5) delegates geometry to this module: for
a *frozen* slicing topology, optimise the equal-offset division ratios of the
free branches (one DOF per cut, ``solver.free_branches`` lowest-storey cut
ownership) against the FULL fitness. Never a proxy objective §4.2 falsified
that; the full objective's ``0.5^n`` failure cliff is what protects the inner
loop from trading into new failures (§4.5).
Fitness comes from the Perl oracle for now. The optimiser is a batched compass
(pattern) search: each iteration proposes ``2 × DOF`` candidate points and
scores them in ONE ``oracle.score_batch`` call, so the Perl startup amortises
across the population (§4.6). Warm-starting from a parent's optimised ratios is
just ``x0=`` (§5 decision 6, Lamarckian inheritance).
Budgets are counted in oracle evaluations (scored ``.dom`` files), the only
currency that matters while the oracle is the bottleneck.
"""
from __future__ import annotations
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from . import dom, oracle, solver
_EPS = 0.02 # keep cuts off the edges; matches solver/_experiments convention
@dataclass
class Result:
x: np.ndarray # best equal-offset ratios, aligned with solver.free_branches(root)
fitness: float
n_fails: int
fail_lines: tuple[str, ...]
x0_fitness: float
x0_n_fails: int
n_evals: int # oracle evaluations consumed (scored .dom files)
n_oracle_calls: int # perl invocations
class OracleEvaluator:
"""Scores ratio vectors for a frozen topology via the batched oracle.
Owns a scratch directory seeded with the programme config (and occlusion
field, if any) so ``urb-fitness.pl`` finds them in its working directory.
Use as a context manager, or call ``close()``.
"""
_CONFIGS = ("patterns.config", "costs.config", "occlusion.field")
def __init__(
self,
root: dom.Node,
programme_dir: str | Path,
urb_root: str | Path = oracle.DEFAULT_URB_ROOT,
):
self.root = root
self.free = solver.free_branches(root)
self.urb_root = Path(urb_root)
self._dir = Path(tempfile.mkdtemp(prefix="innerloop_"))
for name in self._CONFIGS:
src = Path(programme_dir) / name
if src.exists():
shutil.copy(src, self._dir)
self.n_evals = 0
self.n_oracle_calls = 0
def __enter__(self) -> "OracleEvaluator":
return self
def __exit__(self, *exc) -> None:
self.close()
def close(self) -> None:
shutil.rmtree(self._dir, ignore_errors=True)
@property
def x_current(self) -> np.ndarray:
# Midpoint projection: legacy designs carry slightly unequal offsets
# (a != b); (a+b)/2 is the least-damaging equal-offset start.
return np.array([(b.division[0] + b.division[1]) / 2 for b in self.free], dtype=float)
def apply(self, x: np.ndarray) -> None:
xc = np.clip(x, _EPS, 1 - _EPS)
for j, b in enumerate(self.free):
b.division = [float(xc[j]), float(xc[j])]
def evaluate(self, xs: list[np.ndarray]) -> list[oracle.Score]:
"""Score a population of ratio vectors in one oracle invocation."""
paths = []
for i, x in enumerate(xs):
self.apply(x)
p = self._dir / f"member_{i:04d}.dom"
dom.dump(self.root, str(p))
paths.append(p)
scores = oracle.score_batch(paths, self.urb_root)
self.n_evals += len(xs)
self.n_oracle_calls += 1
return scores
def compass_search(
ev: OracleEvaluator,
x0: np.ndarray,
budget: int = 200,
step0: float = 0.25,
step_tol: float = 1e-3,
n_random: int | None = None,
seed: int = 0,
) -> Result:
"""Batched compass search with pattern-move and random augmentation.
Each iteration proposes ±step along every axis, Hooke-Jeeves *pattern
moves* (1x and 2x extrapolations of the last successful displacement),
and ``n_random`` random unit directions (default DOF of them) all
scored in ONE oracle call moves greedily to the best improver, and
halves the step when none improves. The augmentations matter: the
``0.5^n`` failure cliff creates diagonal ridges where every single-axis
move adds a failure but coordinated moves do not; pure compass search
stalls there, and with only ~budget/(3·DOF) moves available each move
must be able to cover ground along a ridge.
"""
rng = np.random.default_rng(seed)
x = np.clip(np.asarray(x0, dtype=float), _EPS, 1 - _EPS)
n = len(x)
if n_random is None:
n_random = n
s = ev.evaluate([x])[0]
best = Result(
x=x.copy(), fitness=s.fitness, n_fails=s.n_fails, fail_lines=s.fail_lines,
x0_fitness=s.fitness, x0_n_fails=s.n_fails,
n_evals=0, n_oracle_calls=0,
)
step = step0
momentum: np.ndarray | None = None
while step >= step_tol and ev.n_evals < budget:
cands = []
for i in range(n):
for sign in (1.0, -1.0):
c = best.x.copy()
c[i] = np.clip(c[i] + sign * step, _EPS, 1 - _EPS)
if abs(c[i] - best.x[i]) > 1e-12:
cands.append(c)
if momentum is not None:
for scale in (1.0, 2.0):
c = np.clip(best.x + scale * momentum, _EPS, 1 - _EPS)
if np.max(np.abs(c - best.x)) > 1e-12:
cands.append(c)
for _ in range(n_random):
d = rng.standard_normal(n)
d /= np.linalg.norm(d)
c = np.clip(best.x + step * d, _EPS, 1 - _EPS)
if np.max(np.abs(c - best.x)) > 1e-12:
cands.append(c)
if not cands:
break
scores = ev.evaluate(cands)
i_best = max(range(len(cands)), key=lambda i: scores[i].fitness)
s = scores[i_best]
if s.fitness > best.fitness:
momentum = cands[i_best] - best.x
best.x = cands[i_best]
best.fitness = s.fitness
best.n_fails = s.n_fails
best.fail_lines = s.fail_lines
else:
momentum = None
step *= 0.5
best.n_evals = ev.n_evals
best.n_oracle_calls = ev.n_oracle_calls
return best
def cma_search(
ev: OracleEvaluator,
x0: np.ndarray,
budget: int = 200,
sigmas: tuple[float, ...] = (0.05, 0.15),
popsize: int | None = None,
seed: int = 0,
) -> Result:
"""Multi-start CMA-ES over the ratio box, one batched oracle call per
generation.
Covariance adaptation handles the diagonal ridges of the ``0.5^n``
landscape that stall axis-aligned pattern search; the ask/tell population
maps one-to-one onto ``OracleEvaluator.evaluate``.
One sigma does not fit all warm starts (measured at budget 200):
2f45907 needs a *local* phase at sigma 0.15 the search wanders out of
the narrow low-failure region near the start and the ``0.5^n`` cliff never
lets it back in (0.0084/3 fails vs 0.0164/2 fails at 0.05) while
candidate-002's best basin is further out and needs sigma 0.15
(0.0160 vs 0.0117 at 0.05). So the budget is split across restart phases
from the same ``x0``, one per entry of ``sigmas``, tracking the global
best across phases. Later phases double the population (IPOP-style):
exploratory phases are otherwise seed-lucky at default popsize,
candidate-002's sigma-0.15 phase scored 0.0160 or 0.0123 depending only
on the seed.
"""
import cma
x = np.clip(np.asarray(x0, dtype=float), _EPS, 1 - _EPS)
n = len(x)
s = ev.evaluate([x])[0]
best = Result(
x=x.copy(), fitness=s.fitness, n_fails=s.n_fails, fail_lines=s.fail_lines,
x0_fitness=s.fitness, x0_n_fails=s.n_fails,
n_evals=0, n_oracle_calls=0,
)
base_popsize = popsize if popsize is not None else 4 + int(3 * np.log(n))
for phase, sigma0 in enumerate(sigmas):
phase_budget = (budget - ev.n_evals) // (len(sigmas) - phase)
phase_end = ev.n_evals + max(phase_budget, 0)
opts = {
"bounds": [_EPS, 1 - _EPS],
# pycma treats seed 0 (and None) as "seed from clock"; offset so
# the default seed=0 is still deterministic
"seed": seed + phase + 1,
"verbose": -9,
"popsize": base_popsize * 2**phase,
}
es = cma.CMAEvolutionStrategy(x, sigma0, opts)
while not es.stop() and ev.n_evals < phase_end:
xs = es.ask()
scores = ev.evaluate([np.asarray(xi) for xi in xs])
es.tell(xs, [-sc.fitness for sc in scores])
i = max(range(len(xs)), key=lambda j: scores[j].fitness)
if scores[i].fitness > best.fitness:
best.x = np.asarray(xs[i]).copy()
best.fitness = scores[i].fitness
best.n_fails = scores[i].n_fails
best.fail_lines = scores[i].fail_lines
best.n_evals = ev.n_evals
best.n_oracle_calls = ev.n_oracle_calls
return best
_METHODS = {"cma": cma_search, "compass": compass_search}
def optimise(
root: dom.Node,
programme_dir: str | Path,
x0: np.ndarray | None = None,
budget: int = 200,
method: str = "cma",
urb_root: str | Path = oracle.DEFAULT_URB_ROOT,
**search_kw,
) -> Result:
"""Optimise the free division ratios of ``root`` in place; return the best.
``x0=None`` starts from the topology's current ratios (cold start); pass a
parent's optimised ratios for a Lamarckian warm start. On return ``root``
carries the best ratios found.
"""
with OracleEvaluator(root, programme_dir, urb_root) as ev:
if x0 is None:
x0 = ev.x_current
result = _METHODS[method](ev, x0, budget=budget, **search_kw)
ev.apply(result.x) # leave the tree at the optimum (Lamarckian write-back)
return result

View file

@ -90,7 +90,6 @@ def solve_ratios(
for b in free: for b in free:
b.division = [0.5, 0.5] b.division = [0.5, 0.5]
per = 1 if perpendicular else 2
x0 = np.array( x0 = np.array(
[b.division[0] for b in free] if perpendicular [b.division[0] for b in free] if perpendicular
else [v for b in free for v in b.division], else [v for b in free for v in b.division],

57
tests/test_dom_corpus.py Normal file
View file

@ -0,0 +1,57 @@
"""Corpus-backed tests for dom round-trip and free-branch ownership.
Skipped when the Urb checkout is absent (these need only its .dom files, not
perl).
"""
from pathlib import Path
import pytest
from homemaker import dom, solver
CORPUS = Path("/home/bruno/src/urb/examples/programme-house")
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Urb corpus not available")
def test_roundtrip_idempotent_and_area_preserving(tmp_path):
# dump() does not reproduce the source bytes (different YAML style); the
# real invariants are that a dumped file reloads to the same dump (stable
# fixed point) and that per-leaf geometry survives the trip (§4.1 is the
# area validation against Urb itself).
from homemaker import geometry
for src in sorted(CORPUS.glob("*.dom")):
root = dom.load(str(src))
areas = [geometry.area(leaf) for lvl in dom.levels(root) for leaf in lvl.leaves()]
once = tmp_path / ("once_" + src.name)
dom.dump(root, str(once))
root2 = dom.load(str(once))
areas2 = [geometry.area(leaf) for lvl in dom.levels(root2) for leaf in lvl.leaves()]
assert areas == pytest.approx(areas2, abs=1e-9), src.name
twice = tmp_path / ("twice_" + src.name)
dom.dump(root2, str(twice))
assert twice.read_bytes() == once.read_bytes(), src.name
def test_free_branches_known_dof():
# DOF figures from DESIGN.md §4.5
expected = {
"2f45907abd9accac2a124d311732f749.dom": 7,
"candidate-002.dom": 6,
"c964435454c459f86c3ed9a5a7621132.dom": 6,
}
for name, dof in expected.items():
root = dom.load(str(CORPUS / name))
assert len(solver.free_branches(root)) == dof, name
def test_free_branches_are_lowest_storey_owners():
for src in sorted(CORPUS.glob("*.dom")):
root = dom.load(str(src))
for b in solver.free_branches(root):
assert b.divided
assert b.below is None or not b.below.divided

64
tests/test_innerloop.py Normal file
View file

@ -0,0 +1,64 @@
"""Inner-loop search tests against a fake evaluator (no perl, no oracle)."""
import numpy as np
import pytest
from homemaker import innerloop
from homemaker.oracle import Score
class FakeEvaluator:
"""Duck-typed OracleEvaluator over an analytic objective."""
def __init__(self, fn):
self.fn = fn
self.n_evals = 0
self.n_oracle_calls = 0
def evaluate(self, xs):
self.n_evals += len(xs)
self.n_oracle_calls += 1
return [Score(fitness=self.fn(np.asarray(x)), fails="") for x in xs]
def concave(x):
# maximum 1.0 at 0.3 in every coordinate
return float(1.0 - np.sum((x - 0.3) ** 2))
@pytest.mark.parametrize("search", [innerloop.compass_search, innerloop.cma_search])
def test_search_converges_on_concave(search):
ev = FakeEvaluator(concave)
r = search(ev, np.full(4, 0.7), budget=400)
assert r.fitness > 0.999
assert np.allclose(r.x, 0.3, atol=0.05)
assert r.x0_fitness == pytest.approx(concave(np.full(4, 0.7)))
@pytest.mark.parametrize("search", [innerloop.compass_search, innerloop.cma_search])
def test_search_respects_budget_and_bounds(search):
seen = []
def spy(x):
seen.append(x.copy())
return concave(x)
ev = FakeEvaluator(spy)
r = search(ev, np.full(3, 0.5), budget=60)
# one batch may run slightly over, but never a whole extra cycle
assert r.n_evals == ev.n_evals <= 60 + 3 * 10
assert all((x >= innerloop._EPS - 1e-12).all() and (x <= 1 - innerloop._EPS + 1e-12).all()
for x in seen)
def test_compass_never_returns_worse_than_start():
# a hostile objective: best at the start, everything else worse
x0 = np.full(3, 0.5)
def hostile(x):
return -float(np.sum(np.abs(x - x0)))
ev = FakeEvaluator(hostile)
r = innerloop.compass_search(ev, x0, budget=100)
assert r.fitness == pytest.approx(0.0)
assert np.allclose(r.x, x0)

31
tests/test_oracle.py Normal file
View file

@ -0,0 +1,31 @@
"""Oracle unit tests that do not invoke perl."""
import pytest
from homemaker import oracle
def test_fail_lines_sorted_and_filtered():
s = oracle.Score(fitness=0.5, fails="---\nb fail\n\na fail\n \n")
assert s.fail_lines == ("a fail", "b fail")
assert s.n_fails == 2
def test_fail_lines_empty():
s = oracle.Score(fitness=0.5, fails="")
assert s.fail_lines == ()
assert s.n_fails == 0
def test_score_batch_empty_list():
assert oracle.score_batch([]) == []
def test_score_batch_rejects_cross_directory(tmp_path):
a = tmp_path / "a" / "x.dom"
b = tmp_path / "b" / "y.dom"
for p in (a, b):
p.parent.mkdir()
p.write_text("")
with pytest.raises(ValueError, match="batch spans directories"):
oracle.score_batch([a, b])