""" Combinatorial type enumeration for convex polyhedra inscribed in the sphere. Generates random point sets on the sphere and counts distinct combinatorial types using canonical graph labelings to avoid O(N^2) isomorphism checks. """ import numpy as np from scipy.spatial import ConvexHull from collections import defaultdict from multiprocessing import Pool, cpu_count import os try: import pynauty PYNAUTY_AVAILABLE = True except ImportError: PYNAUTY_AVAILABLE = False def generate_random_points_on_sphere(n_points, rng=None): """ Generate n_points uniformly distributed on the unit sphere using Marsaglia's method. Args: n_points: Number of points to generate rng: numpy random generator (if None, uses default) Returns: Array of shape (n_points, 3) with points on unit sphere """ if rng is None: rng = np.random.default_rng() # Marsaglia's method: uniform sampling on sphere # Generate points in [-1, 1]^2 and reject those outside unit circle points = [] while len(points) < n_points: # Generate candidate points batch_size = max(n_points - len(points), 100) x = rng.uniform(-1, 1, batch_size) y = rng.uniform(-1, 1, batch_size) r_sq = x**2 + y**2 # Keep only points inside unit circle mask = r_sq < 1 x = x[mask] y = y[mask] r_sq = r_sq[mask] # Map to sphere: (x, y, r^2) -> (2x√(1-r^2), 2y√(1-r^2), 1-2r^2) sqrt_term = np.sqrt(1 - r_sq) sphere_x = 2 * x * sqrt_term sphere_y = 2 * y * sqrt_term sphere_z = 1 - 2 * r_sq for i in range(len(x)): if len(points) < n_points: points.append([sphere_x[i], sphere_y[i], sphere_z[i]]) return np.array(points) def extract_graph_from_hull(vertices_3d): """ Extract the 1-skeleton (edge graph) from the convex hull of vertices. Args: vertices_3d: N x 3 array of vertex coordinates Returns: adjacency: dict mapping vertex index to list of adjacent vertex indices n_vertices: number of vertices """ hull = ConvexHull(vertices_3d) # Build edge list from hull faces (simplices) edges = set() for simplex in hull.simplices: # Each face is a triangle with 3 edges v0, v1, v2 = simplex edges.add(tuple(sorted([v0, v1]))) edges.add(tuple(sorted([v1, v2]))) edges.add(tuple(sorted([v2, v0]))) # Build adjacency list n_vertices = len(vertices_3d) adjacency = {i: [] for i in range(n_vertices)} for v1, v2 in edges: adjacency[v1].append(v2) adjacency[v2].append(v1) return adjacency, n_vertices def compute_canonical_hash(adjacency, n_vertices): """ Compute a canonical hash for a graph using pynauty. Isomorphic graphs will have the same canonical hash. Args: adjacency: dict mapping vertex index to list of adjacent vertices n_vertices: number of vertices in the graph Returns: tuple: canonical hash (hashable and comparable) """ if not PYNAUTY_AVAILABLE: raise ImportError("pynauty not installed. Install with: pip install pynauty") # Create pynauty graph g = pynauty.Graph(number_of_vertices=n_vertices, directed=False, adjacency_dict=adjacency) # Get canonical labeling # canon_label returns a canonical permutation that maps the graph to a canonical form canonical_labeling = pynauty.canon_label(g) # Build canonical adjacency list using the relabeling # canonical_labeling[i] tells us what the new label of vertex i is canonical_edges = [] for v1 in range(n_vertices): for v2 in adjacency[v1]: if v1 < v2: # Only add each edge once # Map to canonical labels c1, c2 = canonical_labeling[v1], canonical_labeling[v2] canonical_edges.append(tuple(sorted([c1, c2]))) # Sort edges for consistent ordering canonical_edges = tuple(sorted(canonical_edges)) # Also include vertex degrees in canonical order canonical_degrees = tuple(sorted([len(adjacency[i]) for i in range(n_vertices)])) return (n_vertices, canonical_degrees, canonical_edges) def enumerate_combinatorial_types(n_vertices, n_samples, seed=None, verbose=True): """ Enumerate distinct combinatorial types of convex polyhedra with n_vertices on the sphere. Args: n_vertices: Number of vertices in each polyhedron n_samples: Number of random samples to generate seed: Random seed (for reproducibility) verbose: If True, print progress updates Returns: dict with keys: - 'n_vertices': number of vertices - 'n_samples': number of samples generated - 'n_types': number of distinct combinatorial types found - 'type_counts': dict mapping canonical hash to count - 'type_examples': dict mapping canonical hash to example vertex set """ if not PYNAUTY_AVAILABLE: raise ImportError("pynauty not installed. Install with: pip install pynauty") rng = np.random.default_rng(seed) # Track unique types type_counts = defaultdict(int) type_examples = {} # Store one example of each type # Track failures (non-simplicial hulls, degenerate cases) n_failures = 0 for i in range(n_samples): if verbose and (i + 1) % max(1, n_samples // 10) == 0: print(f" Sample {i + 1}/{n_samples}: {len(type_counts)} types found") try: # Generate random points on sphere vertices = generate_random_points_on_sphere(n_vertices, rng) # Extract graph from convex hull adjacency, n_verts = extract_graph_from_hull(vertices) # Compute canonical hash canonical_hash = compute_canonical_hash(adjacency, n_verts) # Track this type type_counts[canonical_hash] += 1 # Store example if first time seeing this type if canonical_hash not in type_examples: type_examples[canonical_hash] = vertices.copy() except Exception as e: # Handle degenerate cases (coplanar points, numerical issues, etc.) n_failures += 1 if verbose and n_failures == 1: print(f" Warning: encountered degenerate case: {e}") if verbose: print(f"\nCompleted: {len(type_counts)} distinct types found") if n_failures > 0: print(f" ({n_failures} degenerate cases skipped)") return { 'n_vertices': n_vertices, 'n_samples': n_samples, 'n_types': len(type_counts), 'type_counts': dict(type_counts), 'type_examples': type_examples, 'n_failures': n_failures, } def format_enumeration_report(result): """ Format enumeration results as a readable string. Args: result: Dictionary returned by enumerate_combinatorial_types Returns: Formatted string report """ report = [] report.append(f"\n{'='*60}") report.append(f"Combinatorial Type Enumeration: {result['n_vertices']} vertices") report.append(f"{'='*60}") report.append(f"Samples generated: {result['n_samples']}") report.append(f"Distinct types found: {result['n_types']}") report.append(f"Degenerate cases: {result['n_failures']}") report.append("") # Sort types by frequency sorted_types = sorted(result['type_counts'].items(), key=lambda x: x[1], reverse=True) report.append("Top 10 most common types:") for i, (type_hash, count) in enumerate(sorted_types[:10], 1): n_verts, degrees, edges = type_hash frequency = 100 * count / result['n_samples'] report.append(f" {i}. Type with degree sequence {degrees}: {count} times ({frequency:.2f}%)") if len(sorted_types) > 10: report.append(f" ... and {len(sorted_types) - 10} more rare types") return "\n".join(report) def _worker_enumerate_types(args): """ Worker function for parallel enumeration. Each worker maintains its own local type_counts dictionary - no locking needed! Results are merged after all workers complete. Args: args: tuple of (n_vertices, n_samples_for_worker, worker_seed, worker_id, verbose) Returns: dict with local results from this worker """ n_vertices, n_samples, seed, worker_id, verbose = args if not PYNAUTY_AVAILABLE: raise ImportError("pynauty not installed. Install with: pip install pynauty") # Each worker gets its own RNG seeded differently rng = np.random.default_rng(seed) # Local type tracking (no shared state - no locking!) type_counts = defaultdict(int) type_examples = {} n_failures = 0 for i in range(n_samples): if verbose and (i + 1) % max(1, n_samples // 5) == 0: print(f" Worker {worker_id}: {i + 1}/{n_samples} samples, {len(type_counts)} types") try: vertices = generate_random_points_on_sphere(n_vertices, rng) adjacency, n_verts = extract_graph_from_hull(vertices) canonical_hash = compute_canonical_hash(adjacency, n_verts) type_counts[canonical_hash] += 1 if canonical_hash not in type_examples: type_examples[canonical_hash] = vertices.copy() except Exception as e: n_failures += 1 if verbose and n_failures == 1: print(f" Worker {worker_id}: Warning - degenerate case: {e}") return { 'type_counts': dict(type_counts), 'type_examples': type_examples, 'n_failures': n_failures, } def enumerate_combinatorial_types_parallel(n_vertices, n_samples, seed=None, n_workers=None, verbose=True): """ Enumerate distinct combinatorial types using parallel processing. Uses a partition-and-merge strategy: each worker processes samples independently with no shared state (no locking!), then results are merged at the end. Args: n_vertices: Number of vertices in each polyhedron n_samples: Total number of random samples to generate seed: Random seed (for reproducibility) n_workers: Number of parallel workers (default: cpu_count()) verbose: If True, print progress updates Returns: dict with keys: - 'n_vertices': number of vertices - 'n_samples': number of samples generated - 'n_types': number of distinct combinatorial types found - 'type_counts': dict mapping canonical hash to count - 'type_examples': dict mapping canonical hash to example vertex set - 'n_workers': number of workers used """ if not PYNAUTY_AVAILABLE: raise ImportError("pynauty not installed. Install with: pip install pynauty") if n_workers is None: n_workers = cpu_count() if verbose: print(f"Using {n_workers} parallel workers") # Divide samples among workers samples_per_worker = n_samples // n_workers extra_samples = n_samples % n_workers # Generate independent seeds for each worker if seed is not None: rng = np.random.default_rng(seed) worker_seeds = [rng.integers(0, 2**32) for _ in range(n_workers)] else: worker_seeds = [None] * n_workers # Prepare worker arguments worker_args = [] for i in range(n_workers): n_samples_for_worker = samples_per_worker + (1 if i < extra_samples else 0) worker_args.append((n_vertices, n_samples_for_worker, worker_seeds[i], i, verbose)) # Run workers in parallel if verbose: print(f"Starting parallel enumeration...") with Pool(n_workers) as pool: worker_results = pool.map(_worker_enumerate_types, worker_args) # Merge results from all workers if verbose: print(f"Merging results from {n_workers} workers...") merged_type_counts = defaultdict(int) merged_type_examples = {} total_failures = 0 for result in worker_results: # Merge type counts for type_hash, count in result['type_counts'].items(): merged_type_counts[type_hash] += count # Keep one example (arbitrary which worker's example we keep) if type_hash not in merged_type_examples: merged_type_examples[type_hash] = result['type_examples'][type_hash] total_failures += result['n_failures'] if verbose: print(f"\nCompleted: {len(merged_type_counts)} distinct types found") if total_failures > 0: print(f" ({total_failures} degenerate cases skipped)") return { 'n_vertices': n_vertices, 'n_samples': n_samples, 'n_types': len(merged_type_counts), 'type_counts': dict(merged_type_counts), 'type_examples': merged_type_examples, 'n_failures': total_failures, 'n_workers': n_workers, }