Native fitness: adjacency graph + merge_divided (homemaker-py-3y7)

Two bugs fixed in boundary_id / leaf_graph:
1. 'bid not in "abcd"' used Python substring check, silently dropping the
   root-division boundary (empty-string id).  Fixed to frozenset membership.
2. Upper-storey nodes store their own rotation in the YAML but Urb::Quad::Rotation
   delegates to Below->Rotation.  boundary_id now walks the below-chain to the
   ground-floor rotation, matching Perl exactly.

After fixes all 35 corpus files produce edge counts matching Perl oracle.

Added:
- src/homemaker/graph.py: build_graphs (two-phase pattern), has_adjacency,
  has_vertical_connection (faithful no-overlap stub per DESIGN §8.1),
  find_missing_spaces, check_adjacency, check_level_constraints,
  check_vertical_connectivity
- src/homemaker/dom.py: @dataclass(eq=False) on Node for NetworkX hashability;
  is_outside, is_supported, is_unsupported, merge_divided
- tests/test_graph.py: 7 tests, edge counts vs Perl oracle on all 35 files,
  exact widths for 2f45907, merge_divided smoke, two-phase independence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-12 21:57:45 +01:00
parent f160c6dc9e
commit 9e020f4769
5 changed files with 627 additions and 4 deletions

View file

