optigami / tests /test_verifier.py
sissississi's picture
go-back (#6)
e9b7141
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'] == []