diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 90f5083..24dc148 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,9 @@ +{"id":"homemaker-py-40i","title":"Investigate cf0b8a77e8b2325f ~18% raw_value discrepancy (py lower than oracle)","description":"For prefix cf0b8a77e8b2325f: oracle=1.079112e-03 py=9.133243e-04 ratio=0.8464 (python is ~18% too low). debug_nfails shows py n_fails=5 oracle n_fails=5 (same failures), stair_fits=[1.3145] in python, building_factor=0.1104 (vs oracle's implied ~0.1303). The discrepancy is in raw_value (py=11837 vs oracle implied ~13975) or possibly building_factor. Need to check: (1) per-leaf quality values (crinkliness, area_outside, access) via debug_quality.txt; (2) whether the stair corners differ (cf/rl: py=[2,3] perl=[2,3] — SAME, so corners ok); (3) any quality term not yet ported or computed differently. Run debug_quality.py and compare per-leaf contributions.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T18:08:22Z","created_by":"Bruno Postle","updated_at":"2026-06-13T19:05:10Z","started_at":"2026-06-13T18:12:23Z","closed_at":"2026-06-13T19:05:10Z","close_reason":"Root cause found: Python stair_fit misses entrance-door corners. Perl check_stair_fit calls $level-\u003eEntrances($graph) and adds those corners to corners_in_use before calling Stair_Fit. For cf0b8a77/rl(C): stack=[2,3] + entrance=[1,2] → final=[2,3,1] (3 corners, one_turn, going_b=8, sf=0.9751, sf_factor=1.163, bf=0.1305). Python uses only geometry.corners_in_use=[2,3] (2 corners, two_turn, going_b=3, sf=1.3145, sf_factor=0.985, bf=0.1104). Fix tracked in homemaker-py-w1e.","dependencies":[{"issue_id":"homemaker-py-40i","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-13T19:08:30Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"id":"homemaker-py-w1e","title":"Port Perl entrance-corner logic into Python stair-fit (ca/cb parity)","description":"Perl's check_stair_fit (Leaf.pm:104-142) adds entrance edge corners to corners_in_use before computing stair_fit. Python's process_storey does not. For ca9e80c5c1502f10 and cb93a2d2de7f5d37 the oracle stair leaf 'lr' has corners [1,2,3] (from perl_bf.pl) but debug_corners.py Perl-compatible trace gives [2,3] — the extra corner 1 comes from Entrances(graph)-\u003eBoundary_Id logic. Need to: (1) port dom.Entrances() — returns {leaf_id: boundary_id} for the best-entrance leaf at ground level (Entrances() returns {} if level\u003e0); (2) port leaf.Boundary_Id(side) — returns the node sharing that edge; (3) in fitness.process_storey, after stack_corners_in_use, add entrance-edge corners before computing stair_fit. Acceptance: ca/cb ratio ≈ 1.0 (currently 1.33).","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T18:08:11Z","created_by":"Bruno Postle","updated_at":"2026-06-13T19:53:40Z","started_at":"2026-06-13T19:09:35Z","closed_at":"2026-06-13T19:53:40Z","close_reason":"Fixed: entrance corner logic via _entrance_bid_for_stair (mirrors Perl Entrances), plus root cause: _avg_path_len_from now uses weighted Dijkstra (centroid distances) matching Perl graph.average_path_length — fixes has_circulation edge removal order. All 4 debug prefixes ratio=1.000, 39 tests pass.","dependencies":[{"issue_id":"homemaker-py-w1e","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-13T19:08:29Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"id":"homemaker-py-q70","title":"Fix corners_in_use _ib(None,None) bug: triple-at-idx=3 always passes in Perl","description":"In graph.py corners_in_use(), the _ib helper returns False when pa=None or pb=None. Perl's is_between_2d(point, undef, undef) returns True (distance_2d(undef,x)=0 so abs(0-0-0)\u003c1e-6). At triple-check idx=3, c1=corners[4]=None and c2=None, so _ib(w, None, None) must return True to match Perl — meaning the triple always succeeds at idx=3. Fix: add 'if pa is None and pb is None: return True' before the existing 'if pa is None or pb is None: return False'. This is already applied to graph.py. Needs: run 35-file corpus parity test to confirm aa0dcab98927d2c9 passes (corners [0,1,3] → stair_fit=0.878 → sf_factor≈0.570 ≈ oracle).","status":"open","priority":1,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-13T18:07:54Z","created_by":"Bruno Postle","updated_at":"2026-06-13T18:07:54Z","dependencies":[{"issue_id":"homemaker-py-q70","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-13T19:08:28Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-gp2","title":"Disable occlusion/daylight in Urb oracle (env flag); re-baseline scores","description":"Strategy decision (Bruno, 2026-06-12): occlusion/daylight is orthogonal to whether a better, scalable optimisation system can be built — disable it in Urb rather than port it. Patch Urb behind an env flag (e.g. URB_NO_OCCLUSION=1): quality_daylight returns 1 for outdoor spaces too, and Crinkliness/Area_Outside pins the CIEsky_vertical illumination factor to 1 (simple crinkliness = unweighted external wall area / floor area). Keep the occlusion object plumbing — it carries the Walls/boundaries cache crinkliness needs (ProgrammeDriven.pm:97). Then re-baseline everything once at this clean boundary: corpus .score files, the DESIGN.md $4.5 gains table, accept_innerloop.py gate bars. Also measure oracle s/dom with the flag on — occlusion sampling may be a real slice of the ~1 s/dom cost. The native Python fitness then ships with simple crinkliness only; full occlusion rebuild is deferred post-Phase-5 (homemaker-py-2g5).","acceptance_criteria":"Env-flagged Urb patch; flag on: corpus re-scored, gate bars re-derived, oracle s/dom re-measured; urb-evolve confirmed to respect the flag for the Phase-2 benchmark","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-12T07:27:30Z","created_by":"Bruno Postle","updated_at":"2026-06-12T09:31:40Z","closed_at":"2026-06-12T09:31:40Z","close_reason":"URB_NO_OCCLUSION=1 patch in Urb (Leaf.pm quality_daylight -\u003e 1, Dom.pm Area_Outside illumination pinned; flag-off byte-identical, verified). Corpus re-baselined: 35/35 scores shift, one expected crinkliness failure-set change, 0.92 s/dom batched (x1.08). New reference gains recorded in DESIGN §4.7 and accept_innerloop bars (x1.63/x1.70/x1.68, deterministic seed). urb-evolve respects flag by construction. NOTE: Urb working-tree changes left uncommitted in /home/bruno/src/urb for Bruno's review.","dependency_count":0,"dependent_count":3,"comment_count":0} -{"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-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-40i","type":"blocks","created_at":"2026-06-13T19:08:33Z","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":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-q70","type":"blocks","created_at":"2026-06-13T19:08:31Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-w1e","type":"blocks","created_at":"2026-06-13T19:08:32Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":7,"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":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:26Z","created_by":"Bruno Postle","updated_at":"2026-06-13T18:11:27Z","started_at":"2026-06-13T08:59:14Z","closed_at":"2026-06-13T18:11:27Z","close_reason":"Implementation complete: storey/building checks, failure stacking, staircase logic, public access, circulation ratios, structural checks all ported. 39 tests pass.","dependency_count":0,"dependent_count":4,"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":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:24Z","created_by":"Bruno Postle","updated_at":"2026-06-13T07:00:07Z","started_at":"2026-06-12T21:07:06Z","closed_at":"2026-06-13T07:00:07Z","close_reason":"0-mismatch parity: 35 files, 407 leaves, 2849 factors","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":"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} @@ -19,5 +22,5 @@ {"_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."} -{"_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-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."} diff --git a/CLAUDE.md b/CLAUDE.md index 50af487..082fa79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,4 +66,13 @@ _Add a brief overview of your project architecture_ ## Conventions & Patterns -_Add your project-specific conventions here_ +### Oracle (urb-fitness.pl) + +`urb-fitness.pl` is in PATH. To score a `.dom` file you **must `cd` to the directory containing the `.dom` file first** — the script resolves `patterns.config`, `costs.config`, and writes `.score`/`.fails` relative to `cwd`: + +```bash +cd /home/bruno/src/urb/examples/programme-house +urb-fitness.pl cf0b8a77e8b2325f92a7e7d150184a55.dom +``` + +The score is written to `.dom.score` and failures to `.dom.fails`; the numeric score is also printed to stderr. diff --git a/src/homemaker/fitness.py b/src/homemaker/fitness.py index a982721..24966e2 100644 --- a/src/homemaker/fitness.py +++ b/src/homemaker/fitness.py @@ -156,6 +156,8 @@ class Fitness: self._conf = conf or {} self._cost = cost or {} self.spaces: dict = self._conf.get("spaces") or {} + self._programme_cache: dict | None = None + self._load_programme(self._conf) def conf(self, key: str): v = self._conf.get(key) @@ -486,3 +488,558 @@ class Fitness: def plot_cost(self, root: Node) -> float: """The 'initial cost' term: plot rate x lowest-root area.""" return self.cost("plot") * geometry.area(root) + + # ----------------------------------------------------------------------- # + # Stair geometry (Urb::Misc::Stairs + Urb::Dom::Stair_Fit) + # ----------------------------------------------------------------------- # + + @staticmethod + def _risers_number(height: float, max_riser: float) -> int: + """Number of risers; mirrors ``risers_number`` in ``Urb::Misc::Stairs``.""" + n = height / max_riser + return n if int(n) == n else 1 + int(n) + + @staticmethod + def _ideal_going(riser: float) -> float: + """Ideal going in metres; mirrors ``ideal_going`` in ``Urb::Misc::Stairs``.""" + going = 0.625 - 2 * riser + if going < 0.22: + return 0.22 + if int(going * 200) == going * 200: + return going + return 0.005 + int(going * 200) / 200 + + @staticmethod + def _three_turn(risers: int, going_a: int) -> int: + r = int((risers + 1) / 2) - 5 - int(going_a) + return max(0, r) + + @staticmethod + def _two_turn(risers: int, going_a: int) -> int: + if risers % 2 == 1: + r = int(risers / 2) - 3 - int(going_a / 2) + else: + r = int(risers / 2) - 3 - int((going_a + 1) / 2) + return max(0, r) + + @staticmethod + def _one_turn(risers: int, going_a: int) -> int: + r = risers - 4 - int(going_a) + return max(0, r) + + @staticmethod + def _zero_turn(risers: int, going_a: int) -> int: + if going_a + 2 > risers: + return 0 + return risers - 1 + + def _stair_fit(self, leaf: Node, corners: list[int]) -> float: + """Stair fit score for one circulation leaf; mirrors ``Urb::Dom::Stair_Fit``.""" + root = dom_mod._level_root(leaf) + while root.below is not None: + root = root.below + max_riser = getattr(root, "stair_riser", None) or 0.21 + width = getattr(root, "stair_width", None) or 1.25 + + height = _height(leaf) + risers = self._risers_number(height, max_riser) + going = self._ideal_going(height / risers) + base = geometry.edge_length(leaf, corners[0]) + length = geometry.edge_length(leaf, corners[0] + 1) + + going_a = int((base - 2 * width) / going) + n = len(corners) + if n == 1: + going_b = self._three_turn(risers, going_a) + elif n == 2: + going_b = self._two_turn(risers, going_a) + elif n == 3: + going_b = self._one_turn(risers, going_a) + else: + going_b = self._zero_turn(risers, going_a) + + return length / (width * 2 + going * going_b) + + # ----------------------------------------------------------------------- # + # Building-level ratio helpers (Dom.pm:Ratios/Areas/Area_Internal) + # ----------------------------------------------------------------------- # + + @staticmethod + def _areas(root: Node) -> tuple[float, dict[str, float]]: + """Total usable area and per-type area dict; mirrors ``Urb::Dom::Areas``.""" + area_all = 0.0 + areas: dict[str, float] = {} + for lvl in dom_mod.levels(root): + for leaf in lvl.leaves(): + if not dom_mod.is_usable(leaf): + continue + a = geometry.area(leaf) + area_all += a + t = leaf.type or "" + areas[t] = areas.get(t, 0.0) + a + return area_all, areas + + @staticmethod + def _area_internal(root: Node) -> float: + """Non-outside usable area; mirrors ``Urb::Dom::Area_Internal``.""" + total = 0.0 + for lvl in dom_mod.levels(root): + for leaf in lvl.leaves(): + if dom_mod.is_outside(leaf): + continue + total += geometry.area(leaf) + return total + + def _ratios(self, root: Node) -> dict[str, float]: + """Per-type proportions; mirrors ``Urb::Dom::Ratios``.""" + area_all, areas = self._areas(root) + if area_all == 0.0: + return {} + return {t: a / area_all for t, a in areas.items()} + + def ratio_o(self, ratios: dict[str, float]) -> float: + """Outside/sahn proportion gaussian; mirrors ``ProgrammeDriven::ratio_o``.""" + proportion_o = sum(v for k, v in ratios.items() if k and k[0].lower() in ("o", "s")) + return gaussian(proportion_o, 1.0, *self.conf("ratio_outside")) + + def ratio_type(self, ratios: dict[str, float], code: str, ratio: float, sigma: float) -> float: + """Type-class proportion gaussian; mirrors ``ProgrammeDriven::ratio_type``.""" + proportion_type = sum(v for k, v in ratios.items() if k and k[0].lower() == code[0].lower()) + proportion_non_o = 1.0 - sum(v for k, v in ratios.items() if k and k[0].lower() in ("o", "s")) + if proportion_non_o <= 0.0: + proportion_non_o = 1.0 + return gaussian(proportion_type / proportion_non_o, 1.0, ratio, sigma) + + def quality_staircase_volume(self, *stair_fits: float) -> float: + """Best-stair gaussian; mirrors ``ProgrammeDriven::quality_staircase_volume``.""" + factor = 0.09 + for sf in stair_fits: + if sf < 1: + f2 = gaussian(sf, 1.2, 1.0, 0.1) + else: + f2 = gaussian(sf, 1.2, 1.0, 0.5) + if f2 > factor: + factor = f2 + return factor + + # ----------------------------------------------------------------------- # + # Public access / boundary length helpers + # ----------------------------------------------------------------------- # + + @staticmethod + def _access_external(leaf: Node) -> list[str]: + """External boundary ids ('a'-'d') for each edge of leaf.""" + _EXT = frozenset("abcd") + result = [] + for edge in range(4): + bid = geometry.boundary_id(leaf, edge) + if bid in _EXT: + result.append(bid) + return result + + @staticmethod + def _perimeter_type(root: Node, bid: str) -> str: + """Type string from root perimeter dict ('' if not set).""" + p = root.perimeter + if p is None: + return "" + return p.get(bid) or "" + + def _public_access(self, leaf: Node, root: Node) -> str | None: + """Return external boundary id if leaf has public street access; mirrors + ``Urb::Dom::Public_Access``. Returns None if no public access.""" + if dom_mod.level_of(leaf) != 0: + return None + if leaf.divided: + return None + for bid in self._access_external(leaf): + if self._perimeter_type(root, bid).lower() != "private": + return bid + return None + + def _entrance_bid_for_stair( + self, + stair_leaf: Node, + level_root: Node, + G: nx.Graph, + graph_circ: list, + all_lvls: list, + root: Node, + ) -> str | None: + """Return boundary id if stair_leaf is the building entrance; else None. + + Mirrors the stair-entrance selection in Urb::Dom::Entrances: a stair C + leaf wins (priority 3) only when no non-stair C leaf has a higher-priority + entrance (priority 4 direct, 4.5 via outdoor). Via-outdoor stair entries + (priority 3.5) map to a leaf id, not a boundary, so they never produce + entrance corners in Perl either. + """ + from . import graph as graph_mod + + stair_bid = self._public_access(stair_leaf, root) + if stair_bid is None: + return None + for other in level_root.leaves(): + if other is stair_leaf: + continue + if not other.type or other.type[0].lower() != "c": + continue + other_corners = graph_mod.stack_corners_in_use(other, graph_circ, all_lvls) + if dom_mod.is_covered(other) and other_corners: + continue # also a stair — same priority, skip + if self._public_access(other, root) is not None: + return None + for nb in G.neighbors(other): + if nb.type and nb.type[0].lower() == "o" and self._public_access(nb, root) is not None: + return None + return stair_bid + + def _public_access_outside(self, leaf: Node, G: nx.Graph, root: Node) -> bool: + """True if leaf is an outside street-edge node with an lck neighbour; + mirrors ``Urb::Dom::Public_Access_Outside``.""" + if leaf.divided: + return False + if not dom_mod.is_outside(leaf): + return False + if self._public_access(leaf, root) is None: + return False + for nb in G.neighbors(leaf): + if nb.type and nb.type[0].lower() in ("l", "c", "k"): + return True + return False + + def _public_length(self, leaf: Node, root: Node) -> float: + """Non-private external boundary metres; mirrors ``Urb::Dom::Public_Length``.""" + if dom_mod.level_of(leaf) != 0: + return 0.0 + total = 0.0 + for edge in range(4): + bid = geometry.boundary_id(leaf, edge) + if bid not in frozenset("abcd"): + continue + if self._perimeter_type(root, bid).lower() == "private": + continue + total += geometry.edge_length(leaf, edge) + return total + + def _private_length(self, leaf: Node, root: Node) -> float: + """Private external boundary metres; mirrors ``Urb::Dom::Private_Length``.""" + if dom_mod.level_of(leaf) != 0: + return 0.0 + total = 0.0 + for edge in range(4): + bid = geometry.boundary_id(leaf, edge) + if bid not in frozenset("abcd"): + continue + if self._perimeter_type(root, bid).lower() != "private": + continue + total += geometry.edge_length(leaf, edge) + return total + + # ----------------------------------------------------------------------- # + # Extended process_storey (adds circ, stair, tracking) + # ----------------------------------------------------------------------- # + + def process_storey( + self, + level_root: Node, + G: nx.Graph, + level_id: int, + fail, + graph_circ: list[nx.Graph] | None = None, + tracking: dict | None = None, + lvls: list[Node] | None = None, + root: Node | None = None, + ) -> StoreyEval: + """Per-storey cost, value and leaf evaluations on the MERGED tree. + + Optional ``graph_circ``, ``tracking``, ``lvls``, ``root`` activate the + homemaker-py-hgg storey checks (stair fit, circulation connectivity, + roof-garden, public-access tracking). When omitted the method behaves + as in homemaker-py-gnw (leaf quality + costs only). + """ + from . import graph as graph_mod + + groups = geometry.boundary_groups(level_root) + cost = 0.0 + value = 0.0 + leaves_eval: list[LeafEval] = [] + has_outdoor_space = False + + for leaf in level_root.leaves(): + if dom_mod.is_outside(leaf) and dom_mod.is_covered(leaf) and level_id: + if not dom_mod.is_supported(leaf): + fail(f"{level_id}/{leaf.id} unsupported covered outside") + fail(f"{level_id}/{leaf.id} covered outside above ground") + + cost += self.leaf_cost(leaf) + if not dom_mod.is_usable(leaf): + continue + + if dom_mod.is_outside(leaf): + has_outdoor_space = True + + quality, factors = self.evaluate_leaf(leaf, G, level_id, groups, fail) + rate = self.value_rate(leaf) + value += quality * rate * geometry.area(leaf) + leaves_eval.append( + LeafEval( + level=level_id, + id=leaf.id, + type=leaf.type or "", + area=geometry.area(leaf), + rate=rate, + quality=quality, + factors=factors, + ) + ) + + if graph_circ is not None and tracking is not None and lvls is not None and root is not None: + # Stair fit — ground floor circulation/covered only + stair_fit = 0.0 + if level_id == 0 and leaf.type and leaf.type[0].lower() == "c" and dom_mod.is_covered(leaf): + all_lvls = lvls + corners = graph_mod.stack_corners_in_use(leaf, graph_circ, all_lvls) + n_corners = len(corners) + if n_corners: + # Mirror Perl check_stair_fit: add entrance door corners so + # the stair loses the corner it shares with the entrance. + entrance_bid = self._entrance_bid_for_stair( + leaf, level_root, G, graph_circ, all_lvls, root + ) + if entrance_bid is not None: + for edge in range(4): + if geometry.boundary_id(leaf, edge) == entrance_bid: + for ec in (edge, edge + 1): + if ec not in corners: + corners = corners + [ec] + stair_fit = self._stair_fit(leaf, corners) + tracking["stair_fit"].append(stair_fit) + + # Public access tracking + if root is not None: + if self._public_access_outside(leaf, G, root): + tracking["has_public_access_outside"] = True + if (not stair_fit + and leaf.type and leaf.type[0].lower() == "c" + and self._public_access(leaf, root) is not None): + tracking["has_public_access_inside"] = True + + pub = self._public_length(leaf, root) + tracking["public_length_all"] = tracking.get("public_length_all", 0.0) + pub + if dom_mod.is_outside(leaf): + tracking["public_length_outside"] = tracking.get("public_length_outside", 0.0) + pub + priv = self._private_length(leaf, root) + tracking["private_length_all"] = tracking.get("private_length_all", 0.0) + priv + if dom_mod.is_outside(leaf): + tracking["private_length_outside"] = tracking.get("private_length_outside", 0.0) + priv + + for a, b in G.edges(): + cost += self.edge_cost(G, a, b, fail) + for leaf in level_root.leaves(): + cost += self.outside_edge_cost(leaf, fail) + + if graph_circ is not None: + # Connected_Circulation check on a copy of the circ graph + gc_copy = graph_circ[level_id].copy() if level_id < len(graph_circ) else nx.Graph() + if not graph_mod.connected_circulation(gc_copy): + fail(f"level {level_id} not connected") + + conf_fg = self.conf("force_roof_garden") + if conf_fg and not has_outdoor_space: + fail(f"level {level_id} no outside space") + + return StoreyEval(cost=cost, value=value, leaves=leaves_eval) + + # ----------------------------------------------------------------------- # + # Building-level evaluation + # ----------------------------------------------------------------------- # + + def evaluate_building(self, root: Node, tracking: dict) -> float: + """Building factor; mirrors ``evaluate_building_program_driven``.""" + from . import graph as graph_mod + + ratios = self._ratios(root) + + factor = 1.0 + factor *= self.ratio_o(ratios) + + circ_ratio = self.conf("ratio_circulation") + factor *= self.ratio_type(ratios, "c", circ_ratio[0], circ_ratio[1]) + + min_required = 0.0 + for req in (self._programme or {}).values(): + if req.code and req.code[0].lower() in ("c", "o", "s"): + continue + if req.size > 0: + min_required += req.size * req.count + min_required *= 1.2 + actual_internal = self._area_internal(root) + if actual_internal < min_required and min_required > 0: + f2 = gaussian(actual_internal, 1.0, min_required, min_required * 0.15) + factor *= f2 + + # Public/private ratios (optional config) + pub_all = tracking.get("public_length_all", 0.0) + pub_ratio = tracking.get("public_length_outside", 0.0) / pub_all if pub_all else 0.0 + conf_po = self.conf("ratio_public_outside") + if conf_po and isinstance(conf_po, list): + factor *= gaussian(pub_ratio, 1.0, conf_po[0], conf_po[1]) + + priv_all = tracking.get("private_length_all", 0.0) + priv_ratio = tracking.get("private_length_outside", 0.0) / priv_all if priv_all else 0.0 + conf_pr = self.conf("ratio_private_outside") + if conf_pr and isinstance(conf_pr, list): + factor *= gaussian(priv_ratio, 1.0, conf_pr[0], conf_pr[1]) + + # Staircase volume (multi-level only) + lvls = dom_mod.levels(root) + if len(lvls) > 1: + sf_factor = self.quality_staircase_volume(*tracking.get("stair_fit", [])) + if sf_factor < FAIL_THRESHOLD: + tracking["_failures"].append("staircase volume") + factor *= sf_factor + + stair_min = self.conf("staircase_min") or 1 + stair_max = self.conf("staircase_max") or 1 + stair_count = len(tracking.get("stair_fit", [])) + + if stair_count < stair_min: + tracking["_failures"].append( + f"too few stairs ({stair_count}, min {stair_min})" + ) + if stair_count > stair_max: + tracking["_failures"].append( + f"too many stairs ({stair_count}, max {stair_max})" + ) + + # Storey limit / minimum + n_storeys = len(lvls) + storey_limit = self.conf("storey_limit") or 4 + storey_min = self.conf("storey_minimum") or 2 + if n_storeys - 1 >= storey_limit: + tracking["_failures"].append("storey limit") + if n_storeys < storey_min: + tracking["_failures"].append("storey minimum") + + # Public access + if not (tracking.get("has_public_access_outside") or tracking.get("has_public_access_inside")): + tracking["_failures"].append("no outside public access") + + return factor + + # ----------------------------------------------------------------------- # + # Full pipeline + # ----------------------------------------------------------------------- # + + def evaluate(self, root: Node) -> float: + """Full programme-driven fitness; mirrors ``ProgrammeDriven::_apply``. + + Returns ``value / cost`` (the final score as in Urb). + """ + from . import graph as graph_mod + from .programme import load_programme + + geometry.clear_cache() + + failures: list[str] = [] + tracking: dict = { + "has_public_access_outside": False, + "has_public_access_inside": False, + "public_length_all": 0.0, + "public_length_outside": 0.0, + "private_length_all": 0.0, + "private_length_outside": 0.0, + "stair_fit": [], + "_failures": failures, + } + + programme = self._programme or {} + + # --- Phase 1: UNMERGED tree checks --- + check_fails, missing = graph_mod.check_space_counts(root, programme) + failures.extend(check_fails) + + self.preprocess_building(root) + _, graph_circ_pre = graph_mod.build_graphs_with_circ( + root, self.conf("door_width") or 1.2, failures.append + ) + + graph_base_pre = graph_mod.build_graphs(root, self.conf("door_width") or 1.2) + + failures.extend(graph_mod.check_adjacency(root, programme, graph_base_pre, missing)) + failures.extend(graph_mod.check_level_constraints(root, programme, missing)) + failures.extend(graph_mod.check_vertical_connectivity(root, programme, missing)) + + # --- Phase 2: MERGED tree --- + dom_mod.merge_divided(root) + geometry.clear_cache() # mirror Perl Merge_Divided → Clean_Cache + + _, graph_circ = graph_mod.build_graphs_with_circ( + root, self.conf("door_width") or 1.2, failures.append + ) + graph_base = graph_mod.build_graphs(root, self.conf("door_width") or 1.2) + + cost = self.plot_cost(root) + value = 0.0 + lvls = dom_mod.levels(root) + + for li, lvl in enumerate(lvls): + se = self.process_storey( + lvl, graph_base[li], li, failures.append, + graph_circ=graph_circ, + tracking=tracking, + lvls=lvls, + root=root, + ) + cost += se.cost + value += se.value + + building_factor = self.evaluate_building(root, tracking) + value *= building_factor + + # 0.5^n failure penalty (programme-driven mode, not 0.1^n) + n_fails = len(failures) + value *= 0.5 ** n_fails + + if cost == 0.0: + return 0.0 + return value / cost + + @property + def _programme(self) -> dict | None: + """Programme requirements parsed from config, or None.""" + return self._programme_cache + + def _load_programme(self, conf: dict) -> None: + """Populate ``_programme_cache`` from spaces section of conf dict.""" + from .programme import SpaceReq + _DW = (4.0, 1.0) + _DP = (1.5, 0.5) + spaces = conf.get("spaces") or {} + if not spaces: + self._programme_cache = None + return + reqs: dict = {} + for code, c in spaces.items(): + sz = c.get("size") or [0.0, 1.0] + w = c.get("width") or _DW + pr = c.get("proportion") or _DP + reqs[code] = SpaceReq( + code=code, + name=c.get("name", ""), + size=float(sz[0]), + size_sigma=float(sz[1]), + width=float(w[0]), + width_sigma=float(w[1]), + proportion=float(pr[0]), + proportion_sigma=float(pr[1]), + adjacency=list(c.get("adjacency") or []), + level=c.get("level"), + requires_below=c.get("requires_below"), + count=int(c.get("count") or 1), + has_size="size" in c, + has_width="width" in c, + has_proportion="proportion" in c, + ) + self._programme_cache = reqs diff --git a/src/homemaker/geometry.py b/src/homemaker/geometry.py index 2759279..3fd68c1 100644 --- a/src/homemaker/geometry.py +++ b/src/homemaker/geometry.py @@ -275,14 +275,67 @@ def boundary_pair_overlap(contributors: list[tuple[Node, int]], a: Node, b: Node return _edge_overlap(a, edge_a, b, edge_b) +def is_between_2d(point: Point | None, pa: Point, pb: Point) -> bool: + """True if point lies on segment [pa, pb]; mirrors ``Urb::Math::is_between_2d``. + + Returns False if point is None (matches Perl undef-in-array behaviour where + ``distance_2d(undef, ...)`` returns 0, so the check only passes when the + segment length itself is < 0.000001). + """ + if point is None: + la = 0.0 + lb = 0.0 + else: + la = _dist(pa, point) + lb = _dist(pb, point) + length = _dist(pa, pb) + return abs(length - la - lb) < 0.000001 + + +def _edge_overlap_coords( + a: Node, edge_a: int, b: Node, edge_b: int +) -> list[Point] | None: + """Endpoints of the shared wall segment; ``[[x0,y0],[x1,y1]]`` or None. + + Projects b's edge onto a's edge direction to find the overlap interval and + converts back to 2D. Used to populate ``coordinates`` in ``leaf_graph`` + for stair-corner detection (``Corners_In_Use``). + """ + 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) + dx = p_a1[0] - p_a0[0] + dy = p_a1[1] - p_a0[1] + len_sq = dx * dx + dy * dy + if len_sq < 1e-12: + return None + inv = 1.0 / math.sqrt(len_sq) + ux, uy = dx * inv, dy * inv + len_a = math.sqrt(len_sq) + t_b0 = (p_b0[0] - p_a0[0]) * ux + (p_b0[1] - p_a0[1]) * uy + t_b1 = (p_b1[0] - p_a0[0]) * ux + (p_b1[1] - p_a0[1]) * uy + if t_b0 > t_b1: + t_b0, t_b1 = t_b1, t_b0 + t_start = max(0.0, t_b0) + t_end = min(len_a, t_b1) + if t_start >= t_end - 1e-9: + return None + return [ + [p_a0[0] + t_start * ux, p_a0[1] + t_start * uy], + [p_a0[0] + t_end * ux, p_a0[1] + t_end * uy], + ] + + 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. + edges carry ``width`` (shared boundary metres), ``weight`` (centroid + distance metres), and ``coordinates`` ([[x0,y0],[x1,y1]] wall endpoints or + None). 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 @@ -304,6 +357,7 @@ def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph 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) + coords = _edge_overlap_coords(a, edge_a, b, edge_b) + G.add_edge(a, b, weight=dist, width=width, coordinates=coords) return G diff --git a/src/homemaker/graph.py b/src/homemaker/graph.py index e2c8824..6668982 100644 --- a/src/homemaker/graph.py +++ b/src/homemaker/graph.py @@ -12,6 +12,12 @@ FIDELITY DECISION — ``has_vertical_connection`` (DESIGN.md §8.1): 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. + +PERL CLONE QUIRK — ``has_circulation`` (Base.pm:228-241): + Perl's ``Graph::clone()`` only copies vertices that are part of at least one + edge. Isolated vertices (single-leaf levels or unconnected nodes) are lost. + An empty graph returns ``is_connected = False``. ``has_circulation`` replicates + this by removing isolated vertices before the usability/edge-type filtering. """ from __future__ import annotations @@ -35,12 +41,344 @@ def build_graphs(root: Node, door_width: float = DOOR_WIDTH) -> list[nx.Graph]: 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. + for storey processing. """ return [geometry.leaf_graph(lvl, door_width) for lvl in levels(root)] +def build_graphs_with_circ( + root: Node, + door_width: float, + fail, +) -> tuple[list[nx.Graph], list[nx.Graph]]: + """Build ``(graph_base, graph_circ)`` pairs; mirrors ``setup_storey_graphs`` + in ``Base.pm:217-241``. + + ``graph_base[i]`` is the unfiltered adjacency graph for level i. + ``graph_circ[i]`` is a copy filtered by ``has_circulation``; emits + "N inaccessible usable space" via ``fail`` if a level is disconnected after + filtering. + + Perl clone quirk: ``has_circulation`` removes isolated vertices first, so a + level with no adjacency edges always fires the "inaccessible" failure. + """ + lvls = levels(root) + graph_base: list[nx.Graph] = [] + graph_circ: list[nx.Graph] = [] + for i, lvl in enumerate(lvls): + g = geometry.leaf_graph(lvl, door_width) + graph_base.append(g) + gc = g.copy() + if not has_circulation(gc): + fail(f"{i} inaccessible usable space") + graph_circ.append(gc) + return graph_base, graph_circ + + +# --------------------------------------------------------------------------- # +# Has_Circulation (Base.pm:487-594) +# --------------------------------------------------------------------------- # + +def _avg_path_len_from(G: nx.Graph, node: Node) -> float: + """Average weighted shortest-path length from node to all reachable other nodes. + + Mirrors Perl's ``$graph->average_path_length($node, undef)`` which uses + Dijkstra with edge weights (centroid-to-centroid distances, stored as 'weight'). + """ + try: + lengths = dict(nx.single_source_dijkstra_path_length(G, node, weight="weight")) + vals = [v for v in lengths.values() if v > 0] + return sum(vals) / len(vals) if vals else 0.0 + except Exception: + return 0.0 + + +def has_circulation(G: nx.Graph) -> bool: + """Port of ``Urb::Dom::Has_Circulation`` (modifies G in place). + + Replicates the Perl clone quirk: isolated vertices (degree 0) are removed + first since Perl's ``Graph::clone`` only copies vertices in edges. After + that, removes non-usable nodes, trims bedroom/toilet cross-connections, then + trims excess circulation and outdoor connections using centrality ordering. + Returns True iff the remaining graph is connected. + """ + # Perl clone loses isolated vertices → remove them first + isolated = [v for v in list(G.nodes()) if G.degree(v) == 0] + G.remove_nodes_from(isolated) + + # Remove non-usable nodes (outside above outside etc.) + non_usable = [v for v in list(G.nodes()) if not dom.is_usable(v)] + G.remove_nodes_from(non_usable) + + # Remove b → [lkbt] edges (bedrooms only connect to circulation/outside) + for v in list(G.nodes()): + if not (v.type and v.type[0].lower() == "b"): + continue + to_remove = [ + nb for nb in list(G.neighbors(v)) + if nb.type and nb.type[0].lower() in ("l", "k", "b", "t") + ] + G.remove_edges_from((v, nb) for nb in to_remove) + + # Remove t → [olkt] edges (toilets only connect to bedrooms/circulation) + for v in list(G.nodes()): + if not (v.type and v.type[0].lower() == "t"): + continue + to_remove = [ + nb for nb in list(G.neighbors(v)) + if nb.type and nb.type[0].lower() in ("o", "l", "k", "t") + ] + G.remove_edges_from((v, nb) for nb in to_remove) + + # btlk nodes: keep only one circulation neighbour + for v in list(G.nodes()): + if not (v.type and v.type[0].lower() in ("b", "t", "l", "k")): + continue + circ_nbs = [nb for nb in list(G.neighbors(v)) if dom.is_circulation(nb)] + if len(circ_nbs) <= 1: + continue + circ_nbs.sort(key=lambda nb: _avg_path_len_from(G, nb)) + # b/t: keep least popular (last), remove most popular (first) + # l/k: keep most popular (first), remove least popular (last) + t0 = v.type[0].lower() + while len(circ_nbs) > 1: + if t0 in ("b", "t"): + G.remove_edge(v, circ_nbs.pop(0)) + else: + G.remove_edge(v, circ_nbs.pop()) + + # Clone the current state and run Connected_Outside to get outdoor components + outside_graph = G.copy() + _connected_outside_inplace(outside_graph) + outside_components = list(nx.connected_components(outside_graph)) if len(outside_graph.nodes()) > 0 else [] + + # blkc nodes: keep only one outdoor neighbour per outdoor component + for v in list(G.nodes()): + if not (v.type and v.type[0].lower() in ("b", "l", "k", "c")): + continue + out_nbs = [ + nb for nb in list(G.neighbors(v)) + if dom.is_outside(nb) and dom.is_usable(nb) + ] + if len(out_nbs) <= 1: + continue + out_nbs.sort(key=lambda nb: _avg_path_len_from(G, nb)) + + for component in outside_components: + component_nbs = [nb for nb in out_nbs if nb in component] + if len(component_nbs) <= 1: + continue + t0 = v.type[0].lower() + while len(component_nbs) > 1: + if t0 == "b": + nb = component_nbs.pop(0) + else: + nb = component_nbs.pop() + if G.has_edge(v, nb): + G.remove_edge(v, nb) + + if len(G.nodes()) == 0: + return False + return nx.is_connected(G) + + +def _connected_outside_inplace(G: nx.Graph) -> None: + """Remove all non-outside/non-usable vertices; mirrors ``Connected_Outside``.""" + to_remove = [v for v in list(G.nodes()) if not (dom.is_outside(v) and dom.is_usable(v))] + G.remove_nodes_from(to_remove) + + +def connected_circulation(G: nx.Graph) -> bool: + """True iff circulation nodes are non-empty and connected; mirrors + ``Urb::Dom::Connected_Circulation`` (Storey.pm:106). + + Removes all non-circulation vertices from G in place before checking. + Perl's ``Graph::is_connected`` returns False for an empty graph — replicated + here so "level N not connected" fires when there are no circulation nodes. + """ + to_remove = [v for v in list(G.nodes()) if not dom.is_circulation(v)] + G.remove_nodes_from(to_remove) + if len(G.nodes()) == 0: + return False # Perl Graph::is_connected returns false for empty graph + return nx.is_connected(G) + + +# --------------------------------------------------------------------------- # +# Stair-corner detection (Quad.pm:1490-1544, Dom.pm:648-668) +# --------------------------------------------------------------------------- # + +def _corners_of(leaf: Node) -> list[list[float] | None]: + """4 corner coordinates of leaf (index 0-3), with None at index 4 to + replicate Perl's undef-array-element behaviour in ``Corners_In_Use``.""" + return [geometry.coordinate(leaf, i) for i in range(4)] + [None] + + +def corners_in_use( + leaf: Node, G: nx.Graph, neighbors: list[Node] +) -> list[int]: + """Return the minimum set of consecutive corner indices needed to contain + all shared walls; mirrors ``Urb::Quad::Corners_In_Use`` (Quad.pm:1490). + + Returns raw consecutive indices — may include values > 3 (e.g. [3,4] or + [3,4,5]); the caller (``stack_corners_in_use``) normalises with % 4 after + rotation remapping. + + Perl's ``corners[4]`` is undef. ``is_between_2d(point, undef, undef)`` + always returns True in Perl (distance_2d(undef,x)=0 so abs(0-0-0)<1e-6). + This means the triple check at idx=3 always succeeds in Perl, so [3,4,5] + is always returned when no shorter span works — equivalent to [0,1,3] after + rotation-normalisation. ``_ib`` replicates that: both-None → True, + one-None → False. + """ + walls: list[list] = [] + for nb in neighbors: + if G.has_edge(leaf, nb): + coords = G[leaf][nb].get("coordinates") + if coords is not None: + walls.append(coords) + + corners = _corners_of(leaf) # len==5, corners[4]=None + + ib = geometry.is_between_2d # (point, pa, pb) — handles point=None + + def _ib(point, pa, pb): + if pa is None and pb is None: + return True # Perl: is_between_2d(point,undef,undef) always True + if pa is None or pb is None: + return False + return ib(point, pa, pb) + + # Try single corner + for idx in range(4): + c = corners[idx] + if all(ib(c, w[0], w[1]) for w in walls): + return [idx] + + # Try pair (idx, idx+1); corners[4]=None → _ib returns False + for idx in range(4): + c0, c1 = corners[idx], corners[idx + 1] + ok = True + for w in walls: + if ib(c0, w[0], w[1]): + continue + if ib(c1, w[0], w[1]): + continue + if _ib(w[0], c0, c1): + continue + if _ib(w[1], c0, c1): + continue + ok = False + break + if ok: + return [idx, idx + 1] # raw; may be [3,4] + + # Try triple (idx, idx+1, idx+2); corners[4] and corners[5]=None → _ib False + for idx in range(4): + c0 = corners[idx] + c1 = corners[idx + 1] + c2 = corners[idx + 2] if idx + 2 < len(corners) else None + ok = True + for w in walls: + if ib(c0, w[0], w[1]): + continue + if ib(c1, w[0], w[1]): + continue + if ib(c2, w[0], w[1]): + continue + if _ib(w[0], c0, c1): + continue + if _ib(w[1], c0, c1): + continue + if _ib(w[0], c1, c2): + continue + if _ib(w[1], c1, c2): + continue + ok = False + break + if ok: + return [idx, idx + 1, idx + 2] # raw; may be [3,4,5] + + return [0, 1, 2, 3] + + +def _stack_levels_above(leaf: Node) -> list[Node]: + """Same-path nodes on all levels above leaf; mirrors ``Levels_Above`` on a leaf.""" + result: list[Node] = [] + n = leaf + while True: + above = dom._above_node(n) + if above is None: + break + result.append(above) + n = above + return result + + +def _ground_rotation(node: Node) -> int: + """Rotation of the lowest below node; mirrors Perl's ``Rotation()`` method. + + Perl's ``Rotation`` returns ``$self->Below->Rotation`` when Below is + defined, so upper-storey nodes always report the ground-floor rotation. + Using the raw ``node.rotation`` (stored per-level) would give wrong + rotation corrections in ``stack_corners_in_use``. + """ + while node.below is not None: + node = node.below + return node.rotation + + +def stack_corners_in_use( + leaf: Node, + graph_circ_list: list[nx.Graph], + all_levels: list[Node], +) -> list[int]: + """Minimum set of corners in use for the vertical stair stack; mirrors + ``Urb::Dom::Stack_Corners_In_Use``. + + Returns [] if the stack does not span all levels above leaf, or if any + level's node is not circulation type. + """ + if not (leaf.type and leaf.type[0].lower() == "c"): + return [] + + stack = [leaf] + _stack_levels_above(leaf) + + # The stack must span ALL levels (leaf's level + all above) + li = _level_index(leaf, all_levels) + levels_above_count = len(all_levels) - li - 1 + if len(stack) <= levels_above_count: + return [] + + # All stack nodes must be circulation + if not all(n.type and n.type[0].lower() == "c" for n in stack): + return [] + + leaf_rot = _ground_rotation(leaf) + all_corners: set[int] = set() + for level_offset, node in enumerate(stack): + level_idx = li + level_offset + if level_idx >= len(graph_circ_list): + break + G = graph_circ_list[level_idx] + nbs = list(G.neighbors(node)) if G.has_node(node) else [] + in_use = corners_in_use(node, G, nbs) + # Map to ground-floor rotation frame using ground rotation (Perl + # Rotation() follows Below chain, so upper nodes use ground rotation) + node_rot = _ground_rotation(node) + for c in in_use: + all_corners.add((c - node_rot + leaf_rot) % 4) + + return sorted(all_corners) + + +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) + + # --------------------------------------------------------------------------- # # Adjacency helpers # --------------------------------------------------------------------------- # @@ -85,43 +423,62 @@ def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> b 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) +# Space-count detection + failure stacking (ProgrammeDriven.pm:154-215) # --------------------------------------------------------------------------- # -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``. +def check_space_counts( + root: Node, + targets: dict[str, SpaceReq], +) -> tuple[list[str], list[str]]: + """Check design has exactly the required spaces; mirrors + ``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. + Returns ``(failures, missing_ids)`` where: + - ``failures`` is the stacked failure list (2 base + per-quality per missing + space, up to ~7 per missing space; also "too many" for excess spaces). + - ``missing_ids`` is the list of virtual space ids used to suppress false + adjacency/level/vertical failures for absent spaces. """ - count: dict[str, int] = {} + # Count spaces by type (case-sensitive, as in Perl exact-match for unique) + count: dict[str, list[str]] = {} for lvl in levels(root): for leaf in lvl.leaves(): if leaf.type: - key = leaf.type.lower() - count[key] = count.get(key, 0) + 1 + count.setdefault(leaf.type, []).append(leaf.id) + failures: list[str] = [] 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 + + actual = len(count.get(code, [])) + expected = req.count + + if actual < expected: + n_missing = expected - actual + for i in range(1, n_missing + 1): + mid = code if expected == 1 else f"{code}#{i}" + # 2 base failures + failures.append(f"missing required space: {mid}") + failures.append(f"missing required space: {mid} (critical)") + missing.append(mid) + # Per-quality failures (1 each for explicitly configured params) + if req.has_size: + failures.append(f"missing {mid}: would need size check") + if req.has_width: + failures.append(f"missing {mid}: would need width check") + if req.has_proportion: + failures.append(f"missing {mid}: would need proportion check") + + elif actual > expected: + failures.append( + f"too many spaces: {code} (found {actual}, expected {expected})" + ) + + return failures, missing # --------------------------------------------------------------------------- # diff --git a/src/homemaker/programme.py b/src/homemaker/programme.py index 8f535fd..c0bb2d6 100644 --- a/src/homemaker/programme.py +++ b/src/homemaker/programme.py @@ -29,6 +29,10 @@ class SpaceReq: level: int | None = None requires_below: str | None = None count: int = 1 + # Whether each quality param was explicitly in the config (not a default) + has_size: bool = False + has_width: bool = False + has_proportion: bool = False def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float]: @@ -60,5 +64,8 @@ def load_programme(path: str) -> dict[str, SpaceReq]: level=c.get("level"), requires_below=c.get("requires_below"), count=int(c.get("count") or 1), + has_size="size" in c, + has_width="width" in c, + has_proportion="proportion" in c, ) return out