homemaker-layout/DESIGN.md
Bruno Postle 7fccc05c0d Add comprehensive DESIGN.md capturing this session's findings
Self-contained design + plan structured for breaking into bd (beads) tasks:
domain constraints that fix the slicing representation; what was built;
full empirical record (geometry port validated 35/35; area-proxy solver
falsified; perpendicular artifact resolved via equal-offset cuts; full-fitness
frozen-topology optimisation validated with 24-67% headroom; 0.5^n cliff);
validated memetic architecture; component plan; phased roadmap; risks/open
questions; repro steps; gotchas.

Oracle throughput measured: ~0.99s/dom batched vs 1.65s single (assessment-
dominated). urb-fitness.pl batches many doms per call, so native fitness is a
later speed/scale optimisation, not a gate; favour population/batch optimisers
and prototype the search on the batched oracle first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:45:44 +01:00

17 KiB
Raw Blame History

homemaker — Design & Plan

Status: validated direction, pre-implementation. Audience: a fresh session that will break this into bd (beads) tasks. Self-contained — assumes no memory of the originating conversation.


1. Purpose

homemaker-py is a clean-room Python successor to the Perl Urb project (/home/bruno/src/urb). Urb models a building as a binary slicing tree and evolves layouts with mutation + crossover, scored against Christopher Alexanderstyle pattern fitness. Two long-standing problems motivate the rewrite:

  1. It doesn't scale — beyond a few rooms, evolution never finds layouts an architect would consider obvious.
  2. Local minima — even small programmes converge to poor optima.

The eventual goal is a 100% Python system. During bring-up, Perl Urb is kept as a throwaway fitness oracle behind the .dom file format.


2. Constraints that fix the representation

These come from the problem domain and are not negotiable; importantly, they vindicate the slicing tree rather than argue against it:

  • Multi-storey with stacked walls. An upper storey retains the storey below, except additional divisions/undivisions. Load-bearing walls must stack ⇒ every cut is a full edge-to-edge guillotine cut. Urb already enforces this via Below-inheritance (an upper quad reads its geometry from the matching quad below).
  • Quadrilateral rooms only (no L/Z shapes) — recursive bisection produces exactly this.
  • No pinwheel / non-slicing layouts — undesirable for load-bearing construction and adaptability (cf. Brand, How Buildings Learn). This is the one class a slicing tree can't express, and we don't want it anyway.
  • Plots are near-rectangular but general convex quadrilaterals (not axis-aligned). Geometry must handle skew; the slicing combinatorics are unaffected.

Conclusion: the slicing tree is the correct phenotype. The rewrite is about the genotype, the search, and the fitness shape — not about leaving the slicing class.


3. What we built this session (all committed)

Package src/homemaker/:

  • dom.py.dom YAML ⇄ Node tree. Linkage (parent/below/position), wall_outer inset on load with raw-corner stash for byte-perfect round-trip, emit.
  • geometry.py — faithful port of Urb's top-down geometry (Coordinate/Coordinate_a/_b/Area/Length) + Coordinate_Offset wall inset. Memoised (uncached recursion is exponential in depth).
  • programme.py — parse patterns.config spaces: into per-code size/width/proportion/adjacency/level/count requirements.
  • solver.py — bottom-up division-ratio solver (scipy least_squares). (Outcome: falsified as a standalone component — see §4.2.)
  • oracle.py — Phase-1 fitness bridge: write .dom, run urb-fitness.pl, parse .score + .fails.

Experiments in experiments/: dump_areas.{py,pl}, resolve_ratios.py, refine_sweep.py, sweep_failtypes.py, optimize_fullfitness.py.


4. Empirical findings (the core of this document)

4.1 Geometry port — VALIDATED

Per-leaf areas computed in Python are byte-identical to Urb across all 35 programme-house .dom files, including the wall inset and multi-storey wall-stacking inheritance. (experiments/dump_areas.{py,pl}.) The infrastructure is trustworthy.

4.2 Bottom-up area-proxy sizing solver — FALSIFIED

