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.
Files changed (31) hide show
  1. grid_simulate-0.2.2/.github/workflows/publish.yml +59 -0
  2. grid_simulate-0.2.2/.gitignore +19 -0
  3. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/Cargo.lock +1 -1
  4. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/Cargo.toml +1 -1
  5. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/PKG-INFO +1 -1
  6. grid_simulate-0.2.2/gnn/algorithms.py +193 -0
  7. grid_simulate-0.2.2/gnn/best_model.pt +0 -0
  8. grid_simulate-0.2.2/gnn/compare.py +221 -0
  9. grid_simulate-0.2.2/gnn/dataset.py +90 -0
  10. grid_simulate-0.2.2/gnn/model.py +117 -0
  11. grid_simulate-0.2.2/gnn/train.py +100 -0
  12. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/pyproject.toml +1 -1
  13. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/python/test_bridge.py +2 -2
  14. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/lib.rs +43 -0
  15. grid_simulate-0.2.2/src/pebble.rs +167 -0
  16. grid_simulate-0.1.0/.github/workflows/publish.yml +0 -48
  17. grid_simulate-0.1.0/.gitignore +0 -1
  18. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/.gitignore +0 -0
  19. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/GRiD.iml +0 -0
  20. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  21. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/misc.xml +0 -0
  22. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/modules.xml +0 -0
  23. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/.idea/vcs.xml +0 -0
  24. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/README.md +0 -0
  25. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/main.rs +0 -0
  26. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/node.rs +0 -0
  27. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/rank.rs +0 -0
  28. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/reconstruct.rs +0 -0
  29. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/rigidity.rs +0 -0
  30. {grid_simulate-0.1.0 → grid_simulate-0.2.2}/src/simulation.rs +0 -0
  31. {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,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+
6
+ /target
7
+ *.pyd
8
+ *.so
9
+
10
+ # Models
11
+ *.pt
12
+ *.pth
13
+ *.ckpt
14
+ *.bin
15
+ *.safetensors
16
+
17
+ # IDE
18
+ .idea/
19
+ .vscode/
@@ -25,7 +25,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
25
25
 
26
26
  [[package]]
27
27
  name = "grid"
28
- version = "0.1.0"
28
+ version = "0.2.2"
29
29
  dependencies = [
30
30
  "approx",
31
31
  "ndarray",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "grid"
3
- version = "0.1.0"
3
+ version = "0.2.2"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grid-simulate
3
- Version: 0.1.0
3
+ Version: 0.2.2
4
4
  Summary: Rust + PyO3 simulation library
5
5
  Requires-Python: >=3.8
@@ -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()
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "grid-simulate"
7
- version = "0.1.0"
7
+ version = "0.2.2"
8
8
  description = "Rust + PyO3 simulation library"
9
9
  requires-python = ">=3.8"
10
10
 
@@ -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 GNN training data
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 GNN data")
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
@@ -1 +0,0 @@
1
- /target
File without changes
File without changes
File without changes
File without changes