From 69d4dcbf2363ef95949ca786354784b1efb1fea2 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 13 Jun 2026 21:44:42 +0100 Subject: [PATCH] Phase 3 gate (homemaker-py-uxz): native fitness 35/35 corpus parity; retire oracle from search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fix: _entrance_bid_for_stair now returns None when the stair leaf has an outdoor neighbour with public access — Perl's Entrances function picks the via-outdoor priority (3.5 > 3) which maps the stair to a leaf id rather than a boundary id, so Boundary_Id(edge) eq leaf_id never matches and no entrance corners are added. Without this fix 7 files had an extra 'staircase volume' failure from corners [3,1,2] giving stair_fit=0.718 instead of [3]→1.095. New: Fitness._evaluate_full() extracts the shared pipeline so evaluate() and score_with_fails() both use it. NativeEvaluator added to innerloop.py as a drop-in for OracleEvaluator; optimise() defaults to use_native=True. Gate results: 35/35 score parity (rel_tol=1e-4), 35/35 fail-set identity, native speed ~45ms/eval vs oracle ~1000ms/eval batched = 23x speedup. OracleEvaluator kept for validation; oracle.score_batch unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 11 ++-- src/homemaker/fitness.py | 24 ++++++--- src/homemaker/innerloop.py | 87 ++++++++++++++++++++++++++---- tests/test_dom_corpus.py | 106 ++++++++++++++++++++++++++++++++++++- 4 files changed, 206 insertions(+), 22 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 24dc148..4b41810 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,8 +1,8 @@ -{"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-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:54:04Z","started_at":"2026-06-13T18:12:23Z","closed_at":"2026-06-13T19:54:04Z","close_reason":"Investigation complete: traced 18% discrepancy (cf0b8a77) through entrance corner logic and weighted path length bugs, both now fixed in 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-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":"closed","priority":1,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-13T18:07:54Z","created_by":"Bruno Postle","updated_at":"2026-06-13T19:53:53Z","closed_at":"2026-06-13T19:53:53Z","close_reason":"Fixed as part of homemaker-py-w1e: the _avg_path_len_from weighted Dijkstra fix corrects has_circulation edge removal ordering, which was the actual cause of wrong stack corner counts. The _ib(None,None)=True fix was already in place but the weighted path length was the remaining blocker.","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-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-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":"in_progress","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:27Z","created_by":"Bruno Postle","updated_at":"2026-06-13T19:57:49Z","started_at":"2026-06-13T19:57:48Z","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} @@ -19,8 +19,9 @@ {"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:00Z","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-9gp","title":"Canonical slicing encoding (normalized Polish expression) + shape feasibility","description":"DESIGN.md §5.5, §7 Phase 5. Representation upgrade once core lands: normalized Polish expression / skewed slicing tree (Wong–Liu) for redundancy-free, high-locality topology moves (M1/M2/M3); bottom-up shape-feasibility checks to prune infeasible topologies before the inner loop. Goal: scale to larger programmes. Excluded representations stay excluded (§2): no sequence-pair/B*-tree (non-slicing).","acceptance_criteria":"Encoding round-trips with the genome; M1/M2/M3 moves implemented; measured search improvement on a larger-than-house programme","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:02Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:02Z","dependencies":[{"issue_id":"homemaker-py-9gp","depends_on_id":"homemaker-py-ccw","type":"blocks","created_at":"2026-06-12T00:39:48Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:27:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."} +{"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."} {"_type":"memory","key":"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":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."} {"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."} +{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-py PYTHONPATH: set PYTHONPATH=/home/bruno/src/homemaker-py/src or use 'python -m pytest' from the project root (which reads pyproject.toml and adds src/ automatically). Never try 'pip show' or 'pip install' — it's not installed as a package."} +{"_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)."} diff --git a/src/homemaker/fitness.py b/src/homemaker/fitness.py index 24966e2..9a437ae 100644 --- a/src/homemaker/fitness.py +++ b/src/homemaker/fitness.py @@ -692,6 +692,13 @@ class Fitness: 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 + # If the stair itself has via-outdoor access (Entrances priority 3.5), Perl's + # Entrances maps it to a leaf id, not a boundary id. Boundary_Id(edge) eq + # leaf_id never matches → no entrance corners added. Return None here so + # Python matches that behaviour. + for nb in G.neighbors(stair_leaf): + 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: @@ -937,8 +944,15 @@ class Fitness: Returns ``value / cost`` (the final score as in Urb). """ + score, _ = self._evaluate_full(root) + return score + + def score_with_fails(self, root: Node) -> tuple[float, tuple[str, ...]]: + """Same as ``evaluate`` but also returns the sorted failure strings.""" + return self._evaluate_full(root) + + def _evaluate_full(self, root: Node) -> tuple[float, tuple[str, ...]]: from . import graph as graph_mod - from .programme import load_programme geometry.clear_cache() @@ -999,12 +1013,10 @@ class Fitness: value *= building_factor # 0.5^n failure penalty (programme-driven mode, not 0.1^n) - n_fails = len(failures) - value *= 0.5 ** n_fails + value *= 0.5 ** len(failures) - if cost == 0.0: - return 0.0 - return value / cost + score = value / cost if cost != 0.0 else 0.0 + return score, tuple(sorted(failures)) @property def _programme(self) -> dict | None: diff --git a/src/homemaker/innerloop.py b/src/homemaker/innerloop.py index bad5108..3e77fe0 100644 --- a/src/homemaker/innerloop.py +++ b/src/homemaker/innerloop.py @@ -7,14 +7,10 @@ ownership) against the FULL fitness. Never a proxy objective — §4.2 falsified that; the full objective's ``0.5^n`` failure cliff is what protects the inner loop from trading into new failures (§4.5). -Fitness comes from the Perl oracle for now. The optimiser is a batched compass -(pattern) search: each iteration proposes ``2 × DOF`` candidate points and -scores them in ONE ``oracle.score_batch`` call, so the Perl startup amortises -across the population (§4.6). Warm-starting from a parent's optimised ratios is -just ``x0=`` (§5 decision 6, Lamarckian inheritance). - -Budgets are counted in oracle evaluations (scored ``.dom`` files), the only -currency that matters while the oracle is the bottleneck. +Fitness defaults to the native Python evaluator (Phase 3). The Perl oracle +(``OracleEvaluator``) is kept for validation but is no longer used in search. +Warm-starting from a parent's optimised ratios is ``x0=`` (§5 decision 6, +Lamarckian inheritance). """ from __future__ import annotations @@ -269,12 +265,80 @@ def cma_search( _METHODS = {"cma": cma_search, "compass": compass_search} +from dataclasses import dataclass as _dc + + +@_dc +class _NativeScore: + """oracle.Score-compatible result from native fitness.""" + + fitness: float + fail_lines: tuple + + @property + def n_fails(self) -> int: + return len(self.fail_lines) + + +class NativeEvaluator: + """Scores ratio vectors for a frozen topology via the native Python fitness. + + Drop-in replacement for ``OracleEvaluator``; no temp directory, no Perl + startup overhead. Each ``evaluate`` call runs ``Fitness.score_with_fails`` + serially over the batch (all in-process, no parallelism needed at this + scale). + """ + + def __init__(self, root: dom.Node, programme_dir: str | Path): + from . import fitness as fit_mod + + self.root = root + self.free = solver.free_branches(root) + conf, cost = fit_mod.load_config(programme_dir) + self._fit = fit_mod.Fitness(conf, cost) + self.n_evals = 0 + self.n_oracle_calls = 0 # kept for interface parity with OracleEvaluator + + def __enter__(self) -> "NativeEvaluator": + return self + + def __exit__(self, *exc) -> None: + pass + + @property + def x_current(self) -> np.ndarray: + return np.array( + [(b.division[0] + b.division[1]) / 2 for b in self.free], dtype=float + ) + + def apply(self, x: np.ndarray) -> None: + xc = np.clip(x, _EPS, 1 - _EPS) + for j, b in enumerate(self.free): + b.division = [float(xc[j]), float(xc[j])] + + def evaluate(self, xs: list[np.ndarray]) -> "list[_NativeScore]": + """Score a batch of ratio vectors; returns objects with .fitness / + .n_fails / .fail_lines matching the oracle.Score interface.""" + import copy + + results = [] + for x in xs: + self.apply(x) + root_copy = copy.deepcopy(self.root) + score, fails = self._fit.score_with_fails(root_copy) + results.append(_NativeScore(fitness=score, fail_lines=fails)) + self.n_evals += len(xs) + self.n_oracle_calls += 1 + return results + + def optimise( root: dom.Node, programme_dir: str | Path, x0: np.ndarray | None = None, budget: int = 200, method: str = "cma", + use_native: bool = True, urb_root: str | Path = oracle.DEFAULT_URB_ROOT, **search_kw, ) -> Result: @@ -283,8 +347,13 @@ def optimise( ``x0=None`` starts from the topology's current ratios (cold start); pass a parent's optimised ratios for a Lamarckian warm start. On return ``root`` carries the best ratios found. + + ``use_native=True`` (default) uses the native Python fitness; set False to + fall back to the Perl oracle (kept for validation only). """ - with OracleEvaluator(root, programme_dir, urb_root) as ev: + ev_cls = NativeEvaluator if use_native else OracleEvaluator + ev_args = (root, programme_dir) if use_native else (root, programme_dir, urb_root) + with ev_cls(*ev_args) as ev: if x0 is None: x0 = ev.x_current if len(x0) == 0: # undivided topology (e.g. a bare plot): nothing to optimise diff --git a/tests/test_dom_corpus.py b/tests/test_dom_corpus.py index f19c43a..56161fe 100644 --- a/tests/test_dom_corpus.py +++ b/tests/test_dom_corpus.py @@ -1,9 +1,11 @@ -"""Corpus-backed tests for dom round-trip and free-branch ownership. +"""Corpus-backed tests for dom round-trip, free-branch ownership, and fitness parity. Skipped when the Urb checkout is absent (these need only its .dom files, not -perl). +perl). The parity tests compare native Python fitness against cached oracle +scores and failure sets (generated with URB_NO_OCCLUSION=1). """ +import math from pathlib import Path import pytest @@ -55,3 +57,103 @@ def test_free_branches_are_lowest_storey_owners(): for b in solver.free_branches(root): assert b.divided assert b.below is None or not b.below.divided + + +# --------------------------------------------------------------------------- +# Phase 3 gate: native fitness parity vs oracle (homemaker-py-uxz) +# Oracle scores and failure sets cached as .dom.score / .dom.fails +# generated with URB_NO_OCCLUSION=1 (DESIGN.md §6 descope). +# --------------------------------------------------------------------------- + +def _native_evaluate(src: Path): + """Run native Fitness.evaluate and return (score, frozenset[fail_lines]).""" + from homemaker import fitness as fit_mod, graph as graph_mod, geometry + + root = dom.load(str(src)) + conf, cost = fit_mod.load_config(CORPUS) + fit = fit_mod.Fitness(conf, cost) + + 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 = fit._programme or {} + geometry.clear_cache() + + check_f, missing = graph_mod.check_space_counts(root, programme) + failures.extend(check_f) + fit.preprocess_building(root) + _, gcpre = graph_mod.build_graphs_with_circ(root, fit.conf("door_width") or 1.2, failures.append) + gbpre = graph_mod.build_graphs(root, fit.conf("door_width") or 1.2) + failures.extend(graph_mod.check_adjacency(root, programme, gbpre, missing)) + failures.extend(graph_mod.check_level_constraints(root, programme, missing)) + failures.extend(graph_mod.check_vertical_connectivity(root, programme, missing)) + + dom.merge_divided(root) + geometry.clear_cache() + _, gc = graph_mod.build_graphs_with_circ(root, fit.conf("door_width") or 1.2, failures.append) + gb = graph_mod.build_graphs(root, fit.conf("door_width") or 1.2) + + cost_v = fit.plot_cost(root) + value = 0.0 + lvls = dom.levels(root) + for li, lvl in enumerate(lvls): + se = fit.process_storey( + lvl, gb[li], li, failures.append, + graph_circ=gc, tracking=tracking, lvls=lvls, root=root, + ) + cost_v += se.cost + value += se.value + + bf = fit.evaluate_building(root, tracking) + value *= bf + value *= 0.5 ** len(failures) + score = value / cost_v if cost_v else 0.0 + + return score, frozenset(failures) + + +def _oracle_result(src: Path): + """Read cached oracle score and failure set (URB_NO_OCCLUSION=1).""" + from homemaker.oracle import Score + + score_file = Path(str(src) + ".score") + fails_file = Path(str(src) + ".fails") + if not score_file.exists(): + pytest.skip(f"No cached oracle score for {src.name}") + oracle_score = float(score_file.read_text().strip()) + oracle_fails = Score( + fitness=oracle_score, + fails=fails_file.read_text() if fails_file.exists() else "", + ).fail_lines + return oracle_score, frozenset(oracle_fails) + + +@pytest.mark.parametrize("src", sorted(CORPUS.glob("*.dom")), ids=lambda p: p.name) +def test_native_fitness_score_parity(src): + """Native score matches oracle within 1e-4 relative tolerance.""" + native_score, _ = _native_evaluate(src) + oracle_score, _ = _oracle_result(src) + assert math.isclose(native_score, oracle_score, rel_tol=1e-4, abs_tol=1e-15), ( + f"{src.name}: native={native_score:.6e} oracle={oracle_score:.6e}" + ) + + +@pytest.mark.parametrize("src", sorted(CORPUS.glob("*.dom")), ids=lambda p: p.name) +def test_native_fitness_fail_set_parity(src): + """Native failure set matches oracle failure set exactly.""" + _, native_fails = _native_evaluate(src) + _, oracle_fails = _oracle_result(src) + only_native = native_fails - oracle_fails + only_oracle = oracle_fails - native_fails + assert not only_native and not only_oracle, ( + f"{src.name}: only_native={sorted(only_native)} only_oracle={sorted(only_oracle)}" + )