From 304d5145733583846f6ee17872a408d13c5bc62f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 14 Jun 2026 00:06:02 +0100 Subject: [PATCH] Add unit tests for geometry and fitness modules 26 tests for geometry (area, angles, aspect, boundary ids, centroid, offset, etc.) and 35 tests for fitness (gaussian, config lookup, quality terms, value rates, costs, stair helpers). Suite: 175 passed. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 8 +- tests/test_fitness.py | 236 +++++++++++++++++++++++++++++++++++++++++ tests/test_geometry.py | 186 ++++++++++++++++++++++++++++++++ 3 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 tests/test_fitness.py create mode 100644 tests/test_geometry.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f54b0fb..602b749 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -18,7 +18,7 @@ {"id":"homemaker-py-k2g","title":"Topology genome: base-floor tree + per-floor deltas + type assignment","description":"DESIGN.md §5.2, §7 Phase 2. Genome = base-floor slicing topology (primary) + per-leaf type assignment + per-floor divide/undivide deltas (Below-inheritance as regulariser; cut owned by lowest storey where its path is divided — §10). Must round-trip to/from dom.py Node trees so the oracle and inner loop consume it directly. Includes storey count and per-floor type overrides.","acceptance_criteria":"Genome \u003c-\u003e .dom round-trip on all 35 corpus files preserves fitness; multi-storey wall stacking preserved","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:37:26Z","created_by":"Bruno Postle","updated_at":"2026-06-12T12:52:34Z","started_at":"2026-06-12T10:55:21Z","closed_at":"2026-06-12T12:52:34Z","close_reason":"genome.py encode/decode lands. 35/35 oracle fitness parity after round-trip (flag-on); genome fixed-point + owned-projection tests. Dead-field discovery: corpus upper storeys carry drifted dead divisions (97) and rotations (187) — canonicalised by decode, validated fitness-neutral.","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"homemaker-py-d0s","title":"Experiment: inner-loop optimiser bake-off at equal oracle budgets","description":"DESIGN.md §7 Phase 1, §8.3. DOF is only ~rooms-1 (6–7 on corpus). Compare Nelder-Mead vs CMA-ES vs batched multi-start pattern search at equal oracle-call budgets, measuring fitness gained per oracle call and wall-clock (batch-friendliness matters — §4.6). Measure, don't commit blind.","acceptance_criteria":"Table of fitness-per-budget across \u003e=3 candidates; one optimiser chosen and recorded in DESIGN.md","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:59Z","created_by":"Bruno Postle","updated_at":"2026-06-13T08:48:13Z","started_at":"2026-06-12T21:22:15Z","closed_at":"2026-06-13T08:48:13Z","close_reason":"Bake-off complete: CMA-ES confirmed as Phase 1/2 optimiser. NM wins quality per eval but sequential architecture incompatible with batching (§4.6). Compass stalls on narrow valleys. Results in DESIGN.md §8.3 and experiments/bakeoff_innerloop.*","dependencies":[{"issue_id":"homemaker-py-d0s","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:35Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-9t6","title":"Package install: pyproject.toml with entry points","description":"The project currently requires PYTHONPATH=/home/bruno/src/homemaker-py/src and is run via 'python3 experiments/...'. There is no installable package. Add a pyproject.toml with: package discovery for src/homemaker/, a [project.scripts] entry point for homemaker-evolve (homemaker-py-2wc), and minimal metadata. After 'pip install -e .' the tool should be on PATH and importable without PYTHONPATH. Keep the existing pyproject.toml if one exists and extend it.","acceptance_criteria":"'pip install -e .' succeeds; 'homemaker-evolve --help' works from any directory; 'import homemaker' works without PYTHONPATH","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:35Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:52:35Z","dependencies":[{"issue_id":"homemaker-py-9t6","depends_on_id":"homemaker-py-2wc","type":"blocks","created_at":"2026-06-13T22:52:41Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"homemaker-py-gug","title":"Test suite","description":"There are no automated tests. Validation has been done entirely through experiment scripts and the 35-file corpus parity check (homemaker-py-uxz). This is acceptable during exploration but fragile as the codebase grows. Need pytest-based unit tests covering: geometry port correctness (vs known values, not just vs oracle), fitness term correctness (size/width/proportion/adjacency/access/crinkliness/stair terms individually), genome operators (mutations preserve tree invariants), inner loop (convergence on known landscape), and a fast corpus smoke test (subset of the 35 files, score within tolerance). The corpus parity experiment can be the integration test baseline.","acceptance_criteria":"pytest runs clean; geometry, fitness terms, operators, and inner loop each have unit tests; corpus smoke test covers at least 5 files","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:31Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:52:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"homemaker-py-gug","title":"Test suite","description":"There are no automated tests. Validation has been done entirely through experiment scripts and the 35-file corpus parity check (homemaker-py-uxz). This is acceptable during exploration but fragile as the codebase grows. Need pytest-based unit tests covering: geometry port correctness (vs known values, not just vs oracle), fitness term correctness (size/width/proportion/adjacency/access/crinkliness/stair terms individually), genome operators (mutations preserve tree invariants), inner loop (convergence on known landscape), and a fast corpus smoke test (subset of the 35 files, score within tolerance). The corpus parity experiment can be the integration test baseline.","acceptance_criteria":"pytest runs clean; geometry, fitness terms, operators, and inner loop each have unit tests; corpus smoke test covers at least 5 files","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:31Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:51:04Z","started_at":"2026-06-13T22:40:56Z","closed_at":"2026-06-13T22:51:04Z","close_reason":"Added test_geometry.py (26 tests) and test_fitness.py (35 tests); full suite now 175 tests, all passing","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-5l6","title":"Parallelise outer search population evaluation","description":"The outer memetic search evaluates topologies sequentially. Each eval runs the inner loop (CMA-ES) to convergence — independent across population members. Native fitness is pure Python with no shared mutable state, so population evaluation is embarrassingly parallel. multiprocessing.Pool or concurrent.futures.ProcessPoolExecutor over the child generation batch would give near-linear speedup with population size. At 71.8 evals/s single-threaded on a seeded programme-house run, parallelisation across available cores would proportionally increase the effective budget within the same wall-clock time.","acceptance_criteria":"Population generation parallelised; throughput scales with core count; verified correct (same result distribution as serial)","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:29Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:52:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-d6d","title":"Revisit Nelder-Mead for inner loop (post-oracle)","description":"The Phase 1 bakeoff (homemaker-py-d0s) chose CMA-ES over Nelder-Mead because CMA batches oracle calls (18 vs 200 per topology) — critical when oracle cost is 1 s/dom. That constraint is gone: native fitness evaluates at 71.8 evals/s with no batching penalty. The bakeoff showed NM wins quality per eval by +15% at budget 200 (x1.56 vs x1.41 gain). NM is also simpler, has no hyperparameters, and is inherently sequential which matches the inner loop's single-topology use. Re-run the bakeoff with native fitness; if NM still wins, swap it in. Also evaluate gradient-based optimisation (autograd through the native fitness functions) as a potential further improvement.","acceptance_criteria":"Bakeoff re-run with native fitness; inner loop updated if NM or gradient method outperforms CMA-ES; gain improvement documented","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-13T21:52:27Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:52:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"homemaker-py-2wc","title":"CLI tool: homemaker-evolve (equivalent to urb-evolve.pl)","description":"Wrap the existing memetic search driver as a proper command-line tool, analogous to urb-evolve.pl. The tool should: accept a programme directory and optional seed .dom file as positional args; honour env vars for budget/population (MAX_ITERATIONS, MAX_POP or equivalents); write the best .dom found to the programme directory (or stdout); print progress to stderr; handle SIGINT/SIGTERM gracefully (write best-so-far and exit cleanly). The bulk of the logic already exists in driver.py and experiments/run_search_scaled.py — this is a thin wrapper that makes the search usable from the shell and composable with other tools. Install as bin/homemaker-evolve or src/homemaker/bin/homemaker-evolve.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-13T21:47:55Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:47:55Z","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -27,9 +27,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":"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":"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":"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":"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":"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":"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."} diff --git a/tests/test_fitness.py b/tests/test_fitness.py new file mode 100644 index 0000000..e84b334 --- /dev/null +++ b/tests/test_fitness.py @@ -0,0 +1,236 @@ +"""Unit tests for fitness.py quality terms and helpers (oracle-free).""" + +import pytest + +from homemaker import dom, geometry +from homemaker.dom import Node +from homemaker.fitness import CONF_DEFAULTS, COST_DEFAULTS, Fitness, gaussian + + +def _leaf(type_: str, size: float = 4.0) -> Node: + """Undivided level-root leaf with a square plot of side `size`.""" + geometry.clear_cache() + return Node( + node=[[0.0, 0.0], [size, 0.0], [size, size], [0.0, size]], + type=type_, + ) + + +# --------------------------------------------------------------------------- # +# gaussian +# --------------------------------------------------------------------------- # + + +def test_gaussian_peak_returns_a(): + assert gaussian(5.0, 1.0, 5.0, 1.0) == pytest.approx(1.0) + + +def test_gaussian_peak_scales_by_a(): + assert gaussian(3.0, 2.5, 3.0, 1.0) == pytest.approx(2.5) + + +def test_gaussian_one_sigma_uses_truncated_e(): + # Urb uses e=2.718281828, not math.e; at one sigma the factor is e^-0.5 + e = 2.718281828 + expected = e ** -0.5 + assert gaussian(6.0, 1.0, 5.0, 1.0) == pytest.approx(expected, rel=1e-9) + + +def test_gaussian_symmetry(): + assert gaussian(4.0, 1.0, 5.0, 1.0) == pytest.approx(gaussian(6.0, 1.0, 5.0, 1.0)) + + +# --------------------------------------------------------------------------- # +# Fitness.conf / cost +# --------------------------------------------------------------------------- # + + +def test_conf_falls_back_to_defaults(): + assert Fitness().conf("value_inside") == CONF_DEFAULTS["value_inside"] + + +def test_conf_override_wins(): + assert Fitness(conf={"value_inside": 999.0}).conf("value_inside") == 999.0 + + +def test_conf_unknown_key_returns_none(): + assert Fitness().conf("no_such_key") is None + + +def test_cost_falls_back_to_defaults(): + assert Fitness().cost("inside") == COST_DEFAULTS["inside"] + + +def test_cost_override_wins(): + assert Fitness(cost={"inside": 42.0}).cost("inside") == 42.0 + + +def test_cost_unknown_key_returns_zero(): + assert Fitness().cost("no_such_key") == 0.0 + + +# --------------------------------------------------------------------------- # +# get_space_params lookup chain +# --------------------------------------------------------------------------- # + + +def test_get_space_params_circulation_size(): + assert Fitness().get_space_params("C", "size") == CONF_DEFAULTS["size_circulation"] + + +def test_get_space_params_outside_width(): + assert Fitness().get_space_params("O", "width") == CONF_DEFAULTS["width_outside"] + + +def test_get_space_params_sahn_proportion(): + assert Fitness().get_space_params("S", "proportion") == CONF_DEFAULTS["proportion_outside"] + + +def test_get_space_params_inside_falls_back_to_inside_defaults(): + assert Fitness().get_space_params("k1", "proportion") == CONF_DEFAULTS["proportion_inside"] + assert Fitness().get_space_params("k1", "size") == CONF_DEFAULTS["size_inside"] + + +def test_get_space_params_named_space_overrides_default(): + f = Fitness(conf={"spaces": {"k1": {"size": [20.0, 4.0]}}}) + assert f.get_space_params("k1", "size") == [20.0, 4.0] + + +# --------------------------------------------------------------------------- # +# quality_proportion +# --------------------------------------------------------------------------- # + + +def test_quality_proportion_square_inside_returns_one(): + # aspect=1.0 < proportion_inside[0]=1.5 → 1.0 + assert Fitness().quality_proportion(_leaf("k1")) == pytest.approx(1.0) + + +def test_quality_proportion_square_outside_returns_one(): + # aspect=1.0 < proportion_outside[0]=1.5 → 1.0 + assert Fitness().quality_proportion(_leaf("O")) == pytest.approx(1.0) + + +def test_quality_proportion_square_circulation_returns_one(): + assert Fitness().quality_proportion(_leaf("C")) == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- # +# quality_size +# --------------------------------------------------------------------------- # + + +def test_quality_size_outside_always_one(): + assert Fitness().quality_size(_leaf("O")) == 1.0 + + +def test_quality_size_sahn_always_one(): + assert Fitness().quality_size(_leaf("S")) == 1.0 + + +def test_quality_size_inside_at_peak(): + # size_inside=[16.0,3.5]; leaf is 4×4=16 m² → gaussian at peak → 1.0 + leaf = _leaf("k1", size=4.0) + assert geometry.area(leaf) == pytest.approx(16.0) + assert Fitness().quality_size(leaf) == pytest.approx(1.0) + + +def test_quality_size_circulation_at_peak(): + # size_circulation=[0.0,14.0]; peak at 0, gaussian(area,1,0,14) → always <1 for area>0 + # Just verify it returns a value in [0,1] + f = Fitness().quality_size(_leaf("C", size=4.0)) + assert 0.0 < f <= 1.0 + + +# --------------------------------------------------------------------------- # +# quality_width +# --------------------------------------------------------------------------- # + + +def test_quality_width_wide_inside_returns_one(): + # width_inside=[4.0,1.0]; 10m side > 4.0 → 1.0 + assert Fitness().quality_width(_leaf("k1", size=10.0)) == pytest.approx(1.0) + + +def test_quality_width_wide_circulation_returns_one(): + # width_circulation=[2.4,0.2]; 10m > 2.4 → 1.0 + assert Fitness().quality_width(_leaf("C", size=10.0)) == pytest.approx(1.0) + + +def test_quality_width_wide_outside_ground_uses_gaussian(): + # outside at level 0 falls through to gaussian; 10m > width_outside[0]=3.0 → 1.0 + assert Fitness().quality_width(_leaf("O", size=10.0)) == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- # +# quality_perpendicular +# --------------------------------------------------------------------------- # + + +def test_quality_perpendicular_rectangle_near_one(): + # All four corners of the square are pi/2; perpendicular formula gives ≈1 + leaf = _leaf("k1", size=4.0) + result = Fitness().quality_perpendicular(leaf) + assert result == pytest.approx(1.0, abs=1e-6) + + +# --------------------------------------------------------------------------- # +# value_rate +# --------------------------------------------------------------------------- # + + +def test_value_rate_outside_ground(): + leaf = _leaf("O") + assert dom.level_of(leaf) == 0 + assert Fitness().value_rate(leaf) == pytest.approx(CONF_DEFAULTS["value_outside"]) + + +def test_value_rate_circulation(): + assert Fitness().value_rate(_leaf("C")) == pytest.approx(CONF_DEFAULTS["value_circulation"]) + + +def test_value_rate_inside(): + assert Fitness().value_rate(_leaf("k1")) == pytest.approx(CONF_DEFAULTS["value_inside"]) + + +# --------------------------------------------------------------------------- # +# leaf_cost +# --------------------------------------------------------------------------- # + + +def test_leaf_cost_outside_bare(): + # not covered, not supported → outside rate × area + leaf = _leaf("O", size=4.0) # area = 16.0 + assert Fitness().leaf_cost(leaf) == pytest.approx(COST_DEFAULTS["outside"] * 16.0) + + +def test_leaf_cost_inside(): + leaf = _leaf("k1", size=4.0) + assert Fitness().leaf_cost(leaf) == pytest.approx(COST_DEFAULTS["inside"] * 16.0) + + +# --------------------------------------------------------------------------- # +# Stair helpers +# --------------------------------------------------------------------------- # + + +def test_risers_number_exact_division(): + # 2.0 / 0.25 = 8.0 exactly → returns 8 + assert Fitness._risers_number(2.0, 0.25) == 8 + + +def test_risers_number_rounds_up(): + # 3.0 / 0.19 ≈ 15.789 → rounds up to 16 + assert Fitness._risers_number(3.0, 0.19) == 16 + + +def test_ideal_going_clamps_to_minimum(): + # riser=0.25 → going=0.125 < 0.22 → clamp + assert Fitness._ideal_going(0.25) == 0.22 + + +def test_ideal_going_above_minimum(): + # riser=0.15 → going=0.325 > 0.22; result should be in valid range + result = Fitness._ideal_going(0.15) + assert result >= 0.22 + assert result <= 0.625 diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..01a1532 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,186 @@ +"""Unit tests for geometry.py with known analytic values (oracle-free).""" + +import math + +import pytest + +from homemaker import geometry +from homemaker.dom import Node + + +def _square(size: float = 10.0) -> Node: + geometry.clear_cache() + return Node(node=[[0.0, 0.0], [size, 0.0], [size, size], [0.0, size]]) + + +def _rect(w: float, h: float) -> Node: + geometry.clear_cache() + return Node(node=[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]]) + + +def _divided(size: float = 10.0, split: float = 0.5): + """Return (root, left, right) for a square split at `split`.""" + geometry.clear_cache() + root = Node( + node=[[0.0, 0.0], [size, 0.0], [size, size], [0.0, size]], + division=[split, split], + ) + left = Node(position="l") + right = Node(position="r") + root.left, root.right = left, right + left.parent = right.parent = root + return root, left, right + + +# --------------------------------------------------------------------------- # + + +def test_area_square(): + assert geometry.area(_square(10.0)) == pytest.approx(100.0) + + +def test_area_rectangle(): + assert geometry.area(_rect(6.0, 4.0)) == pytest.approx(24.0) + + +def test_area_divided_halves(): + _, left, right = _divided(10.0, 0.5) + assert geometry.area(left) == pytest.approx(50.0) + assert geometry.area(right) == pytest.approx(50.0) + + +def test_area_divided_unequal(): + _, left, right = _divided(10.0, 0.3) + assert geometry.area(left) == pytest.approx(30.0, rel=1e-9) + assert geometry.area(right) == pytest.approx(70.0, rel=1e-9) + + +def test_edge_length_square_all_equal(): + r = _square(8.0) + for i in range(4): + assert geometry.edge_length(r, i) == pytest.approx(8.0) + + +def test_edge_length_rectangle(): + r = _rect(6.0, 4.0) + assert geometry.edge_length(r, 0) == pytest.approx(6.0) + assert geometry.edge_length(r, 1) == pytest.approx(4.0) + assert geometry.edge_length(r, 2) == pytest.approx(6.0) + assert geometry.edge_length(r, 3) == pytest.approx(4.0) + + +def test_angle_square_corners_are_right_angles(): + r = _square(10.0) + for i in range(4): + assert geometry.angle(r, i) == pytest.approx(math.pi / 2, abs=1e-9) + + +def test_angle_rectangle_corners_are_right_angles(): + r = _rect(6.0, 4.0) + for i in range(4): + assert geometry.angle(r, i) == pytest.approx(math.pi / 2, abs=1e-9) + + +def test_aspect_square_is_one(): + assert geometry.aspect(_square(10.0)) == pytest.approx(1.0) + + +def test_aspect_wide_rectangle(): + assert geometry.aspect(_rect(2.0, 1.0)) == pytest.approx(2.0) + + +def test_aspect_tall_rectangle_is_same_as_wide(): + assert geometry.aspect(_rect(1.0, 2.0)) == pytest.approx(2.0) + + +def test_length_narrowest_square(): + assert geometry.length_narrowest(_square(5.0)) == pytest.approx(5.0) + + +def test_length_narrowest_rectangle(): + assert geometry.length_narrowest(_rect(6.0, 3.0)) == pytest.approx(3.0) + + +def test_centroid_square(): + cx, cy = geometry.centroid(_square(10.0)) + assert cx == pytest.approx(5.0) + assert cy == pytest.approx(5.0) + + +def test_centroid_rectangle(): + cx, cy = geometry.centroid(_rect(6.0, 4.0)) + assert cx == pytest.approx(3.0) + assert cy == pytest.approx(2.0) + + +def test_boundary_id_root_returns_external_letters(): + r = _square() + assert geometry.boundary_id(r, 0) == "a" + assert geometry.boundary_id(r, 1) == "b" + assert geometry.boundary_id(r, 2) == "c" + assert geometry.boundary_id(r, 3) == "d" + + +def test_boundary_id_left_child_internal_edge(): + root, left, right = _divided() + # left child edge 1 (rid=1, position='l') → the division line → parent.id + assert geometry.boundary_id(left, 1) == root.id + assert geometry.boundary_id(left, 1) == "" # root id is '' (empty path) + + +def test_boundary_id_right_child_internal_edge(): + root, left, right = _divided() + # right child edge 3 → division line → parent.id + assert geometry.boundary_id(right, 3) == root.id + + +def test_boundary_id_children_inherit_external_edges(): + _, left, right = _divided() + assert geometry.boundary_id(left, 0) == "a" + assert geometry.boundary_id(left, 3) == "d" + assert geometry.boundary_id(right, 0) == "a" + assert geometry.boundary_id(right, 1) == "b" + + +def test_is_between_2d_midpoint(): + assert geometry.is_between_2d([5.0, 0.0], [0.0, 0.0], [10.0, 0.0]) + + +def test_is_between_2d_endpoint(): + assert geometry.is_between_2d([0.0, 0.0], [0.0, 0.0], [10.0, 0.0]) + + +def test_is_between_2d_outside(): + assert not geometry.is_between_2d([11.0, 0.0], [0.0, 0.0], [10.0, 0.0]) + + +def test_is_between_2d_none(): + assert not geometry.is_between_2d(None, [0.0, 0.0], [10.0, 0.0]) + + +def test_offset_quad_inward_shrinks_area(): + corners = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]] + inset = geometry.offset_quad(corners, -1.0) + # inward offset of a square → smaller square + assert inset[0][0] > corners[0][0] # x moves right + assert inset[0][1] > corners[0][1] # y moves up + assert inset[2][0] < corners[2][0] # top-right x moves left + assert inset[2][1] < corners[2][1] # top-right y moves down + + +def test_boundary_groups_single_leaf(): + r = _square() + groups = geometry.boundary_groups(r) + # undivided root has no internal boundaries + assert len(groups) == 0 + + +def test_boundary_groups_divided_root(): + root, left, right = _divided() + groups = geometry.boundary_groups(root) + # one internal boundary between left and right + assert len(groups) == 1 + contributors = list(groups.values())[0] + nodes = {leaf for leaf, _ in contributors} + assert left in nodes + assert right in nodes