Skip to content

Commit 8e2448d

Browse files
committed
feat: add lazy contraction scheme controls
1 parent 4289812 commit 8e2448d

10 files changed

Lines changed: 879 additions & 113 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Everything below maps to real parameters—there are no hidden mode switches.
104104
| **Display mode** | `show=True` / `False` | If `True`: Jupyter **kernel** uses `IPython.display.display(fig)`; otherwise `plt.show()`. If `False`: neither runs—use for `savefig` / batch. |
105105
| **Label policy** | `PlotConfig` + overrides | Defaults: `show_tensor_labels`, `show_index_labels`. Per-call: `show_tensor_network(..., show_tensor_labels=..., show_index_labels=...)`. |
106106
| **Hover labels** | `PlotConfig(hover_labels=True)` | Tensor names and bond labels appear on pointer hover (2D axes hit-test; 3D screen-space distance). Needs an **interactive** Matplotlib window. |
107-
| **Contraction scheme** | `PlotConfig(show_contraction_scheme=True)` | **Einsum:** cumulative per-step highlights from the trace. **Other engines:** set **`contraction_scheme_by_name`**. **2D:** rounded boxes (AABB + pad); colored borders (no fill by default). **3D:** wireframe box. See **`docs/guide.md`**. |
107+
| **Contraction scheme** | `PlotConfig(show_contraction_scheme=True)` | **Einsum:** cumulative per-step highlights from the trace. **Other engines:** set **`contraction_scheme_by_name`**. Compatible figures now add Matplotlib toggles for **Scheme**, **Playback**, and **Cost hover**; if you start with those flags off, the scheme bundle is computed lazily on first use. **2D:** rounded boxes (AABB + pad); colored borders (no fill by default). **3D:** wireframe box. See **`docs/guide.md`**. |
108108
| **Einsum workflow** | `engine="einsum"` | **Auto:** `EinsumTrace` + `einsum` (binary `pair_tensor`, unary/ternary+ `einsum_trace_step`; implicit `->`, `out=`). **Manual:** `pair_tensor` / `einsum_trace_step` (ellipsis needs `metadata` shapes). See **`examples/einsum_general.py`**. |
109109

110110
## Minimal examples

docs/guide.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,10 @@ with **`PlotConfig(show_contraction_scheme=True)`**; **2D** rounded **`FancyBbox
360360
axis-aligned bounding box of the tensors in that step (not per-tensor tight hulls); **later** steps
361361
drawn **under** earlier ones; **3D** wireframes (no fill). Other engines: **`contraction_scheme_by_name`**
362362
(each step should list the tensors you want in that hull; end with the full network if you want one
363-
global region).
363+
global region). When a figure has usable contraction steps, it also gets Matplotlib toggles for
364+
**`Scheme`**, **`Playback`**, and **`Cost hover`**. If those flags start disabled in
365+
**`PlotConfig`**, the scheme geometry, playback viewer, and cost tooltips are built lazily on the
366+
first toggle that needs them, then reused for the rest of the figure lifetime.
364367

365368
<a id="toc-plotconfig"></a>
366369

@@ -399,6 +402,8 @@ Frozen dataclass in [`src/tensor_network_viz/config.py`](../src/tensor_network_v
399402
| `contraction_scheme_linewidth` | `None` |**`DEFAULT_CONTRACTION_SCHEME_LINEWIDTH`** (scaled like other strokes). |
400403
| `contraction_scheme_colors` | `None` | Cycle of colors; built-in categorical palette if unset. |
401404
| `contraction_scheme_by_name` | `None` | Override schedule: per step, tuple of **`node.name`** strings for non-virtual tensors. |
405+
| `contraction_playback` | `False` | Start with the step slider and **Play/Pause/Reset** visible; still requires **`show_contraction_scheme=True`** in code. |
406+
| `contraction_scheme_cost_hover` | `False` | Show the naive dense contraction-cost tooltip when hovering a visible scheme hull. |
402407

403408
### Recipe: publication palette
404409

@@ -464,6 +469,11 @@ fig, ax = show_tensor_network(
464469
)
465470
```
466471