The original hypothesis: give leaves target sizes, solve cut ratios bottom-up, let the EA search only topology. Tested by re-solving an evolved candidate's ratios from programme targets and scoring via the oracle.

  • resolve_ratios.py on candidate-002: areas recovered accurately (errors collapsed, e.g. t1/t2/t3 from +1.4/+2.4/+4.8 → ~+0.05), and it fixed the original's size failure — but total fitness dropped (0.00737 → 0.00065, 4 fails) because it introduced shape/relational failures.
  • refine_sweep.py (warm-start refine of all 34 candidates): 0/34 improved. Total failures 124 → 297 (equal-offset cuts) and 124 → 626 (independent-offset cuts).
  • sweep_failtypes.py (failure-type histogram, equal-offset):
    type area-dominant Δ shape-aware Δ
    width +82 +29
    proportion +35 +7
    crinkliness +18 +4
    adjacency +18 +13
    size 15 +15
    access +29 +39
    total added +173 +110

Why it fails: in Urb's fitness, every cut position is simultaneously a size knob and an adjacency/access/shape knob. A solver that optimises only size/shape is blind to access/adjacency and trades them away. Refining a co-evolved local optimum with a partial objective is structurally unable to win, and the 0.5^n failure penalty makes every new failure catastrophic while fixes are only linear. The proxy solver is strictly worse than optimising real fitness. Do not pursue it.

4.3 "Perpendicular" failures were an artifact — RESOLVED

Letting the two ends of a cut float independently produced skewed cuts and many perpendicular failures. Tying the two ends (equal offset, a == b, one DOF per cut) produces near-perpendicular walls on these near-rectangular plots and yields zero perpendicular failures. Equal-offset cuts are the only mode to use. This also halves the variable count and matches the slicing model.

4.4 DOF / over-determination — partially real, not fatal

A topology with R rooms has ~R1 cut DOF but ~23 size/shape constraints per room, so a fixed topology can be over-determined: you cannot always hit area + width + proportion for every room at once (heavy shape weighting traded straight into size, §4.2 table). This limits any single-objective sizing pass — but it is not fatal, because optimising the full objective still found large gains (§4.5). The earlier "infeasibility" worry was overstated.

4.5 Full-fitness frozen-topology optimisation — VALIDATED

Drive the equal-offset ratios with Nelder-Mead against the real oracle fitness (whole objective, no proxy), topology frozen (experiments/optimize_fullfitness.py):

candidate DOF original optimised gain fails
2f45907 (best evolved) 7 0.012617 0.015684 ×1.24 2→2
candidate-002 (MCP-refined) 6 0.007375 0.012319 ×1.67 2→2
c964435 (MCP baseline) 6 0.003667 0.005836 ×1.59 3→3

Every design improved 2467%, none added a failure. Headroom widens on weaker designs. Because the optimiser sees the whole objective (including the 0.5^n penalty), it never trades into a new failure — the cliff that destroys the proxy solver protects the full-objective optimiser.

Implications:

  • There is large, unclaimed geometry headroom above every EA design — even the best. Urb's EA under-optimises geometry (its slide mutations are weak/ under-applied).
  • A full-objective geometry inner loop is genuinely valuable (the proxy solver is not).
  • The EA/search should therefore own topology; geometry is delegated to the inner loop. This is the memetic architecture (§5).

4.6 Oracle throughput (measured)

urb-fitness.pl scores many .dom files per invocation, so the Perl startup (~0.65 s) amortises across a batch and cached fields (e.g. occlusion) persist. Measured on the 35-file corpus: 0.99 s/dom batched vs 1.65 s/dom for a single-file call. The cost is assessment-dominated (~1 s/dom of actual work), so startup amortisation gives ~40% — useful but bounded.

Consequences:

  • Batching only helps when evaluations are submitted together — favour population/parallel-evaluating optimisers (CMA-ES, differential evolution, island EA, pattern search) over inherently sequential ones (Nelder-Mead), both inner loop and outer search, so a whole generation scores in one oracle call.
  • The memetic search can be prototyped on the batched oracle before any fitness port. A native Python fitness is strongly desirable (faster assessment, independence, enables penalty reshaping and large programmes) but is a later speed/scale optimisation, not a hard gate.

