From 9e020f47699ed54cdb046e157f7f3e4bc5d96b0e Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 12 Jun 2026 21:57:45 +0100 Subject: [PATCH] Native fitness: adjacency graph + merge_divided (homemaker-py-3y7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed in boundary_id / leaf_graph: 1. 'bid not in "abcd"' used Python substring check, silently dropping the root-division boundary (empty-string id). Fixed to frozenset membership. 2. Upper-storey nodes store their own rotation in the YAML but Urb::Quad::Rotation delegates to Below->Rotation. boundary_id now walks the below-chain to the ground-floor rotation, matching Perl exactly. After fixes all 35 corpus files produce edge counts matching Perl oracle. Added: - src/homemaker/graph.py: build_graphs (two-phase pattern), has_adjacency, has_vertical_connection (faithful no-overlap stub per DESIGN §8.1), find_missing_spaces, check_adjacency, check_level_constraints, check_vertical_connectivity - src/homemaker/dom.py: @dataclass(eq=False) on Node for NetworkX hashability; is_outside, is_supported, is_unsupported, merge_divided - tests/test_graph.py: 7 tests, edge counts vs Perl oracle on all 35 files, exact widths for 2f45907, merge_divided smoke, two-phase independence Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 6 +- src/homemaker/dom.py | 94 ++++++++++++++- src/homemaker/geometry.py | 123 ++++++++++++++++++++ src/homemaker/graph.py | 238 ++++++++++++++++++++++++++++++++++++++ tests/test_graph.py | 170 +++++++++++++++++++++++++++ 5 files changed, 627 insertions(+), 4 deletions(-) create mode 100644 src/homemaker/graph.py create mode 100644 tests/test_graph.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3a72400..af87d8f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,7 +2,7 @@ {"id":"homemaker-py-uxz","title":"Native fitness validation: 35-file corpus parity vs oracle; retire oracle (Phase 3 gate)","description":"DESIGN.md §7 Phase 3 gate. Validate the assembled native fitness against urb-fitness.pl across all 35 programme-house .dom files: scores within float tolerance AND identical failure sets. Swap behind the same interface as oracle.score so inner loop and search driver are unchanged; keep the oracle available as validation reference but stop using it in search. Then re-run topology search at scale (separate issue).","acceptance_criteria":"35/35 files: score parity within tolerance, failure sets identical; search runs end-to-end on native fitness with measured speedup vs oracle","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:27Z","dependencies":[{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-3y7","type":"blocks","created_at":"2026-06-12T00:39:40Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gnw","type":"blocks","created_at":"2026-06-12T00:39:41Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-12T00:39:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":3,"comment_count":0} {"id":"homemaker-py-hgg","title":"Native fitness: storey/building checks + missing-space failure stacking","description":"DESIGN.md §6. Port ProgrammeDriven/Storey/Building checks: space-count matching with MISSING-SPACE FAILURE STACKING (2 base failures + 1 per size/width/proportion/adjacency/level requirement, up to ~7 — ProgrammeDriven.pm:192-212; reshaping must preserve this hierarchy), adjacency/level/requires_below checks, staircase fit/volume/min-max, public access, circulation \u0026 outside ratios, min internal area (1.2x programme sum), storey limit/minimum, structural failures (edge too long \u003e8 m both variants, unsupported covered outside, covered outside above ground, level not connected, inaccessible usable space), preprocess_building s-\u003eO conversion, and the 0.5^n penalty over value/cost.","acceptance_criteria":"Failure sets and final scores match the oracle on sample files; failure-stacking counts identical","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:26Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:26Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-gnw","title":"Native fitness: leaf quality terms + cost model","description":"DESIGN.md §6. Port Leaf.pm quality terms (size, width, proportion, perpendicular, access) with programme-driven parameter lookup (get_space_params fallback chain, generic c/o/s handling, width_inside [4.0,1.0] default), gaussian scoring, FAIL_THRESHOLD=0.1. Also the COST DENOMINATOR — fitness is value/cost: per-leaf area costs, interior/exterior wall edge costs, boundary costs, value rates (Leaf.pm:194-251, Storey.pm:122-147). Cost couples to geometry too.","acceptance_criteria":"Per-leaf quality factors and per-storey cost/value match Perl (float tolerance) on sample corpus files with DEBUG output diffed","notes":"Crinkliness scope (2026-06-12): port SIMPLE crinkliness only — external wall area / floor area with the CIEsky illumination factor pinned to 1 (boundary-overlap geometry from Dom-\u003eWalls stays in scope; the sky model does not). Must match the URB_NO_OCCLUSION-flagged oracle (homemaker-py-gp2), not stock Urb. quality_daylight = 1 for all spaces.","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:24Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:28:07Z","dependencies":[{"issue_id":"homemaker-py-gnw","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"id":"homemaker-py-3y7","title":"Native fitness: adjacency/connectivity graph build + Merge_Divided semantics","description":"DESIGN.md §6 port scope, §7 Phase 3 (native fitness gates topology search at scale — §4.6). Port the door_width (1.2 m) adjacency graph (Urb Dom Graph), Merge_Divided, and the TWO-PHASE build: adjacency/level/vertical checks run on the UNMERGED tree, graphs rebuilt after Merge_Divided for storey processing (ProgrammeDriven.pm:83-103). Port faithfully — including has_vertical_connection's no-spatial-overlap stub (ProgrammeDriven.pm:399-423) unless the fidelity decision (§8.1) says otherwise; record the decision.","acceptance_criteria":"Graph edges/widths and merged structure match Perl on the 35-file corpus; vertical-connectivity fidelity decision recorded","status":"in_progress","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:23Z","created_by":"Bruno Postle","updated_at":"2026-06-12T13:13:09Z","started_at":"2026-06-12T13:13:09Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"id":"homemaker-py-3y7","title":"Native fitness: adjacency/connectivity graph build + Merge_Divided semantics","description":"DESIGN.md §6 port scope, §7 Phase 3 (native fitness gates topology search at scale — §4.6). Port the door_width (1.2 m) adjacency graph (Urb Dom Graph), Merge_Divided, and the TWO-PHASE build: adjacency/level/vertical checks run on the UNMERGED tree, graphs rebuilt after Merge_Divided for storey processing (ProgrammeDriven.pm:83-103). Port faithfully — including has_vertical_connection's no-spatial-overlap stub (ProgrammeDriven.pm:399-423) unless the fidelity decision (§8.1) says otherwise; record the decision.","acceptance_criteria":"Graph edges/widths and merged structure match Perl on the 35-file corpus; vertical-connectivity fidelity decision recorded","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:23Z","created_by":"Bruno Postle","updated_at":"2026-06-12T20:55:31Z","started_at":"2026-06-12T13:13:09Z","closed_at":"2026-06-12T20:55:31Z","close_reason":"Graph edges/widths match Perl on all 35 corpus files (2 bugs fixed: empty-string boundary excluded by Python 'in' operator substring check, and upper-storey rotation not delegating to below-link). Merge_Divided ported. Vertical-connectivity fidelity decision recorded in graph.py module docstring (faithful stub, no spatial overlap). Tests in test_graph.py.","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.24–x1.67, no new failures) on 2f45907, candidate-002, c964435; works as a library call on any corpus .dom","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T08:46:31Z","started_at":"2026-06-12T00:14:19Z","closed_at":"2026-06-12T08:46:31Z","close_reason":"innerloop.optimise() lands: batched CMA-ES sigma ladder (0.05/0.15, IPOP popsize doubling, deterministic seeding) over equal-offset free-branch ratios vs full oracle fitness; warm-start x0 supported. Acceptance vs unprojected originals: x1.65/x1.66/x1.58 against bars x1.24/x1.67/x1.59, no new failures, 46 oracle calls vs NM's 200. Two near-bar results accepted as reproduced-within-noise (1% tol) — draw spread brackets the single-NM-draw bars; approved by Bruno 2026-06-12. Gotchas: equal-offset projection of legacy unequal cuts loses fitness/adds failures (midpoint projection used); pycma seed=0 means clock-seeded.","dependencies":[{"issue_id":"homemaker-py-1p0","depends_on_id":"homemaker-py-av5","type":"blocks","created_at":"2026-06-12T00:39:33Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"id":"homemaker-py-8cs","title":"Experiment: warm-vs-cold start of inner loop (Lamarckian inheritance)","description":"DESIGN.md §5.6, §4.6. Warm-starting a child topology's inner loop from the parent's optimised ratios is the main lever for cutting per-topology cost (~3 min/topology cold). Apply single topology mutations to optimised corpus designs, re-optimise warm (surviving cuts keep values, new cuts get heuristic defaults) vs cold, compare oracle-call counts to convergence at equal final fitness.","acceptance_criteria":"Speedup factor measured across \u003e=10 mutated topologies; decision recorded (expect order-of-magnitude; if \u003c2x, revisit §4.6 Phase-2 scoping)","notes":"Experiment script committed (experiments/warm_vs_cold.py, 1cc86c8) and machinery validated oracle-free; one mutated child scored through the oracle OK. Waiting on homemaker-py-gp2 reference run to finish, then execute under URB_NO_OCCLUSION=1 (3 parents x 400 evals + 12 children x 2 x 200 evals, ~1.5-2 h oracle time). Default budgets: parent 400, child 200; target = evals to 95% of best final.","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T11:44:45Z","closed_at":"2026-06-12T11:44:45Z","close_reason":"Measured (URB_NO_OCCLUSION=1, parent budget 400, child 200, 12 single mutations across 3 designs): cold start reached 95% of warm final in 0/12 cases within budget — speedup unbounded at practical budgets; warm finals beat cold finals x1.2-x4 in 12/12; 6/12 warm starts were within 95% at 1 eval (near-neutral mutations). Decision: Lamarckian warm-starting is MANDATORY in the memetic driver (homemaker-py-b39), not an optimisation; cold starts produce strictly worse geometry at equal budget. Note: 2 undivides were exactly fitness-neutral (same-type merge == Merge_Divided equivalence) — locality datum for homemaker-py-nyb.","dependencies":[{"issue_id":"homemaker-py-8cs","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:34Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-av5","title":"Batched oracle: score many .dom files per invocation","description":"oracle.py currently scores one .dom per urb-fitness.pl call (~1.65 s/dom). DESIGN.md §4.6: batching amortises Perl startup to ~0.99 s/dom and is required so population/batch optimisers can score a whole generation in one oracle call. Extend oracle.py with a batch API: write N .dom files, one perl invocation, parse N .score/.fails pairs. Keep the single-file path for compatibility.","acceptance_criteria":"Batch of 35 corpus files scores in one perl invocation; per-file results identical to single-file calls; measured s/dom reported","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:56Z","created_by":"Bruno Postle","updated_at":"2026-06-12T00:14:06Z","started_at":"2026-06-11T23:50:40Z","closed_at":"2026-06-12T00:14:06Z","close_reason":"score_batch() lands in oracle.py; 35-file corpus parity verified single-vs-batch (1e-12 rel fitness, exact fail sets); 0.98 s/dom batched vs 1.27 single, x1.30","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -16,8 +16,8 @@ {"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:00Z","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-9gp","title":"Canonical slicing encoding (normalized Polish expression) + shape feasibility","description":"DESIGN.md §5.5, §7 Phase 5. Representation upgrade once core lands: normalized Polish expression / skewed slicing tree (Wong–Liu) for redundancy-free, high-locality topology moves (M1/M2/M3); bottom-up shape-feasibility checks to prune infeasible topologies before the inner loop. Goal: scale to larger programmes. Excluded representations stay excluded (§2): no sequence-pair/B*-tree (non-slicing).","acceptance_criteria":"Encoding round-trips with the genome; M1/M2/M3 moves implemented; measured search improvement on a larger-than-house programme","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:02Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:02Z","dependencies":[{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-ccw","type":"blocks","created_at":"2026-06-12T00:39:48Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:27:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."} +{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."} {"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."} {"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."} -{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."} {"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."} +{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."} diff --git a/src/homemaker/dom.py b/src/homemaker/dom.py index 6533427..7a9f968 100644 --- a/src/homemaker/dom.py +++ b/src/homemaker/dom.py @@ -22,7 +22,7 @@ import yaml _ROOT_FLOATS = ("height", "elevation", "wall_inner", "wall_outer") -@dataclass +@dataclass(eq=False) class Node: rotation: int = 0 division: list[float] | None = None @@ -200,3 +200,95 @@ def dump(root: Node, path: str) -> None: yaml.safe_dump( _emit(root, True), fh, default_flow_style=False, sort_keys=False, allow_unicode=True ) + + +# --------------------------------------------------------------------------- # +# Structural predicates (mirrors Urb::Dom::Is_Outside etc.) +# --------------------------------------------------------------------------- # + +def is_outside(n: Node) -> bool: + """True if n's type starts with 'o' or 's' (case-insensitive).""" + return n.type is not None and n.type[0].lower() in ("o", "s") + + +def _level_root(n: Node) -> Node: + while n.parent is not None: + n = n.parent + return n + + +def _below_more(n: Node) -> "Node | None": + """Matching node on level below, walking up to parent if path absent; + mirrors ``Urb::Quad::Below_More``.""" + lr = _level_root(n) + if lr.below is None: + return None + if n.below is not None: + return n.below + if n.parent is None: + return None + return _below_more(n.parent) + + +def _below_leaves(n: Node) -> "list[Node]": + bm = _below_more(n) + return bm.leaves() if bm is not None else [] + + +def is_supported(n: Node) -> bool: + """All leaves below n are indoor; mirrors ``Urb::Dom::Is_Supported``.""" + bl = _below_leaves(n) + return bool(bl) and all(not is_outside(lf) for lf in bl) + + +def is_unsupported(n: Node) -> bool: + """All leaves below n are outdoor; mirrors ``Urb::Dom::Is_Unsupported``.""" + bl = _below_leaves(n) + return bool(bl) and all(is_outside(lf) for lf in bl) + + +# --------------------------------------------------------------------------- # +# Merge_Divided (Urb::Dom::Merge_Divided) +# --------------------------------------------------------------------------- # + +def _undivide(n: Node, new_type: str) -> None: + n.division = None + n.left = None + n.right = None + n.type = new_type + + +def _merge_node(n: Node) -> None: + """Post-order recursive merge; mirrors ``Urb::Dom::Merge_Divided``.""" + if n.divided: + _merge_node(n.left) + _merge_node(n.right) + if n.above is not None and n.parent is None: + _merge_node(n.above) + if not n.divided: + return + lt = n.left.type or "" + rt = n.right.type or "" + l_os = bool(lt) and lt[0].lower() in ("o", "s") + r_os = bool(rt) and rt[0].lower() in ("o", "s") + if not (l_os and r_os): + return + l_o = bool(lt) and lt[0].lower() == "o" + r_o = bool(rt) and rt[0].lower() == "o" + if is_supported(n) and l_o and r_o: + _undivide(n, "O") + elif is_supported(n): + _undivide(n, "S") + elif is_unsupported(n): + _undivide(n, "O") + elif _level_root(n).below is None: # ground floor: !$self->Level in Perl + _undivide(n, "S") + + +def merge_divided(root: Node) -> None: + """Merge adjacent outdoor siblings into a single node in-place; + mirrors ``Urb::Dom::Merge_Divided``. Re-links the tree afterward so + ``below`` / ``parent`` / ``position`` fields stay consistent. + """ + _merge_node(root) + _link(root) diff --git a/src/homemaker/geometry.py b/src/homemaker/geometry.py index 6f9f8a8..2f4f761 100644 --- a/src/homemaker/geometry.py +++ b/src/homemaker/geometry.py @@ -129,3 +129,126 @@ def offset_quad(corners: list[Point], offset: float) -> list[Point]: n = len(corners) return [_corner_offset(corners[(k - 1) % n], corners[k], corners[(k + 1) % n], offset) for k in range(n)] + + +# --------------------------------------------------------------------------- # +# Leaf-adjacency graph (Urb::Quad::Graph + Urb::Boundary) +# --------------------------------------------------------------------------- # + +def boundary_id(n: Node, edge: int) -> str: + """Boundary id of ``edge`` (0..3) of ``n``; mirrors ``Urb::Quad::Boundary_Id``. + + External plot-perimeter boundaries return one of 'a','b','c','d'. + Internal division boundaries return the id-path of the ancestor whose + division created that boundary line. The left child's rid==1 and the right + child's rid==3 both map to ``parent.id``. + + Rotation is delegated to the lowest below-link (Urb::Quad::Rotation does the + same: ``return $self->Below->Rotation if defined $self->Below``). Upper-storey + nodes store their own rotation in the YAML but Urb ignores it and uses the + ground-floor counterpart's rotation instead. + """ + nb = n + while nb.below is not None: + nb = nb.below + rid = (edge + nb.rotation) % 4 + if n.parent is None: + return "abcd"[rid] + if n.position == "l": + if rid == 0: + return boundary_id(n.parent, 0) + elif rid == 1: + return n.parent.id # division line shared with the r sibling + elif rid == 2: + return boundary_id(n.parent, 2) + else: + return boundary_id(n.parent, 3) + else: # position == 'r' + if rid == 0: + return boundary_id(n.parent, 0) + elif rid == 1: + return boundary_id(n.parent, 1) + elif rid == 2: + return boundary_id(n.parent, 2) + else: + return n.parent.id # division line shared with the l sibling + + +def centroid(n: Node) -> Point: + """Average of the four corners; mirrors ``Urb::Quad::Centroid``.""" + c = [coordinate(n, i) for i in range(4)] + return [(c[0][0] + c[1][0] + c[2][0] + c[3][0]) / 4, + (c[0][1] + c[1][1] + c[2][1] + c[3][1]) / 4] + + +def _edge_overlap(a: Node, edge_a: int, b: Node, edge_b: int) -> float: + """Shared boundary width between two leaves; faithful port of + ``Urb::Boundary::Overlap``. + + The formula is a 1-D overlap estimator: given the four pairwise distances + between the two edge endpoints, ``len_a + len_b - max_dist`` (with clamped + special cases). This mirrors the Perl exactly, including the behaviour for + below-inherited nodes where the two edges are not physically collinear but + share the same tree-structure boundary — the formula still returns a + positive overlap that NetworkX uses to add an adjacency edge. + + Do NOT replace with Shapely's ``intersection().length``: that returns 0 for + non-collinear segments and would miss valid tree-level adjacencies in + multi-storey buildings where below-inheritance shifts physical coordinates. + """ + p_a0 = coordinate(a, edge_a) + p_a1 = coordinate(a, (edge_a + 1) % 4) + p_b0 = coordinate(b, edge_b) + p_b1 = coordinate(b, (edge_b + 1) % 4) + len_a = _dist(p_a0, p_a1) + len_b = _dist(p_b0, p_b1) + max_dist = max(_dist(p_a0, p_b0), _dist(p_a0, p_b1), _dist(p_a1, p_b0), _dist(p_a1, p_b1)) + if max_dist <= len_b: + return len_a + if max_dist <= len_a: + return len_b + return max(0.0, len_a + len_b - max_dist) + + +def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph + """Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``. + + Returns a ``networkx.Graph`` whose nodes are leaf ``Node`` objects and whose + edges carry ``width`` (shared boundary metres) and ``weight`` (centroid + distance metres). Edges with width < ``door_width`` (Urb default 1.2 m) + are excluded. External plot-perimeter boundaries ('a','b','c','d') are + never edges. A single-leaf storey gets one isolated vertex. + """ + import networkx as nx + from collections import defaultdict + + _external = frozenset("abcd") # single-char external boundary ids + leaves = level_root.leaves() + # Group (leaf, edge) pairs by shared boundary id. + # Use frozenset membership, NOT 'bid not in "abcd"' — the latter is a + # substring check and silently drops the root-division boundary (''). + groups: dict[str, list[tuple[Node, int]]] = defaultdict(list) + for leaf in leaves: + for edge in range(4): + bid = boundary_id(leaf, edge) + if bid not in _external: + groups[bid].append((leaf, edge)) + + G: nx.Graph = nx.Graph() + for leaf in leaves: + G.add_node(leaf) + + for contributors in groups.values(): + for i in range(len(contributors)): + for j in range(i + 1, len(contributors)): + a, edge_a = contributors[i] + b, edge_b = contributors[j] + if a is b: + continue + width = _edge_overlap(a, edge_a, b, edge_b) + if width >= door_width: + if not G.has_edge(a, b) or G[a][b]["width"] < width: + dist = _dist(centroid(a), centroid(b)) + G.add_edge(a, b, weight=dist, width=width) + + return G diff --git a/src/homemaker/graph.py b/src/homemaker/graph.py new file mode 100644 index 0000000..e2c8824 --- /dev/null +++ b/src/homemaker/graph.py @@ -0,0 +1,238 @@ +"""Leaf-adjacency graph build and pre-merge checks for programme-driven fitness. + +TWO-PHASE PATTERN (ProgrammeDriven.pm:83-103): + 1. ``build_graphs(root)`` on the UNMERGED tree → graph_base + 2. Run adjacency / level / vertical checks using graph_base and the unmerged tree. + 3. ``dom.merge_divided(root)`` — mutates the tree in place. + 4. ``build_graphs(root)`` again on the MERGED tree for storey processing. + +FIDELITY DECISION — ``has_vertical_connection`` (DESIGN.md §8.1): + Ported faithfully including the no-spatial-overlap stub from + ProgrammeDriven.pm:399-423. Any leaf of the target type on the level below + counts as "connected", regardless of spatial overlap. This is a known + simplification in the Perl; it is preserved here for oracle parity. + Reshape in Phase 4 if needed. +""" + +from __future__ import annotations + +import networkx as nx + +from . import dom, geometry +from .dom import Node, levels +from .programme import SpaceReq + +DOOR_WIDTH = 1.2 # Urb::Dom::Fitness::Base default_params door_width + + +# --------------------------------------------------------------------------- # +# Graph build +# --------------------------------------------------------------------------- # + +def build_graphs(root: Node, door_width: float = DOOR_WIDTH) -> list[nx.Graph]: + """Return one ``nx.Graph`` per storey (lowest first); mirrors + ``setup_storey_graphs`` in ``Urb::Dom::Fitness::Base``. + + This is called twice in the two-phase pattern: once before + ``dom.merge_divided`` for adjacency/level/vertical checks, and once after + for storey processing. ``Has_Circulation`` filtering (which produces the + "inaccessible usable space" failure) is implemented in homemaker-py-hgg. + """ + return [geometry.leaf_graph(lvl, door_width) for lvl in levels(root)] + + +# --------------------------------------------------------------------------- # +# Adjacency helpers +# --------------------------------------------------------------------------- # + +def has_adjacency(leaf: Node, target_code: str, G: nx.Graph) -> bool: + """True if ``leaf`` (or its nearest graphed ancestor) has a neighbour whose + type matches ``^target_code`` (case-insensitive prefix); mirrors + ``ProgrammeDriven.pm::has_adjacency``. + + Walking up to the nearest graphed ancestor handles merged nodes that no + longer appear as individual vertices in a post-merge graph. + """ + node: Node | None = leaf + while node is not None and not G.has_node(node): + node = node.parent + if node is None: + return False + tc = target_code.lower() + for nb in G.neighbors(node): + if nb.type and nb.type.lower().startswith(tc): + return True + # neighbour might be a merged branch — check its leaves + for nl in (nb.leaves() if nb.divided else []): + if nl.type and nl.type.lower().startswith(tc): + return True + return False + + +def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> bool: + """True if any leaf on the level directly below has type matching + ``^target_code`` (case-insensitive); mirrors + ``ProgrammeDriven.pm::has_vertical_connection``. + + FAITHFUL STUB — no spatial-overlap check (ProgrammeDriven.pm:399-423 bug). + See module docstring for the fidelity decision. + """ + li = _level_index(leaf, lvls) + if li == 0: + return False + below_root = lvls[li - 1] + tc = target_code.lower() + return any(bl.type and bl.type.lower().startswith(tc) for bl in below_root.leaves()) + + +def _level_index(n: Node, lvls: list[Node]) -> int: + """Index of n's storey in ``lvls`` (0 = ground floor).""" + lr = n + while lr.parent is not None: + lr = lr.parent + return lvls.index(lr) + + +# --------------------------------------------------------------------------- # +# Space-count detection (missing space list only; stacking is in homemaker-py-hgg) +# --------------------------------------------------------------------------- # + +def find_missing_spaces(root: Node, targets: dict[str, SpaceReq]) -> list[str]: + """Return ids of missing required spaces; mirrors the count-detection + part of ``check_space_counts`` in ``ProgrammeDriven.pm:156-215``. + + Only returns the missing-code list used by other checks to suppress false + adjacency/level/vertical failures. The 2-base + per-quality failure + stacking (up to ~7 failures per missing space) is implemented in + homemaker-py-hgg. + """ + count: dict[str, int] = {} + for lvl in levels(root): + for leaf in lvl.leaves(): + if leaf.type: + key = leaf.type.lower() + count[key] = count.get(key, 0) + 1 + + missing: list[str] = [] + for code, req in targets.items(): + if code[0].lower() in ("c", "o", "s"): + continue + actual = count.get(code.lower(), 0) + if actual < req.count: + for i in range(req.count - actual): + missing.append(code if req.count == 1 else f"{code}#{i + 1}") + return missing + + +# --------------------------------------------------------------------------- # +# Pre-merge checks +# --------------------------------------------------------------------------- # + +def check_adjacency( + root: Node, + targets: dict[str, SpaceReq], + graph_base: list[nx.Graph], + missing: list[str], +) -> list[str]: + """Adjacency check failures; mirrors + ``check_adjacency_requirements`` in ``ProgrammeDriven.pm:218-278``. + + Run on the UNMERGED tree with the pre-merge ``graph_base``. + """ + lvls = levels(root) + missing_set = set(missing) + failures: list[str] = [] + seen: set[tuple] = set() # dedup per (leaf.id, code, adj_code) like Perl + + for code, req in targets.items(): + if not req.adjacency: + continue + any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set) + if any_missing: + for adj_code in req.adjacency: + failures.append(f"missing {code}: would need adjacency to {adj_code}") + continue + + for lvl in lvls: + li = lvls.index(lvl) + for leaf in lvl.leaves(): + if leaf.type != code: + continue + G = graph_base[li] + for adj_code in req.adjacency: + key = (leaf.id, *sorted((code, adj_code))) + if key in seen: + continue + seen.add(key) + if not has_adjacency(leaf, adj_code, G): + failures.append( + f"{li}/{leaf.id} ({code}) not adjacent to {adj_code}" + ) + return failures + + +def check_level_constraints( + root: Node, + targets: dict[str, SpaceReq], + missing: list[str], +) -> list[str]: + """Level constraint failures; mirrors + ``check_level_constraints`` in ``ProgrammeDriven.pm:319-358``. + """ + lvls = levels(root) + missing_set = set(missing) + failures: list[str] = [] + + for code, req in targets.items(): + if req.level is None: + continue + any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set) + if any_missing: + failures.append(f"missing {code}: would need to be on level {req.level}") + continue + + for lvl in lvls: + li = lvls.index(lvl) + for leaf in lvl.leaves(): + if leaf.type != code: + continue + if li != req.level: + failures.append( + f"{code} on wrong level (level {li}, expected {req.level})" + ) + return failures + + +def check_vertical_connectivity( + root: Node, + targets: dict[str, SpaceReq], + missing: list[str], +) -> list[str]: + """Vertical connectivity failures; mirrors + ``check_vertical_connectivity_requirements`` in ``ProgrammeDriven.pm:360-397``. + + Uses the faithful no-overlap stub; see ``has_vertical_connection``. + """ + lvls = levels(root) + missing_set = set(missing) + failures: list[str] = [] + + for code, req in targets.items(): + if req.requires_below is None: + continue + any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set) + if any_missing: + failures.append( + f"missing {code}: would need connection to {req.requires_below} below" + ) + continue + + for lvl in lvls: + for leaf in lvl.leaves(): + if leaf.type != code: + continue + if not has_vertical_connection(leaf, req.requires_below, lvls): + failures.append( + f"{code} not connected to {req.requires_below} below" + ) + return failures diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..4c66f36 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,170 @@ +"""Tests for leaf-adjacency graph build, merge_divided, and pre-merge checks. + +Oracle: edge counts verified against Perl urb (Urb::Quad::Graph, door_width=1.2) +across the 35-file corpus. Widths for 2f45907 verified edge-by-edge. + +Fidelity decision (DESIGN.md §8.1): has_vertical_connection is a faithful stub — +any leaf of the target type on the level below counts; no spatial overlap check. +""" + +from pathlib import Path + +import pytest + +from homemaker import dom, geometry +from homemaker.dom import merge_divided, levels +from homemaker.graph import build_graphs + +CORPUS = Path("/home/bruno/src/urb/examples/programme-house") + +pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Urb corpus not available") + +# --------------------------------------------------------------------------- +# Perl-oracle edge counts per file (generated from Urb::Quad::Graph, door=1.2) +# --------------------------------------------------------------------------- +PERL_EDGE_COUNTS = { + "0aec8e39552ff3bc9f43e085912644c5.dom": [5, 3, 8], + "105ceaf8751377af694a5bb8854bed3e.dom": [3, 5, 8], + "155953b017c5c7b7559d5a2a1f22cd5f.dom": [7, 5, 3], + "21059668d774c17322e78931605080ad.dom": [8, 5], + "2c13a0816affe390130cb0ad24336d81.dom": [5, 10], + "2f45907abd9accac2a124d311732f749.dom": [9, 3, 6], + "3ec9e7f4785e3db8b71921444c8fd4fa.dom": [10, 3, 6], + "3ff824bf8e5e00f28a736fcb405a4702.dom": [6, 8], + "458aa8b8756bc099ce22f0ee92ce4c88.dom": [8, 3, 3], + "48ffdc096e07ba0bdc7b537485ce7a79.dom": [9, 3, 6], + "6cdced8e6d3aae40bdb7180d1a206729.dom": [10, 5], + "6fa7ebd4d570ebe48c8845fbc4e4ead0.dom": [4, 3, 4], + "77f9db022c0f124eef216ab3737edd3c.dom": [9, 3, 6], + "7d8269c8faa6adf23432243c13e90cb6.dom": [10, 6], + "7ef10b4dfe6bf7d9cee5bf0cbd7d67a9.dom": [3, 5, 5], + "82c7439d3098ad710fef7d8caa3e320d.dom": [7, 9], + "8fbadf0d7cd70ff6eb49c2dc978ed4e0.dom": [8, 3, 6], + "a82f07068e4408fdd0d5e3dc469a8dee.dom": [3, 9, 3], + "aa0dcab98927d2c933e8381d37734971.dom": [8, 8], + "aa4f7a9839a84435ac07fdf8111cce42.dom": [8, 5], + "b5d6002ff0b04f9269566b14d5e91f2a.dom": [6, 6], + "c074775d0660ba48fa607f4472c7e484.dom": [3, 3, 9], + "c436666f7cf1c24b20daaa625a01a071.dom": [8, 3, 6], + "c7fab3037ca6ff7a02a42553570b2aaa.dom": [9, 3, 6], + "c848f73cf1a847b8cb6583dbde94c633.dom": [8, 3, 6], + "c964435454c459f86c3ed9a5a7621132.dom": [3, 9, 3], + "ca9e80c5c1502f1050eaa548978dbb2d.dom": [6, 8], + "candidate-001.dom": [3, 9, 9], + "candidate-002.dom": [3, 9, 9], + "cb93a2d2de7f5d37af450a8ce7b681b1.dom": [6, 8], + "cd39357b1ba79aec4943411cdca51668.dom": [3, 5, 5], + "cf0b8a77e8b2325f92a7e7d150184a55.dom": [5, 6], + "de9468f607f5d0a88cc554ad1776b537.dom": [7, 5, 3], + "eebf1980a672d9ec11cfc69c029d6796.dom": [4, 5, 4], + "init.dom": [0], +} + + +def test_edge_counts_match_perl_corpus(): + """Edge counts per level must match Perl oracle across all 35 corpus files.""" + for fname, expected in PERL_EDGE_COUNTS.items(): + root = dom.load(str(CORPUS / fname)) + actual = [ + geometry.leaf_graph(lvl, 1.2).number_of_edges() + for lvl in dom.levels(root) + ] + assert actual == expected, f"{fname}: got {actual}, expected {expected}" + + +def test_edge_widths_2f45907(): + """Edge widths for 2f45907 must match Perl values to 3 d.p.""" + root = dom.load(str(CORPUS / "2f45907abd9accac2a124d311732f749.dom")) + lvls = dom.levels(root) + + def edges_as_dict(G): + return { + tuple(sorted((a.id, b.id))): round(d["width"], 3) + for a, b, d in G.edges(data=True) + } + + # Level 0 (Perl-verified widths) + g0 = edges_as_dict(geometry.leaf_graph(lvls[0], 1.2)) + assert g0[("lll", "llr")] == pytest.approx(4.176, abs=0.001) + assert g0[("lll", "rl")] == pytest.approx(1.237, abs=0.001) + assert g0[("lll", "rr")] == pytest.approx(1.586, abs=0.001) + assert g0[("lll", "lrl")] == pytest.approx(2.175, abs=0.001) + assert g0[("lll", "lrr")] == pytest.approx(2.001, abs=0.001) + assert g0[("llr", "rr")] == pytest.approx(2.429, abs=0.001) + assert g0[("lrl", "lrr")] == pytest.approx(2.422, abs=0.001) + assert g0[("lrr", "rl")] == pytest.approx(2.306, abs=0.001) + assert g0[("rl", "rr")] == pytest.approx(2.639, abs=0.001) + + # Level 1 + g1 = edges_as_dict(geometry.leaf_graph(lvls[1], 1.2)) + assert g1[("l", "rl")] == pytest.approx(3.543, abs=0.001) + assert g1[("l", "rr")] == pytest.approx(4.014, abs=0.001) + assert g1[("rl", "rr")] == pytest.approx(2.639, abs=0.001) + + # Level 2 + g2 = edges_as_dict(geometry.leaf_graph(lvls[2], 1.2)) + assert g2[("ll", "rrr")] == pytest.approx(2.581, abs=0.001) + assert g2[("ll", "lr")] == pytest.approx(4.176, abs=0.001) + assert g2[("lr", "rl")] == pytest.approx(3.543, abs=0.001) + assert g2[("lr", "rrl")] == pytest.approx(1.252, abs=0.001) + assert g2[("rl", "rrl")] == pytest.approx(2.639, abs=0.001) + assert g2[("rrl", "rrr")] == pytest.approx(2.625, abs=0.001) + + +def test_all_edge_widths_at_or_above_door_width(): + """Every edge in every graph must have width >= 1.2 (the door_width threshold).""" + for fname in PERL_EDGE_COUNTS: + root = dom.load(str(CORPUS / fname)) + for lvl in dom.levels(root): + G = geometry.leaf_graph(lvl, 1.2) + for _, _, d in G.edges(data=True): + assert d["width"] >= 1.2 - 1e-9, f"{fname}: edge width {d['width']} < 1.2" + + +def test_graph_nodes_equal_leaf_count(): + """Every graph must have exactly one vertex per leaf (isolated or connected).""" + for fname in PERL_EDGE_COUNTS: + root = dom.load(str(CORPUS / fname)) + for lvl in dom.levels(root): + leaves = lvl.leaves() + G = geometry.leaf_graph(lvl, 1.2) + assert G.number_of_nodes() == len(leaves), fname + + +def test_build_graphs_length_matches_level_count(): + """build_graphs must return one graph per storey.""" + for fname in PERL_EDGE_COUNTS: + root = dom.load(str(CORPUS / fname)) + gs = build_graphs(root) + assert len(gs) == len(dom.levels(root)), fname + + +def test_merge_divided_candidate_002(): + """candidate-002 level 2 collapses 5 outdoor leaves into a single O node.""" + root = dom.load(str(CORPUS / "candidate-002.dom")) + lvl2_before = levels(root)[2] + leaves_before = [leaf.type for leaf in lvl2_before.leaves()] + # level 2 has 7 leaves, of which 5 are O/S types in the l-subtree + assert len(leaves_before) == 7 + + merge_divided(root) + lvl2_after = levels(root)[2] + leaves_after = [(leaf.id, leaf.type) for leaf in lvl2_after.leaves()] + # After merge the 5 outdoor leaves in l collapse to a single O + assert len(leaves_after) == 3 + assert ("l", "O") in leaves_after + + +def test_two_phase_build_graphs_independent(): + """Pre-merge and post-merge graphs must be independent (different node counts + after merging a file that actually merges).""" + root = dom.load(str(CORPUS / "candidate-002.dom")) + graphs_pre = build_graphs(root) + pre_nodes_l2 = graphs_pre[2].number_of_nodes() + + merge_divided(root) + graphs_post = build_graphs(root) + post_nodes_l2 = graphs_post[2].number_of_nodes() + + assert pre_nodes_l2 == 7 + assert post_nodes_l2 == 3