homemaker-py-2g7.4: fix shape-curve DP to be rotation-invariant

User review caught a real gap: the DP approximated each quad's (w,h)
via its axis-aligned bounding box in global x/y, correct only because
harbor-house-l0's plot happens to be near-parallel to its own axes
(~7.5% area error). A real building's orthogonal walls need not align
to the survey/CRS axes at all -- confirmed by rotating the plot 45deg,
where the old bbox error jumped to 102% (up to 2x for a rotated square).

Fixed in two steps: (1) measure (w,h) from edge lengths
((edge0+edge2)/2, (edge1+edge3)/2, the geometry.aspect() pairing)
instead of global bbox -- rotation-invariant by construction. (2) this
alone regressed accuracy (99.0% -> 95.5%) because a child's own
rotation parity determines whether its local edge0/edge2 pair aligns
with its parent's edge0/edge2 or edge1/edge3 -- not a matter of degree
to measure empirically (as attempted first) but an exact algebraic
identity (verified float-exact: left.w + right.h == parent.w whenever
left.rotation is even and right.rotation is odd). _child_contrib now
applies this directly, replacing the empirical _orientation/
annotate_orientations machinery entirely -- simpler and correct.

Re-validated: 99.0% agreement on harbor-house-l0 unrotated (back to
matching the original result, same 2 residual mismatches, 0 false
negatives), 100% agreement at 97x speedup on the same plot rotated
45deg (new, via validate_shapecurve.py's rotated_plot_dir helper).
DESIGN.md §37.2 updated with the full correction history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSwQwpEaHFBkeVSDDWd75S
This commit is contained in:
Bruno Postle 2026-08-03 07:06:48 +01:00
parent 85c1183d4c
commit d148f219c8
3 changed files with 273 additions and 161 deletions

157
DESIGN.md
View file

@ -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 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 programme params" quirk — confirmed this is existing product behaviour, not a
bug, by reading `get_space_params`/`quality_size` together). Regions compose 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" bottom-up through the slicing tree: a node's cut ALWAYS sums its two
(children share height, widths sum) or "height-split" (heights sum), measured children's contributions into the node's own "w" (`edge0+edge2`) dimension,
once from the actual baseline geometry (`_orientation`) rather than derived with "h" (`edge1+edge3`) the shared/cross dimension — a fixed convention of
symbolically from `rotation` — robust to any rotation convention. Composition `geometry.py`'s division formula (`coord_a`/`coord_b` always interpolate
is done on a shared log-spaced grid (interval-sum + a numpy-vectorised between edge(0,1) and edge(3,2)), not a per-node choice. The only variable is
inversion, `_invert`); leaf curves themselves are exact closed forms, so all which of a CHILD's own (w, h) plays which role relative to its parent, an
discretisation error is confined to internal-node composition. A top-down EXACT function of that child's `rotation` parity (`_child_contrib` — see the
`realise()` back-substitution converts a feasible root point into actual correction below). Composition runs on a shared log-spaced grid (interval-sum
`division` ratios, so the DP's output is a real, scoreable `.dom` tree, not + a numpy-vectorised inversion, `_invert`); leaf curves themselves are exact
just a yes/no. 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 **Explicit scope (per the plan's own caveats).** Only size/width/proportion
is modelled — crinkliness/adjacency/access/level connectivity are graph is modelled — crinkliness/adjacency/access/level connectivity are graph
terms, out of scope by design. Every quad is approximated by its axis-aligned terms, out of scope by design. Every quad is approximated by a rectangle with
bounding box (exact only for a true rectangle). `leaf_sharing`/`co_type` edge-length-derived (w, h) — exact only for a true rectangle/parallelogram
target-adjustment is not modelled (harbor-house-l0's programme doesn't (see the rotation-invariance correction below for why this is edge lengths,
exercise either). 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 **Validation** (`experiments/validate_shapecurve.py`, harbor-house-l0, 200
`driver.random_topology` topologies, 2-14 leaves, seed 12345): compared `driver.random_topology` topologies, 2-14 leaves, seed 12345): compared
against NM search **minimising shape-fail count directly** (`ShapeFailEvaluator`, 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 version of this harness used the full objective and found spurious
"disagreements" where the DP's own realised point independently verified at "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, **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 shape-feasibility specifically. Minimising shape-fail count alone is the
correct apples-to-apples comparison against what the DP claims to solve. 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% | | agreement | 198/200 = **99.0%** (target >= 95%) | 100/100 = **100.0%** |
| false positives (DP feasible, NM can't reach 0) | 2 | — | | false positives (DP feasible, NM can't reach 0) | 2 | 0 |
| false negatives (DP infeasible, NM reaches 0 anyway) | **0** | — | | false negatives (DP infeasible, NM reaches 0 anyway) | **0** | 0 |
| speedup (grid_n=150, vs 100-eval NM) | **93.6x** | >= 50x | | speedup (grid_n=150, vs 100-eval NM) | **97.2x** (target >= 50x) | 97.1x |
| speedup (grid_n=300) | 42.7x | — | | 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) |
| plot-level bbox area error | measured **+7.5%** overestimate | quantified |
Zero false negatives across 200 topologies: the DP never wrongly rejects a The unrotated-plot numbers are BACK to matching the original (pre-Correction-2)
topology NM finds feasible — the safe direction for a pre-filter (worst case 99.0%/0-false-negative result exactly — same 2 mismatches, same seeds
it fails to prune, never wrongly prunes a viable topology). grid_n=150 vs 300 (`623465425`/`1523713848`) — confirming Correction 2 fixed the regression it
gave **identical** agreement (99.0%, the same 2 mismatches) at 2.2x the introduced without disturbing the genuine, separately-diagnosed residual
speedup — internal-node grid resolution has headroom below 300 with no error below. The 45°-rotated run (`python experiments/validate_shapecurve.py
measured accuracy cost on this benchmark; `_invert`'s pure-Python O(N²) 100 100 150 45` -- same protocol, `n=100` for wall-clock, the plot's `node:`
double loop was ~70% of DP wall-clock before vectorising with numpy corners rotated 45° about their centroid into a scratch copy via
(profiled: 170ms → 40ms/topology at grid_n=300 from that change alone). `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 **Remaining approximation error, root-caused (unchanged by Corrections 1/2 —
bounding-box approximation, not a DP logic bug: the DP's own realised point a different, smaller error source).** Both unrotated false positives trace to
for both cases had one leaf whose bbox-approximated area (e.g. 29.54 m², the rectangle-vs-true-skewed-quad approximation itself (§37.2's plan-flagged
comfortably inside `[27.12, 52.88]`) was a **real skewed quad** whose true "equal-offset skew-quad geometry" caveat), not to global rotation or to
`geometry.area` (26.90 m²) fell just *below* the true lower bound — a bbox composition: the DP's own realised point for both cases had one leaf whose
overestimate of the same ~8-12% magnitude as the plot-level +7.5% figure edge-length-approximated area was comfortably inside its feasible bound, but
above (harbor-house-l0's plot is a near-rectangular trapezoid, not a true whose true `geometry.area` (a real, slightly non-parallelogram quad) fell
rectangle). Every mismatch occurred within one bbox-error-width of a boundary just below the true lower bound — an ~8-12% approximation gap, the same
— exactly the failure mode the plan's caveat predicted ("equal-offset magnitude as harbor-house-l0's own plot-level residual skew. This is a
skew-quad geometry means DP areas are approximate — measure the approximation strictly smaller, already-anticipated error source, distinct from the two
error on real plots first"). 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, **ACCEPTANCE: PASS** — all three criteria cleared (agreement, speedup,
quantified approximation error). **Not done in this session** (follow-on, quantified approximation error), on both the original and the rotated plot.
new bead needed before this can replace `operators.predicted_shape_fails` in **Not done in this session** (follow-on, new bead needed before this can
`driver.py`'s real pre-filter path): wiring the DP into `driver._evaluate`/ replace `operators.predicted_shape_fails` in `driver.py`'s real pre-filter
`innerloop.optimise` as an actual pre-filter + NM warm-start, multi-storey path): wiring the DP into `driver._evaluate`/`innerloop.optimise` as an
(`below`-link) support, `leaf_sharing`/`co_type` modelling, and a true actual pre-filter + NM warm-start, multi-storey (`below`-link) support,
skew-quad (non-bbox) leaf region to remove the measured approximation-error `leaf_sharing`/`co_type` modelling, and a true skew-quad (non-rectangle) leaf
source rather than just quantify it. `experiments/shapecurve_spike.py` is region to remove the remaining ~8-12% approximation-error source rather than
kept as a reference/prototype (the §34 `autodiff_spike.py` precedent), not just quantify it. `experiments/shapecurve_spike.py` is kept as a reference/
wired into `innerloop.py`. prototype (the §34 `autodiff_spike.py` precedent), not wired into
`innerloop.py`.

View file

@ -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``) -- line (``quality_width``), and an aspect-ratio wedge (``quality_proportion``) --
all three are FAIL_THRESHOLD-inversions of the Gaussian/clipped-Gaussian all three are FAIL_THRESHOLD-inversions of the Gaussian/clipped-Gaussian
factors in ``fitness.py`` (see ``leaf_constraints`` below). These per-leaf factors in ``fitness.py`` (see ``leaf_constraints`` below). These per-leaf
regions compose bottom-up through the slicing tree: a "width-split" node regions compose bottom-up through the slicing tree: a node's cut ALWAYS sums
(children share height, widths sum) or "height-split" node (children share its two children's contributions into the node's own "w" (edge0+edge2)
width, heights sum) -- see ``_orientation``. 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): Approximations made explicit (the plan's caveats, DESIGN.md §37 point 2):
* Every quad (leaf or internal) is approximated by its axis-aligned * Every quad (leaf or internal) is approximated by a rectangle with the
bounding-box (w, h) -- exact only for a true rectangle; harbor-house-l0's same edge-length-derived (w, h) as ``geometry.aspect`` uses --
plot is a near-rectangular trapezoid (DESIGN.md says "harbor plot is a ``(edge0+edge2)/2`` and ``(edge1+edge3)/2`` -- exact only for a true
near-rect quad"), so this is the intended first target, not a general rectangle/parallelogram; harbor-house-l0's plot is a near-rectangular
solution for skew quads. trapezoid (DESIGN.md says "harbor plot is a near-rect quad"), so this is
* A node's cut orientation (does it split width or height?) is measured the intended first target, not a general solution for skew quads. This
once from the ACTUAL geometry at ratio=0.5 baseline, not derived from is deliberately NOT the quad's axis-aligned bounding box in global x/y --
``rotation`` symbolically -- robust to any rotation convention, but a an earlier version used that and was wrong for any quad whose (locally
property of the *frozen topology*, computed once, not re-derived by the orthogonal, per Urb's Straighten lineage -- see ``_dims``) walls aren't
DP itself. 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 -- * Leaf curves are EXACT closed forms (hyperbola/line/wedge intersection --
no discretisation error). Internal-node composition is done on a shared no discretisation error). Internal-node composition is done on a shared
discretised grid (log-spaced) with linear interpolation -- this is where 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]: def _dims(n: dom_mod.Node) -> tuple[float, float]:
"""Axis-aligned bounding-box (w, h) of a quad's 4 corners.""" """Rotation-invariant (w, h) of a quad from its own edge lengths (mirrors
xs = [geometry.coordinate(n, i)[0] for i in range(4)] the (edge0+edge2) vs (edge1+edge3) pairing ``geometry.aspect`` uses)."""
ys = [geometry.coordinate(n, i)[1] for i in range(4)] w = (geometry.edge_length(n, 0) + geometry.edge_length(n, 2)) / 2
return (max(xs) - min(xs), max(ys) - min(ys)) h = (geometry.edge_length(n, 1) + geometry.edge_length(n, 3)) / 2
return (w, h)
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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -266,6 +273,15 @@ def make_grid(wmax: float, n: int = 400, wmin: float = 0.1) -> np.ndarray:
return np.geomspace(wmin, wmax, n) 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 @dataclass
class Feasibility: class Feasibility:
feasible: bool feasible: bool
@ -289,85 +305,82 @@ def check_feasible(root_curve: Curve, grid: np.ndarray, w_plot: float, h_plot: f
def realise( def realise(
node: dom_mod.Node, node: dom_mod.Node,
curves: dict[int, tuple[Curve, Curve]], curves: dict[int, tuple[Curve, Curve]],
orientations: dict[int, str],
grid: np.ndarray, grid: np.ndarray,
w: float, w: float,
h: float, h: float,
) -> None: ) -> None:
"""Write ``division`` on every free branch under ``node`` so its subtree """Write ``division`` on every free branch under ``node`` so its subtree
realises the (w, h) target, given each descendant's precomputed curves. 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: if not node.divided:
return return
cl, cr = curves[id(node)] cl, cr = curves[id(node)]
orient = orientations[id(node)] contrib_l = _child_contrib(cl, node.left.rotation)
if orient == "w": contrib_r = _child_contrib(cr, node.right.rotation)
rl = _interp_range(grid, cl.w_of_h, h) rl = _interp_range(grid, contrib_l, h)
rr = _interp_range(grid, cr.w_of_h, h) rr = _interp_range(grid, contrib_r, h)
lo = max(rl[0], w - rr[1]) lo = max(rl[0], w - rr[1])
hi = min(rl[1], w - rr[0]) hi = min(rl[1], w - rr[0])
wl = min(max((lo + hi) / 2.0, rl[0]), rl[1]) wl = min(max((lo + hi) / 2.0, rl[0]), rl[1])
wl = min(max(wl, w - rr[1]), w - rr[0]) wl = min(max(wl, w - rr[1]), w - rr[0])
wr = w - wl wr = w - wl
t = wl / w if w > 0 else 0.5 t = wl / w if w > 0 else 0.5
node.division = [t, t] node.division = [t, t]
realise(node.left, curves, orientations, grid, wl, h) if node.left.rotation % 2 == 0:
realise(node.right, curves, orientations, grid, wr, h) realise(node.left, curves, grid, wl, h)
else: else:
rl = _interp_range(grid, cl.h_of_w, w) realise(node.left, curves, grid, h, wl)
rr = _interp_range(grid, cr.h_of_w, w) if node.right.rotation % 2 == 0:
lo = max(rl[0], h - rr[1]) realise(node.right, curves, grid, wr, h)
hi = min(rl[1], h - rr[0]) else:
hl = min(max((lo + hi) / 2.0, rl[0]), rl[1]) realise(node.right, curves, grid, h, wr)
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)
def build_curves_with_children( 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]], out: dict[int, tuple[Curve, Curve]],
) -> Curve: ) -> Curve:
"""Like ``build_curves`` but also records each internal node's (left, """Bottom-up: leaf curves are exact closed forms; internal nodes compose
right) curves in ``out`` for ``realise`` to consume.""" 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: if not node.divided:
b = leaf_constraints(fit, node) b = leaf_constraints(fit, node)
w_of_h = h_of_w = b.range_grid(grid) w_of_h = h_of_w = b.range_grid(grid)
return Curve(w_of_h=w_of_h, h_of_w=h_of_w) 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) cl = build_curves_with_children(node.left, fit, grid, out)
cr = build_curves_with_children(node.right, fit, orientations, grid, out) cr = build_curves_with_children(node.right, fit, grid, out)
out[id(node)] = (cl, cr) out[id(node)] = (cl, cr)
orient = orientations[id(node)] contrib_l = _child_contrib(cl, node.left.rotation)
if orient == "w": contrib_r = _child_contrib(cr, node.right.rotation)
w_of_h = [_interval_add(cl.w_of_h[i], cr.w_of_h[i]) for i in range(len(grid))] 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) 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)
return Curve(w_of_h=w_of_h, h_of_w=h_of_w) 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]: 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, """End-to-end: compute plot dims, build curves, check root feasibility,
check root feasibility, and (if feasible) write realising ratios in and (if feasible) write realising ratios in place. Returns (feasible,
place. Returns (feasible, info) where info carries timing-relevant info) where info carries timing-relevant intermediates for the caller."""
intermediates for the caller.""" w_plot, h_plot = _dims(level_root)
orientations = annotate_orientations(level_root)
w_plot, h_plot = _bbox(level_root)
grid = make_grid(max(w_plot, h_plot) * 1.2, n=grid_n) grid = make_grid(max(w_plot, h_plot) * 1.2, n=grid_n)
curves_by_node: dict[int, tuple[Curve, Curve]] = {} 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) feas = check_feasible(root_curve, grid, w_plot, h_plot)
if feas.feasible: 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() geometry.clear_cache()
return feas.feasible, { 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, "grid": grid, "h_range_at_w": feas.h_range_at_w, "w_range_at_h": feas.w_range_at_h,
} }

View file

@ -23,10 +23,15 @@ Usage: python experiments/validate_shapecurve.py [n_topologies] [nm_budget]
from __future__ import annotations from __future__ import annotations
import copy import copy
import math
import shutil
import sys import sys
import tempfile
import time import time
from pathlib import Path
import numpy as np import numpy as np
import yaml
from homemaker_layout import dom, driver, fitness as fit_mod, geometry, innerloop 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") _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): class ShapeFailEvaluator(innerloop.NativeEvaluator):
"""Like NativeEvaluator, but ``evaluate`` scores -n_shape_fails (ties """Like NativeEvaluator, but ``evaluate`` scores -n_shape_fails (ties
broken by the real fitness) so nm_search's greedy hill-climb directly broken by the real fitness) so nm_search's greedy hill-climb directly
@ -56,9 +89,10 @@ class ShapeFailEvaluator(innerloop.NativeEvaluator):
return results return results
def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150) -> None: def main(n_topologies: int = 200, nm_budget: int = 100, grid_n: int = 150,
seed_root = dom.load(f"{PROGRAMME_DIR}/init.dom") programme_dir: str = PROGRAMME_DIR) -> None:
conf, cost = fit_mod.load_config(PROGRAMME_DIR) seed_root = dom.load(f"{programme_dir}/init.dom")
conf, cost = fit_mod.load_config(programme_dir)
fit = fit_mod.Fitness(conf, cost) fit = fit_mod.Fitness(conf, cost)
types = sorted(fit.spaces.keys()) 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() t0 = time.time()
topo_nm = copy.deepcopy(topo) topo_nm = copy.deepcopy(topo)
geometry.clear_cache() geometry.clear_cache()
with ShapeFailEvaluator(topo_nm, PROGRAMME_DIR) as ev: with ShapeFailEvaluator(topo_nm, programme_dir) as ev:
x0 = ev.x_current x0 = ev.x_current
if len(x0) == 0: if len(x0) == 0:
nm_shape_fails: list[str] = [] 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__": 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 n = int(sys.argv[1]) if len(sys.argv) > 1 else 200
budget = int(sys.argv[2]) if len(sys.argv) > 2 else 100 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 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)