Fix stair-fit parity: entrance corners + weighted has_circulation (homemaker-py-w1e/q70)

Two root causes found for ca/cb corpus parity failures:

1. _avg_path_len_from used unweighted BFS (hop count) but Perl's
   Graph::average_path_length uses weighted Dijkstra with centroid-to-centroid
   edge distances. This caused wrong edge removal in has_circulation, giving
   wrong stack corner counts (2 instead of 3 for lr in ca9e80c5).

2. Entrance corner logic used _public_access (any street boundary) but Perl's
   Entrances() picks the best entrance route — a stair only gets entrance corners
   if no higher-priority non-stair C leaf has public access.

Also includes homemaker-py-hgg storey/building checks previously uncommitted:
stair fit, circ connectivity, roof-garden, public-access tracking, has_circulation,
corners_in_use, stack_corners_in_use, check_space_counts with failure stacking.

All 4 debug corpus prefixes: ratio=1.000000. 39 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-13 20:55:25 +01:00
parent 8e762b80d8
commit fc10466966
6 changed files with 1022 additions and 35 deletions

View file

@ -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-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-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":"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-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-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-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.24x1.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-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.24x1.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":"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":"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":"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":"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."}

View file

@ -66,4 +66,13 @@ _Add a brief overview of your project architecture_
## Conventions & Patterns ## 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 `<file>.dom.score` and failures to `<file>.dom.fails`; the numeric score is also printed to stderr.

View file

@ -156,6 +156,8 @@ class Fitness:
self._conf = conf or {} self._conf = conf or {}
self._cost = cost or {} self._cost = cost or {}
self.spaces: dict = self._conf.get("spaces") 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): def conf(self, key: str):
v = self._conf.get(key) v = self._conf.get(key)
@ -486,3 +488,558 @@ class Fitness:
def plot_cost(self, root: Node) -> float: def plot_cost(self, root: Node) -> float:
"""The 'initial cost' term: plot rate x lowest-root area.""" """The 'initial cost' term: plot rate x lowest-root area."""
return self.cost("plot") * geometry.area(root) 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

View file

@ -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) 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 def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph
"""Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``. """Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``.
Returns a ``networkx.Graph`` whose nodes are leaf ``Node`` objects and whose Returns a ``networkx.Graph`` whose nodes are leaf ``Node`` objects and whose
edges carry ``width`` (shared boundary metres) and ``weight`` (centroid edges carry ``width`` (shared boundary metres), ``weight`` (centroid
distance metres). Edges with width < ``door_width`` (Urb default 1.2 m) distance metres), and ``coordinates`` ([[x0,y0],[x1,y1]] wall endpoints or
are excluded. External plot-perimeter boundaries ('a','b','c','d') are None). Edges with width < ``door_width`` (Urb default 1.2 m) are excluded.
never edges. A single-leaf storey gets one isolated vertex. External plot-perimeter boundaries ('a','b','c','d') are never edges. A
single-leaf storey gets one isolated vertex.
""" """
import networkx as nx 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 width >= door_width:
if not G.has_edge(a, b) or G[a][b]["width"] < width: if not G.has_edge(a, b) or G[a][b]["width"] < width:
dist = _dist(centroid(a), centroid(b)) 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 return G

View file

@ -12,6 +12,12 @@ FIDELITY DECISION — ``has_vertical_connection`` (DESIGN.md §8.1):
counts as "connected", regardless of spatial overlap. This is a known counts as "connected", regardless of spatial overlap. This is a known
simplification in the Perl; it is preserved here for oracle parity. simplification in the Perl; it is preserved here for oracle parity.
Reshape in Phase 4 if needed. 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 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 This is called twice in the two-phase pattern: once before
``dom.merge_divided`` for adjacency/level/vertical checks, and once after ``dom.merge_divided`` for adjacency/level/vertical checks, and once after
for storey processing. ``Has_Circulation`` filtering (which produces the for storey processing.
"inaccessible usable space" failure) is implemented in homemaker-py-hgg.
""" """
return [geometry.leaf_graph(lvl, door_width) for lvl in levels(root)] 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 # 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()) 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]: def check_space_counts(
"""Return ids of missing required spaces; mirrors the count-detection root: Node,
part of ``check_space_counts`` in ``ProgrammeDriven.pm:156-215``. 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 Returns ``(failures, missing_ids)`` where:
adjacency/level/vertical failures. The 2-base + per-quality failure - ``failures`` is the stacked failure list (2 base + per-quality per missing
stacking (up to ~7 failures per missing space) is implemented in space, up to ~7 per missing space; also "too many" for excess spaces).
homemaker-py-hgg. - ``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 lvl in levels(root):
for leaf in lvl.leaves(): for leaf in lvl.leaves():
if leaf.type: if leaf.type:
key = leaf.type.lower() count.setdefault(leaf.type, []).append(leaf.id)
count[key] = count.get(key, 0) + 1
failures: list[str] = []
missing: list[str] = [] missing: list[str] = []
for code, req in targets.items(): for code, req in targets.items():
if code[0].lower() in ("c", "o", "s"): if code[0].lower() in ("c", "o", "s"):
continue continue
actual = count.get(code.lower(), 0)
if actual < req.count: actual = len(count.get(code, []))
for i in range(req.count - actual): expected = req.count
missing.append(code if req.count == 1 else f"{code}#{i + 1}")
return missing 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
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View file

@ -29,6 +29,10 @@ class SpaceReq:
level: int | None = None level: int | None = None
requires_below: str | None = None requires_below: str | None = None
count: int = 1 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]: 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"), level=c.get("level"),
requires_below=c.get("requires_below"), requires_below=c.get("requires_below"),
count=int(c.get("count") or 1), count=int(c.get("count") or 1),
has_size="size" in c,
has_width="width" in c,
has_proportion="proportion" in c,
) )
return out return out