4.7 The 0.5^n failure penalty is a first-order pathology

Multiplicative 0.5^n over failure count (a) makes the landscape a cliff (no gradient across the huge zero-feasibility region), (b) rewards fewer flags over better geometry (the original outscored better-sized solved designs purely on flag count), and (c) is representation-independent. Reshaping it (additive / soft / multi-objective Pareto) is a high-leverage change that helps Urb today and homemaker tomorrow.


5. Validated architecture

Memetic search, full objective throughout:

            ┌─────────────────────── topology search (OUTER) ───────────────────────┐
            │  genome = slicing topology + per-leaf type assignment + per-floor       │
            │           divide/undivide deltas (base floor is master)                 │
            │  operators = high-locality topology moves (see §6)                      │
            │                                                                          │
            │   for each proposed topology:                                            │
            │      ┌──────────── geometry inner loop ────────────┐                     │
            │      │ optimise equal-offset cut ratios (1 DOF/cut) │                     │
            │      │ against the FULL fitness (derivative-free /  │                     │
            │      │ gradient), to convergence                    │                     │
            │      └──────────────────────────────────────────────┘                    │
            │      score = best full-fitness over inner loop                            │
            └──────────────────────────────────────────────────────────────────────────┘
                                   fitness: NATIVE Python (fast), reshaped penalty

Key decisions, all evidence-backed:

  1. Geometry = inner optimisation against full fitness (§4.5), not an area proxy (§4.2). Equal-offset cuts, one DOF per free branch (§4.3).
  2. Search owns topology only. The base-floor tree is the primary genome; per-floor deltas are a small secondary genome (multi-storey constraint as a regulariser, via Below-inheritance).
  3. Prefer population/batch-evaluating optimisers so the batched oracle is efficient (§4.6). A native Python fitness (faithful to Urb, validated against the oracle on the 35-file corpus) is a later speed/scale optimisation — desirable, not a gate.
  4. Reshape the failure penalty (§4.7) — additive/soft or multi-objective — so the search has a gradient and isn't dominated by flag-count.
  5. Representation upgrade (later): canonical slicing encoding (normalized Polish expression / skewed slicing tree, WongLiu) for redundancy-free, high-locality topology moves; bottom-up shape feasibility checks. Defer until the inner loop + native fitness are in place.

What we are not doing: the bottom-up area-proxy solver; independent-offset cuts; non-slicing representations (sequence-pair/B*-tree — excluded by §2).


6. Component plan

component status notes
dom.py (I/O + linkage) done round-trips byte-perfect; keep
geometry.py (port + cache) done, validated the trusted geometry kernel
programme.py done extend as fitness needs grow
oracle.py (Perl bridge) done throwaway; the validation reference
solver.py (area proxy) ⚠️ keep as artifact falsified; do not build on it
geometry inner loop to build full-objective ratio optimiser (DOF = free branches); batch/population so the oracle batches
topology genome + operators to build base tree + per-floor deltas; high-locality moves
search driver to build memetic EA / SA over topology
native fitness later speed/scale optimisation (not gating); port + validate vs oracle
penalty reshaping to design additive/soft or multi-objective
canonical encoding (Polish expr.) later representation upgrade once core lands

Urb fitness terms the native port must reproduce (all couple to geometry): size, width, proportion, adjacency, access/inaccessible, crinkliness, perpendicular, level, staircase volume/count, public access, circulation & outside ratios, min internal area. Source of truth: /home/bruno/src/urb/lib/Urb/Dom/Fitness/ProgrammeDriven.pm and the Storey/ Building/Leaf/Base submodules.


