Summary
For undirected graphs, the SpatialGraph.edges property (the rtree-backed
query_edges_in_roi) returns edges whose endpoint order is not canonicalized
and is not stable across platforms. On Linux/macOS the endpoints come back in
insertion order; on Windows (MSVC) every edge comes back with its two
endpoints swapped.
This is inconsistent with spatial_graph's other edge APIs, which do document a
canonical ordering:
Graph.edges() generator — documented node1 < node2
edges_by_nodes() — documented node1 <= node2
The .edges property makes no such guarantee, so downstream consumers that
iterate graph.edges (e.g. geff)
get platform-dependent results.
Reproduction
import numpy as np
import spatial_graph as sg # v0.0.6
g = sg.SpatialGraph(
ndims=3,
node_dtype="uint16",
node_attr_dtypes={"position": "double[3]"},
edge_attr_dtypes={},
position_attr="position",
directed=False,
)
nodes = np.array([0, 1, 2, 3, 4], dtype="uint16")
pos = np.array([[i, i, i] for i in range(5)], dtype="double")
g.add_nodes(nodes, position=pos)
g.add_edges(np.array([[0, 1], [1, 2], [2, 3], [3, 4]], dtype="uint16"))
print(np.asarray(g.edges))
- Linux / macOS:
[[0 1] [1 2] [2 3] [3 4]]
- Windows:
[[1 0] [2 1] [3 2] [4 3]] ← every pair reversed
Proposed fix
Canonicalize undirected edges to u <= v in the .edges property
(deterministic everywhere, and consistent with the documented .edges() /
edges_by_nodes() contracts). Leave directed graphs untouched:
@property
def edges(self):
edges = self.query_edges_in_roi(self.roi)
if not self.directed and len(edges) > 0:
swap = edges[:, 0] > edges[:, 1]
edges[swap] = edges[swap][:, ::-1]
return edges
Environment
- spatial_graph v0.0.6 (==
#50, includes the C++ keyword-mangling fix)
- Fails: windows-latest, Python 3.10–3.13
- Passes: ubuntu-latest, macos-latest
Root-cause analysis
Claude traced the full path and found no code that swaps an edge's endpoints —
(u, v) is preserved by construction on every platform:
add_edges → LineRTree.insert_bb_items (convert_pyx_to_c_item: item.u=[0], item.v=[1])
→ rtree_insert / node splits (whole-item copies & node_swap only; .u/.v never touched individually)
→ rtree_search → search_iterator → copy_c_to_pyx_item (writes [0]=u, [1]=v)
→ numpy (n, 2)
Because the readable logic can't produce a within-tuple swap, the Windows
behavior is most likely MSVC-specific UB / struct-layout handling of the
LineRTree item struct:
typedef struct item_t {
item_base_t u;
item_base_t v;
bool corner_mask[DIMS];
} item_t;
(bool + padding across the tidwall single-header rtree.c boundary is a
plausible culprit.) Regardless of the exact MSVC mechanism, the deeper issue is
that the .edges property never guaranteed an ordering, so it's free to
differ per platform.
It may be worth investigating the underlying MSVC swap in
LineRTree (a proper reproducer would compare insert_bb_items output vs
search output directly), since it hints at UB that could bite other item
types.
Summary
For undirected graphs, the
SpatialGraph.edgesproperty (the rtree-backedquery_edges_in_roi) returns edges whose endpoint order is not canonicalizedand is not stable across platforms. On Linux/macOS the endpoints come back in
insertion order; on Windows (MSVC) every edge comes back with its two
endpoints swapped.
This is inconsistent with spatial_graph's other edge APIs, which do document a
canonical ordering:
Graph.edges()generator — documentednode1 < node2edges_by_nodes()— documentednode1 <= node2The
.edgesproperty makes no such guarantee, so downstream consumers thatiterate
graph.edges(e.g. geff)get platform-dependent results.
Reproduction
[[0 1] [1 2] [2 3] [3 4]][[1 0] [2 1] [3 2] [4 3]]← every pair reversedProposed fix
Canonicalize undirected edges to
u <= vin the.edgesproperty(deterministic everywhere, and consistent with the documented
.edges()/edges_by_nodes()contracts). Leave directed graphs untouched:Environment
#50, includes the C++ keyword-mangling fix)Root-cause analysis
Claude traced the full path and found no code that swaps an edge's endpoints —
(u, v)is preserved by construction on every platform:Because the readable logic can't produce a within-tuple swap, the Windows
behavior is most likely MSVC-specific UB / struct-layout handling of the
LineRTreeitem struct:(
bool+ padding across the tidwall single-headerrtree.cboundary is aplausible culprit.) Regardless of the exact MSVC mechanism, the deeper issue is
that the
.edgesproperty never guaranteed an ordering, so it's free todiffer per platform.
It may be worth investigating the underlying MSVC swap in
LineRTree(a proper reproducer would compareinsert_bb_itemsoutput vssearchoutput directly), since it hints at UB that could bite other itemtypes.