grid-simulate 0.1.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- grid_simulate-0.2.2/.github/workflows/publish.yml +59 -0
- grid_simulate-0.2.2/.gitignore +19 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/Cargo.lock +1 -1
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/Cargo.toml +1 -1
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/PKG-INFO +1 -1
- grid_simulate-0.2.2/gnn/algorithms.py +193 -0
- grid_simulate-0.2.2/gnn/best_model.pt +0 -0
- grid_simulate-0.2.2/gnn/compare.py +221 -0
- grid_simulate-0.2.2/gnn/dataset.py +90 -0
- grid_simulate-0.2.2/gnn/model.py +117 -0
- grid_simulate-0.2.2/gnn/train.py +100 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/pyproject.toml +1 -1
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/python/test_bridge.py +2 -2
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/lib.rs +43 -0
- grid_simulate-0.2.2/src/pebble.rs +167 -0
- grid_simulate-0.1.0/.github/workflows/publish.yml +0 -48
- grid_simulate-0.1.0/.gitignore +0 -1
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/.gitignore +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/GRiD.iml +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/misc.xml +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/modules.xml +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/vcs.xml +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/README.md +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/main.rs +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/node.rs +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/rank.rs +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/reconstruct.rs +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/rigidity.rs +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/simulation.rs +0 -0
- {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/traversal.rs +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
17
|
+
|
|
18
|
+
runs-on: ${{ matrix.os }}
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v5
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.13"
|
|
26
|
+
|
|
27
|
+
- name: Install maturin
|
|
28
|
+
run: pip install maturin==1.14.1
|
|
29
|
+
|
|
30
|
+
- name: Build wheels
|
|
31
|
+
env:
|
|
32
|
+
PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1"
|
|
33
|
+
run: |
|
|
34
|
+
maturin build --release --sdist --interpreter python3.11 python3.12 python3.13
|
|
35
|
+
|
|
36
|
+
- uses: actions/upload-artifact@v5
|
|
37
|
+
with:
|
|
38
|
+
name: wheels-${{ matrix.os }}
|
|
39
|
+
path: target/wheels/*
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
publish:
|
|
43
|
+
needs: build
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
|
|
46
|
+
permissions:
|
|
47
|
+
id-token: write
|
|
48
|
+
|
|
49
|
+
environment:
|
|
50
|
+
name: pypi
|
|
51
|
+
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/download-artifact@v5
|
|
54
|
+
with:
|
|
55
|
+
pattern: wheels-*
|
|
56
|
+
merge-multiple: true
|
|
57
|
+
path: dist
|
|
58
|
+
|
|
59
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pebble Game algorithm for 2D rigidity independence.
|
|
3
|
+
Deterministic & O(n^2)
|
|
4
|
+
|
|
5
|
+
(Jacobs & Hendrickson (1997)
|
|
6
|
+
"An Algorithm for Two-Dimensional Rigidity Percolation")
|
|
7
|
+
author: Sezer
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import List, Tuple
|
|
11
|
+
|
|
12
|
+
# Core pebble search
|
|
13
|
+
def _find_pebble(
|
|
14
|
+
v: int,
|
|
15
|
+
visited: set,
|
|
16
|
+
pebbles: List[int],
|
|
17
|
+
out_edges: List[List[int]],
|
|
18
|
+
changes: List[tuple],
|
|
19
|
+
) -> bool:
|
|
20
|
+
"""
|
|
21
|
+
DFS: try to find a free pebble reachable from v by following directed edges.
|
|
22
|
+
All state changes are recorded in 'changes' so they can be rolled back if the
|
|
23
|
+
overall edge turns out to be redundant.
|
|
24
|
+
|
|
25
|
+
A free pebble is reachable from v if:
|
|
26
|
+
- v itself has a pebble, OR
|
|
27
|
+
- v has an outgoing directed edge v->u and a pebble is reachable from u
|
|
28
|
+
(in which case we reverse the edge u->v to move the pebble toward v)
|
|
29
|
+
"""
|
|
30
|
+
if v in visited:
|
|
31
|
+
return False
|
|
32
|
+
visited.add(v)
|
|
33
|
+
|
|
34
|
+
# Base case
|
|
35
|
+
if pebbles[v] > 0:
|
|
36
|
+
pebbles[v] -= 1
|
|
37
|
+
changes.append(('pebble', v))
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
for i in range(len(out_edges[v])):
|
|
41
|
+
u = out_edges[v][i]
|
|
42
|
+
if _find_pebble(u, visited, pebbles, out_edges, changes):
|
|
43
|
+
out_edges[v].pop(i)
|
|
44
|
+
out_edges[u].append(v)
|
|
45
|
+
changes.append(('edge', v, u))
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def _rollback(changes: List[tuple], pebbles: List[int], out_edges: List[List[int]]):
|
|
51
|
+
"""
|
|
52
|
+
Undo all state changes recorded during a failed edge attempt
|
|
53
|
+
Applied in reverse order so each change is cleanly undone
|
|
54
|
+
"""
|
|
55
|
+
for change in reversed(changes):
|
|
56
|
+
if change[0] == 'pebble':
|
|
57
|
+
pebbles[change[1]] += 1
|
|
58
|
+
elif change[0] == 'gain_pebble':
|
|
59
|
+
pebbles[change[1]] -= 1
|
|
60
|
+
elif change[0] == 'edge':
|
|
61
|
+
v, u = change[1], change[2]
|
|
62
|
+
out_edges[u].remove(v)
|
|
63
|
+
out_edges[v].append(u)
|
|
64
|
+
|
|
65
|
+
def pebble_game(
|
|
66
|
+
n: int,
|
|
67
|
+
candidate_edges: List[Tuple[int, int]],
|
|
68
|
+
d: int = 2,
|
|
69
|
+
) -> Tuple[List[int], List[Tuple[int, int]]]:
|
|
70
|
+
"""
|
|
71
|
+
Pebble Game algorithm for finding independent edges in a rigidity framework.
|
|
72
|
+
|
|
73
|
+
Only supports d=2
|
|
74
|
+
For d=3, falls back to gaussian elimination
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
n : number of nodes
|
|
78
|
+
candidate_edges : edges in upper-triangular order (i < j)
|
|
79
|
+
d : embedding dimension (must be 2)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
labels : list[int] 1=independent, 0=redundant
|
|
83
|
+
accepted : list[(i, j)] the independent edges only
|
|
84
|
+
|
|
85
|
+
Complexity: O(n^2)
|
|
86
|
+
"""
|
|
87
|
+
assert d == 2
|
|
88
|
+
|
|
89
|
+
pebbles = [2] * n
|
|
90
|
+
# Directed graph: out_edges[v] = list of nodes v points to
|
|
91
|
+
# A directed edge v->u means "v has borrowed a pebble from u's region"
|
|
92
|
+
out_edges: List[List[int]] = [[] for _ in range(n)]
|
|
93
|
+
|
|
94
|
+
labels = []
|
|
95
|
+
accepted = []
|
|
96
|
+
|
|
97
|
+
for (u, v) in candidate_edges:
|
|
98
|
+
changes = []
|
|
99
|
+
|
|
100
|
+
while pebbles[u] + pebbles[v] < 4:
|
|
101
|
+
|
|
102
|
+
pu, pv = pebbles[u], pebbles[v]
|
|
103
|
+
pebbles[u], pebbles[v] = 0, 0
|
|
104
|
+
|
|
105
|
+
progressed = False
|
|
106
|
+
|
|
107
|
+
if pu < 2 and _find_pebble(u, set(), pebbles, out_edges, changes):
|
|
108
|
+
pu += 1
|
|
109
|
+
changes.append(('gain_pebble', u))
|
|
110
|
+
progressed = True
|
|
111
|
+
elif pv < 2 and _find_pebble(v, set(), pebbles, out_edges, changes):
|
|
112
|
+
pv += 1
|
|
113
|
+
changes.append(('gain_pebble', v))
|
|
114
|
+
progressed = True
|
|
115
|
+
|
|
116
|
+
pebbles[u], pebbles[v] = pu, pv
|
|
117
|
+
|
|
118
|
+
if not progressed:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
if pebbles[u] + pebbles[v] >= 4:
|
|
122
|
+
# Independent: consume exactly 1 pebble to cover this edge
|
|
123
|
+
pebbles[v] -= 1
|
|
124
|
+
out_edges[v].append(u)
|
|
125
|
+
labels.append(1)
|
|
126
|
+
accepted.append((u, v))
|
|
127
|
+
else:
|
|
128
|
+
# Redundant: undo everything collected during this attempt
|
|
129
|
+
_rollback(changes, pebbles, out_edges)
|
|
130
|
+
labels.append(0)
|
|
131
|
+
|
|
132
|
+
return labels, accepted
|
|
133
|
+
|
|
134
|
+
def gaussian_elimination(
|
|
135
|
+
n: int,
|
|
136
|
+
candidate_edges: List[Tuple[int, int]],
|
|
137
|
+
d: int= 2,
|
|
138
|
+
) -> Tuple[List[int], List[Tuple[int, int]]]:
|
|
139
|
+
"""
|
|
140
|
+
Classical Gaussian elimination rank check for edge independence.
|
|
141
|
+
Works for both d=2 and d=3.
|
|
142
|
+
|
|
143
|
+
This mirrors exactly what the Rust simulation does internally,
|
|
144
|
+
implementing it just for comparison
|
|
145
|
+
"""
|
|
146
|
+
ncols = n * d
|
|
147
|
+
pivot_rows: List[List[float]] = []
|
|
148
|
+
pivot_cols: List[int] = []
|
|
149
|
+
tol = 1e-9
|
|
150
|
+
|
|
151
|
+
labels = []
|
|
152
|
+
accepted = []
|
|
153
|
+
|
|
154
|
+
def add_row(row: List[float]) -> bool:
|
|
155
|
+
"""Returns True if row is linearly independent of existing rows."""
|
|
156
|
+
r = row[:]
|
|
157
|
+
|
|
158
|
+
for k, col in enumerate(pivot_cols):
|
|
159
|
+
factor = r[col]
|
|
160
|
+
if abs(factor) > tol:
|
|
161
|
+
for j in range(ncols):
|
|
162
|
+
r[j] -= factor * pivot_rows[k][j]
|
|
163
|
+
# Find new pivot
|
|
164
|
+
new_pivot = next((j for j in range(ncols) if abs(r[j]) > tol), None)
|
|
165
|
+
if new_pivot is None:
|
|
166
|
+
return False # redundant
|
|
167
|
+
# Normalise
|
|
168
|
+
scale = r[new_pivot]
|
|
169
|
+
r = [v / scale for v in r]
|
|
170
|
+
# Back-substitute into existing rows
|
|
171
|
+
for k in range(len(pivot_rows)):
|
|
172
|
+
factor = pivot_rows[k][new_pivot]
|
|
173
|
+
if abs(factor) > tol:
|
|
174
|
+
for j in range(ncols):
|
|
175
|
+
pivot_rows[k][j] -= factor * r[j]
|
|
176
|
+
pivot_rows.append(r)
|
|
177
|
+
pivot_cols.append(new_pivot)
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
for (i, j) in candidate_edges:
|
|
181
|
+
row = [0.0] * ncols
|
|
182
|
+
for dim in range(d):
|
|
183
|
+
row[d * i + dim] = 1.0
|
|
184
|
+
row[d * j + dim] = -1.0
|
|
185
|
+
# This version without coords only checks structural independence
|
|
186
|
+
# Use grid.simulate() for the geometry-aware version
|
|
187
|
+
independent = add_row(row)
|
|
188
|
+
labels.append(1 if independent else 0)
|
|
189
|
+
if independent:
|
|
190
|
+
accepted.append((i, j))
|
|
191
|
+
|
|
192
|
+
return labels, accepted
|
|
193
|
+
|
|
Binary file
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests the Pebble Game algorithm against rust ground truth and trained model
|
|
3
|
+
author: Sezer
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
import grid
|
|
10
|
+
|
|
11
|
+
from algorithms import pebble_game
|
|
12
|
+
from dataset import make_graph
|
|
13
|
+
from model import RigidityGNN
|
|
14
|
+
|
|
15
|
+
### Helpers
|
|
16
|
+
|
|
17
|
+
def upper_triangular_edges(n: int):
|
|
18
|
+
return [(i, j) for i in range(n) for j in range(i + 1, n)]
|
|
19
|
+
|
|
20
|
+
def load_gnn(path: str = "best_model.pt", coord_dim: int = 2, hidden_dim: int = 64,
|
|
21
|
+
n_layers: int = 3):
|
|
22
|
+
""""Load trained GNN"""
|
|
23
|
+
try:
|
|
24
|
+
model = RigidityGNN(coord_dim=coord_dim, hidden_dim=hidden_dim, n_layers=n_layers)
|
|
25
|
+
model.load_state_dict(torch.load(path, map_location='cpu'))
|
|
26
|
+
model.eval()
|
|
27
|
+
print(f" GNN loaded from {path}")
|
|
28
|
+
return model
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
print(f" No trained GNN found at {path}, skipping GNN comparison")
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def run_gnn(model, coords: np.ndarray) -> list:
|
|
34
|
+
"""Run GNN prediction on one graph. Returns label list."""
|
|
35
|
+
graph = make_graph(coords, d=2)
|
|
36
|
+
with torch.no_grad():
|
|
37
|
+
preds = model.predict(graph.x, graph.edge_index, graph.candidate_edges)
|
|
38
|
+
return preds.tolist()
|
|
39
|
+
|
|
40
|
+
# Single graph comparison
|
|
41
|
+
|
|
42
|
+
def compare_single(coords: np.ndarray, gnn_model=None, verbose: bool = True):
|
|
43
|
+
"""
|
|
44
|
+
Run all three methods on one graph and compare results.
|
|
45
|
+
|
|
46
|
+
Returns dict with per-method lables and timing.
|
|
47
|
+
"""
|
|
48
|
+
n = coords.shape[0]
|
|
49
|
+
d = 2
|
|
50
|
+
edges = upper_triangular_edges(n)
|
|
51
|
+
|
|
52
|
+
results = {}
|
|
53
|
+
|
|
54
|
+
# Rust ground truth
|
|
55
|
+
t0 = time.perf_counter()
|
|
56
|
+
rust_result = grid.simulate(coords.astype(np.float64), d=d)
|
|
57
|
+
rust_time = time.perf_counter() - t0
|
|
58
|
+
rust_labels = rust_result["labels"].tolist()
|
|
59
|
+
results["rust"] = {"labels": rust_labels, "time_ms": rust_time * 1000}
|
|
60
|
+
|
|
61
|
+
# Pebble Game
|
|
62
|
+
t0 = time.perf_counter()
|
|
63
|
+
pb_labels, pb_accepted = pebble_game(n, edges, d=d)
|
|
64
|
+
pb_time = time.perf_counter() - t0
|
|
65
|
+
results["pebble"] = {"labels": pb_labels, "time_ms": pb_time * 1000}
|
|
66
|
+
|
|
67
|
+
# GNN
|
|
68
|
+
if gnn_model is not None:
|
|
69
|
+
t0 = time.perf_counter()
|
|
70
|
+
gnn_labels = run_gnn(gnn_model, coords)
|
|
71
|
+
gnn_time = time.perf_counter() - t0
|
|
72
|
+
results["gnn"] = {"labels": gnn_labels, "time_ms": gnn_time * 1000}
|
|
73
|
+
|
|
74
|
+
# Agreement Metrics
|
|
75
|
+
def agreement(labels_a, labels_b):
|
|
76
|
+
matches = sum(a == b for a,b in zip(labels_a, labels_b))
|
|
77
|
+
return matches / len(labels_a) if labels_a else 1.0
|
|
78
|
+
|
|
79
|
+
if verbose:
|
|
80
|
+
print(f"\n n={n} total_edges={len(edges)} max_rank={rust_result['max_rank']}")
|
|
81
|
+
print(f" {'Method':<12} {'Time (ms)':>10} {'vs Rust':>10} {'Independent':>12}")
|
|
82
|
+
print(f" {'-' * 50}")
|
|
83
|
+
|
|
84
|
+
n_rust_indep = sum(rust_labels)
|
|
85
|
+
print(f" {'Rust':<12} {rust_time * 1000:>10.3f} {'(truth)':>10} {n_rust_indep:>12}")
|
|
86
|
+
|
|
87
|
+
n_pb_indep = sum(pb_labels)
|
|
88
|
+
pb_agree = agreement(pb_labels, rust_labels)
|
|
89
|
+
print(f" {'Pebble Game':<12} {pb_time * 1000:>10.3f} {pb_agree:>10.4f} {n_pb_indep:>12}")
|
|
90
|
+
|
|
91
|
+
if gnn_model is not None:
|
|
92
|
+
n_gnn_indep = sum(gnn_labels)
|
|
93
|
+
gnn_agree = agreement(gnn_labels, rust_labels)
|
|
94
|
+
print(f" {'GNN':<12} {gnn_time * 1000:>10.3f} {gnn_agree:>10.4f} {n_gnn_indep:>12}")
|
|
95
|
+
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
# Stress TestL reconstruct from pebble game output
|
|
99
|
+
|
|
100
|
+
def stress_test(coords: np.ndarray, labels: list) -> dict:
|
|
101
|
+
"""
|
|
102
|
+
Use the accepted edges from the pebble game to reconstruct coordinates.
|
|
103
|
+
Measures whether the pebble game found a valid rigid framework.
|
|
104
|
+
"""
|
|
105
|
+
sim = grid.simulate(coords.astype(np.float64), d=2)
|
|
106
|
+
rec = grid.reconstruct(sim["distance_matrix"], d=2)
|
|
107
|
+
return {
|
|
108
|
+
"stress": rec["stress"],
|
|
109
|
+
"is_isometric": rec["is_isometric"],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Batch benchmark
|
|
113
|
+
|
|
114
|
+
def benchmark(n_graphs: int = 200, n_nodes: int = 10, seed: int = 42, gnn_model=None):
|
|
115
|
+
"""
|
|
116
|
+
Run all methods on n_graphs random graphs
|
|
117
|
+
Reports accuracy and timing stats
|
|
118
|
+
"""
|
|
119
|
+
rng = np.random.RandomState(seed)
|
|
120
|
+
d = 2
|
|
121
|
+
|
|
122
|
+
pb_agreements = []
|
|
123
|
+
gnn_agreements = []
|
|
124
|
+
pb_times = []
|
|
125
|
+
rust_times = []
|
|
126
|
+
gnn_times = []
|
|
127
|
+
stress_scores = []
|
|
128
|
+
|
|
129
|
+
print(f"\nBenchmarking {n_graphs} graphs (n={n_nodes}, d={d}) ...")
|
|
130
|
+
print(f"{'=' * 40}")
|
|
131
|
+
|
|
132
|
+
for k in range(n_graphs):
|
|
133
|
+
coords = rng.uniform(0, 10, (n_nodes, d))
|
|
134
|
+
r = compare_single(coords, gnn_model=gnn_model, verbose=False)
|
|
135
|
+
|
|
136
|
+
rust_labels = r["rust"]["labels"]
|
|
137
|
+
pb_labels = r["pebble"]["labels"]
|
|
138
|
+
|
|
139
|
+
agree = sum(a == b for a,b in zip(pb_labels, rust_labels)) / len(rust_labels)
|
|
140
|
+
pb_agreements.append(agree)
|
|
141
|
+
rust_times.append(r["rust"]["time_ms"])
|
|
142
|
+
pb_times.append(r["pebble"]["time_ms"])
|
|
143
|
+
|
|
144
|
+
# Stress test on pebble game result
|
|
145
|
+
s = stress_test(coords, pb_labels)
|
|
146
|
+
stress_scores.append(s["stress"])
|
|
147
|
+
|
|
148
|
+
if gnn_model is not None:
|
|
149
|
+
gnn_labels = r["gnn"]["labels"]
|
|
150
|
+
gnn_agree = sum(a == b for a, b in zip(gnn_labels, rust_labels)) / len(rust_labels)
|
|
151
|
+
gnn_agreements.append(gnn_agree)
|
|
152
|
+
gnn_times.append(r["gnn"]["time_ms"])
|
|
153
|
+
|
|
154
|
+
print(f"\nResults over {n_graphs} graphs:")
|
|
155
|
+
print(f"\n Pebble Game:")
|
|
156
|
+
print(f" Agreement with Rust : {np.mean(pb_agreements):.4f} (std={np.std(pb_agreements):.4f})")
|
|
157
|
+
print(f" Avg time : {np.mean(pb_times):.3f} ms")
|
|
158
|
+
print(f" Rust avg time : {np.mean(rust_times):.3f} ms")
|
|
159
|
+
print(f" Speedup over Rust : {np.mean(rust_times) / np.mean(pb_times):.2f}x")
|
|
160
|
+
print(f" Avg stress : {np.mean(stress_scores):.2e} (0=lossless)")
|
|
161
|
+
print(f" Isometric rate : {sum(s < 1e-6 for s in stress_scores)}/{n_graphs}")
|
|
162
|
+
|
|
163
|
+
if gnn_model is not None:
|
|
164
|
+
print(f"\n GNN:")
|
|
165
|
+
print(f" Agreement with Rust : {np.mean(gnn_agreements):.4f} (std={np.std(gnn_agreements):.4f})")
|
|
166
|
+
print(f" Avg time : {np.mean(gnn_times):.3f} ms")
|
|
167
|
+
|
|
168
|
+
print(
|
|
169
|
+
f"\n Winner: {'Pebble Game' if not gnn_agreements or np.mean(pb_agreements) >= np.mean(gnn_agreements) else 'GNN'}")
|
|
170
|
+
|
|
171
|
+
# Scaling test
|
|
172
|
+
def scaling_test():
|
|
173
|
+
"""
|
|
174
|
+
Show how pebble game scales vs Rust as n grows.
|
|
175
|
+
"""
|
|
176
|
+
print(f"\nScaling test:")
|
|
177
|
+
print(f" {'n':>6} {'Rust (ms)':>10} {'Pebble (ms)':>12} {'Speedup':>8} {'Agreement':>10}")
|
|
178
|
+
print(f" {'-'*55}")
|
|
179
|
+
|
|
180
|
+
rng = np.random.RandomState(0)
|
|
181
|
+
for n in [5, 10, 20, 50, 100, 200]:
|
|
182
|
+
coords = rng.uniform(0, 10, (n, 2))
|
|
183
|
+
edges = upper_triangular_edges(n)
|
|
184
|
+
|
|
185
|
+
t0 = time.perf_counter()
|
|
186
|
+
rust_result = grid.simulate(coords.astype(np.float64), d=2)
|
|
187
|
+
rust_t = (time.perf_counter() - t0) * 1000
|
|
188
|
+
|
|
189
|
+
t0 = time.perf_counter()
|
|
190
|
+
pb_labels, _ = pebble_game(n, edges, d=2)
|
|
191
|
+
pb_t = (time.perf_counter() - t0) * 1000
|
|
192
|
+
|
|
193
|
+
rust_labels = rust_result["labels"].tolist()
|
|
194
|
+
agree = sum(a == b for a, b in zip(pb_labels, rust_labels)) / len(rust_labels)
|
|
195
|
+
speedup = rust_t / pb_t if pb_t > 0 else float('inf')
|
|
196
|
+
|
|
197
|
+
print(f" {n:>6} {rust_t:>10.3f} {pb_t:>12.3f} {speedup:>8.2f}x {agree:>10.4f}")
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
print("GRiD — Algorithm Comparison")
|
|
201
|
+
print("Pebble Game vs Rust Oracle vs GNN")
|
|
202
|
+
print("=" * 60)
|
|
203
|
+
|
|
204
|
+
gnn = load_gnn("best_model.pt")
|
|
205
|
+
|
|
206
|
+
# Single graph detailed view
|
|
207
|
+
print("\n── Single graph (n=5, the R1-R5 example) ──")
|
|
208
|
+
coords_5 = np.array([
|
|
209
|
+
[0.0, 0.0],
|
|
210
|
+
[2.0, 0.0],
|
|
211
|
+
[3.0, 1.5],
|
|
212
|
+
[1.0, 2.5],
|
|
213
|
+
[-1.0, 1.5],
|
|
214
|
+
])
|
|
215
|
+
compare_single(coords_5, gnn_model=gnn, verbose=True)
|
|
216
|
+
|
|
217
|
+
# Batch benchmark
|
|
218
|
+
benchmark(n_graphs=200, n_nodes=10, gnn_model=gnn)
|
|
219
|
+
|
|
220
|
+
# Scaling
|
|
221
|
+
scaling_test()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@author: Sezer
|
|
3
|
+
date: 01/07/2026
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import torch
|
|
8
|
+
from torch_geometric.data import Data
|
|
9
|
+
from torch.utils.data import Dataset
|
|
10
|
+
from torchgen.dest.native_functions import torch_api_key_word_prefix
|
|
11
|
+
import grid
|
|
12
|
+
|
|
13
|
+
class RigidityData(Data):
|
|
14
|
+
"""
|
|
15
|
+
Custom PyG Data subclass.
|
|
16
|
+
|
|
17
|
+
PyG's default bathing auto-offsets 'edge_index' node indices
|
|
18
|
+
but knows nothing about the 'candidate_edges'. Overriding __inc__
|
|
19
|
+
and __cat_dim__ teaches PyG to treat candidate_edges exactly like
|
|
20
|
+
edge_index during batching.
|
|
21
|
+
"""
|
|
22
|
+
def __inc__(self, key, value, *args, **kwargs):
|
|
23
|
+
if key == 'candidate_edges':
|
|
24
|
+
return self.num_nodes
|
|
25
|
+
return super().__inc__(key, value, *args, **kwargs)
|
|
26
|
+
|
|
27
|
+
def __cat_dim__(self, key, value, *args, **kwargs):
|
|
28
|
+
if key == 'candidate_edges':
|
|
29
|
+
return 1 # concat along the edge dim (not node)
|
|
30
|
+
return super().__cat_dim__(key, value, *args, **kwargs)
|
|
31
|
+
|
|
32
|
+
def make_graph(coords: np.ndarray, d: int = 2, tol: float = 1e-9,) -> RigidityData:
|
|
33
|
+
"""
|
|
34
|
+
Convert raw coordinates into a RigidityData graph.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
coords : (n, d) float64 array of node positions
|
|
38
|
+
d : embedding dimension
|
|
39
|
+
tol : rank tolerance passed to grid.simulate
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
RigidityData with:
|
|
43
|
+
x (n, d) node coordinates as float32
|
|
44
|
+
edge_index (2, 2E) both directions, for message passing
|
|
45
|
+
candidate_edges (2, E) upper-triangular only, for scoring
|
|
46
|
+
y (E,) binary labels from Rust simulation
|
|
47
|
+
"""
|
|
48
|
+
coords = np.asarray(coords, dtype=np.float64)
|
|
49
|
+
n = coords.shape[0]
|
|
50
|
+
|
|
51
|
+
result = grid.simulate(coords, d=d, tol=tol)
|
|
52
|
+
|
|
53
|
+
x = torch.tensor(coords, dtype=torch.float32)
|
|
54
|
+
|
|
55
|
+
src = torch.tensor([i for i in range(n) for j in range(i + 1, n)], dtype=torch.long)
|
|
56
|
+
dst = torch.tensor([j for i in range(n) for j in range(i + 1, n)], dtype=torch.long)
|
|
57
|
+
candidate_edges = torch.stack([src, dst], dim=0)
|
|
58
|
+
|
|
59
|
+
edge_index = torch.cat([candidate_edges, candidate_edges[[1, 0]]], dim=1)
|
|
60
|
+
|
|
61
|
+
y = torch.tensor(result["labels"], dtype=torch.float32)
|
|
62
|
+
|
|
63
|
+
return RigidityData(
|
|
64
|
+
x=x,
|
|
65
|
+
edge_index=edge_index,
|
|
66
|
+
candidate_edges=candidate_edges,
|
|
67
|
+
y=y,
|
|
68
|
+
num_nodes=n,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
class RigidityDataset:
|
|
72
|
+
"""
|
|
73
|
+
Generates some random rigidity graphs and caches them in memory.
|
|
74
|
+
"""
|
|
75
|
+
def __init__(self, n_graphs: int, n_nodes: int, d: int = 2, seed: int = 0):
|
|
76
|
+
self.graphs = []
|
|
77
|
+
rng = np.random.RandomState(seed)
|
|
78
|
+
|
|
79
|
+
print(f"Generating {n_graphs} graphs (n={n_nodes}, d={d}")
|
|
80
|
+
for _ in range(n_graphs):
|
|
81
|
+
coords = rng.uniform(0, 10, (n_nodes, d))
|
|
82
|
+
self.graphs.append(make_graph(coords, d=d))
|
|
83
|
+
|
|
84
|
+
print(f"Done, Labels per graph: {len(self.graphs[0].y)}")
|
|
85
|
+
|
|
86
|
+
def __len__(self):
|
|
87
|
+
return len(self.graphs)
|
|
88
|
+
|
|
89
|
+
def __getitem__(self, idx):
|
|
90
|
+
return self.graphs[idx]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import torch.nn as nn
|
|
3
|
+
import torch.nn.functional as F
|
|
4
|
+
from torch_geometric.nn import MessagePassing
|
|
5
|
+
|
|
6
|
+
class RigidityConv(MessagePassing):
|
|
7
|
+
"""
|
|
8
|
+
One graph convolution layer.
|
|
9
|
+
|
|
10
|
+
The rigidity matrix row for edge (i, j) is built from this exact difference
|
|
11
|
+
vector. Learning a function over it, instead of using it directly the MLP
|
|
12
|
+
decides which geometric differences matter.
|
|
13
|
+
|
|
14
|
+
Edges contribute independently to a node's constraint count. Sum preserves
|
|
15
|
+
the total geometric information received.
|
|
16
|
+
"""
|
|
17
|
+
def __init__(self, in_channels: int, out_channels: int):
|
|
18
|
+
super().__init__(aggr='sum')
|
|
19
|
+
|
|
20
|
+
# Maps distances to message vector
|
|
21
|
+
self.msg_mlp = nn.Sequential(
|
|
22
|
+
nn.Linear(in_channels, out_channels),
|
|
23
|
+
nn.ReLU(),
|
|
24
|
+
nn.Linear(out_channels, out_channels),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Combines current embedding with aggregated messages
|
|
28
|
+
self.upd_mlp = nn.Sequential(
|
|
29
|
+
nn.Linear(out_channels, out_channels),
|
|
30
|
+
nn.ReLU(),
|
|
31
|
+
nn.Linear(out_channels, out_channels),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
self.norm = nn.LayerNorm(out_channels)
|
|
35
|
+
|
|
36
|
+
def forward(self, x, edge_index):
|
|
37
|
+
agg = self.propagate(edge_index, x=x) # residual is here
|
|
38
|
+
return self.norm(x + agg)
|
|
39
|
+
|
|
40
|
+
def message(self, x_i, x_j):
|
|
41
|
+
# This IS the rigidity row
|
|
42
|
+
return self.msg_mlp(x_i - x_j)
|
|
43
|
+
|
|
44
|
+
def update(self, aggr_out):
|
|
45
|
+
return self.upd_mlp(aggr_out)
|
|
46
|
+
|
|
47
|
+
class RigidityGNN(nn.Module):
|
|
48
|
+
"""
|
|
49
|
+
Full model: coordinate encoder → GNN layers → edge scorer.
|
|
50
|
+
|
|
51
|
+
Flow:
|
|
52
|
+
1. Project raw coordinates (n, d) → (n, hidden_dim)
|
|
53
|
+
2. k rounds of message passing — builds context-aware node embeddings
|
|
54
|
+
After k=3 layers, each node has seen its 3-hop geometric neighbourhood
|
|
55
|
+
3. For each candidate edge (i,j), score using [h_i || h_j || x_i-x_j]
|
|
56
|
+
|
|
57
|
+
Layer 1: node sees direct neighbours
|
|
58
|
+
Layer 2: node sees triangles and quadrilaterals
|
|
59
|
+
Layer 3: node has global rigidity context
|
|
60
|
+
More layers risk over-smoothing (all embeddings become similar)
|
|
61
|
+
|
|
62
|
+
Why hidden_dim=64:
|
|
63
|
+
Enough capacity to represent rigidity patterns, small enough
|
|
64
|
+
to train quickly on CPU.
|
|
65
|
+
"""
|
|
66
|
+
def __init__(self, coord_dim: int = 2, hidden_dim: int = 64, n_layers: int = 3):
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.coord_dim = coord_dim
|
|
69
|
+
self.hidden_dim = hidden_dim
|
|
70
|
+
|
|
71
|
+
# Initial projection: raw (x,y) is too low-dimensional for meaningful
|
|
72
|
+
self.input_proj = nn.Sequential(
|
|
73
|
+
nn.Linear(coord_dim, hidden_dim),
|
|
74
|
+
nn.ReLU(),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self.convs = nn.ModuleList([
|
|
78
|
+
RigidityConv(hidden_dim, hidden_dim)
|
|
79
|
+
for _ in range(n_layers)
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
# Edge scorer
|
|
83
|
+
# Input: [h_i (hidden) || h_j (hidden) || x_i-x_j (coord_dim)]
|
|
84
|
+
self.edge_scorer = nn.Sequential(
|
|
85
|
+
nn.Linear(2 * hidden_dim + coord_dim, hidden_dim),
|
|
86
|
+
nn.ReLU(),
|
|
87
|
+
nn.Linear(hidden_dim, hidden_dim // 2),
|
|
88
|
+
nn.ReLU(),
|
|
89
|
+
nn.Linear(hidden_dim // 2, 1),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def encode(self, x: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
|
|
93
|
+
h = self.input_proj(x)
|
|
94
|
+
for conv in self.convs:
|
|
95
|
+
h = h + conv(h, edge_index) # residual: preserves input signal
|
|
96
|
+
return h
|
|
97
|
+
|
|
98
|
+
def score_edges(
|
|
99
|
+
self,
|
|
100
|
+
h: torch.Tensor,
|
|
101
|
+
x: torch.Tensor,
|
|
102
|
+
candidate_edges: torch.Tensor,
|
|
103
|
+
) -> torch.Tensor:
|
|
104
|
+
"""Score each candidate edge. Returns raw logits (E,)."""
|
|
105
|
+
src, dst = candidate_edges[0], candidate_edges[1]
|
|
106
|
+
feat = torch.cat([h[src], h[dst], x[src] - x[dst]], dim=-1)
|
|
107
|
+
return self.edge_scorer(feat).squeeze(-1)
|
|
108
|
+
|
|
109
|
+
def forward(self, x, edge_index, candidate_edges):
|
|
110
|
+
h = self.encode(x, edge_index)
|
|
111
|
+
return self.score_edges(h, x, candidate_edges)
|
|
112
|
+
|
|
113
|
+
def predict(self, x, edge_index, candidate_edges, threshold: float = 0.5):
|
|
114
|
+
"""Inference: returns binary predictions (0 or 1)."""
|
|
115
|
+
with torch.no_grad():
|
|
116
|
+
logits = self.forward(x, edge_index, candidate_edges)
|
|
117
|
+
return (torch.sigmoid(logits) >= threshold).long()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import torch.nn.functional as F
|
|
3
|
+
from torch_geometric.loader import DataLoader
|
|
4
|
+
|
|
5
|
+
from dataset import RigidityDataset
|
|
6
|
+
from model import RigidityGNN
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def train_epoch(model, loader, optimizer, device):
|
|
10
|
+
model.train()
|
|
11
|
+
total_loss, correct, total = 0.0, 0, 0
|
|
12
|
+
|
|
13
|
+
for batch in loader:
|
|
14
|
+
batch = batch.to(device)
|
|
15
|
+
optimizer.zero_grad()
|
|
16
|
+
|
|
17
|
+
logits = model(batch.x, batch.edge_index, batch.candidate_edges)
|
|
18
|
+
loss = F.binary_cross_entropy_with_logits(logits, batch.y)
|
|
19
|
+
|
|
20
|
+
loss.backward()
|
|
21
|
+
optimizer.step()
|
|
22
|
+
|
|
23
|
+
n = batch.y.size(0)
|
|
24
|
+
total_loss += loss.item() * n
|
|
25
|
+
correct += ((torch.sigmoid(logits) >= 0.5) == batch.y.bool()).sum().item()
|
|
26
|
+
total += n
|
|
27
|
+
|
|
28
|
+
return total_loss / total, correct / total
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@torch.no_grad()
|
|
32
|
+
def evaluate(model, loader, device):
|
|
33
|
+
model.eval()
|
|
34
|
+
total_loss, correct, total = 0.0, 0, 0
|
|
35
|
+
|
|
36
|
+
for batch in loader:
|
|
37
|
+
batch = batch.to(device)
|
|
38
|
+
logits = model(batch.x, batch.edge_index, batch.candidate_edges)
|
|
39
|
+
loss = F.binary_cross_entropy_with_logits(logits, batch.y)
|
|
40
|
+
|
|
41
|
+
n = batch.y.size(0)
|
|
42
|
+
total_loss += loss.item() * n
|
|
43
|
+
correct += ((torch.sigmoid(logits) >= 0.5) == batch.y.bool()).sum().item()
|
|
44
|
+
total += n
|
|
45
|
+
|
|
46
|
+
return total_loss / total, correct / total
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def train(
|
|
50
|
+
n_train: int = 2000,
|
|
51
|
+
n_val: int = 400,
|
|
52
|
+
n_nodes: int = 10,
|
|
53
|
+
d: int = 2,
|
|
54
|
+
hidden_dim: int = 64,
|
|
55
|
+
n_layers: int = 3,
|
|
56
|
+
lr: float = 1e-3,
|
|
57
|
+
epochs: int = 100,
|
|
58
|
+
batch_size: int = 32,
|
|
59
|
+
seed: int = 0,
|
|
60
|
+
):
|
|
61
|
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
62
|
+
print(f"Device: {device}\n")
|
|
63
|
+
|
|
64
|
+
train_set = RigidityDataset(n_train, n_nodes, d=d, seed=seed)
|
|
65
|
+
val_set = RigidityDataset(n_val, n_nodes, d=d, seed=seed + 99999)
|
|
66
|
+
|
|
67
|
+
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True)
|
|
68
|
+
val_loader = DataLoader(val_set, batch_size=batch_size)
|
|
69
|
+
|
|
70
|
+
model = RigidityGNN(coord_dim=d, hidden_dim=hidden_dim, n_layers=n_layers).to(device)
|
|
71
|
+
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
|
|
72
|
+
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
|
73
|
+
optimizer, mode='min', patience=5, factor=0.5, min_lr=1e-5
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
n_params = sum(p.numel() for p in model.parameters())
|
|
77
|
+
print(f"Model: {n_params:,} parameters")
|
|
78
|
+
print(f"{'Epoch':>6} {'Train Loss':>10} {'Train Acc':>10} {'Val Loss':>10} {'Val Acc':>10}")
|
|
79
|
+
print("-" * 55)
|
|
80
|
+
|
|
81
|
+
best_val_acc = 0.0
|
|
82
|
+
for epoch in range(1, epochs + 1):
|
|
83
|
+
tr_loss, tr_acc = train_epoch(model, train_loader, optimizer, device)
|
|
84
|
+
va_loss, va_acc = evaluate(model, val_loader, device)
|
|
85
|
+
scheduler.step(va_loss)
|
|
86
|
+
|
|
87
|
+
if va_acc > best_val_acc:
|
|
88
|
+
best_val_acc = va_acc
|
|
89
|
+
torch.save(model.state_dict(), "best_model.pt")
|
|
90
|
+
|
|
91
|
+
if epoch % 10 == 0:
|
|
92
|
+
print(f"{epoch:>6} {tr_loss:>10.4f} {tr_acc:>10.4f} {va_loss:>10.4f} {va_acc:>10.4f}")
|
|
93
|
+
|
|
94
|
+
print(f"\nBest validation accuracy: {best_val_acc:.4f}")
|
|
95
|
+
print("Saved to best_model.pt")
|
|
96
|
+
return model
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
train()
|
|
@@ -67,7 +67,7 @@ print(result["distance_matrix"])
|
|
|
67
67
|
################################################################################
|
|
68
68
|
### grid.generate_training_batch ###
|
|
69
69
|
"""
|
|
70
|
-
Generate many random graphs at once for
|
|
70
|
+
Generate many random graphs at once for gnn training data
|
|
71
71
|
All heavy work runs with GIL released so torch can train in parallel
|
|
72
72
|
|
|
73
73
|
Args:
|
|
@@ -80,7 +80,7 @@ Returns:
|
|
|
80
80
|
list[dict] (same dict as simulate(), one per graph)
|
|
81
81
|
"""
|
|
82
82
|
|
|
83
|
-
banner("grid.generate_training_batch - batched random graphs for
|
|
83
|
+
banner("grid.generate_training_batch - batched random graphs for gnn data")
|
|
84
84
|
|
|
85
85
|
batch = grid.generate_training_batch(n_graphs=100, n_nodes=20, d=2, seed=42)
|
|
86
86
|
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
mod node;
|
|
24
24
|
mod rank;
|
|
25
|
+
mod pebble;
|
|
25
26
|
mod reconstruct;
|
|
26
27
|
mod rigidity;
|
|
27
28
|
mod simulation;
|
|
@@ -380,6 +381,47 @@ fn reconstruct_coords(
|
|
|
380
381
|
dict.set_item("is_isometric", result.is_isometric)?;
|
|
381
382
|
Ok(dict.into())
|
|
382
383
|
}
|
|
384
|
+
|
|
385
|
+
/// Run the Pebble Game algorithm — O(n^2), deterministic, no matrix rank needed.
|
|
386
|
+
///
|
|
387
|
+
/// Parameters
|
|
388
|
+
/// ----------
|
|
389
|
+
/// coords : np.ndarray shape (n, d) — node coordinates (only d=2 supported)
|
|
390
|
+
/// d : int — must be 2
|
|
391
|
+
///
|
|
392
|
+
/// Returns dict with keys:
|
|
393
|
+
/// "labels" np.ndarray[u8] 1=independent, 0=redundant
|
|
394
|
+
/// "accepted_edges" list[(i,j)]
|
|
395
|
+
/// "n_nodes" int
|
|
396
|
+
/// "d" int
|
|
397
|
+
#[pyfunction]
|
|
398
|
+
fn pebble_game(py: Python, coords: PyReadonlyArray2<f64>, d: usize) -> PyResult<PyObject> {
|
|
399
|
+
let arr = coords.as_array();
|
|
400
|
+
let n = arr.shape()[0];
|
|
401
|
+
|
|
402
|
+
if d != 2 {
|
|
403
|
+
return Err(pyo3::exceptions::PyValueError::new_err(
|
|
404
|
+
"pebble_game only supports d=2",
|
|
405
|
+
));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let edges: Vec<(usize, usize)> = (0..n)
|
|
409
|
+
.flat_map(|i| (i + 1..n).map(move |j| (i, j)))
|
|
410
|
+
.collect();
|
|
411
|
+
|
|
412
|
+
let (labels, accepted) = py.allow_threads(|| pebble::pebble_game(n, &edges, d));
|
|
413
|
+
|
|
414
|
+
let dict = PyDict::new_bound(py);
|
|
415
|
+
let labels_arr: Array1<u8> = Array1::from(labels);
|
|
416
|
+
dict.set_item("labels", labels_arr.into_pyarray_bound(py))?;
|
|
417
|
+
let edge_list = PyList::new_bound(py, accepted.iter().map(|&(i, j)| (i, j)));
|
|
418
|
+
dict.set_item("accepted_edges", edge_list)?;
|
|
419
|
+
dict.set_item("n_nodes", n)?;
|
|
420
|
+
dict.set_item("d", d)?;
|
|
421
|
+
|
|
422
|
+
Ok(dict.into())
|
|
423
|
+
}
|
|
424
|
+
|
|
383
425
|
// ── Module registration ────────────────────────────────────────────────────────
|
|
384
426
|
|
|
385
427
|
#[pymodule]
|
|
@@ -389,5 +431,6 @@ fn grid(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
389
431
|
m.add_function(wrap_pyfunction!(generate_training_batch, m)?)?;
|
|
390
432
|
m.add_function(wrap_pyfunction!(max_independent, m)?)?;
|
|
391
433
|
m.add_function(wrap_pyfunction!(reconstruct_coords, m)?)?;
|
|
434
|
+
m.add_function(wrap_pyfunction!(pebble_game, m)?)?;
|
|
392
435
|
Ok(())
|
|
393
436
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//! Pebble Game Algorithm for 2D rigidity independence
|
|
2
|
+
//! Deterministic and O(n^2)
|
|
3
|
+
|
|
4
|
+
use std::collections::HashSet;
|
|
5
|
+
|
|
6
|
+
/// A recorded state change, so a failed edge attempt can be undone.
|
|
7
|
+
enum Change {
|
|
8
|
+
// A pebble was consumed at this vertex
|
|
9
|
+
Pebble(usize),
|
|
10
|
+
// A pebble was gained at this vertex during the collection loop
|
|
11
|
+
GainPebble(usize),
|
|
12
|
+
// A directed edge was reversed: was from_ -> to, is now to -> from_.
|
|
13
|
+
Edge(usize, usize),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// DFS: try to find a free pebble reachable from v by following directed edges.
|
|
17
|
+
/// Records the changes made during the DFS search so they can be rolled back if necessary.
|
|
18
|
+
fn find_pebble(
|
|
19
|
+
v: usize,
|
|
20
|
+
visited: &mut HashSet<usize>,
|
|
21
|
+
pebbles: &mut [i32],
|
|
22
|
+
out_edges: &mut Vec<Vec<usize>>,
|
|
23
|
+
changes: &mut Vec<Change>,
|
|
24
|
+
) -> bool {
|
|
25
|
+
if visited.contains(&v) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
visited.insert(v);
|
|
29
|
+
|
|
30
|
+
if pebbles[v] > 0 {
|
|
31
|
+
pebbles[v] -= 1;
|
|
32
|
+
changes.push(Change::Pebble(v));
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for i in 0..out_edges[v].len() {
|
|
37
|
+
let u = out_edges[v][i];
|
|
38
|
+
if find_pebble(u, visited, pebbles, out_edges, changes) {
|
|
39
|
+
out_edges[v].remove(i);
|
|
40
|
+
out_edges[u].push(v);
|
|
41
|
+
changes.push(Change::Edge(v, u));
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Undo all state changes during a failed edge attempt.
|
|
50
|
+
fn rollback(changes: Vec<Change>, pebbles: &mut [i32], out_edges: &mut Vec<Vec<usize>>) {
|
|
51
|
+
for change in changes.into_iter().rev() {
|
|
52
|
+
match change {
|
|
53
|
+
Change::Pebble(v) => pebbles[v] += 1,
|
|
54
|
+
Change::GainPebble(v) => pebbles[v] -= 1,
|
|
55
|
+
Change::Edge(v, u) => {
|
|
56
|
+
if let Some(pos) = out_edges[u].iter().position(|&x| x == v) {
|
|
57
|
+
out_edges[u].remove(pos);
|
|
58
|
+
}
|
|
59
|
+
out_edges[v].push(u);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Pebble Game for 2D rigidity independence
|
|
66
|
+
pub fn pebble_game(
|
|
67
|
+
n: usize,
|
|
68
|
+
candidate_edges: &[(usize, usize)],
|
|
69
|
+
d: usize,
|
|
70
|
+
) -> (Vec<u8>, Vec<(usize, usize)>) {
|
|
71
|
+
assert_eq!(d, 2, "dimension must be 2");
|
|
72
|
+
|
|
73
|
+
let mut pebbles = vec![2i32; n];
|
|
74
|
+
let mut out_edges: Vec<Vec<usize>> = vec![Vec::new(); n];
|
|
75
|
+
let mut labels = Vec::with_capacity(candidate_edges.len());
|
|
76
|
+
let mut accepted = Vec::new();
|
|
77
|
+
|
|
78
|
+
for &(u, v) in candidate_edges {
|
|
79
|
+
let mut changes: Vec<Change> = Vec::new();
|
|
80
|
+
|
|
81
|
+
while pebbles[u] + pebbles[v] < 4 {
|
|
82
|
+
let (mut pu, mut pv) = (pebbles[u], pebbles[v]);
|
|
83
|
+
pebbles[u] = 0;
|
|
84
|
+
pebbles[v] = 0;
|
|
85
|
+
|
|
86
|
+
let mut progressed = false;
|
|
87
|
+
|
|
88
|
+
if pu < 2 {
|
|
89
|
+
let mut visited = HashSet::new();
|
|
90
|
+
if find_pebble(u, &mut visited, &mut pebbles, &mut out_edges, &mut changes) {
|
|
91
|
+
pu += 1;
|
|
92
|
+
changes.push(Change::GainPebble(u));
|
|
93
|
+
progressed = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if !progressed {
|
|
97
|
+
let mut visited = HashSet::new();
|
|
98
|
+
if find_pebble(v, &mut visited, &mut pebbles, &mut out_edges, &mut changes) {
|
|
99
|
+
pv += 1;
|
|
100
|
+
changes.push(Change::GainPebble(v));
|
|
101
|
+
progressed = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pebbles[u] = pu;
|
|
106
|
+
pebbles[v] = pv;
|
|
107
|
+
|
|
108
|
+
if !progressed {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if pebbles[u] + pebbles[v] >= 4 {
|
|
114
|
+
pebbles[v] -= 1;
|
|
115
|
+
out_edges[v].push(u);
|
|
116
|
+
labels.push(1u8);
|
|
117
|
+
accepted.push((u, v));
|
|
118
|
+
} else {
|
|
119
|
+
rollback(changes, &mut pebbles, &mut out_edges);
|
|
120
|
+
labels.push(0u8);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
(labels, accepted)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Testing
|
|
128
|
+
#[cfg(test)]
|
|
129
|
+
mod tests {
|
|
130
|
+
use super::*;
|
|
131
|
+
|
|
132
|
+
fn upper_triangular_edges(n: usize) -> Vec<(usize, usize)> {
|
|
133
|
+
(0..n).flat_map(|i| (i + 1..n).map(move |j| (i, j))).collect()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[test]
|
|
137
|
+
fn test_triangle_all_independent() {
|
|
138
|
+
let edges = upper_triangular_edges(3);
|
|
139
|
+
let (labels, accepted) = pebble_game(3, &edges, 2);
|
|
140
|
+
assert_eq!(labels, vec![1, 1, 1]);
|
|
141
|
+
assert_eq!(accepted.len(), 3);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[test]
|
|
145
|
+
fn test_square_one_redundant() {
|
|
146
|
+
let edges = upper_triangular_edges(4);
|
|
147
|
+
let (labels, accepted) = pebble_game(4, &edges, 2);
|
|
148
|
+
assert_eq!(accepted.len(), 5);
|
|
149
|
+
assert_eq!(labels.iter().filter(|&&l| l == 1).count(), 5);
|
|
150
|
+
assert_eq!(labels.iter().filter(|&&l| l == 0).count(), 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#[test]
|
|
154
|
+
fn test_five_nodes_matches_max_rank() {
|
|
155
|
+
let edges = upper_triangular_edges(5);
|
|
156
|
+
let (labels, accepted) = pebble_game(5, &edges, 2);
|
|
157
|
+
assert_eq!(accepted.len(), 7);
|
|
158
|
+
assert_eq!(labels.iter().filter(|&&l| l == 1).count(), 7);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[test]
|
|
162
|
+
fn test_labels_length_matches_input() {
|
|
163
|
+
let edges = upper_triangular_edges(10);
|
|
164
|
+
let (labels, _) = pebble_game(10, &edges, 2);
|
|
165
|
+
assert_eq!(labels.len(), edges.len());
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
name: Publish to PyPI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [published]
|
|
6
|
-
|
|
7
|
-
permissions:
|
|
8
|
-
contents: read
|
|
9
|
-
id-token: write
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
build:
|
|
13
|
-
runs-on: ubuntu-latest
|
|
14
|
-
|
|
15
|
-
steps:
|
|
16
|
-
- uses: actions/checkout@v4
|
|
17
|
-
|
|
18
|
-
- name: Build wheels with maturin
|
|
19
|
-
uses: PyO3/maturin-action@v1
|
|
20
|
-
with:
|
|
21
|
-
command: build
|
|
22
|
-
args: --release --sdist
|
|
23
|
-
|
|
24
|
-
- name: Upload artifacts
|
|
25
|
-
uses: actions/upload-artifact@v4
|
|
26
|
-
with:
|
|
27
|
-
name: dist
|
|
28
|
-
path: target/wheels/
|
|
29
|
-
|
|
30
|
-
publish:
|
|
31
|
-
needs: build
|
|
32
|
-
runs-on: ubuntu-latest
|
|
33
|
-
|
|
34
|
-
permissions:
|
|
35
|
-
id-token: write
|
|
36
|
-
|
|
37
|
-
environment:
|
|
38
|
-
name: pypi
|
|
39
|
-
|
|
40
|
-
steps:
|
|
41
|
-
- name: Download artifacts
|
|
42
|
-
uses: actions/download-artifact@v4
|
|
43
|
-
with:
|
|
44
|
-
name: dist
|
|
45
|
-
path: dist/
|
|
46
|
-
|
|
47
|
-
- name: Publish to PyPI
|
|
48
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
grid_simulate-0.1.0/.gitignore
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
/target
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|