Skip to content

Commit b027f4a

Browse files
committed
Geometry for 3D cubic structures
1 parent de9f007 commit b027f4a

6 files changed

Lines changed: 242 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- `show_tensor_network` package export: `TYPE_CHECKING` import preserves lazy runtime import while exposing the real signature, annotations, and docstring to IDEs.
1717

18+
### Changed
19+
- Refactor of the `_core` module into tiny pieces.
20+
1821
## [1.4.0] — 2026-03-28
1922

2023
### Added

CONTRIBUTING.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,72 @@ With the project venv (Windows):
5151

5252
Add tests for new features or bug fixes. All tests must pass before opening a PR.
5353

54+
### Optional: manual example smoke checks
55+
56+
Automated tests do not open interactive Matplotlib windows. After **`pytest`** passes, you can sanity-check **layout and drawing** by running the examples below from the **repository root** (with **`pip install -e ".[dev]"`** or the matching optional extras). Run **one command at a time** (each line is a separate invocation).
57+
58+
**2D (default labels):**
59+
60+
```bash
61+
python examples/tensorkrowch_demo.py mps 2d
62+
python examples/tensorkrowch_demo.py disconnected 2d
63+
python examples/tensornetwork_demo.py weird 2d
64+
python examples/mera_tree_demo.py 2d
65+
python examples/cubic_peps_demo.py 2d
66+
python examples/quimb_demo.py hyper 2d
67+
python examples/tenpy_demo.py imps 2d
68+
python examples/tenpy_demo.py impo 2d
69+
python examples/einsum_demo.py peps 2d
70+
python examples/tn_tsp.py -n 4 --view 2d
71+
```
72+
73+
**2D with `--hover-labels`** (interactive window only):
74+
75+
```bash
76+
python examples/tensorkrowch_demo.py mps 2d --hover-labels
77+
python examples/tensorkrowch_demo.py disconnected 2d --hover-labels
78+
python examples/tensornetwork_demo.py weird 2d --hover-labels
79+
python examples/mera_tree_demo.py 2d --hover-labels
80+
python examples/cubic_peps_demo.py 2d --hover-labels
81+
python examples/quimb_demo.py hyper 2d --hover-labels
82+
python examples/tenpy_demo.py imps 2d --hover-labels
83+
python examples/tenpy_demo.py impo 2d --hover-labels
84+
python examples/einsum_demo.py peps 2d --hover-labels
85+
python examples/tn_tsp.py -n 4 --view 2d --hover-labels
86+
```
87+
88+
**3D (default labels):**
89+
90+
```bash
91+
python examples/tensorkrowch_demo.py mps 3d
92+
python examples/tensorkrowch_demo.py weird 3d
93+
python examples/tensorkrowch_demo.py disconnected 3d
94+
python examples/mera_tree_demo.py 3d --mera-log2 5 --tree-depth 4
95+
python examples/cubic_peps_demo.py 3d --lx 3 --ly 3 --lz 4
96+
python examples/quimb_demo.py hyper 3d
97+
python examples/tenpy_demo.py imps 3d
98+
python examples/tenpy_demo.py impo 3d
99+
python examples/einsum_demo.py peps 3d
100+
python examples/tn_tsp.py -n 5 --view 3d
101+
```
102+
103+
**3D with `--hover-labels`** (interactive window only):
104+
105+
```bash
106+
python examples/tensorkrowch_demo.py mps 3d --hover-labels
107+
python examples/tensorkrowch_demo.py weird 3d --hover-labels
108+
python examples/tensorkrowch_demo.py disconnected 3d --hover-labels
109+
python examples/mera_tree_demo.py 3d --mera-log2 5 --tree-depth 4 --hover-labels
110+
python examples/cubic_peps_demo.py 3d --lx 3 --ly 3 --lz 4 --hover-labels
111+
python examples/quimb_demo.py hyper 3d --hover-labels
112+
python examples/tenpy_demo.py imps 3d --hover-labels
113+
python examples/tenpy_demo.py impo 3d --hover-labels
114+
python examples/einsum_demo.py peps 3d --hover-labels
115+
python examples/tn_tsp.py -n 5 --view 3d --hover-labels
116+
```
117+
118+
See **`examples/README.md`** for per-script options and dependencies.
119+
54120
## Lint and Type Checks
55121

