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:
parent
d4266f46dc
commit
0dcdf1f29f
10 changed files with 636 additions and 2 deletions
|
|
@ -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 (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-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}
|
{"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)."}
|
||||||
|
|
|
||||||
77
experiments/accept_innerloop.py
Normal file
77
experiments/accept_innerloop.py
Normal 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())
|
||||||
60
experiments/diag_2f45907.py
Normal file
60
experiments/diag_2f45907.py
Normal 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()
|
||||||
71
experiments/rebaseline_no_occlusion.py
Normal file
71
experiments/rebaseline_no_occlusion.py
Normal 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())
|
||||||
|
|
@ -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
271
src/homemaker/innerloop.py
Normal 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
|
||||||
|
|
@ -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
57
tests/test_dom_corpus.py
Normal 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
64
tests/test_innerloop.py
Normal 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
31
tests/test_oracle.py
Normal 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])
|
||||||
Loading…
Add table
Reference in a new issue