Spaces:
Running
Running
| import pytest | |
| import numpy as np | |
| from env.graph import CreaseGraph | |
| from env.paper_state import PaperState | |
| from env.verifier import ( | |
| check_kawasaki_at_vertex, | |
| check_maekawa_at_vertex, | |
| check_blb_at_vertex, | |
| geometric_crease_coverage, | |
| check_all_vertices, | |
| check_degree_sanity, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def make_cross_graph(center_coords=(0.5, 0.5), assignment='M') -> tuple[CreaseGraph, int]: | |
| """ | |
| Degree-4 vertex at center with 4 spokes pointing N/S/E/W. | |
| All spokes have the given assignment. | |
| """ | |
| g = CreaseGraph() | |
| cx, cy = center_coords | |
| vid = g.add_vertex(cx, cy) | |
| neighbors = [ | |
| (0.0, cy), # left (180°) | |
| (1.0, cy), # right (0°) | |
| (cx, 0.0), # down (-90°) | |
| (cx, 1.0), # up (90°) | |
| ] | |
| for nx, ny in neighbors: | |
| nid = g.add_vertex(nx, ny) | |
| g.add_edge(vid, nid, assignment) | |
| return g, vid | |
| # --------------------------------------------------------------------------- | |
| # Kawasaki tests | |
| # --------------------------------------------------------------------------- | |
| def test_kawasaki_no_interior_vertices(): | |
| paper = PaperState() | |
| paper.add_crease([0, 0.5], [1, 0.5], 'V') | |
| assert paper.graph.interior_vertices() == [] | |
| result = check_all_vertices(paper.graph) | |
| assert result['kawasaki'] == 1.0 | |
| assert result['n_interior'] == 0 | |
| def test_kawasaki_valid_degree4_vertex(): | |
| """Equal 90° sectors → alternating sum = 0 → Kawasaki satisfied.""" | |
| g, vid = make_cross_graph() | |
| ok, err = check_kawasaki_at_vertex(vid, g) | |
| assert ok == True | |
| assert err == pytest.approx(0.0, abs=1e-9) | |
| def test_kawasaki_invalid_vertex(): | |
| """ | |
| Manually construct a degree-4 vertex whose sectors are 60°,120°,80°,100°. | |
| Alternating sum = 60 - 120 + 80 - 100 = -80° ≠ 0 → should fail. | |
| """ | |
| g = CreaseGraph() | |
| cx, cy = 0.5, 0.5 | |
| vid = g.add_vertex(cx, cy) | |
| # Place neighbours at specific angles so sectors are exactly as desired. | |
| # Sectors are measured CCW between consecutive rays. | |
| # We choose ray angles (from center) in ascending arctan2 order: | |
| # a0 = 0° | |
| # a1 = 60° (sector0 = 60°) | |
| # a2 = 180° (sector1 = 120°) | |
| # a3 = 260° = -100° (sector2 = 80°) | |
| # sector3 (wraparound to a0) = 360° - 260° = 100° | |
| # alt_sum = 60 - 120 + 80 - 100 = -80° → |alt_sum| ≈ 1.396 rad | |
| r = 0.3 | |
| angles_deg = [0.0, 60.0, 180.0, 260.0] | |
| for deg in angles_deg: | |
| rad = np.deg2rad(deg) | |
| nx = cx + r * np.cos(rad) | |
| ny = cy + r * np.sin(rad) | |
| nid = g.add_vertex(nx, ny) | |
| g.add_edge(vid, nid, 'M') | |
| ok, err = check_kawasaki_at_vertex(vid, g) | |
| assert ok == False | |
| expected_err = abs(np.deg2rad(60 - 120 + 80 - 100)) | |
| assert err == pytest.approx(expected_err, abs=1e-6) | |
| # --------------------------------------------------------------------------- | |
| # Maekawa tests | |
| # --------------------------------------------------------------------------- | |
| def test_maekawa_excludes_boundary(): | |
| """ | |
| Boundary edges at a vertex should NOT count toward M/V tally. | |
| A corner vertex has only boundary edges; Maekawa should return True | |
| (fewer than 4 fold edges → vacuously satisfied). | |
| """ | |
| g = CreaseGraph() | |
| corner_id = 0 # vertex (0,0) | |
| assert check_maekawa_at_vertex(corner_id, g) is True | |
| def test_maekawa_valid(): | |
| """3 M + 1 V → |3-1| = 2 → True.""" | |
| g = CreaseGraph() | |
| cx, cy = 0.5, 0.5 | |
| vid = g.add_vertex(cx, cy) | |
| r = 0.3 | |
| angles_deg = [0.0, 90.0, 180.0, 270.0] | |
| assignments = ['M', 'M', 'M', 'V'] | |
| for deg, asgn in zip(angles_deg, assignments): | |
| rad = np.deg2rad(deg) | |
| nid = g.add_vertex(cx + r * np.cos(rad), cy + r * np.sin(rad)) | |
| g.add_edge(vid, nid, asgn) | |
| assert check_maekawa_at_vertex(vid, g) is True | |
| def test_maekawa_invalid(): | |
| """2 M + 2 V → |2-2| = 0 → False.""" | |
| g = CreaseGraph() | |
| cx, cy = 0.5, 0.5 | |
| vid = g.add_vertex(cx, cy) | |
| r = 0.3 | |
| angles_deg = [0.0, 90.0, 180.0, 270.0] | |
| assignments = ['M', 'M', 'V', 'V'] | |
| for deg, asgn in zip(angles_deg, assignments): | |
| rad = np.deg2rad(deg) | |
| nid = g.add_vertex(cx + r * np.cos(rad), cy + r * np.sin(rad)) | |
| g.add_edge(vid, nid, asgn) | |
| assert check_maekawa_at_vertex(vid, g) is False | |
| # --------------------------------------------------------------------------- | |
| # BLB tests | |
| # --------------------------------------------------------------------------- | |
| def test_blb_no_violations_equal_sectors(): | |
| """Equal 90° sectors → no strict local minimum → BLB returns [].""" | |
| g, vid = make_cross_graph() | |
| violations = check_blb_at_vertex(vid, g) | |
| assert violations == [] | |
| def test_blb_violation_detected(): | |
| """ | |
| Create a vertex with a strict local-minimum sector whose bounding edges | |
| share the same MV assignment → BLB violation. | |
| Use angles 0°, 10°, 180°, 270° so sector[0]=10° is the strict local min | |
| relative to sector[3] (90°) and sector[1] (170°). The two bounding edges | |
| are at 0° and 10°; assign both 'M' → violation. | |
| """ | |
| g = CreaseGraph() | |
| cx, cy = 0.5, 0.5 | |
| vid = g.add_vertex(cx, cy) | |
| r = 0.3 | |
| # angles ascending (arctan2 order): 0°, 10°, 180°, 270° (= -90°) | |
| # sorted arctan2: -90°, 0°, 10°, 180° | |
| # sectors: 90°, 10°, 170°, 90° (sum=360°) | |
| # sector at index 1 (between 0° and 10°) = 10° is strict local min (90 > 10 < 170) | |
| angles_deg = [0.0, 10.0, 180.0, 270.0] | |
| edge_ids = [] | |
| for deg in angles_deg: | |
| rad = np.deg2rad(deg) | |
| nid = g.add_vertex(cx + r * np.cos(rad), cy + r * np.sin(rad)) | |
| eid = g.add_edge(vid, nid, 'M') | |
| edge_ids.append(eid) | |
| violations = check_blb_at_vertex(vid, g) | |
| assert len(violations) > 0 | |
| def test_blb_no_violation_when_opposite_assignments(): | |
| """ | |
| Same geometry as above but with opposite assignments on the two edges | |
| bounding the small sector → no BLB violation. | |
| """ | |
| g = CreaseGraph() | |
| cx, cy = 0.5, 0.5 | |
| vid = g.add_vertex(cx, cy) | |
| r = 0.3 | |
| angles_deg = [0.0, 10.0, 180.0, 270.0] | |
| # sorted arctan2: -90°(270°), 0°, 10°, 180° | |
| # small sector is between 0° and 10° (index 1 and 2 in sorted order) | |
| # assign them opposite assignments | |
| assignments_by_angle = { | |
| 0.0: 'M', | |
| 10.0: 'V', | |
| 180.0: 'M', | |
| 270.0: 'V', | |
| } | |
| for deg in angles_deg: | |
| rad = np.deg2rad(deg) | |
| nid = g.add_vertex(cx + r * np.cos(rad), cy + r * np.sin(rad)) | |
| g.add_edge(vid, nid, assignments_by_angle[deg]) | |
| violations = check_blb_at_vertex(vid, g) | |
| assert violations == [] | |
| # --------------------------------------------------------------------------- | |
| # Coverage tests | |
| # --------------------------------------------------------------------------- | |
| def test_coverage_exact_match(): | |
| """Add exact crease matching target → coverage = 1.0, economy = 1.0.""" | |
| paper = PaperState() | |
| paper.add_crease([0.0, 0.5], [1.0, 0.5], 'M') | |
| target = [{'v1': (0.0, 0.5), 'v2': (1.0, 0.5), 'assignment': 'M'}] | |
| coverage, economy, assignment_accuracy = geometric_crease_coverage(paper, target) | |
| assert coverage == pytest.approx(1.0) | |
| assert economy == pytest.approx(1.0) | |
| assert assignment_accuracy == pytest.approx(1.0) | |
| def test_coverage_no_match(): | |
| """No creases added → coverage = 0.0.""" | |
| paper = PaperState() | |
| target = [{'v1': (0.0, 0.5), 'v2': (1.0, 0.5), 'assignment': 'M'}] | |
| coverage, economy, assignment_accuracy = geometric_crease_coverage(paper, target) | |
| assert coverage == pytest.approx(0.0) | |
| assert assignment_accuracy == pytest.approx(1.0) # vacuous: no positional matches | |
| def test_coverage_excess_penalty(): | |
| """ | |
| Target has 1 crease. Add 3 non-intersecting creases, one matching target. | |
| coverage = 1.0, economy = 1 - 2/1 → clamped to 0.0 (economy < 1.0). | |
| Uses non-intersecting extras to avoid PaperState edge splitting the target crease. | |
| """ | |
| paper = PaperState() | |
| paper.add_crease([0.0, 0.5], [1.0, 0.5], 'M') # matches target (midpoint 0.5,0.5) | |
| paper.add_crease([0.0, 0.3], [0.5, 0.3], 'V') # extra, no intersection | |
| paper.add_crease([0.0, 0.7], [0.5, 0.7], 'V') # extra, no intersection | |
| target = [{'v1': (0.0, 0.5), 'v2': (1.0, 0.5), 'assignment': 'M'}] | |
| coverage, economy, assignment_accuracy = geometric_crease_coverage(paper, target) | |
| assert coverage == pytest.approx(1.0) | |
| assert economy < 1.0 | |
| assert assignment_accuracy == pytest.approx(1.0) | |
| def test_coverage_assignment_mismatch_partial_credit(): | |
| """ | |
| Position matches but assignment doesn't → coverage = 0.5 (partial), | |
| assignment_accuracy = 0.0 (no correct assignments among matched). | |
| """ | |
| paper = PaperState() | |
| paper.add_crease([0.0, 0.5], [1.0, 0.5], 'V') # paper has V, target expects M | |
| target = [{'v1': (0.0, 0.5), 'v2': (1.0, 0.5), 'assignment': 'M'}] | |
| coverage, economy, assignment_accuracy = geometric_crease_coverage(paper, target) | |
| assert coverage == pytest.approx(0.5) | |
| assert assignment_accuracy == pytest.approx(0.0) | |
| # --------------------------------------------------------------------------- | |
| # check_degree_sanity tests | |
| # --------------------------------------------------------------------------- | |
| def test_check_degree_sanity_no_interior(): | |
| """No interior vertices → returns 1.0 (vacuous).""" | |
| paper = PaperState() | |
| paper.add_crease([0.0, 0.5], [1.0, 0.5], 'V') | |
| assert check_degree_sanity(paper.graph) == pytest.approx(1.0) | |
| def test_check_degree_sanity_even_degree(): | |
| """Degree-4 interior vertex → even degree → returns 1.0.""" | |
| g, _ = make_cross_graph() | |
| assert check_degree_sanity(g) == pytest.approx(1.0) | |
| def test_check_degree_sanity_odd_degree(): | |
| """Degree-3 interior vertex → odd degree → returns 0.0.""" | |
| g = CreaseGraph() | |
| cx, cy = 0.5, 0.5 | |
| vid = g.add_vertex(cx, cy) | |
| r = 0.3 | |
| for deg in [0.0, 120.0, 240.0]: | |
| rad = np.deg2rad(deg) | |
| nid = g.add_vertex(cx + r * np.cos(rad), cy + r * np.sin(rad)) | |
| g.add_edge(vid, nid, 'M') | |
| assert check_degree_sanity(g) == pytest.approx(0.0) | |
| # --------------------------------------------------------------------------- | |
| # check_all_vertices vacuous test | |
| # --------------------------------------------------------------------------- | |
| def test_check_all_vertices_vacuous(): | |
| """Single horizontal crease → no interior vertices → all scores = 1.0.""" | |
| paper = PaperState() | |
| paper.add_crease([0.0, 0.5], [1.0, 0.5], 'V') | |
| result = check_all_vertices(paper.graph) | |
| assert result['kawasaki'] == 1.0 | |
| assert result['maekawa'] == 1.0 | |
| assert result['blb'] == 1.0 | |
| assert result['n_interior'] == 0 | |
| assert result['per_vertex'] == [] | |