472+
Compatible figures show the three figure-level toggles even if you leave these flags off in
473+
**`PlotConfig`**. Turning on **`Playback`** or **`Cost hover`** from the UI auto-enables
474+
**`Scheme`**. Turning **`Scheme`** off hides the hulls and playback widgets, but remembered child
475+
states are restored instantly when you turn it back on.
476+
467477
### Recipe: custom positions with validation
468478

469479
```python

src/tensor_network_viz/_core/draw/graph_pipeline.py

Lines changed: 169 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
from typing import Any, Literal
44

55
from ...config import PlotConfig
6-
from ...contraction_viewer import attach_playback_to_tensor_network_figure
6+
from ...contraction_viewer import (
7+
_ContractionControls,
8+
_ContractionSchemeBundle,
9+
attach_playback_to_tensor_network_figure,
10+
)
711
from ...einsum_module.contraction_cost import format_contraction_step_tooltip
812
from ..contractions import _ContractionGroups
913
from ..graph import _GraphData
@@ -15,12 +19,150 @@
1519
)
1620
from .disk_metrics import _tensor_disk_radius_px_3d_nominal
1721
from .render_prep import (
22+
_apply_render_hover_state,
1823
_draw_edges_nodes_and_labels,
1924
_prepare_render_context,
2025
_register_render_hover,
26+
_RenderPrepContext,
2127
)
2228

2329

30+
def _has_contraction_scheme_source(
31+
graph: _GraphData,
32+
config: PlotConfig,
33+
) -> bool:
34+
if config.contraction_scheme_by_name is not None:
35+
return len(config.contraction_scheme_by_name) > 0
36+
steps = graph.contraction_steps
37+
return steps is not None and len(steps) > 0
38+
39+
40+
def _scheme_bounds_2d(
41+
artists_by_step: list[Any | None],
42+
) -> tuple[float, float, float, float] | None:
43+
xs: list[float] = []
44+
ys: list[float] = []
45+
for artist in artists_by_step:
46+
if artist is None:
47+
continue
48+
get_x = getattr(artist, "get_x", None)
49+
get_y = getattr(artist, "get_y", None)
50+
get_width = getattr(artist, "get_width", None)
51+
get_height = getattr(artist, "get_height", None)
52+
if not all(callable(fn) for fn in (get_x, get_y, get_width, get_height)):
53+
continue
54+
x0 = float(get_x())
55+
y0 = float(get_y())
56+
x1 = x0 + float(get_width())
57+
y1 = y0 + float(get_height())
58+
xs.extend((x0, x1))
59+
ys.extend((y0, y1))
60+
if not xs or not ys:
61+
return None
62+
return (min(xs), max(xs), min(ys), max(ys))
63+
64+
65+
def _scheme_bounds_3d(
66+
scheme_aabb: list[tuple[float, float, float, float, float, float] | None],
67+
) -> tuple[float, float, float, float, float, float] | None:
68+
boxes = [box for box in scheme_aabb if box is not None]
69+
if not boxes:
70+
return None
71+
xmin = min(box[0] for box in boxes)
72+
xmax = max(box[1] for box in boxes)
73+
ymin = min(box[2] for box in boxes)
74+
ymax = max(box[3] for box in boxes)
75+
zmin = min(box[4] for box in boxes)
76+
zmax = max(box[5] for box in boxes)
77+
return (xmin, xmax, ymin, ymax, zmin, zmax)
78+
79+
80+
def _expand_axes_for_scheme_bounds(
81+
*,
82+
ax: Any,
83+
dimensions: Literal[2, 3],
84+
bounds_2d: tuple[float, float, float, float] | None,
85+
bounds_3d: tuple[float, float, float, float, float, float] | None,
86+
) -> None:
87+
if dimensions == 2 and bounds_2d is not None:
88+
xmin, xmax, ymin, ymax = bounds_2d
89+
cur_x0, cur_x1 = ax.get_xlim()
90+
cur_y0, cur_y1 = ax.get_ylim()
91+
ax.set_xlim(min(float(cur_x0), xmin), max(float(cur_x1), xmax))
92+
ax.set_ylim(min(float(cur_y0), ymin), max(float(cur_y1), ymax))
93+
return
94+
if dimensions == 3 and bounds_3d is not None:
95+
xmin, xmax, ymin, ymax, zmin, zmax = bounds_3d
96+
cur_x0, cur_x1 = ax.get_xlim3d()
97+
cur_y0, cur_y1 = ax.get_ylim3d()
98+
cur_z0, cur_z1 = ax.get_zlim3d()
99+
ax.set_xlim3d(min(float(cur_x0), xmin), max(float(cur_x1), xmax))
100+
ax.set_ylim3d(min(float(cur_y0), ymin), max(float(cur_y1), ymax))
101+
ax.set_zlim3d(min(float(cur_z0), zmin), max(float(cur_z1), zmax))
102+
103+
104+
def _build_contraction_scheme_bundle(
105+
*,
106+
ax: Any,
107+
graph: _GraphData,
108+
positions: NodePositions,
109+
config: PlotConfig,
110+
dimensions: Literal[2, 3],
111+
scale: float,
112+
context: _RenderPrepContext,
113+
strict: bool,
114+
) -> _ContractionSchemeBundle:
115+
scheme_steps_eff = _effective_contraction_steps(graph, config)
116+
if not scheme_steps_eff:
117+
raise ValueError("contraction scheme requires a non-empty contraction step sequence.")
118+
119+
per_step_artists, scheme_aabb = _draw_contraction_scheme(
120+
ax=ax,
121+
graph=graph,
122+
positions=positions,
123+
steps=scheme_steps_eff,
124+
config=config,
125+
dimensions=dimensions,
126+
scale=scale,
127+
p=context.params,
128+
)
129+
has_drawable_artists = any(artist is not None for artist in per_step_artists)
130+
if strict and not has_drawable_artists:
131+
raise ValueError("contraction scheme requires at least one drawable contraction step.")
132+
133+
metrics_row = _contraction_step_metrics_for_draw(graph, scheme_steps_eff)
134+
tooltips = tuple(
135+
format_contraction_step_tooltip(metric) if metric is not None else None
136+
for metric in (metrics_row or ())
137+
)
138+
viewer = attach_playback_to_tensor_network_figure(
139+
artists_by_step=per_step_artists,
140+
fig=ax.figure,
141+
ax=ax,
142+
config=config,
143+
build_ui=False,
144+
)
145+
bounds_2d = _scheme_bounds_2d(per_step_artists) if dimensions == 2 else None
146+
bounds_3d = _scheme_bounds_3d(scheme_aabb) if dimensions == 3 else None
147+
_expand_axes_for_scheme_bounds(
148+
ax=ax,
149+
dimensions=dimensions,
150+
bounds_2d=bounds_2d,
151+
bounds_3d=bounds_3d,
152+
)
153+
return _ContractionSchemeBundle(
154+
availability="computed",
155+
steps=scheme_steps_eff,
156+
artists_by_step=per_step_artists,
157+
scheme_aabb=scheme_aabb,
158+
metrics_row=tuple(metrics_row) if metrics_row is not None else None,
159+
tooltips=tooltips,
160+
viewer=viewer,
161+
bounds_2d=bounds_2d,
162+
bounds_3d=bounds_3d,
163+
)
164+
165+
24166
def _draw_graph(
25167
*,
26168
ax: Any,
@@ -54,39 +196,6 @@ def _draw_graph(
54196
"steps can be drawn and stepped."
55197
)
56198

57-
per_step_artists: list[Any | None] | None = None
58-
scheme_steps_eff: tuple[frozenset[int], ...] | None = None
59-
scheme_aabb: list[tuple[float, float, float, float, float, float] | None] | None = None
60-
if config.show_contraction_scheme:
61-
scheme_steps_eff = _effective_contraction_steps(graph, config)
62-
if scheme_steps_eff:
63-
per_step_artists, scheme_aabb = _draw_contraction_scheme(
64-
ax=ax,
65-
graph=graph,
66-
positions=positions,
67-
steps=scheme_steps_eff,
68-
config=config,
69-
dimensions=dimensions,
70-
scale=scale,
71-
p=context.params,
72-
)
73-
if config.contraction_playback:
74-
if not any(artist is not None for artist in per_step_artists):
75-
raise ValueError(
76-
"contraction_playback requires at least one drawable "
77-
"contraction scheme step."
78-
)
79-
attach_playback_to_tensor_network_figure(
80-
artists_by_step=per_step_artists,
81-
fig=ax.figure,
82-
ax=ax,
83-
config=config,
84-
)
85-
elif config.contraction_playback:
86-
raise ValueError(
87-
"contraction_playback requires a non-empty contraction step sequence on the graph."
88-
)
89-
90199
tensor_disk_radius_px_3d: float | None = None
91200
if dimensions == 3 and config.approximate_3d_tensor_disk_px:
92201
tensor_disk_radius_px_3d = _tensor_disk_radius_px_3d_nominal(ax, context.params)
@@ -100,40 +209,38 @@ def _draw_graph(
100209
tensor_disk_radius_px_3d=tensor_disk_radius_px_3d,
101210
)
102211

103-
metrics_row = (
104-
_contraction_step_metrics_for_draw(graph, scheme_steps_eff) if scheme_steps_eff else None
105-
)
106-
scheme_patches_2d: list[tuple[Any, str]] = []
107-
scheme_aabbs_3d: list[tuple[tuple[float, float, float, float, float, float], str, Any]] = []
108-
if (
109-
config.contraction_scheme_cost_hover
110-
and metrics_row is not None
111-
and per_step_artists is not None
112-
and scheme_aabb is not None
113-
):
114-
for index, artist in enumerate(per_step_artists):
115-
if index >= len(metrics_row):
116-
break
117-
metric = metrics_row[index]
118-
if metric is None or artist is None:
119-
continue
120-
tooltip = format_contraction_step_tooltip(metric)
121-
if dimensions == 2:
122-
scheme_patches_2d.append((artist, tooltip))
123-
continue
124-
box = scheme_aabb[index] if index < len(scheme_aabb) else None
125-
if box is not None:
126-
scheme_aabbs_3d.append((box, tooltip, artist))
127-
128-
_register_render_hover(
212+
hover_state = _register_render_hover(
129213
ax=ax,
130214
context=context,
131215
show_tensor_labels=show_tensor_labels,
132216
show_index_labels=show_index_labels,
133-
scheme_patches_2d=scheme_patches_2d,
134-
scheme_aabbs_3d=scheme_aabbs_3d,
217+
scheme_patches_2d=[],
218+
scheme_aabbs_3d=[],
135219
tensor_disk_radius_px_3d=tensor_disk_radius_px_3d,
136220
)
221+
if not _has_contraction_scheme_source(graph, config):
222+
return
223+
224+
_ContractionControls(
225+
fig=ax.figure,
226+
ax=ax,
227+
config=config,
228+
bundle_builder=lambda strict: _build_contraction_scheme_bundle(
229+
ax=ax,
230+
graph=graph,
231+
positions=positions,
232+
config=config,
233+
dimensions=dimensions,
234+
scale=scale,
235+
context=context,
236+
strict=strict,
237+
),
238+
refresh_hover=lambda scheme_patches_2d, scheme_aabbs_3d: _apply_render_hover_state(
239+
hover_state,
240+
scheme_patches_2d=scheme_patches_2d,
241+
scheme_aabbs_3d=scheme_aabbs_3d,
242+
),
243+
)
137244

138245

139246
__all__ = ["_draw_graph"]

0 commit comments

Comments
 (0)