diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5dd31f9..8ee8b24 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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-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":"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":"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)."} diff --git a/experiments/accept_innerloop.py b/experiments/accept_innerloop.py new file mode 100644 index 0000000..7c10512 --- /dev/null +++ b/experiments/accept_innerloop.py @@ -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()) diff --git a/experiments/diag_2f45907.py b/experiments/diag_2f45907.py new file mode 100644 index 0000000..5287f6e --- /dev/null +++ b/experiments/diag_2f45907.py @@ -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() diff --git a/experiments/rebaseline_no_occlusion.py b/experiments/rebaseline_no_occlusion.py new file mode 100644 index 0000000..c7fea39 --- /dev/null +++ b/experiments/rebaseline_no_occlusion.py @@ -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()) diff --git a/pyproject.toml b/pyproject.toml index 873fb59..8699135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "scipy>=1.11", "shapely>=2.0", "networkx>=3.0", + "cma>=3.0", ] [project.optional-dependencies] @@ -21,5 +22,8 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"] +[tool.pytest.ini_options] +pythonpath = ["src"] + [tool.ruff] line-length = 100 diff --git a/src/homemaker/innerloop.py b/src/homemaker/innerloop.py new file mode 100644 index 0000000..97b8374 --- /dev/null +++ b/src/homemaker/innerloop.py @@ -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 diff --git a/src/homemaker/solver.py b/src/homemaker/solver.py index ad9da61..cc4a2bb 100644 --- a/src/homemaker/solver.py +++ b/src/homemaker/solver.py @@ -90,7 +90,6 @@ def solve_ratios( for b in free: b.division = [0.5, 0.5] - per = 1 if perpendicular else 2 x0 = np.array( [b.division[0] for b in free] if perpendicular else [v for b in free for v in b.division], diff --git a/tests/test_dom_corpus.py b/tests/test_dom_corpus.py new file mode 100644 index 0000000..f19c43a --- /dev/null +++ b/tests/test_dom_corpus.py @@ -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 diff --git a/tests/test_innerloop.py b/tests/test_innerloop.py new file mode 100644 index 0000000..01ab36b --- /dev/null +++ b/tests/test_innerloop.py @@ -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) diff --git a/tests/test_oracle.py b/tests/test_oracle.py new file mode 100644 index 0000000..2d6d11a --- /dev/null +++ b/tests/test_oracle.py @@ -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])