Skip to content

SpatialGraph.edges does not define endpoint order for undirected graphs #55

Description

@cmalinmayor

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions