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:
parent
85c1183d4c
commit
d148f219c8
3 changed files with 273 additions and 161 deletions
157
DESIGN.md
157
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
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue