Skip to content

Commit d457d86

Browse files
authored
refactor!: 🔥 drop quimb as a runtime dependency (#24)
* refactor!: 🔥 drop quimb as a runtime dependency quimb contributed nothing to the SRC algorithm itself: every hot path is opt_einsum over plain numpy/cupy arrays. It was used only as a container type (isinstance dispatch, attribute access, re-wrapping results) and for the sub-3-site SVD fallback, yet it pulled cotengra, cytoolz, psutil, scipy and tqdm into every install. BREAKING CHANGE: `apply` and `compress` now take and return plain lists of per-site arrays instead of quimb MPS/MPO objects. The array layout is unchanged (default quimb index ordering), so callers can round-trip with `qtn.MatrixProductOperator(result)` / `qtn.MatrixProductState(result)`. - infer MPS vs MPO from the rank of the first site tensor - replace the quimb sub-3-site fallback with an exact two-site SVD - move quimb to the `test` dependency group, where it is still used to build reference networks and measure distances * build!: 🔥 drop unused runtime dependencies With quimb gone, the remaining runtime dependency list was mostly dead weight. `numba` and `llvmlite` are imported nowhere in `src/` — they were transitively required by quimb, not by us. `cyclopts` is only used by the benchmark scripts under `benches/`, which are not packaged. `cmaes` is not dead: cotengra (via quimb) discovers it by name as a hyper-optimization backend and warns when it is missing, which `filterwarnings = ["error"]` promotes to a test failure. It moves to the `test` group alongside quimb rather than being removed. A runtime install is now numpy + opt_einsum + structlog. - move `cyclopts` to the `dev` group (benchmarks) - move `cmaes` to the `test` group (cotengra path optimizer) - drop the now-dead `@jit(` / `@njit(` coverage excludes * chore: remove verbose comments. * fix: 🐛 validate site counts and accept device arrays in the exact path Addresses the Copilot review on #24. The exact two-site path called `np.linalg.svd` on whatever the caller passed, so it broke on device arrays while the SRC sweep handled them via `xp.asarray`. It now brings inputs onto the host with `to_numpy`, matching the sweep and always returning numpy arrays. `apply` also accepted trains of different lengths: the sweep sizes itself from the left train, so a 4-site MPO applied to a 6-site MPS silently returned a 4-site result. quimb used to reject this; the list-based API has to do it itself. - reject mismatched site counts in `apply` - move the two-site guard to the public boundary as `check_exact_supported`, so degenerate trains raise instead of first logging a fallback warning - cover both guards, and assert the warning is not emitted when raising
1 parent 3d1b553 commit d457d86

12 files changed

Lines changed: 579 additions & 195 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ comment on that thread and applies to all your future contributions.
1414

1515
Open an issue with:
1616

17-
- the versions of `src_method`, `numpy`, `quimb` and (if relevant) `cupy`,
17+
- the versions of `src_method`, `numpy` and (if relevant) `cupy`,
1818
- a minimal reproducer, ideally with a fixed `seed=`,
1919
- the observed and expected behaviour.
2020

‎README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The following primitives are supported:
1515
3. MPO randomized compression.
1616
4. MPS randomized compression.
1717

18-
The package is designed to work with [Quimb](https://quimb.readthedocs.io/en/latest/autoapi/quimb/tensor/index.html) tensor network objects, as such, the API loosely follows its naming conventions:
18+
`src_method` has no tensor-network framework dependency: it takes and returns plain lists of per-site NumPy arrays, one array per site.
1919

2020
```python
2121
from src_method import apply, compress
@@ -25,11 +25,19 @@ The `apply` function covers cases 1 and 2 above, while the `compress` function c
2525
manage the assignment of the returned objects, possibly overwriting the input variables.
2626
See the [reference documentation](algorithmiq.github.io/src_method/) for details, and the [tests](tests/) or [benchmarks](benches/) folders for usage examples.
2727

28-
**NOTE**: the current implementation targets tensor networks with 3 or more sites. For smaller networks, the corresponding Quimb primitive with randomized SVD is dispatched, with a warning.
28+
Whether a train is an MPS or an MPO is inferred from the rank of its first site tensor, so no wrapper type is needed.
29+
30+
**NOTE**: the current implementation targets tensor networks with 3 or more sites. For smaller networks, an exact SVD-based fallback is dispatched, with a warning.
2931

3032
### Tensor Indexing Conventions
3133

32-
This library follows the default `quimb` tensor indexing conventions.
34+
The array layout follows the default `quimb` tensor indexing conventions, so results round-trip through [Quimb](https://quimb.readthedocs.io/en/latest/autoapi/quimb/tensor/index.html) without any permutation:
35+
36+
```python
37+
import quimb.tensor as qtn
38+
39+
result = qtn.MatrixProductOperator(apply(H1.arrays, H2.arrays, chi_out=64))
40+
```
3341

3442
- **MPO Tensors:** Bulk tensors have index order `('l', 'r', 'u', 'd')`.
3543
Boundary tensors (at the edges) are rank-3, dropping the outer `'l'` or `'r'` index.

‎SECURITY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ arrays supplied by the caller and does not parse untrusted input formats,
2323
open network connections, or execute user-supplied code. The most likely
2424
security-relevant issues are therefore memory-safety problems surfaced through
2525
the optional CuPy backend, or dependency vulnerabilities. Reports about the
26-
behaviour of `numpy`, `quimb` or `cupy` themselves should go to those projects.
26+
behaviour of `numpy` or `cupy` themselves should go to those projects.

‎benches/primitives/leonardo/bench_mpo_mpo.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ def main(
7878
if run == "src":
7979
logger.info("Computing SRC's MPO-MPO contraction (with compression)...")
8080
tms = perf_counter_ns()
81-
H_src = apply(H1, H2, chi_out=chi_out, dtype=array_type)
81+
# src_method takes and returns plain lists of site arrays; quimb is only
82+
# used here to build the inputs and to measure the distance.
83+
H_src = qtn.MatrixProductOperator(
84+
apply(H1.arrays, H2.arrays, chi_out=chi_out, dtype=array_type)
85+
)
8286
tms = perf_counter_ns() - tms
8387
logger.info(" SRC's contraction-compression took %s s", tms * 1e-9)
8488
if compare == "yes":

‎docs/index.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The following primitives are supported:
1313
3. MPO randomized compression.
1414
4. MPS randomized compression.
1515

16-
The package is designed to work with [Quimb](https://quimb.readthedocs.io/en/latest/autoapi/quimb/tensor/index.html) tensor network objects, as such, the API loosely follows its naming conventions:
16+
`src_method` has no tensor-network framework dependency: it takes and returns plain lists of per-site NumPy arrays, one array per site.
1717

1818
```python
1919
from src_method import apply, compress
@@ -23,11 +23,19 @@ The `apply` function covers cases 1 and 2 above, while the `compress` function c
2323
manage the assignment of the returned objects, possibly overwriting the input variables.
2424
See the [reference documentation](algorithmiq.github.io/src_method/) for details, and the [tests](../tests/) or [benchmarks](../benches/) folders for usage examples.
2525

26-
**NOTE**: the current implementation targets tensor networks with 3 or more sites. For smaller networks, the corresponding Quimb primitive with randomized SVD is dispatched, with a warning.
26+
Whether a train is an MPS or an MPO is inferred from the rank of its first site tensor, so no wrapper type is needed.
27+
28+
**NOTE**: the current implementation targets tensor networks with 3 or more sites. For smaller networks, an exact SVD-based fallback is dispatched, with a warning.
2729

2830
### Tensor Indexing Conventions
2931

30-
This library follows the default `quimb` tensor indexing conventions.
32+
The array layout follows the default `quimb` tensor indexing conventions, so results round-trip through [Quimb](https://quimb.readthedocs.io/en/latest/autoapi/quimb/tensor/index.html) without any permutation:
33+
34+
```python
35+
import quimb.tensor as qtn
36+
37+
result = qtn.MatrixProductOperator(apply(H1.arrays, H2.arrays, chi_out=64))
38+
```
3139

3240
- **MPO Tensors:** Bulk tensors have index order `('l', 'r', 'u', 'd')`.
3341
Boundary tensors (at the edges) are rank-3, dropping the outer `'l'` or `'r'` index.

‎pyproject.toml‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,9 @@ classifiers = [
3232
]
3333
dynamic = ["version"]
3434
dependencies = [
35-
"cmaes>=0.12.0",
36-
"cyclopts>=3.20",
37-
"llvmlite>=0.44",
38-
"numba>=0.65.0",
3935
"numpy>=2.4.4,<3",
4036
"opt_einsum>=3.4.0",
4137
"structlog>=25.5.0",
42-
"quimb>=1.14.0,<2",
4338
]
4439

4540

@@ -67,8 +62,20 @@ Discussions = "https://github.com/Algorithmiq/src-method/discussions"
6762

6863

6964
[dependency-groups]
70-
test = ["pytest-cov>=4.0", "pytest>=9.1.0,<10.0", "pytest-benchmark>=5.2.3"]
71-
dev = ["scalene", "pre-commit>=3", "ruff==0.16.5", { include-group = "test" }]
65+
test = [
66+
"pytest-cov>=4.0",
67+
"pytest>=9.1.0,<10.0",
68+
"pytest-benchmark>=5.2.3",
69+
"quimb>=1.14.0,<2",
70+
"cmaes>=0.12.0",
71+
]
72+
dev = [
73+
"scalene",
74+
"pre-commit>=3",
75+
"ruff==0.16.5",
76+
"cyclopts>=3.20",
77+
{ include-group = "test" },
78+
]
7279
interactive = [
7380
"ipykernel>=7.2.0",
7481
"matplotlib>=3.11.0",
@@ -77,7 +84,7 @@ interactive = [
7784
"pylatexenc",
7885
]
7986
docs = [
80-
"black[jupyter]>=26.5.1", # format extracted function signatures
87+
"black[jupyter]>=26.5.1",
8188
"mkdocs-gen-files>=0.5.0",
8289
"mkdocs-jupyter>=0.24.2",
8390
"mkdocs-literate-nav>=0.6.0",
@@ -138,8 +145,6 @@ exclude_also = [
138145
"class .*\\bProtocol\\):",
139146
"@(abc\\.)?abstractmethod",
140147
"if (typing\\.)?TYPE_CHECKING:",
141-
"@jit\\(",
142-
"@njit\\(",
143148
'^\s*\.\.\.',
144149
"logger =",
145150
"logger\\.",

‎src/src_method/_tensor_train.py‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Array-list tensor-train conventions and exact small-system primitives.
2+
3+
Tensor trains are plain lists of arrays, one per site. The index ordering
4+
matches the default `quimb` layout, so a result can be handed straight to
5+
``qtn.MatrixProductState(arrays)`` / ``qtn.MatrixProductOperator(arrays)``
6+
without any permutation:
7+
8+
* MPS: ``(bond_r, phys)``, ``(bond_l, bond_r, phys)``, ..., ``(bond_l, phys)``
9+
* MPO: ``(bond_r, up, down)``, ``(bond_l, bond_r, up, down)``, ...,
10+
``(bond_l, up, down)``
11+
12+
The SRC sweep needs at least three sites, so two-site trains are handled here
13+
instead. At that size the whole network fits in a single dense matrix, and one
14+
exact SVD is both cheaper and more accurate than a randomized sketch.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import TYPE_CHECKING, Literal
20+
21+
import numpy as np
22+
from opt_einsum import contract
23+
24+
from .utils import to_numpy
25+
26+
if TYPE_CHECKING:
27+
from collections.abc import Sequence
28+
29+
from numpy.typing import NDArray
30+
31+
# Minimum number of sites for which the randomized SRC sweep is defined.
32+
MIN_SRC_SITES = 3
33+
34+
# The only sub-``MIN_SRC_SITES`` size the exact path can handle.
35+
_EXACT_SITES = 2
36+
37+
# Rank of a boundary (first / last) site tensor, which identifies the train type.
38+
_MPS_BOUNDARY_NDIM = 2
39+
_MPO_BOUNDARY_NDIM = 3
40+
41+
TrainKind = Literal["mps", "mpo"]
42+
43+
__all__ = [
44+
"MIN_SRC_SITES",
45+
"TrainKind",
46+
"check_exact_supported",
47+
"exact_apply",
48+
"exact_compress",
49+
"infer_kind",
50+
]
51+
52+
53+
def infer_kind(arrays: Sequence[NDArray]) -> TrainKind | None:
54+
"""Classify a tensor train from the rank of its first site tensor.
55+
56+
A boundary site carries one bond index plus either a single physical
57+
index (MPS) or an upper/lower pair (MPO), so the rank is unambiguous.
58+
59+
Args:
60+
arrays: The site tensors of the train.
61+
62+
Returns:
63+
``"mps"``, ``"mpo"``, or ``None`` if the layout is unrecognised.
64+
"""
65+
if len(arrays) == 0:
66+
return None
67+
ndim = np.ndim(arrays[0])
68+
if ndim == _MPS_BOUNDARY_NDIM:
69+
return "mps"
70+
if ndim == _MPO_BOUNDARY_NDIM:
71+
return "mpo"
72+
return None
73+
74+
75+
def check_exact_supported(n_sites: int) -> None:
76+
"""Reject sub-``MIN_SRC_SITES`` trains the exact path cannot handle.
77+
78+
Called at the public boundary before the fallback is announced, so that a
79+
degenerate train raises instead of first logging a misleading warning.
80+
81+
Args:
82+
n_sites: The number of sites in the train.
83+
84+
Raises:
85+
ValueError: If the train does not have exactly two sites.
86+
"""
87+
if n_sites != _EXACT_SITES:
88+
msg = (
89+
f"Expected a two-site tensor train, got {n_sites} site(s). "
90+
"Single-site trains are degenerate; use three or more sites for SRC."
91+
)
92+
raise ValueError(msg)
93+
94+
95+
def exact_compress(
96+
arrays: Sequence[NDArray], chi_out: int, kind: TrainKind
97+
) -> list[NDArray]:
98+
"""Compress a two-site train exactly via a single truncated SVD.
99+
100+
Site counts are validated by the caller via `check_exact_supported`.
101+
102+
Args:
103+
arrays: The two site tensors of the train.
104+
chi_out: The maximum bond dimension to keep.
105+
kind: Whether the train is an ``"mps"`` or an ``"mpo"``.
106+
107+
Returns:
108+
The compressed train, in right-canonical form, as numpy arrays.
109+
"""
110+
# The dense SVD is host-side, so accept device arrays like the sweep does.
111+
arrays = [to_numpy(arr) for arr in arrays]
112+
if kind == "mps":
113+
# (b, p0) x (b, p1) -> (p0, p1)
114+
theta = contract("ab,ac->bc", arrays[0], arrays[1])
115+
left, right = _truncated_svd(theta, chi_out)
116+
return [left.T, right]
117+
118+
# (b, u0, d0) x (b, u1, d1) -> (u0, d0, u1, d1)
119+
theta = contract("aij,akl->ijkl", arrays[0], arrays[1])
120+
up_l, down_l, up_r, down_r = theta.shape
121+
left, right = _truncated_svd(theta.reshape(up_l * down_l, up_r * down_r), chi_out)
122+
rank = left.shape[1]
123+
return [
124+
left.reshape(up_l, down_l, rank).transpose(2, 0, 1),
125+
right.reshape(rank, up_r, down_r),
126+
]
127+
128+
129+
def exact_apply(
130+
left_tensor: Sequence[NDArray],
131+
right_tensor: Sequence[NDArray],
132+
chi_out: int,
133+
kind: TrainKind,
134+
) -> list[NDArray]:
135+
"""Contract and compress two two-site trains exactly.
136+
137+
The MPO on the left is contracted site-wise with the right train, fusing
138+
the two bond indices, and the result is compressed with a single SVD.
139+
Site counts are validated by the caller via `check_exact_supported`.
140+
141+
Args:
142+
left_tensor: The two site tensors of the left MPO.
143+
right_tensor: The two site tensors of the right MPS or MPO.
144+
chi_out: The maximum bond dimension to keep.
145+
kind: Whether ``right_tensor`` is an ``"mps"`` or an ``"mpo"``.
146+
147+
Returns:
148+
The compressed product, in right-canonical form, as numpy arrays.
149+
"""
150+
left_tensor = [to_numpy(arr) for arr in left_tensor]
151+
right_tensor = [to_numpy(arr) for arr in right_tensor]
152+
if kind == "mps":
153+
# Contract the MPO lower leg with the MPS physical leg, fusing both bonds.
154+
product = [
155+
contract("aij,bj->abi", left_tensor[i], right_tensor[i]).reshape(
156+
-1, left_tensor[i].shape[1]
157+
)
158+
for i in range(2)
159+
]
160+
else:
161+
product = [
162+
contract("aij,bjk->abik", left_tensor[i], right_tensor[i]).reshape(
163+
-1, left_tensor[i].shape[1], right_tensor[i].shape[2]
164+
)
165+
for i in range(2)
166+
]
167+
return exact_compress(product, chi_out, kind)
168+
169+
170+
def _truncated_svd(theta: NDArray, chi_out: int) -> tuple[NDArray, NDArray]:
171+
"""Split a matrix as ``(U @ diag(S), Vh)``, keeping at most ``chi_out`` values."""
172+
U, S, Vh = np.linalg.svd(theta, full_matrices=False)
173+
rank = min(chi_out, S.size)
174+
return U[:, :rank] * S[:rank], Vh[:rank]

0 commit comments

Comments
 (0)