"""Spike (homemaker-py-2ax): torch-autodiff ratio optimisation vs nm_search. DESIGN.md 4.5's ``0.5^n`` failure-count cliff and the discrete adjacency/access/ merge machinery around it mean the FULL fitness is not literally differentiable end-to-end (staircase fit truncates to integers, adjacency existence is a door_width threshold on wall overlap, access is a categorical neighbour-type test). So this does not attempt a faithful whole-pipeline port. Instead it builds a genuinely differentiable LOCAL proxy for a frozen topology: * Geometry (``coordinate``/``area``/``edge_length``/``angle``/``aspect``, geometry.py) is ordinary arithmetic on the free cut ratios and is ported to torch exactly. * All *structural* facts that do not vary continuously with the ratios for a frozen topology -- which leaves are adjacent (door_width-thresholded wall overlap), boundary ids, leaf types/params, which fails are structural (missing/adjacency/level/vertical/access/staircase/storey/edge-too-long) -- are snapshotted ONCE from a real ``fitness.py`` evaluation at the start ratios and held frozen. * The 5 per-leaf quality factors that vary continuously with geometry (perpendicular, proportion, size, width, crinkliness) are recomputed from torch geometry every step, and their FAIL_THRESHOLD tests are relaxed to a steep sigmoid so the ``0.5^n`` cliff itself is smoothly differentiable -- this directly probes the risk flagged in the issue. * ``building_factor`` (programme area-ratio Gaussians, staircase volume, storey/public-access checks) is frozen as a single calibration constant -- it is a second-order function of the ratios (areas shift slightly) that was out of scope to port faithfully for a spike. The comparison: for each frozen topology, run ``innerloop.nm_search`` (real fitness, one oracle call per eval) and this torch proxy (Adam over the proxy, re-evaluated against real fitness periodically, with periodic structural resnapshots) for a matched evaluation/gradient-step budget, and report the resulting TRUE fitness and wall-clock time for each. """ from __future__ import annotations import copy import math import sys import time from pathlib import Path import numpy as np import torch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from homemaker_layout import dom as dom_mod # noqa: E402 from homemaker_layout import fitness as fit_mod # noqa: E402 from homemaker_layout import geometry # noqa: E402 from homemaker_layout import graph as graph_mod # noqa: E402 from homemaker_layout import innerloop as il # noqa: E402 from homemaker_layout import solver # noqa: E402 torch.set_default_dtype(torch.float64) _EPS = il._EPS FAIL_THRESHOLD = fit_mod.FAIL_THRESHOLD _E = fit_mod._E _EXTERNAL = geometry._EXTERNAL _CONTINUOUS_SUFFIXES = ("perpendicular", "proportion", "size", "width", "crinkliness") _STEEPNESS = 60.0 # sigmoid steepness for the soft fail-threshold relaxation # --------------------------------------------------------------------------- # # torch mirrors of geometry.py -- same recursion, tensors instead of floats. # --------------------------------------------------------------------------- # def _interp_t(a, b, t): return a * (1 - t) + b * t def _dist_t(a, b): return torch.sqrt(torch.clamp((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2, min=1e-24)) def _triangle_area_t(a, b, c): da, db, dc = _dist_t(b, c), _dist_t(a, c), _dist_t(a, b) s = (da + db + dc) / 2 return torch.sqrt(torch.clamp(s * (s - da) * (s - db) * (s - dc), min=0.0)) class TorchGeom: """Differentiable ``coordinate``/``coord_a``/``coord_b`` recursion. ``xmap`` holds one torch scalar per FREE branch (``innerloop.free_with_keys`` order); every other divided node's division is never read by the real recursion either (it delegates to ``below``), so nothing else is needed. """ def __init__(self, xmap: dict[int, torch.Tensor]): self.xmap = xmap self._cache: dict = {} def coordinate(self, n, idx: int): key = (id(n), idx) hit = self._cache.get(key) if hit is not None: return hit if n.below is not None: result = self.coordinate(n.below, idx) else: rid = (idx + n.rotation) % 4 if n.parent is None: pt = n.node[rid] result = torch.tensor([float(pt[0]), float(pt[1])]) else: p = n.parent if n.position == "l": result = {0: self.coordinate(p, 0), 1: self.coord_a(p), 2: self.coord_b(p), 3: self.coordinate(p, 3)}[rid] else: result = {0: self.coord_a(p), 1: self.coordinate(p, 1), 2: self.coordinate(p, 2), 3: self.coord_b(p)}[rid] self._cache[key] = result return result def coord_a(self, n): key = (id(n), "a") hit = self._cache.get(key) if hit is not None: return hit if n.below is not None and n.below.divided: result = self.coord_a(n.below) else: t = self.xmap[id(n)] result = _interp_t(self.coordinate(n, 0), self.coordinate(n, 1), t) self._cache[key] = result return result def coord_b(self, n): key = (id(n), "b") hit = self._cache.get(key) if hit is not None: return hit if n.below is not None and n.below.divided: result = self.coord_b(n.below) else: t = self.xmap[id(n)] result = _interp_t(self.coordinate(n, 3), self.coordinate(n, 2), t) self._cache[key] = result return result def area(self, n): c = [self.coordinate(n, i) for i in range(4)] return _triangle_area_t(c[0], c[1], c[2]) + _triangle_area_t(c[0], c[2], c[3]) def edge_length(self, n, idx: int): return _dist_t(self.coordinate(n, idx), self.coordinate(n, (idx + 1) % 4)) def angle(self, n, idx: int): a = self.edge_length(n, idx) b = self.edge_length(n, (idx + 3) % 4) c = _dist_t(self.coordinate(n, (idx + 1) % 4), self.coordinate(n, (idx + 3) % 4)) cos_t = torch.clamp((a * a + b * b - c * c) / (2 * a * b), -1.0 + 1e-9, 1.0 - 1e-9) return torch.acos(cos_t) def aspect(self, n): asp = (self.edge_length(n, 0) + self.edge_length(n, 2)) / ( self.edge_length(n, 1) + self.edge_length(n, 3)) return torch.where(asp < 1, 1.0 / asp, asp) def length_narrowest(self, n): return torch.min(torch.stack([self.edge_length(n, i) for i in range(4)])) def gaussian_t(x, a, b, c): return a * torch.exp(-((x - b) ** 2) / (2 * c * c)) def clipped_gaussian_t(x, target: float, sigma: float, good_side: str): gauss = gaussian_t(x, 1.0, target, sigma) flat = x > target if good_side == "above" else x < target return torch.where(flat, torch.ones_like(gauss), gauss) def soft_fail(value: torch.Tensor) -> torch.Tensor: """Smooth relaxation of ``value < FAIL_THRESHOLD``: ~1 well below the threshold, ~0 well above, matching sign/shape but differentiable everywhere -- this is the part of the spike that stands in for the real 0.5^n cliff.""" return torch.sigmoid(_STEEPNESS * (FAIL_THRESHOLD - value)) # --------------------------------------------------------------------------- # # Frozen-structure snapshot + differentiable proxy objective # --------------------------------------------------------------------------- # class TorchTopology: def __init__(self, root, programme_dir, conf_overrides: dict | None = None): self.programme_dir = programme_dir conf, cost = fit_mod.load_config(programme_dir, overrides=conf_overrides) self.fit = fit_mod.Fitness(conf, cost) self.root = root self.free = solver.free_branches(root) self._snapshot() # -- structural snapshot (all plain python/numpy, no torch) -- # def _snapshot(self) -> None: geometry.clear_cache() root = self.root self.fit.preprocess_building(root) door_width = self.fit.conf("door_width") or 1.2 levels = dom_mod.levels(root) self.levels = levels graph_base = graph_mod.build_graphs(root, door_width) self.graph_base = graph_base # true full evaluation (fresh deep copy) -- ground truth at this x, and # the source of the frozen structural fail count. true_score, true_fails = self.fit.score_with_fails(copy.deepcopy(root)) self.x0_true_score = true_score self.x0_true_fails = true_fails n_continuous = sum( 1 for f in true_fails if f.rsplit(" ", 1)[-1] in _CONTINUOUS_SUFFIXES ) self.n_fails_frozen = len(true_fails) - n_continuous self.plot_cost_frozen = self.fit.plot_cost(levels[0] if levels[0].below is None else _lowest(levels[0])) self.leaf_facts: list[dict] = [] # one entry per usable leaf self.leaf_cost_facts: list[dict] = [] # one entry per ALL leaves (usable or not) self.edge_facts: list[dict] = [] # frozen adjacency edges (all levels) self.outside_edge_facts: list[dict] = [] # frozen per-leaf external edges for li, lvl in enumerate(levels): groups = geometry.boundary_groups(lvl) G = graph_base[li] leaves = lvl.leaves() for leaf in leaves: self.leaf_cost_facts.append(self._leaf_cost_fact(leaf)) if not dom_mod.is_usable(leaf): continue self.leaf_facts.append(self._leaf_quality_facts(leaf, li, G, groups)) self.outside_edge_facts.append(self._outside_edge_fact(leaf)) for a, b in G.edges(): edge_a, edge_b = _find_edge_indices(groups, a, b) if edge_a is None: continue # defensive; should not happen for a real G edge a_out, b_out = dom_mod.is_outside(a), dom_mod.is_outside(b) if a_out and b_out: rate = 0.0 else: rate = (self.fit.cost("interior_wall") if not a_out and not b_out else self.fit.cost("exterior_wall")) self.edge_facts.append(dict( a=a, edge_a=edge_a, b=b, edge_b=edge_b, rate=rate, height=fit_mod._height(a), )) # building_factor calibration: fold the un-ported (mostly x-insensitive # for a frozen topology) building-level factor into one constant so the # proxy's absolute scale is comparable to the real score. proxy0 = self._raw_value_cost(self.free) raw_score0 = (proxy0["value"] / proxy0["cost"]) if proxy0["cost"] else 0.0 self.calibration = (true_score / raw_score0) if raw_score0 else 1.0 def _leaf_cost_fact(self, leaf) -> dict: if dom_mod.is_outside(leaf): covered, supported = dom_mod.is_covered(leaf), dom_mod.is_supported(leaf) if covered and supported: rate = self.fit.cost("outside_covered_supported") elif covered: rate = self.fit.cost("outside_covered") elif supported: rate = self.fit.cost("outside_supported") else: rate = self.fit.cost("outside") else: rate = self.fit.cost("inside") return dict(leaf=leaf, rate=rate) def _outside_edge_fact(self, leaf) -> dict: rate = self.fit.cost("boundary") if dom_mod.is_outside(leaf) else self.fit.cost("boundary_wall") edges = [e for e in range(4) if geometry.boundary_id(leaf, e) in _EXTERNAL] return dict(leaf=leaf, rate=rate, edges=edges) def _leaf_quality_facts(self, leaf, level_id: int, G, groups) -> dict: t0 = fit_mod._t0(leaf) is_out = dom_mod.is_outside(leaf) # perpendicular: sigma scalar, target 1.570796, product over 4 corners perp_sigma = self.fit.conf("perpendicular_outside" if is_out else "perpendicular_inside") # proportion if t0 in ("o", "s"): prop_params = self.fit.conf("proportion_outside") elif t0 == "c": prop_params = self.fit.conf("proportion_circulation") else: prop_params = self.fit.get_space_params(leaf.type, "proportion") # size: 'constant_one' for outside/sahn; symmetric gaussian otherwise size_const = t0 in ("o", "s") if not size_const: size_params = (self.fit.conf("size_circulation") if t0 == "c" else self.fit.get_space_params(leaf.type, "size")) else: size_params = None # width: 'constant_one' special case, else clipped-above gaussian width_const = ( t0 in ("o", "s") and not dom_mod.is_covered(leaf) and not dom_mod.is_supported(leaf) and dom_mod.level_of(leaf) ) if not width_const: if t0 in ("o", "s"): width_params = self.fit.conf("width_outside") elif t0 == "c": width_params = self.fit.conf("width_circulation") else: width_params = self.fit.get_space_params(leaf.type, "width") else: width_params = None # crinkliness / uncrinkliness crink_const = is_out and not dom_mod.is_covered(leaf) if not crink_const: key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness" crink_params = self.fit.conf(key) # frozen neighbour/edge-index facts for area_outside nb_edges = [] for nb in G.neighbors(leaf): if not dom_mod.is_outside(nb) or dom_mod.is_covered(nb): continue if geometry.boundary_pair_overlap(groups_contributors(groups), leaf, nb) <= 0: pass ea, eb = _find_edge_indices(groups, leaf, nb) if ea is not None: nb_edges.append((nb, ea, eb)) own_edges = [] perimeter = fit_mod._perimeter(leaf) for e in range(4): bid = geometry.boundary_id(leaf, e) if bid not in _EXTERNAL: continue ptype = (perimeter.get(bid) or "").lower() if ptype in ("private", "fortified"): continue own_edges.append(e) else: crink_params = None nb_edges = [] own_edges = [] # access: frozen boolean (adjacency SET + types are both frozen) access_ok = len(self.fit.access(leaf, G)) > 0 or ( not dom_mod.level_of(leaf) and is_out ) access_factor = 1.0 if access_ok else 0.01 rate = self.fit.value_rate(leaf) height = fit_mod._height(leaf) return dict( leaf=leaf, level=level_id, is_out=is_out, perp_sigma=perp_sigma, prop_target=prop_params[0], prop_sigma=prop_params[1], size_const=size_const, size_target=(size_params[0] if size_params else None), size_sigma=(size_params[1] if size_params else None), width_const=width_const, width_target=(width_params[0] if width_params else None), width_sigma=(width_params[1] if width_params else None), crink_const=crink_const, crink_target=(crink_params[0] if crink_params else None), crink_sigma=(crink_params[1] if crink_params else None), nb_edges=nb_edges, own_edges=own_edges, height=height, access_factor=access_factor, rate=rate, ) # -- differentiable proxy -- # def _raw_value_cost(self, free_nodes, x: torch.Tensor | None = None) -> dict: """value/cost BEFORE calibration, at ``x`` (defaults to the frozen ratios currently stored on the tree, i.e. x0).""" if x is None: x = torch.tensor([float(b.division[0]) for b in free_nodes]) xmap = {id(b): x[j] for j, b in enumerate(free_nodes)} geo = TorchGeom(xmap) value = torch.zeros(()) soft_n_fails = torch.zeros(()) for lf in self.leaf_facts: leaf = lf["leaf"] area = geo.area(leaf) perp = torch.ones(()) for i in range(4): perp = perp * gaussian_t(geo.angle(leaf, i), 1.0, 1.570796, lf["perp_sigma"]) soft_n_fails = soft_n_fails + soft_fail(perp) aspect = geo.aspect(leaf) prop = clipped_gaussian_t(aspect, lf["prop_target"], lf["prop_sigma"], "below") soft_n_fails = soft_n_fails + soft_fail(prop) if lf["size_const"]: size = torch.ones(()) else: size = gaussian_t(area, 1.0, lf["size_target"], lf["size_sigma"]) soft_n_fails = soft_n_fails + soft_fail(size) if lf["width_const"]: width = torch.ones(()) else: w = geo.length_narrowest(leaf) width = clipped_gaussian_t(w, lf["width_target"], lf["width_sigma"], "above") soft_n_fails = soft_n_fails + soft_fail(width) if lf["crink_const"]: crink_q = torch.ones(()) else: outside_len = torch.zeros(()) for nb, ea, eb in lf["nb_edges"]: outside_len = outside_len + _edge_overlap_t(geo, lf["leaf"], ea, nb, eb) for e in lf["own_edges"]: outside_len = outside_len + geo.edge_length(lf["leaf"], e) area_outside = outside_len * lf["height"] crink = area_outside / torch.clamp(area, min=1e-9) inv_crink = torch.where(crink > 1e-9, 1.0 / torch.clamp(crink, min=1e-9), torch.zeros(())) crink_q = torch.where( crink > 1e-9, gaussian_t(inv_crink, 1.0, lf["crink_target"], lf["crink_sigma"]), torch.zeros(()), ) soft_n_fails = soft_n_fails + soft_fail(crink_q) quality = perp * prop * size * width * crink_q * lf["access_factor"] value = value + quality * lf["rate"] * area cost = torch.tensor(float(self.plot_cost_frozen)) for cf in self.leaf_cost_facts: cost = cost + cf["rate"] * geo.area(cf["leaf"]) for ef in self.edge_facts: if ef["rate"] == 0.0: continue width = _edge_overlap_t(geo, ef["a"], ef["edge_a"], ef["b"], ef["edge_b"]) cost = cost + ef["rate"] * width * ef["height"] for of in self.outside_edge_facts: length = torch.zeros(()) for e in of["edges"]: length = length + geo.edge_length(of["leaf"], e) cost = cost + of["rate"] * length * fit_mod._height(of["leaf"]) penalty = torch.exp((self.n_fails_frozen + soft_n_fails) * math.log(0.5)) return dict(value=value * penalty, cost=cost, soft_n_fails=soft_n_fails) def proxy_score(self, x: torch.Tensor) -> torch.Tensor: rc = self._raw_value_cost(self.free, x) score = rc["value"] / torch.clamp(rc["cost"], min=1e-9) return score * self.calibration def true_score(self, x: np.ndarray) -> tuple[float, int]: for j, b in enumerate(self.free): b.division = [float(x[j]), float(x[j])] s, fails = self.fit.score_with_fails(copy.deepcopy(self.root)) return s, len(fails) def groups_contributors(groups): out = [] for v in groups.values(): out.extend(v) return out def _find_edge_indices(groups, a, b): best = None best_w = -1.0 for contributors in groups.values(): ea = eb = None for leaf, edge in contributors: if leaf is a: ea = edge if leaf is b: eb = edge if ea is not None and eb is not None: w = geometry._edge_overlap(a, ea, b, eb) if w > best_w: best, best_w = (ea, eb), w return best if best is not None else (None, None) def _edge_overlap_t(geo: TorchGeom, a, edge_a: int, b, edge_b: int): p_a0, p_a1 = geo.coordinate(a, edge_a), geo.coordinate(a, (edge_a + 1) % 4) p_b0, p_b1 = geo.coordinate(b, edge_b), geo.coordinate(b, (edge_b + 1) % 4) len_a, len_b = _dist_t(p_a0, p_a1), _dist_t(p_b0, p_b1) dists = torch.stack([_dist_t(p_a0, p_b0), _dist_t(p_a0, p_b1), _dist_t(p_a1, p_b0), _dist_t(p_a1, p_b1)]) max_dist = torch.max(dists) return torch.where( max_dist <= len_b, len_a, torch.where(max_dist <= len_a, len_b, torch.clamp(len_a + len_b - max_dist, min=0.0)), ) def _lowest(n): while n.below is not None: n = n.below return n # --------------------------------------------------------------------------- # # Optimiser drivers # --------------------------------------------------------------------------- # def run_nm(root, programme_dir, x0: np.ndarray, budget: int) -> dict: root_c = copy.deepcopy(root) for j, b in enumerate(solver.free_branches(root_c)): b.division = [float(x0[j]), float(x0[j])] t0 = time.perf_counter() result = il.optimise(root_c, programme_dir, x0=x0.copy(), budget=budget, method="nm") dt = time.perf_counter() - t0 return dict(fitness=result.fitness, n_fails=result.n_fails, evals=result.n_evals, seconds=dt) def run_torch(root, programme_dir, x0: np.ndarray, steps: int, resnapshot_every: int, lr: float = 0.05) -> dict: root_c = copy.deepcopy(root) for j, b in enumerate(solver.free_branches(root_c)): b.division = [float(x0[j]), float(x0[j])] t0 = time.perf_counter() topo = TorchTopology(root_c, programme_dir) x_raw = torch.tensor(x0.copy(), requires_grad=True) opt = torch.optim.Adam([x_raw], lr=lr, maximize=True) best_true, best_fails = topo.x0_true_score, len(topo.x0_true_fails) best_x = x0.copy() n_evals = 1 # the initial snapshot's true eval for step in range(steps): opt.zero_grad() score = topo.proxy_score(x_raw) (-score).backward() opt.step() with torch.no_grad(): x_raw.clamp_(_EPS, 1 - _EPS) if resnapshot_every and (step + 1) % resnapshot_every == 0: x_np = x_raw.detach().numpy() true_s, n_fails = topo.true_score(x_np) n_evals += 1 if true_s > best_true: best_true, best_fails, best_x = true_s, n_fails, x_np.copy() topo = TorchTopology(root_c, programme_dir) # re-snapshot at current x x_raw = torch.tensor(x_np, requires_grad=True) opt = torch.optim.Adam([x_raw], lr=lr, maximize=True) x_np = x_raw.detach().numpy() true_s, n_fails = topo.true_score(x_np) n_evals += 1 if true_s > best_true: best_true, best_fails, best_x = true_s, n_fails, x_np.copy() dt = time.perf_counter() - t0 return dict(fitness=best_true, n_fails=best_fails, evals=n_evals, seconds=dt) def main() -> None: cases = [ ("programme-house/candidate-002.dom", "examples/programme-house", 200), ("programme-house/cf0b8a77e8b2325f92a7e7d150184a55.dom", "examples/programme-house", 200), ("harbor-house/3m.dom", "examples/harbor-house", 400), ] repo = Path(__file__).resolve().parents[1] for rel, prog_dir, budget in cases: path = repo / "examples" / rel root = dom_mod.load(str(path)) free = solver.free_branches(root) x0 = np.array([(b.division[0] + b.division[1]) / 2 for b in free], dtype=float) print(f"\n=== {rel} (DOF={len(x0)}, budget={budget}) ===") nm = run_nm(root, str(repo / prog_dir), x0, budget) print(f"nm_search fitness={nm['fitness']:.6g} fails={nm['n_fails']} " f"evals={nm['evals']} time={nm['seconds']:.2f}s") for resnap in (max(budget // 10, 5),): td = run_torch(root, str(repo / prog_dir), x0, steps=budget, resnapshot_every=resnap) print(f"torch(Adam) fitness={td['fitness']:.6g} fails={td['n_fails']} " f"evals={td['evals']} steps={budget} resnap={resnap} time={td['seconds']:.2f}s") if __name__ == "__main__": main()