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
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`.

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``) --
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,21 +305,27 @@ 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)
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])
@ -311,63 +333,54 @@ def realise(
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)
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))]
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)
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)
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,
}

View file

@ -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)