Skip to content

Commit a38c44b

Browse files
Fix Numba layout handling in GrapPA features
1 parent c91d8d5 commit a38c44b

7 files changed

Lines changed: 96 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [0.14.1] - 2026-06-27
4+
5+
### Fixed
6+
- **GrapPA inference stability**: Normalize indexed cluster coordinate views before Numba-compiled distance, endpoint, node-feature, and edge-feature helpers so full-chain GrapPA inference no longer fails intermittently on arbitrary-layout arrays in batch jobs.
7+
- **Regression coverage**: Add focused tests for arbitrary-layout Numba callers in distance helpers and GrapPA cluster/node/edge feature extraction.
8+
39
## [0.14.0] - 2026-06-26
410

511
### Added

src/spine/math/distance.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def pdist(
194194
"""
195195
# Check on the input
196196
assert x.shape[1] == 3, "Only supports 3D points for now."
197+
x = np.ascontiguousarray(x)
197198

198199
# Dispatch (faster this way than dispatching at each distance call)
199200
if metric_id == MINKOWSKI:
@@ -293,6 +294,8 @@ def cdist(
293294
"""
294295
# Check on the input
295296
assert x1.shape[1] == 3 and x2.shape[1] == 3, "Only supports 3D points for now."
297+
x1 = np.ascontiguousarray(x1)
298+
x2 = np.ascontiguousarray(x2)
296299

297300
# Dispatch (faster this way than dispatching at each distance call)
298301
if metric_id == MINKOWSKI:
@@ -395,6 +398,8 @@ def farthest_pair(
395398
float
396399
Distance between the two points
397400
"""
401+
x = np.ascontiguousarray(x)
402+
398403
# To save time, if Euclidean distance is used, use its square
399404
is_euclidean = False
400405
if metric_id == EUCLIDEAN:
@@ -461,6 +466,9 @@ def closest_pair_legacy(
461466
set switch after each closest-point update. New code should use
462467
:func:`closest_pair`.
463468
"""
469+
x1 = np.ascontiguousarray(x1)
470+
x2 = np.ascontiguousarray(x2)
471+
464472
# To save time, if Euclidean distance is used, use its square
465473
is_euclidean = False
466474
if metric_id == EUCLIDEAN:
@@ -566,6 +574,9 @@ def closest_pair(
566574
float
567575
Distance between the two points
568576
"""
577+
x1 = np.ascontiguousarray(x1)
578+
x2 = np.ascontiguousarray(x2)
579+
569580
# To save time, if Euclidean distance is used, use its square
570581
is_euclidean = False
571582
if metric_id == EUCLIDEAN:

src/spine/utils/gnn/cluster.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -846,7 +846,7 @@ def _get_cluster_features_base(
846846
for k in nb.prange(len(clusts)):
847847
# Get list of voxels in the cluster
848848
clust = clusts[ids[k]]
849-
x = data[clust][:, COORD_COLS_LO:COORD_COLS_HI]
849+
x = np.ascontiguousarray(data[clust][:, COORD_COLS_LO:COORD_COLS_HI])
850850

851851
# Get cluster center
852852
center = sm.mean(x, 0)

src/spine/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Module which stores the current software version."""
22

3-
__version__ = "0.14.0"
3+
__version__ = "0.14.1"

test/test_math/test_distance_regression.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Regression tests for distance helpers."""
22

3+
import numba as nb
34
import numpy as np
45
import pytest
56

@@ -17,6 +18,13 @@
1718
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 2.0, 0.0]],
1819
dtype=np.float32,
1920
)
21+
SQEUCLIDEAN_METRIC = METRICS["sqeuclidean"]
22+
23+
24+
@nb.njit(nb.float32[:, :](nb.float32[:, :]), cache=True)
25+
def _pdist_sqeuclidean_arbitrary_layout(points):
26+
"""Compile pdist from an arbitrary-layout caller, as PPN does."""
27+
return pdist(points, SQEUCLIDEAN_METRIC)
2028

2129

2230
def test_get_metric_id_dispatches_minkowski_aliases_and_errors():
@@ -58,6 +66,17 @@ def test_pdist_dispatches_all_metrics_and_errors():
5866
pdist(POINTS, np.int64(99))
5967

6068

69+
def test_pdist_accepts_arbitrary_layout_from_jitted_callers():
70+
"""Nested Numba callers may pass arbitrary-layout row views."""
71+
distances = _pdist_sqeuclidean_arbitrary_layout(POINTS)
72+
73+
np.testing.assert_allclose(
74+
distances,
75+
[[0.0, 1.0, 4.0], [1.0, 0.0, 5.0], [4.0, 5.0, 0.0]],
76+
atol=1e-5,
77+
)
78+
79+
6180
def test_cdist_dispatches_all_metrics_and_errors():
6281
"""Cross-distance matrix should support every metric enumerator."""
6382
x1 = POINTS[:2]

test/test_utils/test_gnn_cluster.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import numpy as np
44

5-
from spine.utils.gnn.cluster import cluster_dedx
5+
from spine.utils.gnn.cluster import cluster_dedx, get_cluster_features_base
66

77

88
def test_cluster_dedx_accepts_mixed_coordinate_dtypes():
@@ -17,3 +17,28 @@ def test_cluster_dedx_accepts_mixed_coordinate_dtypes():
1717
dedx = cluster_dedx(voxels, values, start, 5.0, True)
1818

1919
assert dedx == np.float32(3.0)
20+
21+
22+
def test_cluster_features_base_accepts_indexed_float32_coordinates():
23+
"""Indexed cluster coordinate views should compile through Numba helpers."""
24+
data = np.array(
25+
[
26+
[0.0, 0.0, 0.0, 1.0],
27+
[1.0, 0.0, 0.0, 2.0],
28+
[0.0, 1.0, 0.0, 3.0],
29+
[2.0, 0.0, 0.0, 4.0],
30+
[2.0, 1.0, 0.0, 5.0],
31+
[3.0, 0.0, 0.0, 6.0],
32+
],
33+
dtype=np.float32,
34+
)
35+
clusts = [
36+
np.array([0, 2, 1], dtype=np.int64),
37+
np.array([3, 5, 4], dtype=np.int64),
38+
]
39+
40+
feats = get_cluster_features_base(data, clusts)
41+
42+
assert feats.shape == (2, 16)
43+
np.testing.assert_allclose(feats[0, :3], [1.0 / 3.0, 0.0, 2.0])
44+
np.testing.assert_allclose(feats[1, :3], [1.0 / 3.0, 0.0, 5.0])

test/test_utils/test_gnn_network.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import numpy as np
77
import pytest
88

9-
from spine.utils.gnn.network import inter_cluster_distance
9+
from spine.utils.gnn.network import get_cluster_edge_features, inter_cluster_distance
1010

1111
GRAPH_BASE_PATH = (
1212
Path(__file__).resolve().parents[2]
@@ -93,3 +93,34 @@ def test_inter_cluster_distance_can_use_legacy_iterative_closest_pair():
9393
assert fixed_index[0, 1] == 11
9494
assert np.isclose(legacy_dist[0, 1], 0.7099366)
9595
assert legacy_index[0, 1] == 10
96+
97+
98+
def test_cluster_edge_features_accept_indexed_float32_coordinates_legacy():
99+
"""Indexed cluster coordinate views should compile in legacy CPA mode."""
100+
data = np.array(
101+
[
102+
[0.0, 0.0, 0.0, 1.0],
103+
[0.0, 1.0, 0.0, 2.0],
104+
[0.0, 0.0, 1.0, 3.0],
105+
[0.0, 4.0, 0.0, 4.0],
106+
[0.0, 4.0, 1.0, 5.0],
107+
[0.0, 5.0, 0.0, 6.0],
108+
],
109+
dtype=np.float32,
110+
)
111+
clusts = [
112+
np.array([0, 2, 1], dtype=np.int64),
113+
np.array([3, 5, 4], dtype=np.int64),
114+
]
115+
edge_index = np.array([[0, 1]], dtype=np.int64)
116+
117+
feats = get_cluster_edge_features(
118+
data,
119+
clusts,
120+
edge_index,
121+
iterative=False,
122+
use_legacy_distance=True,
123+
)
124+
125+
assert feats.shape == (1, 19)
126+
assert feats.dtype == np.float32

0 commit comments

Comments
 (0)