gridity 0.2.2__tar.gz → 0.3.0__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 (79) hide show
  1. {gridity-0.2.2 → gridity-0.3.0}/.idea/modules.xml +1 -1
  2. {gridity-0.2.2 → gridity-0.3.0}/Cargo.lock +1 -1
  3. gridity-0.3.0/Cargo.toml +32 -0
  4. gridity-0.3.0/PKG-INFO +231 -0
  5. gridity-0.3.0/README.md +204 -0
  6. gridity-0.3.0/pyproject.toml +54 -0
  7. gridity-0.3.0/python/benchmark_formats.py +347 -0
  8. gridity-0.3.0/python/compression.py +26 -0
  9. gridity-0.3.0/python/compression_ratio.png +0 -0
  10. gridity-0.3.0/python/experiments/dataset_generator.py +93 -0
  11. gridity-0.3.0/python/experiments/figures/stage0_tol_sweep.png +0 -0
  12. gridity-0.3.0/python/experiments/figures/stage3_rate_distortion.png +0 -0
  13. gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_clustered.png +0 -0
  14. gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_exact_all.png +0 -0
  15. gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_generic.png +0 -0
  16. gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_lattice.png +0 -0
  17. gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_planted_blob.png +0 -0
  18. gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_planted_line.png +0 -0
  19. gridity-0.3.0/python/experiments/figures/stage4_conditioning.png +0 -0
  20. gridity-0.3.0/python/experiments/figures/stage4_epsilon.png +0 -0
  21. gridity-0.3.0/python/experiments/figures/stage7_noise.png +0 -0
  22. gridity-0.3.0/python/experiments/metrics.py +332 -0
  23. gridity-0.3.0/python/experiments/results/stage0.json +65 -0
  24. gridity-0.3.0/python/experiments/results/stage4_uniqueness.json +114 -0
  25. gridity-0.3.0/python/experiments/stage0_tol_sweep.py +36 -0
  26. gridity-0.3.0/python/experiments/stage1_baselines.py +34 -0
  27. gridity-0.3.0/python/experiments/stage2_diagnostic.py +252 -0
  28. gridity-0.3.0/python/experiments/stage2_gnn.py +138 -0
  29. gridity-0.3.0/python/experiments/stage3_rate_distortion.py +88 -0
  30. gridity-0.3.0/python/experiments/stage4_conditioning.py +151 -0
  31. gridity-0.3.0/python/experiments/stage4_epsilon.py +66 -0
  32. gridity-0.3.0/python/experiments/stage4_uniqueness.py +163 -0
  33. gridity-0.3.0/python/experiments/stage5_intrinsic_dim.py +58 -0
  34. gridity-0.3.0/python/experiments/stage6_landmark_scaling.py +81 -0
  35. gridity-0.3.0/python/experiments/stage7_noise.py +132 -0
  36. gridity-0.3.0/python/gridity_format.py +659 -0
  37. gridity-0.3.0/python/real_data_experiments/budget_sweep.py +71 -0
  38. gridity-0.3.0/python/real_data_experiments/compress_real_proteins.py +74 -0
  39. gridity-0.3.0/python/real_data_experiments/compression_size.py +69 -0
  40. gridity-0.3.0/python/real_data_experiments/contact_map_impact.py +85 -0
  41. gridity-0.3.0/python/real_data_experiments/protein_file_list.json +1 -0
  42. gridity-0.3.0/python/real_data_experiments/rd_exp.py +53 -0
  43. gridity-0.3.0/python/real_data_experiments/save_file_list.py +9 -0
  44. gridity-0.3.0/python/real_data_experiments/save_real_data.py +44 -0
  45. {gridity-0.2.2 → gridity-0.3.0}/python/test_bridge.py +34 -19
  46. gridity-0.3.0/python/test_gridity_format.py +214 -0
  47. gridity-0.3.0/python/test_gridity_rust.py +360 -0
  48. gridity-0.3.0/src/format/bits.rs +279 -0
  49. gridity-0.3.0/src/format/build.rs +566 -0
  50. gridity-0.3.0/src/format/header.rs +214 -0
  51. gridity-0.3.0/src/format/linalg.rs +887 -0
  52. gridity-0.3.0/src/format/place.rs +449 -0
  53. gridity-0.3.0/src/format.rs +986 -0
  54. {gridity-0.2.2 → gridity-0.3.0}/src/lib.rs +198 -0
  55. gridity-0.2.2/.idea/GRiD.iml +0 -8
  56. gridity-0.2.2/Cargo.toml +0 -26
  57. gridity-0.2.2/PKG-INFO +0 -5
  58. gridity-0.2.2/README.md +0 -42
  59. gridity-0.2.2/gnn/algorithms.py +0 -193
  60. gridity-0.2.2/gnn/best_model.pt +0 -0
  61. gridity-0.2.2/gnn/compare.py +0 -221
  62. gridity-0.2.2/gnn/dataset.py +0 -90
  63. gridity-0.2.2/gnn/model.py +0 -117
  64. gridity-0.2.2/gnn/train.py +0 -100
  65. gridity-0.2.2/pyproject.toml +0 -12
  66. {gridity-0.2.2 → gridity-0.3.0}/.github/workflows/publish.yml +0 -0
  67. {gridity-0.2.2 → gridity-0.3.0}/.gitignore +0 -0
  68. {gridity-0.2.2 → gridity-0.3.0}/.idea/.gitignore +0 -0
  69. {gridity-0.2.2 → gridity-0.3.0}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  70. {gridity-0.2.2 → gridity-0.3.0}/.idea/misc.xml +0 -0
  71. {gridity-0.2.2 → gridity-0.3.0}/.idea/vcs.xml +0 -0
  72. {gridity-0.2.2 → gridity-0.3.0}/src/main.rs +0 -0
  73. {gridity-0.2.2 → gridity-0.3.0}/src/node.rs +0 -0
  74. {gridity-0.2.2 → gridity-0.3.0}/src/pebble.rs +0 -0
  75. {gridity-0.2.2 → gridity-0.3.0}/src/rank.rs +0 -0
  76. {gridity-0.2.2 → gridity-0.3.0}/src/reconstruct.rs +0 -0
  77. {gridity-0.2.2 → gridity-0.3.0}/src/rigidity.rs +0 -0
  78. {gridity-0.2.2 → gridity-0.3.0}/src/simulation.rs +0 -0
  79. {gridity-0.2.2 → gridity-0.3.0}/src/traversal.rs +0 -0
@@ -2,7 +2,7 @@
2
2
  <project version="4">
3
3
  <component name="ProjectModuleManager">
4
4
  <modules>
5
- <module fileurl="file://$PROJECT_DIR$/.idea/GRiD.iml" filepath="$PROJECT_DIR$/.idea/GRiD.iml" />
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/gridity.iml" filepath="$PROJECT_DIR$/.idea/gridity.iml" />
6
6
  </modules>
7
7
  </component>
8
8
  </project>
@@ -25,7 +25,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
25
25
 
26
26
  [[package]]
27
27
  name = "gridity"
28
- version = "0.2.2"
28
+ version = "0.3.0"
29
29
  dependencies = [
30
30
  "approx",
31
31
  "ndarray",
@@ -0,0 +1,32 @@
1
+ [package]
2
+ name = "gridity"
3
+ # keep in step with pyproject.toml: `gridity.__version__` is read from here
4
+ version = "0.3.0"
5
+ edition = "2021"
6
+ readme = "README.md"
7
+
8
+ [lib]
9
+ name = "gridity"
10
+ # rlib alongside cdylib so `cargo test` can link the unit tests against the crate
11
+ crate-type = ["cdylib", "rlib"]
12
+
13
+ [dependencies]
14
+ # `extension-module` is NOT enabled here on purpose. With it on, the library
15
+ # leaves Python's symbols to be resolved by the interpreter at load time, which
16
+ # is right for the wheel but means `cargo test` cannot link. maturin turns it on
17
+ # via the `features` list in pyproject.toml when it builds the extension.
18
+ pyo3 = { version = "0.22" }
19
+ numpy = "0.22"
20
+ ndarray = "0.16"
21
+
22
+ [profile.release]
23
+ opt-level = 3
24
+ lto = true
25
+ codegen-units = 1
26
+
27
+ [dev-dependencies]
28
+ approx = "0.5"
29
+
30
+ [[bin]]
31
+ name = "grid_sim_demo"
32
+ path = "src/main.rs"
gridity-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: gridity
3
+ Version: 0.3.0
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Intended Audience :: Science/Research
6
+ Classifier: Intended Audience :: Developers
7
+ Classifier: Programming Language :: Rust
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: Implementation :: CPython
13
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
14
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
15
+ Classifier: Topic :: System :: Archiving :: Compression
16
+ Classifier: Operating System :: OS Independent
17
+ Requires-Dist: numpy>=1.21
18
+ Summary: Compress a distance matrix down to the handful of distances that actually pin the geometry, using rigidity theory.
19
+ Keywords: compression,distance-matrix,distance-geometry,rigidity,rigidity-theory,point-cloud,multidimensional-scaling,mds,trilateration,sensor-localization
20
+ Author: Sezer Aptourachman
21
+ Requires-Python: >=3.8, <3.14
22
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
23
+ Project-URL: Homepage, https://github.com/JuL-sezaR/grid
24
+ Project-URL: Issues, https://github.com/JuL-sezaR/grid/issues
25
+ Project-URL: Repository, https://github.com/JuL-sezaR/grid
26
+
27
+ # gridity
28
+
29
+ **Compress a distance matrix down to the handful of distances that actually pin the geometry.**
30
+
31
+ If you have `n` points, there are `n(n-1)/2` distances between them — but the points
32
+ themselves only have `n·d` coordinates. Most of that matrix is redundant. `gridity` works out
33
+ which distances carry the shape and which are implied by the others, then writes only the
34
+ first group to a file.
35
+
36
+ Rust core, Python API, no runtime dependencies beyond NumPy.
37
+
38
+ ```bash
39
+ pip install gridity
40
+ ```
41
+
42
+ ---
43
+
44
+ ## The short version
45
+
46
+ ```python
47
+ import numpy as np
48
+ import gridity
49
+
50
+ # 1000 points in 3D, and the distances between every pair of them
51
+ pts = np.random.rand(1000, 3) * 10
52
+ D = np.linalg.norm(pts[:, None] - pts[None], axis=-1)
53
+
54
+ gridity.compress(D, "cloud.gridity", d=3, bits=0) # 27,952 bytes
55
+ back = gridity.decompress("cloud.gridity") # the full 1000x1000 matrix
56
+
57
+ np.abs(back - D).max() # ~1e-13
58
+ ```
59
+
60
+ That matrix is 3,996,000 bytes as float64. `lzma` gets it to 3,654,072. `gridity` gets it to
61
+ 27,952 — and decompresses back to the same matrix.
62
+
63
+ ---
64
+
65
+ ## Why not just zip it?
66
+
67
+ Because distance data is nearly incompressible. The mantissa bits of a float64 distance are
68
+ effectively random, so a general-purpose compressor has almost nothing to find. Measured on
69
+ uniform 3D clouds, same matrices, all lossless:
70
+
71
+ | n | float64 triangle | `+ lzma -9` | `.gridity` | vs lzma |
72
+ |---|---|---|---|---|
73
+ | 100 | 39,600 B | 36,428 B | **2,721 B** | 13× smaller |
74
+ | 300 | 358,800 B | 328,996 B | **8,240 B** | 40× smaller |
75
+ | 1000 | 3,996,000 B | 3,654,072 B | **27,952 B** | 131× smaller |
76
+
77
+ The gap widens with `n`, because the matrix grows as O(n²) while the geometry only grows
78
+ as O(n). `gridity` stores the geometry.
79
+
80
+ A general-purpose compressor sees a bag of numbers. `gridity` knows they are distances
81
+ between points in `d` dimensions, and that is the entire trick.
82
+
83
+ ---
84
+
85
+ ## Where this is the right tool
86
+
87
+ The target case is **you have distances and never had coordinates**:
88
+
89
+ - wireless sensor localisation — time-of-flight or signal strength between nodes, no GPS
90
+ - molecular structure from NMR — inter-atomic distances, not positions
91
+ - any distance-geometry problem where the O(n²) matrix is the thing you must store or send
92
+
93
+ If you already have coordinates, store those instead; see *Honest limits* below.
94
+
95
+ ---
96
+
97
+ ## Spending fewer bits
98
+
99
+ Rigidity is a threshold. Below a certain number of distances the shape comes apart
100
+ completely, so there is no point asking for "slightly fewer distances". What you can trade
101
+ is **precision per distance**:
102
+
103
+ ```python
104
+ gridity.compress_to_bytes(D, d=3, bits=8) # 6,994 B, error 8.2e-03
105
+ gridity.compress_to_bytes(D, d=3, bits=12) # 8,491 B, error 6.0e-04
106
+ gridity.compress_to_bytes(D, d=3, bits=16) # 9,988 B, error 4.3e-05
107
+ gridity.compress_to_bytes(D, d=3, bits=0) # 27,952 B, exact
108
+ ```
109
+
110
+ Every two extra bits cuts the error roughly fourfold. If you would rather state the error
111
+ than guess the budget, say so and let the encoder find it:
112
+
113
+ ```python
114
+ blob = gridity.compress_to_bytes(D, d=3, target_error=1e-3)
115
+ ```
116
+
117
+ That bound is **measured, not predicted** — the encoder decodes its own output and checks
118
+ before settling on a budget.
119
+
120
+ ---
121
+
122
+ ## How it works
123
+
124
+ `max_rank = n·d − d(d+1)/2` distances are enough to make a point cloud *rigid*. That is the
125
+ classical result, and it is where the O(1/n) compression ratio comes from.
126
+
127
+ But rigid is not the same as *uniquely determined*. A minimal rigid edge set is satisfied by
128
+ many different point clouds — about twenty for a ten-point cloud — because each point can
129
+ reflect across the plane through its anchors. Storing only those distances would give you a
130
+ file that decodes to the wrong shape.
131
+
132
+ So the edges are picked as a **trilateration**: a small seed simplex first, then every
133
+ remaining point tied to exactly `d` points already placed. That is exactly `max_rank` edges,
134
+ and it makes the ambiguity explicit — each point has precisely two possible positions, so
135
+ **one bit per point** says which. The result:
136
+
137
+ ```
138
+ max_rank distances + n bits = unique reconstruction
139
+ ```
140
+
141
+ `n` bits against an O(n²) matrix costs nothing, so the ratio survives intact.
142
+
143
+ Two modes are available. `minimal` (the default) is the above. `redundant` ties each point
144
+ to `d+1` anchors instead, which needs no sign bits and averages quantisation error down
145
+ rather than letting it land on one coordinate — larger files, better accuracy per bit.
146
+
147
+ ---
148
+
149
+ ## Honest limits
150
+
151
+ Worth knowing before you adopt this.
152
+
153
+ **If your matrix is exactly Euclidean, storing coordinates beats storing distances.** Run
154
+ MDS, keep the `n·d` coordinates, and you are about 1.8× smaller than `.gridity` at the same
155
+ accuracy (at n=300, d=3: 1,374 B against 2,429 B, both at ~3e-04). The reason is structural
156
+ rather than a missing optimisation: naming *which* distances you kept costs about as much as
157
+ the distances themselves, and that is already near the information-theoretic floor for
158
+ choosing a subset. `gridity.explain()` shows the split.
159
+
160
+ So `gridity` earns its place when:
161
+
162
+ - the matrix is **not** exactly embeddable in `d` dimensions — measured data never is — and
163
+ you want the measurements kept rather than projected away. At `bits=0` every retained
164
+ distance is reproduced *exactly*, because each point is placed to satisfy its own anchor
165
+ distances by construction. On a noisy 300-point matrix that is 3e-16 against 1.7e-02 for
166
+ MDS coordinates.
167
+ - you want to know which distances matter, not just a smaller file.
168
+
169
+ The flip side of the same fact: MDS fits all `n²` distances at once and averages noise down,
170
+ so on a noisy matrix it wins on *all-pairs* error even while losing on the retained ones.
171
+ Which of those you care about decides the tool.
172
+
173
+ **Other limits.** `d` must be 2 or 3. The encoder runs an MDS pass, so it is O(n²) in memory
174
+ and wants the full matrix up front; the decoder is O(n·pool). Reconstruction is up to a
175
+ rigid motion, so the recovered coordinates may be rotated, translated or reflected relative
176
+ to the originals — the distances are what is preserved.
177
+
178
+ ---
179
+
180
+ ## API
181
+
182
+ | Call | What it does |
183
+ |---|---|
184
+ | `compress(D, path, d=3, bits=12, mode="minimal", pool=64, target_error=None)` | matrix → file; returns the same dict as `inspect` |
185
+ | `decompress(path, return_coords=False)` | file → matrix (and coordinates, if asked) |
186
+ | `compress_to_bytes(D, ...)` | same as `compress`, returns `bytes` |
187
+ | `decompress_from_bytes(data, return_coords=False)` | same as `decompress`, takes `bytes` |
188
+ | `inspect(path_or_bytes)` | header only: `n`, `d`, `bits`, `n_edges`, `mode`, `ratio`, … |
189
+ | `explain(path_or_bytes)` | byte budget: header / seed / edge index / sign bits / distances |
190
+
191
+ Arguments: `d` is the dimension the points live in (2 or 3). `bits` is the quantiser width
192
+ per distance, `0` meaning raw float64. `mode` is `"minimal"` or `"redundant"`. `pool` is how
193
+ many nearby points the encoder considers as anchors — raise it for odd geometry, lower it
194
+ for speed. `target_error` overrides `bits`.
195
+
196
+ Bad input raises `ValueError` with a specific message — not square, asymmetric, negative,
197
+ NaN, non-zero diagonal, too few points, bad `d`. Every file carries a CRC-32, so a truncated
198
+ or corrupted file is refused rather than quietly decoded into the wrong shape.
199
+
200
+ ### The rigidity core
201
+
202
+ The compression is built on a rigidity engine that is also exposed directly:
203
+
204
+ | Call | What it does |
205
+ |---|---|
206
+ | `max_independent(n, d)` | `n·d − d(d+1)/2`, the number of distances that pin `n` points |
207
+ | `simulate(coords, d, tol=None)` | which edges are independent, by Gaussian elimination on the rigidity matrix |
208
+ | `pebble_game(coords, d)` | the same question combinatorially, O(n²), `d=2` only |
209
+ | `reconstruct(D, d, tol=None)` | classical MDS (see the caveat below) |
210
+ | `gram_from_dist(D)` | Gram matrix by double-centring |
211
+
212
+ Two notes on these. `simulate` and `pebble_game` agree exactly on points in general
213
+ position; on degenerate inputs they will not, and that is correct rather than a bug — the
214
+ pebble game decides *generic* rigidity from the graph alone and never looks at the
215
+ coordinates. And `reconstruct` is accurate only to about n=50; the container does not use
216
+ it, it runs its own MDS.
217
+
218
+ ---
219
+
220
+ ## Building from source
221
+
222
+ ```bash
223
+ pip install maturin
224
+ maturin develop --release
225
+ cargo test # 119 unit tests
226
+ python python/test_gridity_rust.py
227
+ ```
228
+
229
+ Prebuilt wheels cover CPython 3.11–3.13 on Linux, Windows and macOS. Other versions in range
230
+ build from the sdist and need a Rust toolchain.
231
+
@@ -0,0 +1,204 @@
1
+ # gridity
2
+
3
+ **Compress a distance matrix down to the handful of distances that actually pin the geometry.**
4
+
5
+ If you have `n` points, there are `n(n-1)/2` distances between them — but the points
6
+ themselves only have `n·d` coordinates. Most of that matrix is redundant. `gridity` works out
7
+ which distances carry the shape and which are implied by the others, then writes only the
8
+ first group to a file.
9
+
10
+ Rust core, Python API, no runtime dependencies beyond NumPy.
11
+
12
+ ```bash
13
+ pip install gridity
14
+ ```
15
+
16
+ ---
17
+
18
+ ## The short version
19
+
20
+ ```python
21
+ import numpy as np
22
+ import gridity
23
+
24
+ # 1000 points in 3D, and the distances between every pair of them
25
+ pts = np.random.rand(1000, 3) * 10
26
+ D = np.linalg.norm(pts[:, None] - pts[None], axis=-1)
27
+
28
+ gridity.compress(D, "cloud.gridity", d=3, bits=0) # 27,952 bytes
29
+ back = gridity.decompress("cloud.gridity") # the full 1000x1000 matrix
30
+
31
+ np.abs(back - D).max() # ~1e-13
32
+ ```
33
+
34
+ That matrix is 3,996,000 bytes as float64. `lzma` gets it to 3,654,072. `gridity` gets it to
35
+ 27,952 — and decompresses back to the same matrix.
36
+
37
+ ---
38
+
39
+ ## Why not just zip it?
40
+
41
+ Because distance data is nearly incompressible. The mantissa bits of a float64 distance are
42
+ effectively random, so a general-purpose compressor has almost nothing to find. Measured on
43
+ uniform 3D clouds, same matrices, all lossless:
44
+
45
+ | n | float64 triangle | `+ lzma -9` | `.gridity` | vs lzma |
46
+ |---|---|---|---|---|
47
+ | 100 | 39,600 B | 36,428 B | **2,721 B** | 13× smaller |
48
+ | 300 | 358,800 B | 328,996 B | **8,240 B** | 40× smaller |
49
+ | 1000 | 3,996,000 B | 3,654,072 B | **27,952 B** | 131× smaller |
50
+
51
+ The gap widens with `n`, because the matrix grows as O(n²) while the geometry only grows
52
+ as O(n). `gridity` stores the geometry.
53
+
54
+ A general-purpose compressor sees a bag of numbers. `gridity` knows they are distances
55
+ between points in `d` dimensions, and that is the entire trick.
56
+
57
+ ---
58
+
59
+ ## Where this is the right tool
60
+
61
+ The target case is **you have distances and never had coordinates**:
62
+
63
+ - wireless sensor localisation — time-of-flight or signal strength between nodes, no GPS
64
+ - molecular structure from NMR — inter-atomic distances, not positions
65
+ - any distance-geometry problem where the O(n²) matrix is the thing you must store or send
66
+
67
+ If you already have coordinates, store those instead; see *Honest limits* below.
68
+
69
+ ---
70
+
71
+ ## Spending fewer bits
72
+
73
+ Rigidity is a threshold. Below a certain number of distances the shape comes apart
74
+ completely, so there is no point asking for "slightly fewer distances". What you can trade
75
+ is **precision per distance**:
76
+
77
+ ```python
78
+ gridity.compress_to_bytes(D, d=3, bits=8) # 6,994 B, error 8.2e-03
79
+ gridity.compress_to_bytes(D, d=3, bits=12) # 8,491 B, error 6.0e-04
80
+ gridity.compress_to_bytes(D, d=3, bits=16) # 9,988 B, error 4.3e-05
81
+ gridity.compress_to_bytes(D, d=3, bits=0) # 27,952 B, exact
82
+ ```
83
+
84
+ Every two extra bits cuts the error roughly fourfold. If you would rather state the error
85
+ than guess the budget, say so and let the encoder find it:
86
+
87
+ ```python
88
+ blob = gridity.compress_to_bytes(D, d=3, target_error=1e-3)
89
+ ```
90
+
91
+ That bound is **measured, not predicted** — the encoder decodes its own output and checks
92
+ before settling on a budget.
93
+
94
+ ---
95
+
96
+ ## How it works
97
+
98
+ `max_rank = n·d − d(d+1)/2` distances are enough to make a point cloud *rigid*. That is the
99
+ classical result, and it is where the O(1/n) compression ratio comes from.
100
+
101
+ But rigid is not the same as *uniquely determined*. A minimal rigid edge set is satisfied by
102
+ many different point clouds — about twenty for a ten-point cloud — because each point can
103
+ reflect across the plane through its anchors. Storing only those distances would give you a
104
+ file that decodes to the wrong shape.
105
+
106
+ So the edges are picked as a **trilateration**: a small seed simplex first, then every
107
+ remaining point tied to exactly `d` points already placed. That is exactly `max_rank` edges,
108
+ and it makes the ambiguity explicit — each point has precisely two possible positions, so
109
+ **one bit per point** says which. The result:
110
+
111
+ ```
112
+ max_rank distances + n bits = unique reconstruction
113
+ ```
114
+
115
+ `n` bits against an O(n²) matrix costs nothing, so the ratio survives intact.
116
+
117
+ Two modes are available. `minimal` (the default) is the above. `redundant` ties each point
118
+ to `d+1` anchors instead, which needs no sign bits and averages quantisation error down
119
+ rather than letting it land on one coordinate — larger files, better accuracy per bit.
120
+
121
+ ---
122
+
123
+ ## Honest limits
124
+
125
+ Worth knowing before you adopt this.
126
+
127
+ **If your matrix is exactly Euclidean, storing coordinates beats storing distances.** Run
128
+ MDS, keep the `n·d` coordinates, and you are about 1.8× smaller than `.gridity` at the same
129
+ accuracy (at n=300, d=3: 1,374 B against 2,429 B, both at ~3e-04). The reason is structural
130
+ rather than a missing optimisation: naming *which* distances you kept costs about as much as
131
+ the distances themselves, and that is already near the information-theoretic floor for
132
+ choosing a subset. `gridity.explain()` shows the split.
133
+
134
+ So `gridity` earns its place when:
135
+
136
+ - the matrix is **not** exactly embeddable in `d` dimensions — measured data never is — and
137
+ you want the measurements kept rather than projected away. At `bits=0` every retained
138
+ distance is reproduced *exactly*, because each point is placed to satisfy its own anchor
139
+ distances by construction. On a noisy 300-point matrix that is 3e-16 against 1.7e-02 for
140
+ MDS coordinates.
141
+ - you want to know which distances matter, not just a smaller file.
142
+
143
+ The flip side of the same fact: MDS fits all `n²` distances at once and averages noise down,
144
+ so on a noisy matrix it wins on *all-pairs* error even while losing on the retained ones.
145
+ Which of those you care about decides the tool.
146
+
147
+ **Other limits.** `d` must be 2 or 3. The encoder runs an MDS pass, so it is O(n²) in memory
148
+ and wants the full matrix up front; the decoder is O(n·pool). Reconstruction is up to a
149
+ rigid motion, so the recovered coordinates may be rotated, translated or reflected relative
150
+ to the originals — the distances are what is preserved.
151
+
152
+ ---
153
+
154
+ ## API
155
+
156
+ | Call | What it does |
157
+ |---|---|
158
+ | `compress(D, path, d=3, bits=12, mode="minimal", pool=64, target_error=None)` | matrix → file; returns the same dict as `inspect` |
159
+ | `decompress(path, return_coords=False)` | file → matrix (and coordinates, if asked) |
160
+ | `compress_to_bytes(D, ...)` | same as `compress`, returns `bytes` |
161
+ | `decompress_from_bytes(data, return_coords=False)` | same as `decompress`, takes `bytes` |
162
+ | `inspect(path_or_bytes)` | header only: `n`, `d`, `bits`, `n_edges`, `mode`, `ratio`, … |
163
+ | `explain(path_or_bytes)` | byte budget: header / seed / edge index / sign bits / distances |
164
+
165
+ Arguments: `d` is the dimension the points live in (2 or 3). `bits` is the quantiser width
166
+ per distance, `0` meaning raw float64. `mode` is `"minimal"` or `"redundant"`. `pool` is how
167
+ many nearby points the encoder considers as anchors — raise it for odd geometry, lower it
168
+ for speed. `target_error` overrides `bits`.
169
+
170
+ Bad input raises `ValueError` with a specific message — not square, asymmetric, negative,
171
+ NaN, non-zero diagonal, too few points, bad `d`. Every file carries a CRC-32, so a truncated
172
+ or corrupted file is refused rather than quietly decoded into the wrong shape.
173
+
174
+ ### The rigidity core
175
+
176
+ The compression is built on a rigidity engine that is also exposed directly:
177
+
178
+ | Call | What it does |
179
+ |---|---|
180
+ | `max_independent(n, d)` | `n·d − d(d+1)/2`, the number of distances that pin `n` points |
181
+ | `simulate(coords, d, tol=None)` | which edges are independent, by Gaussian elimination on the rigidity matrix |
182
+ | `pebble_game(coords, d)` | the same question combinatorially, O(n²), `d=2` only |
183
+ | `reconstruct(D, d, tol=None)` | classical MDS (see the caveat below) |
184
+ | `gram_from_dist(D)` | Gram matrix by double-centring |
185
+
186
+ Two notes on these. `simulate` and `pebble_game` agree exactly on points in general
187
+ position; on degenerate inputs they will not, and that is correct rather than a bug — the
188
+ pebble game decides *generic* rigidity from the graph alone and never looks at the
189
+ coordinates. And `reconstruct` is accurate only to about n=50; the container does not use
190
+ it, it runs its own MDS.
191
+
192
+ ---
193
+
194
+ ## Building from source
195
+
196
+ ```bash
197
+ pip install maturin
198
+ maturin develop --release
199
+ cargo test # 119 unit tests
200
+ python python/test_gridity_rust.py
201
+ ```
202
+
203
+ Prebuilt wheels cover CPython 3.11–3.13 on Linux, Windows and macOS. Other versions in range
204
+ build from the sdist and need a Rust toolchain.
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.5,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "gridity"
7
+ version = "0.3.0"
8
+ description = "Compress a distance matrix down to the handful of distances that actually pin the geometry, using rigidity theory."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8,<3.14"
11
+ authors = [{ name = "Sezer Aptourachman" }]
12
+ keywords = [
13
+ "compression",
14
+ "distance-matrix",
15
+ "distance-geometry",
16
+ "rigidity",
17
+ "rigidity-theory",
18
+ "point-cloud",
19
+ "multidimensional-scaling",
20
+ "mds",
21
+ "trilateration",
22
+ "sensor-localization",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Science/Research",
27
+ "Intended Audience :: Developers",
28
+ "Programming Language :: Rust",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: Implementation :: CPython",
34
+ "Topic :: Scientific/Engineering :: Mathematics",
35
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
36
+ "Topic :: System :: Archiving :: Compression",
37
+ "Operating System :: OS Independent",
38
+ ]
39
+ dependencies = ["numpy>=1.21"]
40
+
41
+ # The upper Python bound is real: the PyO3 version this builds against supports
42
+ # CPython up to 3.13. Without the bound, installing on 3.14 fails from source
43
+ # with a confusing linker-level message instead of a clean "requires Python".
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/JuL-sezaR/grid"
47
+ Repository = "https://github.com/JuL-sezaR/grid"
48
+ Issues = "https://github.com/JuL-sezaR/grid/issues"
49
+
50
+ [tool.maturin]
51
+ # Enabled only for the wheel. It is deliberately NOT a default feature in
52
+ # Cargo.toml, because with it on the crate leaves Python's symbols to the
53
+ # interpreter and `cargo test` cannot link.
54
+ features = ["pyo3/extension-module"]