@ -2,7 +2,7 @@
{"id":"homemaker-py-uxz","title":"Native fitness validation: 35-file corpus parity vs oracle; retire oracle (Phase 3 gate)","description":"DESIGN.md §7 Phase 3 gate. Validate the assembled native fitness against urb-fitness.pl across all 35 programme-house .dom files: scores within float tolerance AND identical failure sets. Swap behind the same interface as oracle.score so inner loop and search driver are unchanged; keep the oracle available as validation reference but stop using it in search. Then re-run topology search at scale (separate issue).","acceptance_criteria":"35/35 files: score parity within tolerance, failure sets identical; search runs end-to-end on native fitness with measured speedup vs oracle","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:27Z","dependencies":[{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-3y7","type":"blocks","created_at":"2026-06-12T00:39:40Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gnw","type":"blocks","created_at":"2026-06-12T00:39:41Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-12T00:39:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":3,"comment_count":0} {"id":"homemaker-py-uxz","title":"Native fitness validation: 35-file corpus parity vs oracle; retire oracle (Phase 3 gate)","description":"DESIGN.md §7 Phase 3 gate. Validate the assembled native fitness against urb-fitness.pl across all 35 programme-house .dom files: scores within float tolerance AND identical failure sets. Swap behind the same interface as oracle.score so inner loop and search driver are unchanged; keep the oracle available as validation reference but stop using it in search. Then re-run topology search at scale (separate issue).","acceptance_criteria":"35/35 files: score parity within tolerance, failure sets identical; search runs end-to-end on native fitness with measured speedup vs oracle","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:27Z","dependencies":[{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-3y7","type":"blocks","created_at":"2026-06-12T00:39:40Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gnw","type":"blocks","created_at":"2026-06-12T00:39:41Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-12T00:39:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":3,"comment_count":0}
{"id":"homemaker-py-hgg","title":"Native fitness: storey/building checks + missing-space failure stacking","description":"DESIGN.md §6. Port ProgrammeDriven/Storey/Building checks: space-count matching with MISSING-SPACE FAILURE STACKING (2 base failures + 1 per size/width/proportion/adjacency/level requirement, up to ~7 — ProgrammeDriven.pm:192-212; reshaping must preserve this hierarchy), adjacency/level/requires_below checks, staircase fit/volume/min-max, public access, circulation \u0026 outside ratios, min internal area (1.2x programme sum), storey limit/minimum, structural failures (edge too long \u003e8 m both variants, unsupported covered outside, covered outside above ground, level not connected, inaccessible usable space), preprocess_building s-\u003eO conversion, and the 0.5^n penalty over value/cost.","acceptance_criteria":"Failure sets and final scores match the oracle on sample files; failure-stacking counts identical","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:26Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:26Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-hgg","title":"Native fitness: storey/building checks + missing-space failure stacking","description":"DESIGN.md §6. Port ProgrammeDriven/Storey/Building checks: space-count matching with MISSING-SPACE FAILURE STACKING (2 base failures + 1 per size/width/proportion/adjacency/level requirement, up to ~7 — ProgrammeDriven.pm:192-212; reshaping must preserve this hierarchy), adjacency/level/requires_below checks, staircase fit/volume/min-max, public access, circulation \u0026 outside ratios, min internal area (1.2x programme sum), storey limit/minimum, structural failures (edge too long \u003e8 m both variants, unsupported covered outside, covered outside above ground, level not connected, inaccessible usable space), preprocess_building s-\u003eO conversion, and the 0.5^n penalty over value/cost.","acceptance_criteria":"Failure sets and final scores match the oracle on sample files; failure-stacking counts identical","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:26Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:26Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"homemaker-py-gnw","title":"Native fitness: leaf quality terms + cost model","description":"DESIGN.md §6. Port Leaf.pm quality terms (size, width, proportion, perpendicular, access) with programme-driven parameter lookup (get_space_params fallback chain, generic c/o/s handling, width_inside [4.0,1.0] default), gaussian scoring, FAIL_THRESHOLD=0.1. Also the COST DENOMINATOR — fitness is value/cost: per-leaf area costs, interior/exterior wall edge costs, boundary costs, value rates (Leaf.pm:194-251, Storey.pm:122-147). Cost couples to geometry too.","acceptance_criteria":"Per-leaf quality factors and per-storey cost/value match Perl (float tolerance) on sample corpus files with DEBUG output diffed","notes":"Crinkliness scope (2026-06-12): port SIMPLE crinkliness only — external wall area / floor area with the CIEsky illumination factor pinned to 1 (boundary-overlap geometry from Dom-\u003eWalls stays in scope; the sky model does not). Must match the URB_NO_OCCLUSION-flagged oracle (homemaker-py-gp2), not stock Urb. quality_daylight = 1 for all spaces.","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:24Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:28:07Z","dependencies":[{"issue_id":"homemaker-py-gnw","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-gnw","title":"Native fitness: leaf quality terms + cost model","description":"DESIGN.md §6. Port Leaf.pm quality terms (size, width, proportion, perpendicular, access) with programme-driven parameter lookup (get_space_params fallback chain, generic c/o/s handling, width_inside [4.0,1.0] default), gaussian scoring, FAIL_THRESHOLD=0.1. Also the COST DENOMINATOR — fitness is value/cost: per-leaf area costs, interior/exterior wall edge costs, boundary costs, value rates (Leaf.pm:194-251, Storey.pm:122-147). Cost couples to geometry too.","acceptance_criteria":"Per-leaf quality factors and per-storey cost/value match Perl (float tolerance) on sample corpus files with DEBUG output diffed","notes":"Crinkliness scope (2026-06-12): port SIMPLE crinkliness only — external wall area / floor area with the CIEsky illumination factor pinned to 1 (boundary-overlap geometry from Dom-\u003eWalls stays in scope; the sky model does not). Must match the URB_NO_OCCLUSION-flagged oracle (homemaker-py-gp2), not stock Urb. quality_daylight = 1 for all spaces.","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:24Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:28:07Z","dependencies":[{"issue_id":"homemaker-py-gnw","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"homemaker-py-3y7","title":"Native fitness: adjacency/connectivity graph build + Merge_Divided semantics","description":"DESIGN.md §6 port scope, §7 Phase 3 (native fitness gates topology search at scale — §4.6). Port the door_width (1.2 m) adjacency graph (Urb Dom Graph), Merge_Divided, and the TWO-PHASE build: adjacency/level/vertical checks run on the UNMERGED tree, graphs rebuilt after Merge_Divided for storey processing (ProgrammeDriven.pm:83-103). Port faithfully — including has_vertical_connection's no-spatial-overlap stub (ProgrammeDriven.pm:399-423) unless the fidelity decision (§8.1) says otherwise; record the decision.","acceptance_criteria":"Graph edges/widths and merged structure match Perl on the 35-file corpus; vertical-connectivity fidelity decision recorded","status":"in_progress","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:23Z","created_by":"Bruno Postle","updated_at":"2026-06-12T13:13:09Z","started_at":"2026-06-12T13:13:09Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-3y7","title":"Native fitness: adjacency/connectivity graph build + Merge_Divided semantics","description":"DESIGN.md §6 port scope, §7 Phase 3 (native fitness gates topology search at scale — §4.6). Port the door_width (1.2 m) adjacency graph (Urb Dom Graph), Merge_Divided, and the TWO-PHASE build: adjacency/level/vertical checks run on the UNMERGED tree, graphs rebuilt after Merge_Divided for storey processing (ProgrammeDriven.pm:83-103). Port faithfully — including has_vertical_connection's no-spatial-overlap stub (ProgrammeDriven.pm:399-423) unless the fidelity decision (§8.1) says otherwise; record the decision.","acceptance_criteria":"Graph edges/widths and merged structure match Perl on the 35-file corpus; vertical-connectivity fidelity decision recorded","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:23Z","created_by":"Bruno Postle","updated_at":"2026-06-12T20:55:31Z","started_at":"2026-06-12T13:13:09Z","closed_at":"2026-06-12T20:55:31Z","close_reason":"Graph edges/widths match Perl on all 35 corpus files (2 bugs fixed: empty-string boundary excluded by Python 'in' operator substring check, and upper-storey rotation not delegating to below-link). Merge_Divided ported. Vertical-connectivity fidelity decision recorded in graph.py module docstring (faithful stub, no spatial overlap). Tests in test_graph.py.","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.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}
{"id":"homemaker-py-8cs","title":"Experiment: warm-vs-cold start of inner loop (Lamarckian inheritance)","description":"DESIGN.md §5.6, §4.6. Warm-starting a child topology's inner loop from the parent's optimised ratios is the main lever for cutting per-topology cost (~3 min/topology cold). Apply single topology mutations to optimised corpus designs, re-optimise warm (surviving cuts keep values, new cuts get heuristic defaults) vs cold, compare oracle-call counts to convergence at equal final fitness.","acceptance_criteria":"Speedup factor measured across \u003e=10 mutated topologies; decision recorded (expect order-of-magnitude; if \u003c2x, revisit §4.6 Phase-2 scoping)","notes":"Experiment script committed (experiments/warm_vs_cold.py, 1cc86c8) and machinery validated oracle-free; one mutated child scored through the oracle OK. Waiting on homemaker-py-gp2 reference run to finish, then execute under URB_NO_OCCLUSION=1 (3 parents x 400 evals + 12 children x 2 x 200 evals, ~1.5-2 h oracle time). Default budgets: parent 400, child 200; target = evals to 95% of best final.","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T11:44:45Z","closed_at":"2026-06-12T11:44:45Z","close_reason":"Measured (URB_NO_OCCLUSION=1, parent budget 400, child 200, 12 single mutations across 3 designs): cold start reached 95% of warm final in 0/12 cases within budget — speedup unbounded at practical budgets; warm finals beat cold finals x1.2-x4 in 12/12; 6/12 warm starts were within 95% at 1 eval (near-neutral mutations). Decision: Lamarckian warm-starting is MANDATORY in the memetic driver (homemaker-py-b39), not an optimisation; cold starts produce strictly worse geometry at equal budget. Note: 2 undivides were exactly fitness-neutral (same-type merge == Merge_Divided equivalence) — locality datum for homemaker-py-nyb.","dependencies":[{"issue_id":"homemaker-py-8cs","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:34Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-8cs","title":"Experiment: warm-vs-cold start of inner loop (Lamarckian inheritance)","description":"DESIGN.md §5.6, §4.6. Warm-starting a child topology's inner loop from the parent's optimised ratios is the main lever for cutting per-topology cost (~3 min/topology cold). Apply single topology mutations to optimised corpus designs, re-optimise warm (surviving cuts keep values, new cuts get heuristic defaults) vs cold, compare oracle-call counts to convergence at equal final fitness.","acceptance_criteria":"Speedup factor measured across \u003e=10 mutated topologies; decision recorded (expect order-of-magnitude; if \u003c2x, revisit §4.6 Phase-2 scoping)","notes":"Experiment script committed (experiments/warm_vs_cold.py, 1cc86c8) and machinery validated oracle-free; one mutated child scored through the oracle OK. Waiting on homemaker-py-gp2 reference run to finish, then execute under URB_NO_OCCLUSION=1 (3 parents x 400 evals + 12 children x 2 x 200 evals, ~1.5-2 h oracle time). Default budgets: parent 400, child 200; target = evals to 95% of best final.","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T11:44:45Z","closed_at":"2026-06-12T11:44:45Z","close_reason":"Measured (URB_NO_OCCLUSION=1, parent budget 400, child 200, 12 single mutations across 3 designs): cold start reached 95% of warm final in 0/12 cases within budget — speedup unbounded at practical budgets; warm finals beat cold finals x1.2-x4 in 12/12; 6/12 warm starts were within 95% at 1 eval (near-neutral mutations). Decision: Lamarckian warm-starting is MANDATORY in the memetic driver (homemaker-py-b39), not an optimisation; cold starts produce strictly worse geometry at equal budget. Note: 2 undivides were exactly fitness-neutral (same-type merge == Merge_Divided equivalence) — locality datum for homemaker-py-nyb.","dependencies":[{"issue_id":"homemaker-py-8cs","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:34Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"homemaker-py-av5","title":"Batched oracle: score many .dom files per invocation","description":"oracle.py currently scores one .dom per urb-fitness.pl call (~1.65 s/dom). DESIGN.md §4.6: batching amortises Perl startup to ~0.99 s/dom and is required so population/batch optimisers can score a whole generation in one oracle call. Extend oracle.py with a batch API: write N .dom files, one perl invocation, parse N .score/.fails pairs. Keep the single-file path for compatibility.","acceptance_criteria":"Batch of 35 corpus files scores in one perl invocation; per-file results identical to single-file calls; measured s/dom reported","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:56Z","created_by":"Bruno Postle","updated_at":"2026-06-12T00:14:06Z","started_at":"2026-06-11T23:50:40Z","closed_at":"2026-06-12T00:14:06Z","close_reason":"score_batch() lands in oracle.py; 35-file corpus parity verified single-vs-batch (1e-12 rel fitness, exact fail sets); 0.98 s/dom batched vs 1.27 single, x1.30","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-av5","title":"Batched oracle: score many .dom files per invocation","description":"oracle.py currently scores one .dom per urb-fitness.pl call (~1.65 s/dom). DESIGN.md §4.6: batching amortises Perl startup to ~0.99 s/dom and is required so population/batch optimisers can score a whole generation in one oracle call. Extend oracle.py with a batch API: write N .dom files, one perl invocation, parse N .score/.fails pairs. Keep the single-file path for compatibility.","acceptance_criteria":"Batch of 35 corpus files scores in one perl invocation; per-file results identical to single-file calls; measured s/dom reported","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:56Z","created_by":"Bruno Postle","updated_at":"2026-06-12T00:14:06Z","started_at":"2026-06-11T23:50:40Z","closed_at":"2026-06-12T00:14:06Z","close_reason":"score_batch() lands in oracle.py; 35-file corpus parity verified single-vs-batch (1e-12 rel fitness, exact fail sets); 0.98 s/dom batched vs 1.27 single, x1.30","dependency_count":0,"dependent_count":1,"comment_count":0}
@ -16,8 +16,8 @@
{"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:39:00Z","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-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 (WongLiu) 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-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 (WongLiu) 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} {"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-12T07:27:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."} {"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
{"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."} {"_type":"memory","key":"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":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."} {"_type":"memory","key":"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."}

View file

@ -22,7 +22,7 @@ import yaml
_ROOT_FLOATS = ("height", "elevation", "wall_inner", "wall_outer") _ROOT_FLOATS = ("height", "elevation", "wall_inner", "wall_outer")
@dataclass @dataclass(eq=False)
class Node: class Node:
rotation: int = 0 rotation: int = 0
division: list[float] | None = None division: list[float] | None = None
@ -200,3 +200,95 @@ def dump(root: Node, path: str) -> None:
yaml.safe_dump( yaml.safe_dump(
_emit(root, True), fh, default_flow_style=False, sort_keys=False, allow_unicode=True _emit(root, True), fh, default_flow_style=False, sort_keys=False, allow_unicode=True
) )
# --------------------------------------------------------------------------- #
# Structural predicates (mirrors Urb::Dom::Is_Outside etc.)
# --------------------------------------------------------------------------- #
def is_outside(n: Node) -> bool:
"""True if n's type starts with 'o' or 's' (case-insensitive)."""
return n.type is not None and n.type[0].lower() in ("o", "s")
def _level_root(n: Node) -> Node:
while n.parent is not None:
n = n.parent
return n
def _below_more(n: Node) -> "Node | None":
"""Matching node on level below, walking up to parent if path absent;
mirrors ``Urb::Quad::Below_More``."""
lr = _level_root(n)
if lr.below is None:
return None
if n.below is not None:
return n.below
if n.parent is None:
return None
return _below_more(n.parent)
def _below_leaves(n: Node) -> "list[Node]":
bm = _below_more(n)
return bm.leaves() if bm is not None else []
def is_supported(n: Node) -> bool:
"""All leaves below n are indoor; mirrors ``Urb::Dom::Is_Supported``."""
bl = _below_leaves(n)
return bool(bl) and all(not is_outside(lf) for lf in bl)
def is_unsupported(n: Node) -> bool:
"""All leaves below n are outdoor; mirrors ``Urb::Dom::Is_Unsupported``."""
bl = _below_leaves(n)
return bool(bl) and all(is_outside(lf) for lf in bl)
# --------------------------------------------------------------------------- #
# Merge_Divided (Urb::Dom::Merge_Divided)
# --------------------------------------------------------------------------- #
def _undivide(n: Node, new_type: str) -> None:
n.division = None
n.left = None
n.right = None
n.type = new_type
def _merge_node(n: Node) -> None:
"""Post-order recursive merge; mirrors ``Urb::Dom::Merge_Divided``."""
if n.divided:
_merge_node(n.left)
_merge_node(n.right)
if n.above is not None and n.parent is None:
_merge_node(n.above)
if not n.divided:
return
lt = n.left.type or ""
rt = n.right.type or ""
l_os = bool(lt) and lt[0].lower() in ("o", "s")
r_os = bool(rt) and rt[0].lower() in ("o", "s")
if not (l_os and r_os):
return
l_o = bool(lt) and lt[0].lower() == "o"
r_o = bool(rt) and rt[0].lower() == "o"
if is_supported(n) and l_o and r_o:
_undivide(n, "O")
elif is_supported(n):
_undivide(n, "S")
elif is_unsupported(n):
_undivide(n, "O")
elif _level_root(n).below is None: # ground floor: !$self->Level in Perl
_undivide(n, "S")
def merge_divided(root: Node) -> None:
"""Merge adjacent outdoor siblings into a single node in-place;
mirrors ``Urb::Dom::Merge_Divided``. Re-links the tree afterward so
``below`` / ``parent`` / ``position`` fields stay consistent.
"""
_merge_node(root)
_link(root)

View file

@ -129,3 +129,126 @@ def offset_quad(corners: list[Point], offset: float) -> list[Point]:
n = len(corners) n = len(corners)
return [_corner_offset(corners[(k - 1) % n], corners[k], corners[(k + 1) % n], offset) return [_corner_offset(corners[(k - 1) % n], corners[k], corners[(k + 1) % n], offset)
for k in range(n)] for k in range(n)]
# --------------------------------------------------------------------------- #
# Leaf-adjacency graph (Urb::Quad::Graph + Urb::Boundary)
# --------------------------------------------------------------------------- #
def boundary_id(n: Node, edge: int) -> str:
"""Boundary id of ``edge`` (0..3) of ``n``; mirrors ``Urb::Quad::Boundary_Id``.
External plot-perimeter boundaries return one of 'a','b','c','d'.
Internal division boundaries return the id-path of the ancestor whose
division created that boundary line. The left child's rid==1 and the right
child's rid==3 both map to ``parent.id``.
Rotation is delegated to the lowest below-link (Urb::Quad::Rotation does the
same: ``return $self->Below->Rotation if defined $self->Below``). Upper-storey
nodes store their own rotation in the YAML but Urb ignores it and uses the
ground-floor counterpart's rotation instead.
"""
nb = n
while nb.below is not None:
nb = nb.below
rid = (edge + nb.rotation) % 4
if n.parent is None:
return "abcd"[rid]
if n.position == "l":
if rid == 0:
return boundary_id(n.parent, 0)
elif rid == 1:
return n.parent.id # division line shared with the r sibling
elif rid == 2:
return boundary_id(n.parent, 2)
else:
return boundary_id(n.parent, 3)
else: # position == 'r'
if rid == 0:
return boundary_id(n.parent, 0)
elif rid == 1:
return boundary_id(n.parent, 1)
elif rid == 2:
return boundary_id(n.parent, 2)
else:
return n.parent.id # division line shared with the l sibling
def centroid(n: Node) -> Point:
"""Average of the four corners; mirrors ``Urb::Quad::Centroid``."""
c = [coordinate(n, i) for i in range(4)]
return [(c[0][0] + c[1][0] + c[2][0] + c[3][0]) / 4,
(c[0][1] + c[1][1] + c[2][1] + c[3][1]) / 4]
def _edge_overlap(a: Node, edge_a: int, b: Node, edge_b: int) -> float:
"""Shared boundary width between two leaves; faithful port of
``Urb::Boundary::Overlap``.
The formula is a 1-D overlap estimator: given the four pairwise distances
between the two edge endpoints, ``len_a + len_b - max_dist`` (with clamped
special cases). This mirrors the Perl exactly, including the behaviour for
below-inherited nodes where the two edges are not physically collinear but
share the same tree-structure boundary the formula still returns a
positive overlap that NetworkX uses to add an adjacency edge.
Do NOT replace with Shapely's ``intersection().length``: that returns 0 for
non-collinear segments and would miss valid tree-level adjacencies in
multi-storey buildings where below-inheritance shifts physical coordinates.
"""
p_a0 = coordinate(a, edge_a)
p_a1 = coordinate(a, (edge_a + 1) % 4)
p_b0 = coordinate(b, edge_b)
p_b1 = coordinate(b, (edge_b + 1) % 4)
len_a = _dist(p_a0, p_a1)
len_b = _dist(p_b0, p_b1)
max_dist = max(_dist(p_a0, p_b0), _dist(p_a0, p_b1), _dist(p_a1, p_b0), _dist(p_a1, p_b1))
if max_dist <= len_b:
return len_a
if max_dist <= len_a:
return len_b
return max(0.0, len_a + len_b - max_dist)
def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph
"""Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``.
Returns a ``networkx.Graph`` whose nodes are leaf ``Node`` objects and whose
edges carry ``width`` (shared boundary metres) and ``weight`` (centroid
distance metres). Edges with width < ``door_width`` (Urb default 1.2 m)
are excluded. External plot-perimeter boundaries ('a','b','c','d') are
never edges. A single-leaf storey gets one isolated vertex.
"""
import networkx as nx
from collections import defaultdict
_external = frozenset("abcd") # single-char external boundary ids
leaves = level_root.leaves()
# Group (leaf, edge) pairs by shared boundary id.
# Use frozenset membership, NOT 'bid not in "abcd"' — the latter is a
# substring check and silently drops the root-division boundary ('').
groups: dict[str, list[tuple[Node, int]]] = defaultdict(list)
for leaf in leaves:
for edge in range(4):
bid = boundary_id(leaf, edge)
if bid not in _external:
groups[bid].append((leaf, edge))
G: nx.Graph = nx.Graph()
for leaf in leaves:
G.add_node(leaf)
for contributors in groups.values():
for i in range(len(contributors)):
for j in range(i + 1, len(contributors)):
a, edge_a = contributors[i]
b, edge_b = contributors[j]
if a is b:
continue
width = _edge_overlap(a, edge_a, b, edge_b)
if width >= door_width:
if not G.has_edge(a, b) or G[a][b]["width"] < width:
dist = _dist(centroid(a), centroid(b))
G.add_edge(a, b, weight=dist, width=width)
return G

238
src/homemaker/graph.py Normal file
View file

@ -0,0 +1,238 @@
"""Leaf-adjacency graph build and pre-merge checks for programme-driven fitness.
TWO-PHASE PATTERN (ProgrammeDriven.pm:83-103):
1. ``build_graphs(root)`` on the UNMERGED tree graph_base
2. Run adjacency / level / vertical checks using graph_base and the unmerged tree.
3. ``dom.merge_divided(root)`` mutates the tree in place.
4. ``build_graphs(root)`` again on the MERGED tree for storey processing.
FIDELITY DECISION ``has_vertical_connection`` (DESIGN.md §8.1):
Ported faithfully including the no-spatial-overlap stub from
ProgrammeDriven.pm:399-423. Any leaf of the target type on the level below
counts as "connected", regardless of spatial overlap. This is a known
simplification in the Perl; it is preserved here for oracle parity.
Reshape in Phase 4 if needed.
"""
from __future__ import annotations
import networkx as nx
from . import dom, geometry
from .dom import Node, levels
from .programme import SpaceReq
DOOR_WIDTH = 1.2 # Urb::Dom::Fitness::Base default_params door_width
# --------------------------------------------------------------------------- #
# Graph build
# --------------------------------------------------------------------------- #
def build_graphs(root: Node, door_width: float = DOOR_WIDTH) -> list[nx.Graph]:
"""Return one ``nx.Graph`` per storey (lowest first); mirrors
``setup_storey_graphs`` in ``Urb::Dom::Fitness::Base``.
This is called twice in the two-phase pattern: once before
``dom.merge_divided`` for adjacency/level/vertical checks, and once after
for storey processing. ``Has_Circulation`` filtering (which produces the
"inaccessible usable space" failure) is implemented in homemaker-py-hgg.
"""
return [geometry.leaf_graph(lvl, door_width) for lvl in levels(root)]
# --------------------------------------------------------------------------- #
# Adjacency helpers
# --------------------------------------------------------------------------- #
def has_adjacency(leaf: Node, target_code: str, G: nx.Graph) -> bool:
"""True if ``leaf`` (or its nearest graphed ancestor) has a neighbour whose
type matches ``^target_code`` (case-insensitive prefix); mirrors
``ProgrammeDriven.pm::has_adjacency``.
Walking up to the nearest graphed ancestor handles merged nodes that no
longer appear as individual vertices in a post-merge graph.
"""
node: Node | None = leaf
while node is not None and not G.has_node(node):
node = node.parent
if node is None:
return False
tc = target_code.lower()
for nb in G.neighbors(node):
if nb.type and nb.type.lower().startswith(tc):
return True
# neighbour might be a merged branch — check its leaves
for nl in (nb.leaves() if nb.divided else []):
if nl.type and nl.type.lower().startswith(tc):
return True
return False
def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> bool:
"""True if any leaf on the level directly below has type matching
``^target_code`` (case-insensitive); mirrors
``ProgrammeDriven.pm::has_vertical_connection``.
FAITHFUL STUB no spatial-overlap check (ProgrammeDriven.pm:399-423 bug).
See module docstring for the fidelity decision.
"""
li = _level_index(leaf, lvls)
if li == 0:
return False
below_root = lvls[li - 1]
tc = target_code.lower()
return any(bl.type and bl.type.lower().startswith(tc) for bl in below_root.leaves())
def _level_index(n: Node, lvls: list[Node]) -> int:
"""Index of n's storey in ``lvls`` (0 = ground floor)."""
lr = n
while lr.parent is not None:
lr = lr.parent
return lvls.index(lr)
# --------------------------------------------------------------------------- #
# Space-count detection (missing space list only; stacking is in homemaker-py-hgg)
# --------------------------------------------------------------------------- #
def find_missing_spaces(root: Node, targets: dict[str, SpaceReq]) -> list[str]:
"""Return ids of missing required spaces; mirrors the count-detection
part of ``check_space_counts`` in ``ProgrammeDriven.pm:156-215``.
Only returns the missing-code list used by other checks to suppress false
adjacency/level/vertical failures. The 2-base + per-quality failure
stacking (up to ~7 failures per missing space) is implemented in
homemaker-py-hgg.
"""
count: dict[str, int] = {}
for lvl in levels(root):
for leaf in lvl.leaves():
if leaf.type:
key = leaf.type.lower()
count[key] = count.get(key, 0) + 1
missing: list[str] = []
for code, req in targets.items():
if code[0].lower() in ("c", "o", "s"):
continue
actual = count.get(code.lower(), 0)
if actual < req.count:
for i in range(req.count - actual):
missing.append(code if req.count == 1 else f"{code}#{i + 1}")
return missing
# --------------------------------------------------------------------------- #
# Pre-merge checks
# --------------------------------------------------------------------------- #
def check_adjacency(
root: Node,
targets: dict[str, SpaceReq],
graph_base: list[nx.Graph],
missing: list[str],
) -> list[str]:
"""Adjacency check failures; mirrors
``check_adjacency_requirements`` in ``ProgrammeDriven.pm:218-278``.
Run on the UNMERGED tree with the pre-merge ``graph_base``.
"""
lvls = levels(root)
missing_set = set(missing)
failures: list[str] = []
seen: set[tuple] = set() # dedup per (leaf.id, code, adj_code) like Perl
for code, req in targets.items():
if not req.adjacency:
continue
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
if any_missing:
for adj_code in req.adjacency:
failures.append(f"missing {code}: would need adjacency to {adj_code}")
continue
for lvl in lvls:
li = lvls.index(lvl)
for leaf in lvl.leaves():
if leaf.type != code:
continue
G = graph_base[li]
for adj_code in req.adjacency:
key = (leaf.id, *sorted((code, adj_code)))
if key in seen:
continue
seen.add(key)
if not has_adjacency(leaf, adj_code, G):
failures.append(
f"{li}/{leaf.id} ({code}) not adjacent to {adj_code}"
)
return failures
def check_level_constraints(
root: Node,
targets: dict[str, SpaceReq],
missing: list[str],
) -> list[str]:
"""Level constraint failures; mirrors
``check_level_constraints`` in ``ProgrammeDriven.pm:319-358``.
"""
lvls = levels(root)
missing_set = set(missing)
failures: list[str] = []
for code, req in targets.items():
if req.level is None:
continue
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
if any_missing:
failures.append(f"missing {code}: would need to be on level {req.level}")
continue
for lvl in lvls:
li = lvls.index(lvl)
for leaf in lvl.leaves():
if leaf.type != code:
continue
if li != req.level:
failures.append(
f"{code} on wrong level (level {li}, expected {req.level})"
)
return failures
def check_vertical_connectivity(
root: Node,
targets: dict[str, SpaceReq],
missing: list[str],
) -> list[str]:
"""Vertical connectivity failures; mirrors
``check_vertical_connectivity_requirements`` in ``ProgrammeDriven.pm:360-397``.
Uses the faithful no-overlap stub; see ``has_vertical_connection``.
"""
lvls = levels(root)
missing_set = set(missing)
failures: list[str] = []
for code, req in targets.items():
if req.requires_below is None:
continue
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
if any_missing:
failures.append(
f"missing {code}: would need connection to {req.requires_below} below"
)
continue
for lvl in lvls:
for leaf in lvl.leaves():
if leaf.type != code:
continue
if not has_vertical_connection(leaf, req.requires_below, lvls):
failures.append(
f"{code} not connected to {req.requires_below} below"
)
return failures

170
tests/test_graph.py Normal file
View file

@ -0,0 +1,170 @@
"""Tests for leaf-adjacency graph build, merge_divided, and pre-merge checks.
Oracle: edge counts verified against Perl urb (Urb::Quad::Graph, door_width=1.2)
across the 35-file corpus. Widths for 2f45907 verified edge-by-edge.
Fidelity decision (DESIGN.md §8.1): has_vertical_connection is a faithful stub
any leaf of the target type on the level below counts; no spatial overlap check.
"""
from pathlib import Path
import pytest
from homemaker import dom, geometry
from homemaker.dom import merge_divided, levels
from homemaker.graph import build_graphs
CORPUS = Path("/home/bruno/src/urb/examples/programme-house")
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Urb corpus not available")
# ---------------------------------------------------------------------------
# Perl-oracle edge counts per file (generated from Urb::Quad::Graph, door=1.2)
# ---------------------------------------------------------------------------
PERL_EDGE_COUNTS = {
"0aec8e39552ff3bc9f43e085912644c5.dom": [5, 3, 8],
"105ceaf8751377af694a5bb8854bed3e.dom": [3, 5, 8],
"155953b017c5c7b7559d5a2a1f22cd5f.dom": [7, 5, 3],
"21059668d774c17322e78931605080ad.dom": [8, 5],
"2c13a0816affe390130cb0ad24336d81.dom": [5, 10],
"2f45907abd9accac2a124d311732f749.dom": [9, 3, 6],
"3ec9e7f4785e3db8b71921444c8fd4fa.dom": [10, 3, 6],
"3ff824bf8e5e00f28a736fcb405a4702.dom": [6, 8],
"458aa8b8756bc099ce22f0ee92ce4c88.dom": [8, 3, 3],
"48ffdc096e07ba0bdc7b537485ce7a79.dom": [9, 3, 6],
"6cdced8e6d3aae40bdb7180d1a206729.dom": [10, 5],
"6fa7ebd4d570ebe48c8845fbc4e4ead0.dom": [4, 3, 4],
"77f9db022c0f124eef216ab3737edd3c.dom": [9, 3, 6],
"7d8269c8faa6adf23432243c13e90cb6.dom": [10, 6],
"7ef10b4dfe6bf7d9cee5bf0cbd7d67a9.dom": [3, 5, 5],
"82c7439d3098ad710fef7d8caa3e320d.dom": [7, 9],
"8fbadf0d7cd70ff6eb49c2dc978ed4e0.dom": [8, 3, 6],
"a82f07068e4408fdd0d5e3dc469a8dee.dom": [3, 9, 3],
"aa0dcab98927d2c933e8381d37734971.dom": [8, 8],
"aa4f7a9839a84435ac07fdf8111cce42.dom": [8, 5],
"b5d6002ff0b04f9269566b14d5e91f2a.dom": [6, 6],
"c074775d0660ba48fa607f4472c7e484.dom": [3, 3, 9],
"c436666f7cf1c24b20daaa625a01a071.dom": [8, 3, 6],
"c7fab3037ca6ff7a02a42553570b2aaa.dom": [9, 3, 6],
"c848f73cf1a847b8cb6583dbde94c633.dom": [8, 3, 6],
"c964435454c459f86c3ed9a5a7621132.dom": [3, 9, 3],
"ca9e80c5c1502f1050eaa548978dbb2d.dom": [6, 8],
"candidate-001.dom": [3, 9, 9],
"candidate-002.dom": [3, 9, 9],
"cb93a2d2de7f5d37af450a8ce7b681b1.dom": [6, 8],
"cd39357b1ba79aec4943411cdca51668.dom": [3, 5, 5],
"cf0b8a77e8b2325f92a7e7d150184a55.dom": [5, 6],
"de9468f607f5d0a88cc554ad1776b537.dom": [7, 5, 3],
"eebf1980a672d9ec11cfc69c029d6796.dom": [4, 5, 4],
"init.dom": [0],
}
def test_edge_counts_match_perl_corpus():
"""Edge counts per level must match Perl oracle across all 35 corpus files."""
for fname, expected in PERL_EDGE_COUNTS.items():
root = dom.load(str(CORPUS / fname))
actual = [
geometry.leaf_graph(lvl, 1.2).number_of_edges()
for lvl in dom.levels(root)
]
assert actual == expected, f"{fname}: got {actual}, expected {expected}"
def test_edge_widths_2f45907():
"""Edge widths for 2f45907 must match Perl values to 3 d.p."""
root = dom.load(str(CORPUS / "2f45907abd9accac2a124d311732f749.dom"))
lvls = dom.levels(root)
def edges_as_dict(G):
return {
tuple(sorted((a.id, b.id))): round(d["width"], 3)
for a, b, d in G.edges(data=True)
}
# Level 0 (Perl-verified widths)
g0 = edges_as_dict(geometry.leaf_graph(lvls[0], 1.2))
assert g0[("lll", "llr")] == pytest.approx(4.176, abs=0.001)
assert g0[("lll", "rl")] == pytest.approx(1.237, abs=0.001)
assert g0[("lll", "rr")] == pytest.approx(1.586, abs=0.001)
assert g0[("lll", "lrl")] == pytest.approx(2.175, abs=0.001)
assert g0[("lll", "lrr")] == pytest.approx(2.001, abs=0.001)
assert g0[("llr", "rr")] == pytest.approx(2.429, abs=0.001)
assert g0[("lrl", "lrr")] == pytest.approx(2.422, abs=0.001)
assert g0[("lrr", "rl")] == pytest.approx(2.306, abs=0.001)
assert g0[("rl", "rr")] == pytest.approx(2.639, abs=0.001)
# Level 1
g1 = edges_as_dict(geometry.leaf_graph(lvls[1], 1.2))
assert g1[("l", "rl")] == pytest.approx(3.543, abs=0.001)
assert g1[("l", "rr")] == pytest.approx(4.014, abs=0.001)
assert g1[("rl", "rr")] == pytest.approx(2.639, abs=0.001)
# Level 2
g2 = edges_as_dict(geometry.leaf_graph(lvls[2], 1.2))
assert g2[("ll", "rrr")] == pytest.approx(2.581, abs=0.001)
assert g2[("ll", "lr")] == pytest.approx(4.176, abs=0.001)
assert g2[("lr", "rl")] == pytest.approx(3.543, abs=0.001)
assert g2[("lr", "rrl")] == pytest.approx(1.252, abs=0.001)
assert g2[("rl", "rrl")] == pytest.approx(2.639, abs=0.001)
assert g2[("rrl", "rrr")] == pytest.approx(2.625, abs=0.001)
def test_all_edge_widths_at_or_above_door_width():
"""Every edge in every graph must have width >= 1.2 (the door_width threshold)."""
for fname in PERL_EDGE_COUNTS:
root = dom.load(str(CORPUS / fname))
for lvl in dom.levels(root):
G = geometry.leaf_graph(lvl, 1.2)
for _, _, d in G.edges(data=True):
assert d["width"] >= 1.2 - 1e-9, f"{fname}: edge width {d['width']} < 1.2"
def test_graph_nodes_equal_leaf_count():
"""Every graph must have exactly one vertex per leaf (isolated or connected)."""
for fname in PERL_EDGE_COUNTS:
root = dom.load(str(CORPUS / fname))
for lvl in dom.levels(root):
leaves = lvl.leaves()
G = geometry.leaf_graph(lvl, 1.2)
assert G.number_of_nodes() == len(leaves), fname
def test_build_graphs_length_matches_level_count():
"""build_graphs must return one graph per storey."""
for fname in PERL_EDGE_COUNTS:
root = dom.load(str(CORPUS / fname))
gs = build_graphs(root)
assert len(gs) == len(dom.levels(root)), fname
def test_merge_divided_candidate_002():
"""candidate-002 level 2 collapses 5 outdoor leaves into a single O node."""
root = dom.load(str(CORPUS / "candidate-002.dom"))
lvl2_before = levels(root)[2]
leaves_before = [leaf.type for leaf in lvl2_before.leaves()]
# level 2 has 7 leaves, of which 5 are O/S types in the l-subtree
assert len(leaves_before) == 7
merge_divided(root)
lvl2_after = levels(root)[2]
leaves_after = [(leaf.id, leaf.type) for leaf in lvl2_after.leaves()]
# After merge the 5 outdoor leaves in l collapse to a single O
assert len(leaves_after) == 3
assert ("l", "O") in leaves_after
def test_two_phase_build_graphs_independent():
"""Pre-merge and post-merge graphs must be independent (different node counts
after merging a file that actually merges)."""
root = dom.load(str(CORPUS / "candidate-002.dom"))
graphs_pre = build_graphs(root)
pre_nodes_l2 = graphs_pre[2].number_of_nodes()
merge_divided(root)
graphs_post = build_graphs(root)
post_nodes_l2 = graphs_post[2].number_of_nodes()
assert pre_nodes_l2 == 7
assert post_nodes_l2 == 3