diff --git a/DESIGN.md b/DESIGN.md index 2caf92f..4188941 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4021,28 +4021,76 @@ formulas by construction, not reimplemented magic numbers: same `conf`/ starting with 'c' or 's'/'o' hits the circulation/outside branch, not its own programme params" quirk — confirmed this is existing product behaviour, not a bug, by reading `get_space_params`/`quality_size` together). Regions compose -bottom-up through the slicing tree: a node's cut is either a "width-split" -(children share height, widths sum) or "height-split" (heights sum), measured -once from the actual baseline geometry (`_orientation`) rather than derived -symbolically from `rotation` — robust to any rotation convention. Composition -is done on a shared log-spaced grid (interval-sum + a numpy-vectorised -inversion, `_invert`); leaf curves themselves are exact closed forms, so all -discretisation error is confined to internal-node composition. A top-down -`realise()` back-substitution converts a feasible root point into actual -`division` ratios, so the DP's output is a real, scoreable `.dom` tree, not -just a yes/no. +bottom-up through the slicing tree: a node's cut ALWAYS sums its two +children's contributions into the node's own "w" (`edge0+edge2`) dimension, +with "h" (`edge1+edge3`) the shared/cross dimension — a fixed convention of +`geometry.py`'s division formula (`coord_a`/`coord_b` always interpolate +between edge(0,1) and edge(3,2)), not a per-node choice. The only variable is +which of a CHILD's own (w, h) plays which role relative to its parent, an +EXACT function of that child's `rotation` parity (`_child_contrib` — see the +correction below). Composition runs on a shared log-spaced grid (interval-sum ++ a numpy-vectorised inversion, `_invert`); leaf curves themselves are exact +closed forms, so all discretisation error is confined to internal-node +composition. A top-down `realise()` back-substitution converts a feasible +root point into actual `division` ratios, so the DP's output is a real, +scoreable `.dom` tree, not just a yes/no. **Explicit scope (per the plan's own caveats).** Only size/width/proportion is modelled — crinkliness/adjacency/access/level connectivity are graph -terms, out of scope by design. Every quad is approximated by its axis-aligned -bounding box (exact only for a true rectangle). `leaf_sharing`/`co_type` -target-adjustment is not modelled (harbor-house-l0's programme doesn't -exercise either). +terms, out of scope by design. Every quad is approximated by a rectangle with +edge-length-derived (w, h) — exact only for a true rectangle/parallelogram +(see the rotation-invariance correction below for why this is edge lengths, +not a bounding box). `leaf_sharing`/`co_type` target-adjustment is not +modelled (harbor-house-l0's programme doesn't exercise either). + +**Correction 1 (caught in review): bounding-box (w, h) is not rotation-invariant.** +The first version measured each quad's (w, h) from its axis-aligned bounding +box in global x/y — silently correct only because harbor-house-l0's plot +happens to be near-parallel to its own x/y axes (~7.5% bbox-area error, see +below). Flagged in review: Urb's Perl ancestor (`Urb::Quad::Straighten`/ +`Straighten_Root`) explicitly keeps internal walls mutually orthogonal but +NEVER assumes them axis-aligned — `Straighten()` aligns a division parallel/ +perpendicular to its PARENT's own division line, not to global x/y, so a +real building's walls can legitimately run at any angle (45° tried explicitly +below) to the survey/CRS axes the plot's `node:` corners are recorded in. +Confirmed by rotating harbor-house-l0's plot 45° about its centroid: bbox +area error jumped from 7.5% to **102%** (a rotated square's bbox is up to 2x +its true area). Fix: `_dims` measures (w, h) from `(edge0+edge2)/2` and +`(edge1+edge3)/2` — the same pairing `geometry.aspect()` already uses — +which depends only on the quad's own edge lengths, never on global +coordinates. This port's equal-offset division convention already gives the +local-orthogonality property Urb's `Straighten()` provides explicitly (no +such pass exists or is needed in `operators.py`), so this is a safe +substitution, not a new modelling assumption. + +**Correction 2 (caught in review, and this one REGRESSED accuracy before +being fixed properly): which dimension sums is not a matter of degree.** +Switching to edge-length (w, h) alone was not sufficient — a first attempt +kept the "measure orientation empirically, per node" structure from the bbox +version (comparing children's summed dims against the parent's under two +hypotheses, picking whichever fit better) and this DROPPED agreement on the +untouched harbor-house-l0 benchmark from 99.0% to **95.5%**, with a false +negative appearing for the first time (previously zero). Root cause: +`geometry.coordinate()` applies a node's OWN `rotation` field even when +reading corners it inherited from its parent — a node with odd rotation has +its local edge0/edge2 pair correspond to its PARENT's edge1/edge3 pair +instead (rotation parity selects between a quad's two possible opposite-edge +pairings; `operators.mutate_divide` randomises this on every newly-divided +node, so it's common, not an edge case). This is not something to measure and +approximate — it's an exact algebraic identity: verified numerically +(float-exact, `29.533730484465025 == 29.533730484465025`) that +`left.w + right.h == parent.w` whenever `left.rotation` is even and +`right.rotation` is odd, independent of skew or global orientation. +`_child_contrib(curve, rotation)` applies this directly (`curve.w_of_h` for +even rotation, `curve.h_of_w` for odd) — no geometry measurement, no +baseline-ratio pass, no heuristic threshold, and the empirical `_orientation`/ +`annotate_orientations` machinery from both prior versions was deleted +entirely (simpler code, not just more correct). **Validation** (`experiments/validate_shapecurve.py`, harbor-house-l0, 200 `driver.random_topology` topologies, 2-14 leaves, seed 12345): compared against NM search **minimising shape-fail count directly** (`ShapeFailEvaluator`, -budget 100), not `innerloop.optimise`'s full aggregate objective — the first +budget 100), not `innerloop.optimise`'s full aggregate objective — an earlier version of this harness used the full objective and found spurious "disagreements" where the DP's own realised point independently verified at **zero** shape fails but NM's full-objective search had wandered away from it, @@ -4051,43 +4099,52 @@ penalty swamps the objective and NM has no pressure to preserve shape-feasibility specifically. Minimising shape-fail count alone is the correct apples-to-apples comparison against what the DP claims to solve. -| metric | result | target | +| metric | harbor-house-l0 (unrotated) | harbor-house-l0 rotated 45° | |---|---|---| -| agreement | 198/200 = **99.0%** | >= 95% | -| false positives (DP feasible, NM can't reach 0) | 2 | — | -| false negatives (DP infeasible, NM reaches 0 anyway) | **0** | — | -| speedup (grid_n=150, vs 100-eval NM) | **93.6x** | >= 50x | -| speedup (grid_n=300) | 42.7x | — | -| plot-level bbox area error | measured **+7.5%** overestimate | quantified | +| agreement | 198/200 = **99.0%** (target >= 95%) | 100/100 = **100.0%** | +| false positives (DP feasible, NM can't reach 0) | 2 | 0 | +| false negatives (DP infeasible, NM reaches 0 anyway) | **0** | 0 | +| speedup (grid_n=150, vs 100-eval NM) | **97.2x** (target >= 50x) | 97.1x | +| plot-level (w,h)-approximation area error | **+7.5%** (bbox, pre-fix) / ~0.1% (edge-length, post-fix) | 102% (bbox, pre-fix) / ~0.1% (edge-length, post-fix) | -Zero false negatives across 200 topologies: the DP never wrongly rejects a -topology NM finds feasible — the safe direction for a pre-filter (worst case -it fails to prune, never wrongly prunes a viable topology). grid_n=150 vs 300 -gave **identical** agreement (99.0%, the same 2 mismatches) at 2.2x the -speedup — internal-node grid resolution has headroom below 300 with no -measured accuracy cost on this benchmark; `_invert`'s pure-Python O(N²) -double loop was ~70% of DP wall-clock before vectorising with numpy -(profiled: 170ms → 40ms/topology at grid_n=300 from that change alone). +The unrotated-plot numbers are BACK to matching the original (pre-Correction-2) +99.0%/0-false-negative result exactly — same 2 mismatches, same seeds +(`623465425`/`1523713848`) — confirming Correction 2 fixed the regression it +introduced without disturbing the genuine, separately-diagnosed residual +error below. The 45°-rotated run (`python experiments/validate_shapecurve.py +100 100 150 45` -- same protocol, `n=100` for wall-clock, the plot's `node:` +corners rotated 45° about their centroid into a scratch copy via +`rotated_plot_dir`) is the direct, reproducible test of the concern that +motivated Correction 1: 100% agreement, confirming the fix generalises and +isn't overfit to harbor-house-l0's near-axis-aligned plot. Zero false +negatives in both: the DP never wrongly rejects a topology NM finds feasible +— the safe direction for a pre-filter (worst case it fails to prune, never +wrongly prunes a viable topology). `_invert`'s pure-Python O(N²) double loop +was ~70% of DP wall-clock before vectorising with numpy (profiled: 170ms → +40ms/topology at grid_n=300 from that change alone; grid_n=150 is the +shipped default, no measured accuracy cost vs. 300 on this benchmark). -**Approximation error, root-caused.** Both false positives were traced to the -bounding-box approximation, not a DP logic bug: the DP's own realised point -for both cases had one leaf whose bbox-approximated area (e.g. 29.54 m², -comfortably inside `[27.12, 52.88]`) was a **real skewed quad** whose true -`geometry.area` (26.90 m²) fell just *below* the true lower bound — a bbox -overestimate of the same ~8-12% magnitude as the plot-level +7.5% figure -above (harbor-house-l0's plot is a near-rectangular trapezoid, not a true -rectangle). Every mismatch occurred within one bbox-error-width of a boundary -— exactly the failure mode the plan's caveat predicted ("equal-offset -skew-quad geometry means DP areas are approximate — measure the approximation -error on real plots first"). +**Remaining approximation error, root-caused (unchanged by Corrections 1/2 — +a different, smaller error source).** Both unrotated false positives trace to +the rectangle-vs-true-skewed-quad approximation itself (§37.2's plan-flagged +"equal-offset skew-quad geometry" caveat), not to global rotation or to +composition: the DP's own realised point for both cases had one leaf whose +edge-length-approximated area was comfortably inside its feasible bound, but +whose true `geometry.area` (a real, slightly non-parallelogram quad) fell +just below the true lower bound — an ~8-12% approximation gap, the same +magnitude as harbor-house-l0's own plot-level residual skew. This is a +strictly smaller, already-anticipated error source, distinct from the two +corrections above (which were about measuring w/h and composing them +correctly, not about the rectangle-vs-skew-quad approximation itself). **ACCEPTANCE: PASS** — all three criteria cleared (agreement, speedup, -quantified approximation error). **Not done in this session** (follow-on, -new bead needed before this can replace `operators.predicted_shape_fails` in -`driver.py`'s real pre-filter path): wiring the DP into `driver._evaluate`/ -`innerloop.optimise` as an actual pre-filter + NM warm-start, multi-storey -(`below`-link) support, `leaf_sharing`/`co_type` modelling, and a true -skew-quad (non-bbox) leaf region to remove the measured approximation-error -source rather than just quantify it. `experiments/shapecurve_spike.py` is -kept as a reference/prototype (the §34 `autodiff_spike.py` precedent), not -wired into `innerloop.py`. +quantified approximation error), on both the original and the rotated plot. +**Not done in this session** (follow-on, new bead needed before this can +replace `operators.predicted_shape_fails` in `driver.py`'s real pre-filter +path): wiring the DP into `driver._evaluate`/`innerloop.optimise` as an +actual pre-filter + NM warm-start, multi-storey (`below`-link) support, +`leaf_sharing`/`co_type` modelling, and a true skew-quad (non-rectangle) leaf +region to remove the remaining ~8-12% approximation-error source rather than +just quantify it. `experiments/shapecurve_spike.py` is kept as a reference/ +prototype (the §34 `autodiff_spike.py` precedent), not wired into +`innerloop.py`. diff --git a/experiments/shapecurve_spike.py b/experiments/shapecurve_spike.py index e87caf5..5066333 100644 --- a/experiments/shapecurve_spike.py +++ b/experiments/shapecurve_spike.py @@ -9,22 +9,33 @@ height) region is bounded by an area hyperbola (``quality_size``), a min-width line (``quality_width``), and an aspect-ratio wedge (``quality_proportion``) -- all three are FAIL_THRESHOLD-inversions of the Gaussian/clipped-Gaussian factors in ``fitness.py`` (see ``leaf_constraints`` below). These per-leaf -regions compose bottom-up through the slicing tree: a "width-split" node -(children share height, widths sum) or "height-split" node (children share -width, heights sum) -- see ``_orientation``. +regions compose bottom-up through the slicing tree: a node's cut ALWAYS sums +its two children's contributions into the node's own "w" (edge0+edge2) +dimension, with "h" (edge1+edge3) the shared/cross dimension -- a fixed +convention of ``geometry.py``'s division formula, no per-node ambiguity. +The only variable is which of a CHILD's own (w, h) plays which role, an +EXACT function of that child's ``rotation`` parity -- see ``_child_contrib``. Approximations made explicit (the plan's caveats, DESIGN.md §37 point 2): - * Every quad (leaf or internal) is approximated by its axis-aligned - bounding-box (w, h) -- exact only for a true rectangle; harbor-house-l0's - plot is a near-rectangular trapezoid (DESIGN.md says "harbor plot is a - near-rect quad"), so this is the intended first target, not a general - solution for skew quads. - * A node's cut orientation (does it split width or height?) is measured - once from the ACTUAL geometry at ratio=0.5 baseline, not derived from - ``rotation`` symbolically -- robust to any rotation convention, but a - property of the *frozen topology*, computed once, not re-derived by the - DP itself. + * Every quad (leaf or internal) is approximated by a rectangle with the + same edge-length-derived (w, h) as ``geometry.aspect`` uses -- + ``(edge0+edge2)/2`` and ``(edge1+edge3)/2`` -- exact only for a true + rectangle/parallelogram; harbor-house-l0's plot is a near-rectangular + trapezoid (DESIGN.md says "harbor plot is a near-rect quad"), so this is + the intended first target, not a general solution for skew quads. This + is deliberately NOT the quad's axis-aligned bounding box in global x/y -- + an earlier version used that and was wrong for any quad whose (locally + orthogonal, per Urb's Straighten lineage -- see ``_dims``) walls aren't + near-parallel to the plot's global x/y axes; edge lengths are + rotation-invariant by construction. + * Composition itself (which of a child's local w/h sums into its parent's + w) is NOT approximated or measured -- an earlier version measured it + empirically per node (comparing children's summed dims against the + parent's) and that REGRESSED accuracy (99.0% -> 95.5% on the 200- + topology validation, with a spurious false negative). It's an exact + algebraic identity determined purely by ``child.rotation % 2`` -- see + ``_child_contrib`` and the Stage 2 note above ``_dims``. * Leaf curves are EXACT closed forms (hyperbola/line/wedge intersection -- no discretisation error). Internal-node composition is done on a shared discretised grid (log-spaced) with linear interpolation -- this is where @@ -163,55 +174,51 @@ def leaf_constraints(fit, leaf: dom_mod.Node) -> LeafBounds: # --------------------------------------------------------------------------- # -# Bounding-box geometry + cut-orientation detection (rectangular approximation) +# Local-edge-length dimensions + EXACT rotation-parity composition. +# +# NB (fixed after initial review, in two stages): +# +# Stage 1: the first version measured (w, h) from each quad's axis-aligned +# bounding box in GLOBAL x/y -- correct only when the plot/walls happen to be +# near-parallel to the global axes (true for harbor-house-l0's near- +# rectangular trapezoid, ~7.5% bbox-area error there, but WRONG in general: a +# perfectly rectangular room whose walls run at 45 deg to the survey/CRS axes +# gets a bbox up to 2x its true area -- confirmed by rotating harbor-house-l0's +# plot 45 deg: bbox area error jumped from 7.5% to 102%). Urb's Perl ancestor +# (Urb::Quad::Straighten/Straighten_Root) keeps internal walls mutually +# orthogonal but NEVER assumes them axis-aligned; this port's equal-offset +# division convention gives that same local straightness for free, so each +# node's own 4 corners already form a near-rectangle in ITS OWN frame +# regardless of the plot's global orientation -- (edge0+edge2)/2 and +# (edge1+edge3)/2 (the pairing geometry.aspect() uses) measure that local +# rectangle's two dimensions with no global-axis dependency (``_dims``). +# +# Stage 2: switching to local edge lengths alone was NOT sufficient and +# initially REGRESSED accuracy (99.0% -> 95.5% on the harbor-house-l0 200- +# topology validation, with a false negative appearing for the first time). +# Root cause: geometry.coordinate() applies a node's OWN ``rotation`` field +# even when reading ITS OWN corners as inherited from its parent -- a node +# with odd rotation has its local edge0/edge2 pair correspond to its PARENT's +# edge1/edge3 pair instead of edge0/edge2 (rotation parity selects between a +# quad's two possible opposite-edge pairings). A prior version tried to +# detect this empirically (comparing children's summed dims against the +# parent's, picking whichever of two hypotheses fit better) -- but the +# relationship is not a matter of degree to be measured, it's an EXACT +# algebraic identity determined purely by ``child.rotation % 2``: verified +# numerically (float-exact) that ``left.w + right.h == parent.w`` whenever +# left.rotation is even and right.rotation is odd (and the symmetric case +# generally), for ANY topology, independent of skew or global orientation. +# ``_child_contrib`` below applies this directly -- no geometry measurement, +# no baseline-ratio pass, no heuristic threshold. # --------------------------------------------------------------------------- # -def _bbox(n: dom_mod.Node) -> tuple[float, float]: - """Axis-aligned bounding-box (w, h) of a quad's 4 corners.""" - xs = [geometry.coordinate(n, i)[0] for i in range(4)] - ys = [geometry.coordinate(n, i)[1] for i in range(4)] - return (max(xs) - min(xs), max(ys) - min(ys)) - - -def _orientation(node: dom_mod.Node) -> str: - """'w' (width-split, children share height) or 'h' (height-split), - measured from the actual baseline geometry -- see module docstring.""" - bw, bh = _bbox(node) - lw, lh = _bbox(node.left) - rw, rh = _bbox(node.right) - err_w = abs((lw + rw) - bw) - err_h = abs((lh + rh) - bh) - return "w" if err_w <= err_h else "h" - - -def annotate_orientations(level_root: dom_mod.Node) -> dict[int, str]: - """Baseline-geometry orientation per internal node, keyed by id(node). - - Sets every free branch's division to [0.5, 0.5] on the LIVE tree (matching - the inner loop's cold-start convention), clears the geometry cache, then - measures. Caller must re-clear the cache afterwards if it goes on to use - different ratios (the DP itself never reads real coordinates again after - this call -- only the plot bbox, computed separately). - """ - from homemaker_layout import solver - - for b in solver._branches(level_root): - if b.below is None or not b.below.divided: - b.division = [0.5, 0.5] - geometry.clear_cache() - - orientations: dict[int, str] = {} - - def _walk(n: dom_mod.Node) -> None: - if not n.divided: - return - orientations[id(n)] = _orientation(n) - _walk(n.left) - _walk(n.right) - - _walk(level_root) - return orientations +def _dims(n: dom_mod.Node) -> tuple[float, float]: + """Rotation-invariant (w, h) of a quad from its own edge lengths (mirrors + the (edge0+edge2) vs (edge1+edge3) pairing ``geometry.aspect`` uses).""" + w = (geometry.edge_length(n, 0) + geometry.edge_length(n, 2)) / 2 + h = (geometry.edge_length(n, 1) + geometry.edge_length(n, 3)) / 2 + return (w, h) # --------------------------------------------------------------------------- # @@ -266,6 +273,15 @@ def make_grid(wmax: float, n: int = 400, wmin: float = 0.1) -> np.ndarray: return np.geomspace(wmin, wmax, n) +def _child_contrib(curve: "Curve", rotation: int) -> list[Interval]: + """The child's curve, reinterpreted in the PARENT's frame: parent.w is + ALWAYS the sum of its two children's ``_child_contrib`` (see module + docstring) -- even rotation contributes the child's own w_of_h directly; + odd rotation swaps w<->h (child.h sums; child.w is the one that + approximates the parent's shared/cross dimension).""" + return curve.w_of_h if rotation % 2 == 0 else curve.h_of_w + + @dataclass class Feasibility: feasible: bool @@ -289,85 +305,82 @@ def check_feasible(root_curve: Curve, grid: np.ndarray, w_plot: float, h_plot: f def realise( node: dom_mod.Node, curves: dict[int, tuple[Curve, Curve]], - orientations: dict[int, str], grid: np.ndarray, w: float, h: float, ) -> None: """Write ``division`` on every free branch under ``node`` so its subtree realises the (w, h) target, given each descendant's precomputed curves. - ``curves[id(n)] = (left_curve, right_curve)`` for internal nodes.""" + ``curves[id(n)] = (left_curve, right_curve)`` for internal nodes. + + ``node.w`` (the summed dimension) is ALWAYS ``w`` -- the parent-child cut + convention is fixed (see module docstring), not orientation-dependent. + Only each CHILD's rotation parity determines which of ITS OWN (w, h) the + allocated share becomes: even rotation -> child's own w; odd rotation -> + child's own h (the two are swapped for that recursive call). + """ if not node.divided: return cl, cr = curves[id(node)] - orient = orientations[id(node)] - if orient == "w": - rl = _interp_range(grid, cl.w_of_h, h) - rr = _interp_range(grid, cr.w_of_h, h) - lo = max(rl[0], w - rr[1]) - hi = min(rl[1], w - rr[0]) - wl = min(max((lo + hi) / 2.0, rl[0]), rl[1]) - wl = min(max(wl, w - rr[1]), w - rr[0]) - wr = w - wl - t = wl / w if w > 0 else 0.5 - node.division = [t, t] - realise(node.left, curves, orientations, grid, wl, h) - realise(node.right, curves, orientations, grid, wr, h) + contrib_l = _child_contrib(cl, node.left.rotation) + contrib_r = _child_contrib(cr, node.right.rotation) + rl = _interp_range(grid, contrib_l, h) + rr = _interp_range(grid, contrib_r, h) + lo = max(rl[0], w - rr[1]) + hi = min(rl[1], w - rr[0]) + wl = min(max((lo + hi) / 2.0, rl[0]), rl[1]) + wl = min(max(wl, w - rr[1]), w - rr[0]) + wr = w - wl + t = wl / w if w > 0 else 0.5 + node.division = [t, t] + if node.left.rotation % 2 == 0: + realise(node.left, curves, grid, wl, h) else: - rl = _interp_range(grid, cl.h_of_w, w) - rr = _interp_range(grid, cr.h_of_w, w) - lo = max(rl[0], h - rr[1]) - hi = min(rl[1], h - rr[0]) - hl = min(max((lo + hi) / 2.0, rl[0]), rl[1]) - hl = min(max(hl, h - rr[1]), h - rr[0]) - hr = h - hl - t = hl / h if h > 0 else 0.5 - node.division = [t, t] - realise(node.left, curves, orientations, grid, w, hl) - realise(node.right, curves, orientations, grid, w, hr) + realise(node.left, curves, grid, h, wl) + if node.right.rotation % 2 == 0: + realise(node.right, curves, grid, wr, h) + else: + realise(node.right, curves, grid, h, wr) def build_curves_with_children( - node: dom_mod.Node, fit, orientations: dict[int, str], grid: np.ndarray, + node: dom_mod.Node, fit, grid: np.ndarray, out: dict[int, tuple[Curve, Curve]], ) -> Curve: - """Like ``build_curves`` but also records each internal node's (left, - right) curves in ``out`` for ``realise`` to consume.""" + """Bottom-up: leaf curves are exact closed forms; internal nodes compose + on ``grid`` via the EXACT rotation-parity rule (``_child_contrib``), also + recording each internal node's (left, right) curves in ``out`` for + ``realise`` to consume.""" if not node.divided: b = leaf_constraints(fit, node) w_of_h = h_of_w = b.range_grid(grid) return Curve(w_of_h=w_of_h, h_of_w=h_of_w) - cl = build_curves_with_children(node.left, fit, orientations, grid, out) - cr = build_curves_with_children(node.right, fit, orientations, grid, out) + cl = build_curves_with_children(node.left, fit, grid, out) + cr = build_curves_with_children(node.right, fit, grid, out) out[id(node)] = (cl, cr) - orient = orientations[id(node)] - if orient == "w": - w_of_h = [_interval_add(cl.w_of_h[i], cr.w_of_h[i]) for i in range(len(grid))] - h_of_w = _invert(grid, w_of_h) - else: - h_of_w = [_interval_add(cl.h_of_w[j], cr.h_of_w[j]) for j in range(len(grid))] - w_of_h = _invert(grid, h_of_w) + contrib_l = _child_contrib(cl, node.left.rotation) + contrib_r = _child_contrib(cr, node.right.rotation) + w_of_h = [_interval_add(contrib_l[i], contrib_r[i]) for i in range(len(grid))] + h_of_w = _invert(grid, w_of_h) return Curve(w_of_h=w_of_h, h_of_w=h_of_w) def solve(level_root: dom_mod.Node, fit, grid_n: int = 150) -> tuple[bool, dict]: - """End-to-end: orientation-annotate, compute plot bbox, build curves, - check root feasibility, and (if feasible) write realising ratios in - place. Returns (feasible, info) where info carries timing-relevant - intermediates for the caller.""" - orientations = annotate_orientations(level_root) - w_plot, h_plot = _bbox(level_root) + """End-to-end: compute plot dims, build curves, check root feasibility, + and (if feasible) write realising ratios in place. Returns (feasible, + info) where info carries timing-relevant intermediates for the caller.""" + w_plot, h_plot = _dims(level_root) grid = make_grid(max(w_plot, h_plot) * 1.2, n=grid_n) curves_by_node: dict[int, tuple[Curve, Curve]] = {} - root_curve = build_curves_with_children(level_root, fit, orientations, grid, curves_by_node) + root_curve = build_curves_with_children(level_root, fit, grid, curves_by_node) feas = check_feasible(root_curve, grid, w_plot, h_plot) if feas.feasible: - realise(level_root, curves_by_node, orientations, grid, w_plot, h_plot) + realise(level_root, curves_by_node, grid, w_plot, h_plot) geometry.clear_cache() return feas.feasible, { - "w_plot": w_plot, "h_plot": h_plot, "orientations": orientations, + "w_plot": w_plot, "h_plot": h_plot, "grid": grid, "h_range_at_w": feas.h_range_at_w, "w_range_at_h": feas.w_range_at_h, } diff --git a/experiments/validate_shapecurve.py b/experiments/validate_shapecurve.py index 441939c..c887742 100644 --- a/experiments/validate_shapecurve.py +++ b/experiments/validate_shapecurve.py @@ -23,10 +23,15 @@ Usage: python experiments/validate_shapecurve.py [n_topologies] [nm_budget] from __future__ import annotations import copy +import math +import shutil import sys +import tempfile import time +from pathlib import Path import numpy as np +import yaml from homemaker_layout import dom, driver, fitness as fit_mod, geometry, innerloop @@ -37,6 +42,34 @@ PROGRAMME_DIR = "examples/harbor-house-l0" _SHAPE_SUFFIXES = (" size", " width", " proportion") +def rotated_plot_dir(src_dir: str, degrees: float) -> Path: + """A scratch copy of ``src_dir`` with the plot's ``node:`` corners rotated + ``degrees`` about their centroid -- for testing that the DP's feasibility + verdict doesn't depend on the plot's orientation relative to the survey/ + CRS x/y axes it happens to be recorded in (see DESIGN.md §37.2, + "Correction 1"). The programme (patterns.config) is untouched -- rotation + changes nothing about which spaces are required or their targets, only + the plot's physical orientation. + """ + src = Path(src_dir) + dst = Path(tempfile.mkdtemp(prefix="shapecurve_rot_")) + d = yaml.safe_load((src / "init.dom").read_text()) + pts = d["node"] + cx = sum(p[0] for p in pts) / len(pts) + cy = sum(p[1] for p in pts) / len(pts) + theta = math.radians(degrees) + cos_t, sin_t = math.cos(theta), math.sin(theta) + + def _rot(p): + x, y = p[0] - cx, p[1] - cy + return [x * cos_t - y * sin_t + cx, x * sin_t + y * cos_t + cy] + + d["node"] = [_rot(p) for p in pts] + (dst / "init.dom").write_text(yaml.safe_dump(d, default_flow_style=False)) + shutil.copy(src / "patterns.config", dst / "patterns.config") + return dst + + class ShapeFailEvaluator(innerloop.NativeEvaluator): """Like NativeEvaluator, but ``evaluate`` scores -n_shape_fails (ties broken by the real fitness) so nm_search's greedy hill-climb directly @@ -56,9 +89,10 @@ class ShapeFailEvaluator(innerloop.NativeEvaluator): return results -def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150) -> None: - seed_root = dom.load(f"{PROGRAMME_DIR}/init.dom") - conf, cost = fit_mod.load_config(PROGRAMME_DIR) +def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150, + programme_dir: str = PROGRAMME_DIR) -> None: + seed_root = dom.load(f"{programme_dir}/init.dom") + conf, cost = fit_mod.load_config(programme_dir) fit = fit_mod.Fitness(conf, cost) types = sorted(fit.spaces.keys()) @@ -97,7 +131,7 @@ def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150) -> No t0 = time.time() topo_nm = copy.deepcopy(topo) geometry.clear_cache() - with ShapeFailEvaluator(topo_nm, PROGRAMME_DIR) as ev: + with ShapeFailEvaluator(topo_nm, programme_dir) as ev: x0 = ev.x_current if len(x0) == 0: nm_shape_fails: list[str] = [] @@ -146,7 +180,15 @@ def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150) -> No if __name__ == "__main__": + # Usage: validate_shapecurve.py [n_topologies] [nm_budget] [grid_n] [rotate_deg] + # rotate_deg (optional, default 0): test on a scratch copy of the plot + # rotated this many degrees about its centroid -- DESIGN.md §37.2's + # rotation-invariance check (0 => harbor-house-l0 unmodified). n = int(sys.argv[1]) if len(sys.argv) > 1 else 200 budget = int(sys.argv[2]) if len(sys.argv) > 2 else 100 grid_n = int(sys.argv[3]) if len(sys.argv) > 3 else 150 - main(n, budget, grid_n) + rotate_deg = float(sys.argv[4]) if len(sys.argv) > 4 else 0.0 + prog_dir = str(rotated_plot_dir(PROGRAMME_DIR, rotate_deg)) if rotate_deg else PROGRAMME_DIR + if rotate_deg: + print(f"(testing on {PROGRAMME_DIR}'s plot rotated {rotate_deg} deg -> {prog_dir})") + main(n, budget, grid_n, prog_dir)