56122
**Ruff** (lint and format):

examples/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ other scripts.
149149

150150
## Run-all cheat sheet
151151

152+
For a **short pre-PR smoke checklist** (2D / 3D, default labels vs `--hover-labels`), see
153+
[CONTRIBUTING.md](../CONTRIBUTING.md#optional-manual-example-smoke-checks).
154+
152155
```bash
153156
python examples/tensorkrowch_demo.py mps 2d
154157
python examples/tensorkrowch_demo.py mps 3d

src/tensor_network_viz/_core/layout/body.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,12 @@ def _lift_component_layout_3d(
168168
node_id: np.array([coords[0], coords[1], 0.0], dtype=float)
169169
for node_id, coords in positions_2d.items()
170170
}
171+
if component.structure_kind == "grid3d" and component.grid3d_mapping is not None:
172+
for node_id, (i, j, k) in component.grid3d_mapping.items():
173+
positions[node_id] = np.array([float(i), float(j), float(k)], dtype=float)
171174
_place_trimmed_leaf_nodes_3d(component, positions)
172-
_promote_3d_layers(graph, component, positions)
175+
if component.structure_kind != "grid3d" or component.grid3d_mapping is None:
176+
_promote_3d_layers(graph, component, positions)
173177
return positions
174178

175179

src/tensor_network_viz/_core/layout_structure.py

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from .contractions import _iter_contractions
1313
from .graph import _GraphData
1414

15-
StructureKind = Literal["chain", "grid", "tree", "planar", "generic"]
15+
StructureKind = Literal["chain", "grid", "grid3d", "tree", "planar", "generic"]
16+
17+
# Skew in 2D projection (i,j,k) → layout xy so sites with same (i,j) and different k stay separated.
18+
_GRID3D_PROJECTION_K: float = 0.4
1619

1720

1821
@dataclass(frozen=True)
@@ -29,6 +32,7 @@ class _LayoutComponent:
2932
structure_kind: StructureKind
3033
chain_order: tuple[int, ...]
3134
grid_mapping: dict[int, tuple[int, int]] | None
35+
grid3d_mapping: dict[int, tuple[int, int, int]] | None
3236
tree_root: int | None
3337

3438

@@ -61,7 +65,9 @@ def _analyze_layout_components(graph: _GraphData) -> tuple[_LayoutComponent, ...
6165
),
6266
proxy_visible_graph=proxy_visible_graph,
6367
)
64-
structure_kind, chain_order, grid_mapping, tree_root = _classify_anchor_graph(anchor_graph)
68+
structure_kind, chain_order, grid_mapping, grid3d_mapping, tree_root = (
69+
_classify_anchor_graph(anchor_graph)
70+
)
6571
components.append(
6672
_LayoutComponent(
6773
node_ids=tuple(sorted(component_node_ids)),
@@ -76,6 +82,7 @@ def _analyze_layout_components(graph: _GraphData) -> tuple[_LayoutComponent, ...
7682
structure_kind=structure_kind,
7783
chain_order=chain_order,
7884
grid_mapping=grid_mapping,
85+
grid3d_mapping=grid3d_mapping,
7986
tree_root=tree_root,
8087
)
8188
)
@@ -199,23 +206,33 @@ def _sorted_connected_components(nx_graph: nx.Graph) -> list[tuple[int, ...]]:
199206

200207
def _classify_anchor_graph(
201208
anchor_graph: nx.Graph,
202-
) -> tuple[StructureKind, tuple[int, ...], dict[int, tuple[int, int]] | None, int | None]:
209+
) -> tuple[
210+
StructureKind,
211+
tuple[int, ...],
212+
dict[int, tuple[int, int]] | None,
213+
dict[int, tuple[int, int, int]] | None,
214+
int | None,
215+
]:
203216
chain_order = _detect_chain(anchor_graph)
204217
if chain_order is not None:
205-
return "chain", chain_order, None, None
218+
return "chain", chain_order, None, None, None
206219

207220
grid_mapping = _detect_grid(anchor_graph)
208221
if grid_mapping is not None:
209-
return "grid", (), grid_mapping, None
222+
return "grid", (), grid_mapping, None, None
223+
224+
grid3d_mapping = _detect_grid_3d(anchor_graph)
225+
if grid3d_mapping is not None:
226+
return "grid3d", (), None, grid3d_mapping, None
210227

211228
if anchor_graph.number_of_nodes() > 0 and nx.is_tree(anchor_graph):
212-
return "tree", (), None, _tree_root(anchor_graph)
229+
return "tree", (), None, None, _tree_root(anchor_graph)
213230

214231
is_planar, _ = nx.check_planarity(anchor_graph)
215232
if anchor_graph.number_of_nodes() > 0 and is_planar:
216-
return "planar", (), None, None
233+
return "planar", (), None, None, None
217234

218-
return "generic", (), None, None
235+
return "generic", (), None, None, None
219236

220237

221238
def _specialized_anchor_positions(component: _LayoutComponent) -> dict[int, np.ndarray]:
@@ -225,6 +242,8 @@ def _specialized_anchor_positions(component: _LayoutComponent) -> dict[int, np.n
225242
return _layout_chain(component.chain_order)
226243
if component.structure_kind == "grid" and component.grid_mapping is not None:
227244
return _layout_grid(component.grid_mapping)
245+
if component.structure_kind == "grid3d" and component.grid3d_mapping is not None:
246+
return _layout_grid3d_projection_2d(component.grid3d_mapping)
228247
if component.structure_kind == "tree" and component.tree_root is not None:
229248
return _layout_tree(component.anchor_graph, component.tree_root)
230249
if component.structure_kind == "planar":
@@ -308,6 +327,59 @@ def _layout_grid(grid_mapping: dict[int, tuple[int, int]]) -> dict[int, np.ndarr
308327
}
309328

310329

330+
def _expected_edges_3d_grid(lx: int, ly: int, lz: int) -> int:
331+
return (lx - 1) * ly * lz + lx * (ly - 1) * lz + lx * ly * (lz - 1)
332+
333+
334+
def _detect_grid_3d(nx_graph: nx.Graph) -> dict[int, tuple[int, int, int]] | None:
335+
"""If *nx_graph* is a 3D rectangular grid (all factors >= 1), return node → (i,j,k)."""
336+
n = nx_graph.number_of_nodes()
337+
if n <= 1 or not nx.is_connected(nx_graph):
338+
return None
339+
m = nx_graph.number_of_edges()
340+
best_mn = -1
341+
best_shape: tuple[int, int, int] = (-1, -1, -1)
342+
best_mapping: dict[int, tuple[int, int, int]] | None = None
343+
344+
for lx in range(1, n + 1):
345+
if n % lx != 0:
346+
continue
347+
rest = n // lx
348+
for ly in range(1, rest + 1):
349+
if rest % ly != 0:
350+
continue
351+
lz = rest // ly
352+
if _expected_edges_3d_grid(lx, ly, lz) != m:
353+
continue
354+
template = nx.grid_graph((lx, ly, lz))
355+
mapping = nx.vf2pp_isomorphism(nx_graph, template)
356+
if mapping is None:
357+
continue
358+
mn = min(lx, ly, lz)
359+
shape = (lx, ly, lz)
360+
if mn > best_mn or (mn == best_mn and shape > best_shape):
361+
best_mn = mn
362+
best_shape = shape
363+
best_mapping = {
364+
int(node_id): (int(a), int(b), int(c)) for node_id, (a, b, c) in mapping.items()
365+
}
366+
367+
return best_mapping
368+
369+
370+
def _layout_grid3d_projection_2d(
371+
grid3d_mapping: dict[int, tuple[int, int, int]],
372+
) -> dict[int, np.ndarray]:
373+
alpha = _GRID3D_PROJECTION_K
374+
return {
375+
node_id: np.array(
376+
[float(i) + alpha * float(k), float(j) + alpha * float(k)],
377+
dtype=float,
378+
)
379+
for node_id, (i, j, k) in grid3d_mapping.items()
380+
}
381+
382+
311383
def _layout_planar(nx_graph: nx.Graph) -> dict[int, np.ndarray]:
312384
positions = nx.planar_layout(nx_graph)
313385
return {node_id: np.array(positions[node_id], dtype=float) for node_id in nx_graph.nodes}

tests/test_layout_core.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,65 @@ def test_resolve_draw_scale_heuristic_when_no_contraction_edges() -> None:
168168
assert lo <= s <= hi
169169

170170

171+
def _build_3d_grid_graph(lx: int, ly: int, lz: int) -> _GraphData:
172+
"""3D nearest-neighbor cubic lattice; same bond topology as cubic PEPS (no physical legs)."""
173+
174+
def node_index(i: int, j: int, k: int) -> int:
175+
return i * ly * lz + j * lz + k
176+
177+
nodes = {}
178+
edge_specs: list[tuple[int, int, str, str]] = []
179+
180+
for i in range(lx):
181+
for j in range(ly):
182+
for k in range(lz):
183+
axes_names: list[str] = []
184+
if i > 0:
185+
axes_names.append("xm")
186+
if i < lx - 1:
187+
axes_names.append("xp")
188+
if j > 0:
189+
axes_names.append("ym")
190+
if j < ly - 1:
191+
axes_names.append("yp")
192+
if k > 0:
193+
axes_names.append("zm")
194+
if k < lz - 1:
195+
axes_names.append("zp")
196+
nid = node_index(i, j, k)
197+
nodes[nid] = _make_node(f"P{i}_{j}_{k}", tuple(axes_names))
198+
199+
axis_lookup = {
200+
node_id: {name: index for index, name in enumerate(node.axes_names)}
201+
for node_id, node in nodes.items()
202+
}
203+
204+
for i in range(lx):
205+
for j in range(ly):
206+
for k in range(lz):
207+
nid = node_index(i, j, k)
208+
if i < lx - 1:
209+
rid = node_index(i + 1, j, k)
210+
edge_specs.append((nid, rid, "xp", "xm"))
211+
if j < ly - 1:
212+
rid = node_index(i, j + 1, k)
213+
edge_specs.append((nid, rid, "yp", "ym"))
214+
if k < lz - 1:
215+
rid = node_index(i, j, k + 1)
216+
edge_specs.append((nid, rid, "zp", "zm"))
217+
218+
edges = [
219+
_make_contraction_edge(
220+
_EdgeEndpoint(left_id, axis_lookup[left_id][left_name], left_name),
221+
_EdgeEndpoint(right_id, axis_lookup[right_id][right_name], right_name),
222+
name=f"{left_id}_{right_id}",
223+
label=None,
224+
)
225+
for left_id, right_id, left_name, right_name in edge_specs
226+
]
227+
return _GraphData(nodes=nodes, edges=tuple(edges))
228+
229+
171230
def _build_grid_graph(rows: int, cols: int) -> _GraphData:
172231
nodes = {}
173232
edge_specs: list[tuple[int, int, str, str]] = []
@@ -554,6 +613,32 @@ def test_compute_layout_grid_2d_places_nodes_on_regular_lattice() -> None:
554613
)
555614

556615

616+
def test_compute_layout_3d_grid_spans_three_axes() -> None:
617+
graph = _build_3d_grid_graph(2, 2, 2)
618+
619+
positions = _compute_layout(graph, dimensions=3, seed=0)
620+
coords = np.stack([positions[node_id] for node_id in sorted(graph.nodes)])
621+
622+
assert float(coords[:, 0].std()) > 1e-6
623+
assert float(coords[:, 1].std()) > 1e-6
624+
assert float(coords[:, 2].std()) > 1e-6
625+
626+
627+
def test_compute_layout_3d_grid_uniform_nearest_neighbor_spacing() -> None:
628+
graph = _build_3d_grid_graph(2, 3, 2)
629+
630+
positions = _compute_layout(graph, dimensions=3, seed=0)
631+
lengths: list[float] = []
632+
for edge in graph.edges:
633+
left_ep, right_ep = edge.endpoints
634+
delta = positions[left_ep.node_id] - positions[right_ep.node_id]
635+
lengths.append(float(np.linalg.norm(delta)))
636+
637+
assert lengths
638+
mean_len = float(np.mean(lengths))
639+
assert all(math.isclose(L, mean_len, rel_tol=1e-5, abs_tol=1e-8) for L in lengths)
640+
641+
557642
def test_compute_layout_planar_cycle_3d_stays_on_single_plane() -> None:
558643
graph = _build_planar_cycle_graph()
559644

0 commit comments

Comments
 (0)