7. Phased roadmap

  • Phase 0 — diagnostics (done this session): geometry port validated; proxy solver falsified; full-fitness geometry headroom validated; oracle throughput measured (~1 s/dom batched).
  • Phase 1 — geometry inner loop (on batched oracle): full-objective ratio optimiser; use a population/batch optimiser so a generation scores in one oracle call. Reproduce/exceed the §4.5 gains. Integrate as optimise(topology) -> (geometry, fitness).
  • Phase 2 — topology search (on batched oracle): base-tree + per-floor-delta genome, high-locality operators, memetic driver wrapping the Phase-1 inner loop. Compare against urb-evolve from the same seeds/programmes.
  • Phase 3 — native Python fitness: port Urb's programme-driven fitness; validate score + failure set against the oracle across the 35-file corpus (float tolerance, identical failure sets). Swap behind the same interface for speed/scale; retire the oracle.
  • Phase 4 — penalty reshaping: replace 0.5^n with additive/soft or multi-objective (easier once fitness is native); measure landscape + search.
  • Phase 5 — representation upgrade: canonical slicing encoding (Polish expression) + bottom-up shape feasibility; scale to larger programmes.

Each phase has a concrete go/no-go gate; do not advance on faith.


8. Risks & open questions (decisions for the next session)

  1. Native-fitness fidelity vs simplification. Port Urb's fitness exactly (maximise comparability) or take the opportunity to clean up known issues (the 0.5^n cliff, the t3 width-default contradiction below)? Recommend: port faithfully first, validate, then reshape in Phase 3.
  2. Programme contradictions exist. e.g. t3 (3 m² WC) inherits a 4 m default width — geometrically impossible; the original "passes" only by failing size instead. Need a sane width default scaled to area, or per-room widths.
  3. Inner-loop optimiser choice. Nelder-Mead worked for diagnostics; for production consider CMA-ES, Powell, or gradient-based once native fitness is differentiable-ish. DOF is small (≈ rooms1).
  4. Search algorithm for topology. Memetic GA (keep crossover — now meaningful, since a subtree = a contiguous region) vs simulated annealing (the floorplanning workhorse with M1/M2/M3 moves on Polish expressions).
  5. End-state confirmed: 100% Python; Perl oracle is scaffold only.

9. How to reproduce (for the next session)

cd /home/bruno/src/homemaker-py
# deps: pyyaml numpy scipy (shapely networkx for later phases)

# geometry port vs Urb (must be identical):
for d in /home/bruno/src/urb/examples/programme-house/*.dom; do
  diff <(perl -I/home/bruno/src/urb/lib experiments/dump_areas.pl "$d") \
       <(python3 experiments/dump_areas.py "$d") || echo "MISMATCH $d"
done

python3 experiments/resolve_ratios.py        # proxy solver (falsified)
python3 experiments/sweep_failtypes.py       # failure-type histogram
python3 experiments/optimize_fullfitness.py 200   # full-fitness headroom (validated)

Oracle invocation (see oracle.py): cwd = the .dom's directory (so patterns.config is found), perl -I<urb>/lib <urb>/bin/urb-fitness.pl <file>, env DEBUG=1 to defeat the skip-if-newer cache; reads <file>.score and <file>.fails.


10. Key gotchas discovered (carry forward)

  • Wall inset: the .dom plot is the outer boundary; Urb insets the root by wall_outer on load (Urb::Dom::_deserialise, Dom.pm:458) and offsets back out on save. geometry.offset_quad mirrors it; dom.py stashes raw corners in node_file. Skipping this makes all areas ~14% too large.
  • Multi-storey Below-inheritance: an upper quad's coordinates come from the matching quad below; a cut is "owned" by the lowest storey where its path is divided (solver.free_branches selects these). Walls stack for free.
  • Geometry must be cached — the pull-based recursion is exponential in depth otherwise (geometry._cache, cleared on dom.load and after each solver mutation).
  • Equal-offset cuts (a == b) ⇒ perpendicular walls, 1 DOF/cut. Independent offsets are wrong.
  • 0.5^n cliff dominates fitness; it punishes new failures catastrophically (good for the inner loop, brutal for search gradient).
  • Oracle ≈ 1 s/dom batched (1.65 s single; assessment-dominated, startup ~0.65 s amortises across a batch). Submit many .doms per call and prefer population optimisers; native fitness is a later speed/scale win, not a gate.