igriv's picture
Add Rivin-Delaunay optimization and arithmeticity checking
c89947b
"""
rivin_holonomy.py
-----------------
Reference implementation of the Penner–Rivin holonomy algorithm for an ideal triangulation
with optional shear data. Pure-Python (no external deps).
Model:
- Triangles are labeled 0..F-1, each with sides 0,1,2 in cyclic order (counterclockwise).
- Adjacency maps each oriented side (t, s) to (u, su, eid), where u is the triangle glued across side s,
su is the side index in u, and eid is a global id for the underlying edge.
- Ori gives, for each global eid, a chosen orientation as ((t_from, s_from), (t_to, s_to)).
- Shears Z: dict eid -> real number (use 0.0 for zero-shear case).
Outputs:
- Generators as 2x2 matrices in SL(2,R) (project to PSL(2,R)).
"""
import math
from collections import deque, defaultdict
from typing import Dict, List, Tuple, Optional, Any
def matmul(A, B):
return [[A[0][0]*B[0][0] + A[0][1]*B[1][0], A[0][0]*B[0][1] + A[0][1]*B[1][1]],
[A[1][0]*B[0][0] + A[1][1]*B[1][0], A[1][0]*B[0][1] + A[1][1]*B[1][1]]]
def matI():
return [[1.0, 0.0], [0.0, 1.0]]
def matR():
return [[1.0, 1.0], [-1.0, 0.0]]
def matL():
return [[0.0, -1.0], [1.0, 1.0]]
def matX(z):
ez = math.exp(z/2.0)
return [[0.0, -ez], [1.0/ez, 0.0]]
class Triangulation:
def __init__(self, F, adjacency, order, orientation):
"""
F: number of triangles
adjacency: dict (t, s) -> (u, su, eid)
order: dict t -> list [0,1,2] (cyclic order of sides in triangle t)
orientation: dict eid -> ((t_from, s_from), (t_to, s_to))
"""
self.F = F
self.Adj = dict(adjacency)
self.Order = {t: list(order[t]) for t in order}
self.Ori = dict(orientation)
# basic sanity
for (t,s), (u,su,eid) in self.Adj.items():
rt = self.Adj.get((u,su))
assert rt is not None and rt[0]==t and rt[1]==s and rt[2]==eid, "Adjacency must be symmetric"
for t in range(F):
assert t in self.Order and len(self.Order[t])==3, "Order missing for triangle {}".format(t)
def dual_graph(self):
"""Return dual graph structure: dict t -> list of (u, s_in_t, s_in_u, eid)."""
G = defaultdict(list)
for (t,s), (u,su,eid) in self.Adj.items():
G[t].append((u, s, su, eid))
return G
def bfs_spanning_tree(G, root=0):
parent = {root: None}
parent_edge = {root: None} # for v!=root: (eid, side_in_v, side_in_parent)
Q = deque([root])
while Q:
v = Q.popleft()
for (w, sv, sw, eid) in G[v]:
if w not in parent:
parent[w] = v
parent_edge[w] = (eid, sw, sv)
Q.append(w)
return parent, parent_edge
def path_in_tree(parent, v, u):
"""Return list of vertices along the unique path v -> ... -> u in the tree."""
Pv = []
x = v
while x is not None:
Pv.append(x); x = parent[x]
Pu = []
x = u
while x is not None:
Pu.append(x); x = parent[x]
i = len(Pv)-1; j = len(Pu)-1
while i>=0 and j>=0 and Pv[i]==Pu[j]:
i -= 1; j -= 1
up = Pv[:i+1]
down = list(reversed(Pu[:j+1]))
return up + [Pv[i+1]] + down
def turn(order, side_in, side_out):
i = order.index(side_in); j = order.index(side_out)
d = (j - i) % 3
if d == 1: return 'L'
if d == 2: return 'R'
raise ValueError("Degenerate turn inside triangle")
def tokens_for_path(T: Triangulation, parent, parent_edge, path):
"""
Convert a path of triangles into a token list of 'L'/'R' and ('X', eid, sign).
"""
tokens = []
side_in_prev = None
for k in range(len(path)-1):
a, b = path[k], path[k+1]
if parent[b] == a:
eid, side_in_b, side_in_a = parent_edge[b] # from a to b
elif parent[a] == b:
eid, side_in_a, side_in_b = parent_edge[a] # from a to b
else:
raise RuntimeError("Non-tree step in tokens_for_path")
if side_in_prev is not None:
tokens.append(('T', a, side_in_prev, side_in_a))
(tf, sf), (tt, st) = T.Ori[eid]
if (tf == a and tt == b): sign = +1
elif (tf == b and tt == a): sign = -1
else: raise RuntimeError("Edge orientation inconsistent with step")
tokens.append(('X', eid, sign))
side_in_prev = side_in_b
return _resolve_turns(T, tokens)
def _resolve_turns(T: Triangulation, tokens):
resolved = []
for tok in tokens:
if isinstance(tok, tuple) and tok and tok[0]=='T':
_, tri, s_in, s_out = tok
resolved.append(turn(T.Order[tri], s_in, s_out))
else:
resolved.append(tok)
return resolved
def tokens_to_matrix(tokens, Z):
M = matI()
for tok in tokens:
if tok == 'L':
M = matmul(M, matL())
elif tok == 'R':
M = matmul(M, matR())
else:
_, eid, sign = tok
z = Z.get(eid, 0.0)
if sign < 0: z = -z
M = matmul(M, matX(z))
return M
def generators_from_triangulation(T: Triangulation, Z, root=0):
"""
Compute a generating set for the holonomy:
one generator per non-tree dual edge.
Returns: list of (u, v, tokens, matrix)
"""
G = T.dual_graph()
parent, parent_edge = bfs_spanning_tree(G, root=root)
tree_edges = set()
for v, p in parent.items():
if p is None: continue
tree_edges.add(tuple(sorted((v, p))))
all_edges = set()
for v, lst in G.items():
for (w, sv, sw, eid) in lst:
if v < w:
all_edges.add((v, w, eid))
gens = []
for (u, v, eid) in all_edges:
if tuple(sorted((u, v))) in tree_edges:
continue
path_u = path_in_tree(parent, root, u)
path_v = path_in_tree(parent, root, v)
tok1 = tokens_for_path(T, parent, parent_edge, path_u) # root -> u
(tf, sf), (tt, st) = T.Ori[eid]
if (tf == u and tt == v): sign = +1
elif (tf == v and tt == u): sign = -1
else: raise RuntimeError("Orientation mismatch on non-tree edge")
tok_mid = [('X', eid, sign)]
tok2 = tokens_for_path(T, parent, parent_edge, list(reversed(path_v))) # v -> root
tokens = tok1 + tok_mid + tok2
M = tokens_to_matrix(tokens, Z)
gens.append((u, v, tokens, M))
return gens
def triangulation_from_faces(triangles: List[Tuple[int, int, int]]) -> Tuple[Triangulation, Dict[int, float]]:
"""
Build a Triangulation object from a list of triangle faces.
Args:
triangles: List of triangles, each as (v0, v1, v2) tuple of vertex indices
Returns:
Tuple of (Triangulation, Z) where Z is zero shear dict
"""
F = len(triangles)
adjacency = {}
edge_id_map = {}
edge_id = 0
for i, tri_i in enumerate(triangles):
for side_i in range(3):
v1_i, v2_i = tri_i[side_i], tri_i[(side_i + 1) % 3]
edge = tuple(sorted([v1_i, v2_i]))
for j, tri_j in enumerate(triangles):
if i == j:
continue
for side_j in range(3):
v1_j, v2_j = tri_j[side_j], tri_j[(side_j + 1) % 3]
if set([v1_j, v2_j]) == set([v1_i, v2_i]):
if (i, side_i) not in adjacency:
if edge not in edge_id_map:
edge_id_map[edge] = edge_id
edge_id += 1
adjacency[(i, side_i)] = (j, side_j, edge_id_map[edge])
order = {t: [0, 1, 2] for t in range(F)}
orientation = {}
for edge, eid in edge_id_map.items():
for (t, s), (u, su, e) in adjacency.items():
if e == eid:
orientation[eid] = ((t, s), (u, su))
break
T = Triangulation(F, adjacency, order, orientation)
Z = {eid: 0.0 for eid in range(edge_id)}
return T, Z
def check_arithmeticity(triangles: List[Tuple[int, int, int]],
tolerance: float = 0.01,
verbose: bool = False) -> Dict[str, Any]:
"""
Check if a triangulation has arithmetic holonomy (all traces are integers).
A triangulation is arithmetic if all holonomy generator traces lie in Z.
This is a necessary condition for the ideal polyhedron to be arithmetic
(i.e., commensurable with PSL(2, O_K) for some number field K).
Args:
triangles: List of triangles as (v0, v1, v2) tuples
tolerance: Maximum distance from nearest integer to count as integral
verbose: If True, print detailed trace information
Returns:
Dict with keys:
- 'is_arithmetic': bool, True if all traces are integers
- 'n_generators': int, number of holonomy generators
- 'n_integral': int, number of generators with integral traces
- 'traces': list of trace values
- 'trace_details': list of dicts with trace analysis for each generator
"""
T, Z = triangulation_from_faces(triangles)
gens = generators_from_triangulation(T, Z, root=0)
trace_details = []
integral_count = 0
for u, v, tokens, M in gens:
trace = M[0][0] + M[1][1]
nearest_int = round(trace)
distance = abs(trace - nearest_int)
is_integral = distance < tolerance
if is_integral:
integral_count += 1
detail = {
'generator': (u, v),
'trace': trace,
'nearest_int': nearest_int,
'distance': distance,
'is_integral': is_integral
}
trace_details.append(detail)
if verbose:
status = "INTEGRAL" if is_integral else ""
print(f" Generator {u}-{v}: trace = {trace:.6f}, "
f"nearest int = {nearest_int}, dist = {distance:.6f} {status}")
is_arithmetic = (integral_count == len(gens)) if len(gens) > 0 else True
return {
'is_arithmetic': is_arithmetic,
'n_generators': len(gens),
'n_integral': integral_count,
'traces': [d['trace'] for d in trace_details],
'trace_details': trace_details
}
def compute_shears_from_vertices(triangles: List[Tuple[int, int, int]],
vertices_complex) -> Dict[int, float]:
"""
Compute shear parameters from actual vertex positions.
For each interior edge shared by two triangles, the shear is computed
from the cross-ratio of the four vertices involved.
The shear z on edge (v2, v3) shared by triangles (v1, v2, v3) and (v2, v4, v3) is:
z = log|cross_ratio(v1, v2, v4, v3)|
where cross_ratio(a, b, c, d) = (a-c)(b-d) / ((a-d)(b-c))
Args:
triangles: List of triangles as (v0, v1, v2) tuples
vertices_complex: Array of complex vertex coordinates
Returns:
Dict mapping edge_id -> shear value
"""
import numpy as np
# Build edge adjacency structure
edge_triangles = {} # edge -> list of (tri_idx, opposite_vertex)
for tri_idx, tri in enumerate(triangles):
for i in range(3):
v1, v2 = tri[i], tri[(i + 1) % 3]
opposite = tri[(i + 2) % 3]
edge = tuple(sorted([v1, v2]))
if edge not in edge_triangles:
edge_triangles[edge] = []
edge_triangles[edge].append((tri_idx, opposite, v1, v2))
# Assign edge IDs and compute shears
shears = {}
edge_id = 0
for edge, tri_list in edge_triangles.items():
if len(tri_list) == 2: # Interior edge
# Get the four vertices: two on the edge, two opposite
_, opp1, e1, e2 = tri_list[0]
_, opp2, _, _ = tri_list[1]
# Get complex coordinates
z1 = complex(vertices_complex[opp1]) # opposite vertex 1
z2 = complex(vertices_complex[e1]) # edge vertex 1
z3 = complex(vertices_complex[e2]) # edge vertex 2
z4 = complex(vertices_complex[opp2]) # opposite vertex 2
# Compute cross-ratio: (z1-z3)(z2-z4) / ((z1-z4)(z2-z3))
num = (z1 - z3) * (z2 - z4)
den = (z1 - z4) * (z2 - z3)
if abs(den) > 1e-10:
cr = num / den
# Shear is log of absolute value of cross-ratio
shear = math.log(abs(cr)) if abs(cr) > 1e-10 else 0.0
else:
shear = 0.0
shears[edge_id] = shear
edge_id += 1
return shears
def check_arithmeticity_from_vertices(vertices_complex, tolerance: float = 0.01,
verbose: bool = False) -> Dict[str, Any]:
"""
Check arithmeticity given complex vertex coordinates.
Computes Delaunay triangulation and checks holonomy traces using
the actual shear parameters derived from the geometry.
For random vertex positions, shears are non-zero and traces are
typically non-integral (non-arithmetic).
For Rivin-Delaunay optimized positions, shears are zero and traces
are integers (arithmetic).
Args:
vertices_complex: Array of complex numbers (vertex positions)
tolerance: Maximum distance from nearest integer to count as integral
verbose: If True, print detailed trace information
Returns:
Same as check_arithmeticity(), plus 'shears' key with computed shears
"""
import numpy as np
from scipy.spatial import Delaunay
# Compute Delaunay triangulation
pts = np.column_stack([np.real(vertices_complex), np.imag(vertices_complex)])
tri = Delaunay(pts)
triangles = [tuple(simplex) for simplex in tri.simplices]
# Build triangulation structure
T, Z_zero = triangulation_from_faces(triangles)
# Compute actual shears from geometry
shears = compute_shears_from_vertices(triangles, vertices_complex)
# Use actual shears for holonomy computation
gens = generators_from_triangulation(T, shears, root=0)
trace_details = []
integral_count = 0
for u, v, tokens, M in gens:
trace = M[0][0] + M[1][1]
nearest_int = round(trace)
distance = abs(trace - nearest_int)
is_integral = distance < tolerance
if is_integral:
integral_count += 1
detail = {
'generator': (u, v),
'trace': trace,
'nearest_int': nearest_int,
'distance': distance,
'is_integral': is_integral
}
trace_details.append(detail)
if verbose:
status = "INTEGRAL" if is_integral else ""
print(f" Generator {u}-{v}: trace = {trace:.6f}, "
f"nearest int = {nearest_int}, dist = {distance:.6f} {status}")
is_arithmetic = (integral_count == len(gens)) if len(gens) > 0 else True
return {
'is_arithmetic': is_arithmetic,
'n_generators': len(gens),
'n_integral': integral_count,
'traces': [d['trace'] for d in trace_details],
'trace_details': trace_details,
'shears': shears,
}
if __name__ == "__main__":
import argparse
import json
import sys
parser = argparse.ArgumentParser(
description="Check arithmeticity of an ideal polyhedron via Penner-Rivin holonomy.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s config.json # Check arithmeticity of configuration in JSON file
%(prog)s config.json --verbose # Show detailed trace information
JSON file format:
{
"vertices_real": [0.0, 1.0, 0.5, ...],
"vertices_imag": [0.0, 0.0, 0.866, ...]
}
OR
{
"triangles": [[0, 1, 2], [1, 2, 3], ...]
}
Output:
ARITHMETIC - All holonomy traces are integers (exit code 0)
NON-ARITHMETIC - Some traces are not integers (exit code 1)
"""
)
parser.add_argument('input', nargs='?', help='JSON file with configuration')
parser.add_argument('--verbose', '-v', action='store_true',
help='Show detailed trace information')
parser.add_argument('--tolerance', '-t', type=float, default=0.01,
help='Tolerance for integrality check (default: 0.01)')
args = parser.parse_args()
if args.input is None:
print("Penner-Rivin Holonomy Arithmeticity Checker")
print("=" * 50)
print()
print("This module checks if an ideal polyhedron is arithmetic by computing")
print("the holonomy generators and verifying that all traces are integers.")
print()
print("Usage:")
print(" python -m ideal_poly_volume_toolkit.rivin_holonomy config.json")
print(" python -m ideal_poly_volume_toolkit.rivin_holonomy config.json --verbose")
print()
print("See --help for more information.")
sys.exit(0)
# Load configuration
with open(args.input, 'r') as f:
config = json.load(f)
# Check if we have vertices or triangles
if 'triangles' in config:
triangles = [tuple(t) for t in config['triangles']]
result = check_arithmeticity(triangles, args.tolerance, args.verbose)
elif 'vertices_real' in config and 'vertices_imag' in config:
import numpy as np
vertices = np.array(config['vertices_real']) + 1j * np.array(config['vertices_imag'])
result = check_arithmeticity_from_vertices(vertices, args.tolerance, args.verbose)
else:
print("Error: JSON must contain either 'triangles' or 'vertices_real'/'vertices_imag'")
sys.exit(2)
# Output result
if args.verbose:
print()
print("=" * 50)
print(f"Generators: {result['n_generators']}")
print(f"Integral traces: {result['n_integral']}/{result['n_generators']}")
if result['is_arithmetic']:
print("Result: ARITHMETIC")
sys.exit(0)
else:
print("Result: NON-ARITHMETIC")
sys.exit(1)