sdplab 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. sdplab/__init__.py +115 -0
  2. sdplab/_version.py +17 -0
  3. sdplab/examples/__init__.py +56 -0
  4. sdplab/examples/_max_cut.py +168 -0
  5. sdplab/examples/q_tomography/__init__.py +24 -0
  6. sdplab/examples/q_tomography/_build.py +130 -0
  7. sdplab/examples/qot/__init__.py +31 -0
  8. sdplab/examples/qot/_random.py +132 -0
  9. sdplab/linalg/__init__.py +22 -0
  10. sdplab/linalg/dense/__init__.py +23 -0
  11. sdplab/linalg/dense/_kron.py +66 -0
  12. sdplab/linalg/dense/_ptrace.py +65 -0
  13. sdplab/problem/__init__.py +70 -0
  14. sdplab/problem/_base.py +313 -0
  15. sdplab/problem/_constraint.py +253 -0
  16. sdplab/problem/_cost.py +434 -0
  17. sdplab/regularization/__init__.py +51 -0
  18. sdplab/regularization/_base.py +295 -0
  19. sdplab/regularization/_eval_space.py +77 -0
  20. sdplab/regularization/_functional.py +221 -0
  21. sdplab/regularization/entropy.py +87 -0
  22. sdplab/regularization/quadratic.py +170 -0
  23. sdplab/solvers/__init__.py +30 -0
  24. sdplab/solvers/_common.py +80 -0
  25. sdplab/solvers/_cvxpy.py +156 -0
  26. sdplab/solvers/_regularized.py +234 -0
  27. sdplab/special/__init__.py +15 -0
  28. sdplab/special/pauli/__init__.py +25 -0
  29. sdplab/special/pauli/_operators.py +791 -0
  30. sdplab/special/pauli/_pauli.py +110 -0
  31. sdplab/special/pauli/_string.py +48 -0
  32. sdplab/special/qot/__init__.py +33 -0
  33. sdplab/special/qot/_constraint_op.py +398 -0
  34. sdplab/special/qot/_cvxpy.py +119 -0
  35. sdplab-0.0.1.dist-info/METADATA +167 -0
  36. sdplab-0.0.1.dist-info/RECORD +39 -0
  37. sdplab-0.0.1.dist-info/WHEEL +5 -0
  38. sdplab-0.0.1.dist-info/licenses/LICENSE +201 -0
  39. sdplab-0.0.1.dist-info/top_level.txt +1 -0
sdplab/__init__.py ADDED
@@ -0,0 +1,115 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ r"""Semidefinite and conic programs over spacecore Euclidean Jordan algebras.
16
+
17
+ .. math::
18
+
19
+ \min_X \quad \langle C, X\rangle
20
+ \quad \text{s.t.} \quad \mathcal{A}X = b,\quad X \succeq 0
21
+
22
+ ``X`` is any Jordan-algebra element -- Hermitian matrix, nonnegative vector, or
23
+ a tree of such blocks -- and :math:`X \succeq 0` means a nonnegative spectrum.
24
+ Build with ``SDPProblem(C, A, b)``; solve with :func:`run_cvxpy_solver`, or
25
+ smooth it: a :class:`Regularizer` through
26
+ :class:`RegularizedSDPDualFunctional`, ``.bind(eps)``, then
27
+ :func:`run_regularized_solver`.
28
+
29
+ Smoothing adds :math:`\varepsilon \operatorname{Tr}[\varphi(X)]` to the primal,
30
+ giving the differentiable dual
31
+
32
+ .. math::
33
+
34
+ D_\varepsilon(y) = \langle b, y\rangle - \varepsilon
35
+ \operatorname{Tr}\!\big[\psi\big(
36
+ (\mathcal{A}^\dagger y - C)/\varepsilon\big)\big],
37
+
38
+ maximized over ``y`` in ``cod``, with :math:`\psi'` of the dual slack
39
+ recovering the primal. :math:`\varepsilon` is per-call, so continuation needs
40
+ no rebuild.
41
+
42
+ Subpackages: :mod:`~sdplab.problem`, :mod:`~sdplab.regularization`,
43
+ :mod:`~sdplab.solvers`, :mod:`~sdplab.examples`, :mod:`~sdplab.linalg`,
44
+ :mod:`~sdplab.special`.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from typing import TYPE_CHECKING
50
+
51
+ from .problem import (
52
+ ConstraintOp,
53
+ Cost,
54
+ DenseConstraintOp,
55
+ MatrixFreeConstraintOp,
56
+ SDPProblem,
57
+ SparseConstraintOp,
58
+ )
59
+ from .regularization import (
60
+ BoundDualFunctional,
61
+ EntropyReg,
62
+ QuadraticReg,
63
+ RegularizedSDPDualFunctional,
64
+ Regularizer,
65
+ )
66
+ from ._version import __version__
67
+
68
+ # Solvers pull in CVXPY (~1.2s), making an optional backend a hard requirement
69
+ # for anyone who only builds problems. PEP 562 keeps them reachable as
70
+ # ``sdplab.run_*`` while deferring the cost to first use.
71
+ _LAZY = {
72
+ "run_regularized_solver": "sdplab.solvers",
73
+ "run_cvxpy_solver": "sdplab.solvers",
74
+ }
75
+
76
+ if TYPE_CHECKING: # type checkers and IDEs do not run __getattr__
77
+ from .solvers import run_cvxpy_solver, run_regularized_solver
78
+
79
+
80
+ def __getattr__(name: str):
81
+ """Resolve the lazily-exported solver entry points on first access."""
82
+ module = _LAZY.get(name)
83
+ if module is None:
84
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
85
+ import importlib
86
+
87
+ value = getattr(importlib.import_module(module), name)
88
+ globals()[name] = value # cache, so this runs once per name
89
+ return value
90
+
91
+
92
+ def __dir__() -> list[str]:
93
+ """Include the lazy names in ``dir(sdplab)`` and tab completion."""
94
+ return sorted(set(globals()) | set(_LAZY))
95
+
96
+
97
+ __all__ = [
98
+ "__version__",
99
+ # problem data
100
+ "SDPProblem",
101
+ "Cost",
102
+ "ConstraintOp",
103
+ "DenseConstraintOp",
104
+ "SparseConstraintOp",
105
+ "MatrixFreeConstraintOp",
106
+ # regularization and the smoothed dual
107
+ "Regularizer",
108
+ "EntropyReg",
109
+ "QuadraticReg",
110
+ "RegularizedSDPDualFunctional",
111
+ "BoundDualFunctional",
112
+ # solvers (lazy)
113
+ "run_regularized_solver",
114
+ "run_cvxpy_solver",
115
+ ]
sdplab/_version.py ADDED
@@ -0,0 +1,17 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Single source of truth for the SDPLab package version."""
16
+
17
+ __version__ = "0.0.1"
@@ -0,0 +1,56 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ r"""Ready-made SDP instances for testing and benchmarking.
16
+
17
+ Each builder returns a :class:`~sdplab.problem.SDPProblem`; they differ in the
18
+ structure of :math:`\mathcal{A}`.
19
+
20
+ ``generate_max_cut``
21
+ Diagonal extraction, :math:`\operatorname{Herm}(n) \to \mathbb{R}^n`. Real,
22
+ and **gauge-degenerate**: :math:`\mathcal{A}^\dagger \mathbf{1} = \mathbb{1}`,
23
+ so the dual is fixed only modulo :math:`\operatorname{span}\{\mathbf 1\}`.
24
+ ``generate_random_qot``
25
+ One-body partial traces,
26
+ :math:`\operatorname{Herm}(d^N) \to \operatorname{Herm}(d)^N`. Complex,
27
+ stacked-block codomain. Returns ``(problem, state)``, ``state`` feasible.
28
+ ``generate_qubit_tomography``
29
+ :math:`\operatorname{Tr}[M_i X] = b_i` plus a unit-trace row. Zero cost, so
30
+ pure feasibility.
31
+
32
+ All three domains are :class:`~spacecore.HermitianSpace`, but the interfaces
33
+ are written against the general Euclidean Jordan algebra contract -- orthant
34
+ spaces, block products, trees mixing cone types. Prefer space-level operations
35
+ (``spectrum``, ``flatten``, ``axpy``) over anything assuming a dense block.
36
+ """
37
+
38
+ from ._max_cut import (
39
+ MaxCutOperator,
40
+ generate_erdos_renyi_graph_laplacian,
41
+ generate_max_cut,
42
+ )
43
+ from .q_tomography import generate_qubit_tomography
44
+ from .qot import QOTConstraintOp, generate_random_qot
45
+
46
+ __all__ = [
47
+ # problem generators
48
+ "generate_max_cut",
49
+ "generate_qubit_tomography",
50
+ "generate_random_qot",
51
+ # constraint operators, for hand-assembled instances
52
+ "MaxCutOperator",
53
+ "QOTConstraintOp",
54
+ # supporting utilities
55
+ "generate_erdos_renyi_graph_laplacian",
56
+ ]
@@ -0,0 +1,168 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+ from spacecore import (
19
+ Context,
20
+ DenseArray,
21
+ DenseVectorSpace,
22
+ HermitianSpace,
23
+ LinOp,
24
+ NumpyOps,
25
+ checked_method,
26
+ jax_pytree_class,
27
+ )
28
+
29
+ from sdplab.problem import SDPProblem
30
+
31
+
32
+ @jax_pytree_class
33
+ class MaxCutOperator(LinOp[HermitianSpace, DenseVectorSpace]):
34
+ r"""Diagonal-extraction operator :math:`\mathcal{A}` for the Max-Cut SDP.
35
+
36
+ The linear map is
37
+ :math:`\mathcal{A}: \operatorname{Herm}(n) \to \mathbb{R}^n` with
38
+ :math:`(\mathcal{A}X)_i = X_{ii}`, so the constraint
39
+ :math:`\mathcal{A}X = \mathbf{1}` fixes every diagonal entry to one. Its
40
+ adjoint sends a vector to the matrix with that diagonal,
41
+ :math:`\mathcal{A}^\dagger y = \operatorname{diag}(y)`, which satisfies
42
+ :math:`\operatorname{Tr}[(\mathcal{A}X)y] = \operatorname{Tr}[X \operatorname{diag}(y)]`.
43
+ """
44
+
45
+ @checked_method(in_space="domain", out_space="codomain")
46
+ def apply(self, X: DenseArray) -> DenseArray:
47
+ r"""Return :math:`\mathcal{A}X = \operatorname{diag}(X)`, the diagonal of ``X``."""
48
+ return self.ops.diag(X)
49
+
50
+ @checked_method(in_space="codomain", out_space="domain")
51
+ def rapply(self, y: DenseArray) -> DenseArray:
52
+ r"""Return :math:`\mathcal{A}^\dagger y = \operatorname{diag}(y)`."""
53
+ return self.ops.diag(y)
54
+
55
+ def to_dense(self) -> DenseArray:
56
+ r"""Return the operator tensor of shape ``codomain.shape + domain.shape``.
57
+
58
+ The entry ``T[i, p, q]`` is one iff ``i == p == q``, so that
59
+ ``sum_{p,q} T[i, p, q] X[p, q] = X[i, i]``.
60
+ """
61
+ n = self.cod.shape[0]
62
+ idx = self.ops.arange(n)
63
+ mask = (idx[:, None, None] == idx[None, :, None]) & (
64
+ idx[:, None, None] == idx[None, None, :]
65
+ )
66
+ return self.ops.asarray(mask, dtype=self.dtype)
67
+
68
+ def tree_flatten(self):
69
+ """The operator carries no arrays; spaces and context are static aux."""
70
+ return (), (self.dom, self.cod, self.ctx)
71
+
72
+ @classmethod
73
+ def tree_unflatten(cls, aux, children):
74
+ """Rebuild the operator from JAX PyTree data."""
75
+ dom, cod, ctx = aux
76
+ return cls(dom, cod, ctx)
77
+
78
+ def _convert(self, new_ctx: Context) -> MaxCutOperator:
79
+ """Return an equivalent operator in ``new_ctx``."""
80
+ return MaxCutOperator(self.dom.convert(new_ctx), self.cod.convert(new_ctx), new_ctx)
81
+
82
+
83
+ def generate_erdos_renyi_graph_laplacian(
84
+ n: int,
85
+ p: float = 0.3,
86
+ seed: int | None = None,
87
+ weighted: bool = True,
88
+ weight_low: float = 0.0,
89
+ weight_high: float = 1.0,
90
+ ) -> np.ndarray:
91
+ """
92
+ Generate an undirected Erdős–Rényi graph G(n, p).
93
+
94
+ Returns
95
+ -------
96
+ L:
97
+ Graph Laplacian L = D - W in S^n,
98
+ where D[i, i] = sum_j W[i, j].
99
+ """
100
+ if n <= 0:
101
+ raise ValueError("n must be positive.")
102
+ if not (0.0 <= p <= 1.0):
103
+ raise ValueError("p must be in [0, 1].")
104
+ if weight_low < 0 or weight_high < weight_low:
105
+ raise ValueError("Require 0 <= weight_low <= weight_high.")
106
+
107
+ rng = np.random.default_rng(seed)
108
+
109
+ # Upper-triangular edge mask, excluding diagonal.
110
+ edge_mask = rng.random((n, n)) < p
111
+ edge_mask = np.triu(edge_mask, k=1)
112
+
113
+ if weighted:
114
+ weights = rng.uniform(weight_low, weight_high, size=(n, n))
115
+ W_upper = edge_mask * weights
116
+ else:
117
+ W_upper = edge_mask.astype(float)
118
+
119
+ W = W_upper + W_upper.T
120
+
121
+ degrees = W.sum(axis=1)
122
+ L = np.diag(degrees) - W
123
+
124
+ return L
125
+
126
+
127
+ def generate_max_cut(
128
+ n: int,
129
+ p: float = 0.3,
130
+ seed: int | None = None,
131
+ weighted: bool = True,
132
+ weight_low: float = 0.0,
133
+ weight_high: float = 1.0,
134
+ atol: float = 0.0,
135
+ rtol: float = 0.0,
136
+ enforce_herm: bool = True,
137
+ unit_trace: bool = False,
138
+ ctx: Context | str | None = None
139
+ ):
140
+ r"""Return the Max-Cut SDP for an Erdős–Rényi graph on ``n`` vertices.
141
+
142
+ The constraint is :math:`\operatorname{diag}(X) = \mathbf{1}`, hence
143
+ :math:`\operatorname{Tr}X = n`. With ``unit_trace=True`` the substitution
144
+ :math:`X = n\tilde X` is applied instead, giving
145
+ :math:`\operatorname{diag}(\tilde X) = \mathbf{1}/n`,
146
+ :math:`\operatorname{Tr}\tilde X = 1`, and cost :math:`nC` -- the same
147
+ problem in a rescaled variable, so the optimal value is unchanged. Use it
148
+ when a unit-trace primal is assumed, as by the fixed-trace (log-partition)
149
+ form of the entropy dual.
150
+ """
151
+ np_ctx = Context(NumpyOps(), dtype="float64", check_level="none")
152
+
153
+ L = generate_erdos_renyi_graph_laplacian(n, p, seed, weighted, weight_low, weight_high)
154
+ C = -L / 4 # Minus to turn maximization into minimization
155
+ b = np.ones(n)
156
+ if unit_trace:
157
+ C = C * n
158
+ b = b / n
159
+
160
+ dom = HermitianSpace(n, atol=atol, rtol=rtol, enforce_herm=enforce_herm, ctx=np_ctx)
161
+ cod = DenseVectorSpace((n,), ctx=np_ctx)
162
+ A = MaxCutOperator(dom, cod, np_ctx)
163
+
164
+ sdp = SDPProblem(C, A, b, ctx=np_ctx)
165
+
166
+ if ctx is not None:
167
+ sdp = sdp.convert(ctx)
168
+ return sdp
@@ -0,0 +1,24 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ r"""State tomography as feasibility: find :math:`X \succeq 0` with
16
+ :math:`\operatorname{Tr}[M_i X] = b_i` and :math:`\operatorname{Tr}[X] = 1`.
17
+
18
+ Unit trace is appended as one more measurement row. See :mod:`._build` for the
19
+ transpose the dense operator needs.
20
+ """
21
+
22
+ from ._build import generate_qubit_tomography
23
+
24
+ __all__ = ["generate_qubit_tomography"]
@@ -0,0 +1,130 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ r"""Builders for qubit tomography SDP feasibility problems.
16
+
17
+ Quantum tomography estimates an unknown density matrix
18
+ :math:`X \in \operatorname{Herm}(d)` from linear measurement constraints.
19
+ This module builds the feasibility SDP
20
+
21
+ .. math::
22
+
23
+ \min_{X \in \operatorname{Herm}(d)}\quad 0
24
+ \quad \text{s.t.} \quad
25
+ \mathcal{A}X = b_{\mathrm{obs}},\quad
26
+ \operatorname{Tr}[X] = 1,\quad
27
+ X \succeq 0.
28
+
29
+ Here :math:`\mathcal{A}: \operatorname{Herm}(d) \to \mathbb{R}^m` is the
30
+ measurement map :math:`(\mathcal{A}X)_i = \operatorname{Tr}[M_i X]` and
31
+ :math:`b_{\mathrm{obs}}` stores the observed expectation values. The unit-trace
32
+ constraint is appended as one extra row measuring the identity, so the returned
33
+ object is a plain :class:`~sdplab.problem.SDPProblem`.
34
+
35
+ The measurement map is a dense tensor contraction and nothing more, so it is a
36
+ :class:`~sdplab.problem.DenseConstraintOp` rather than a hand-written operator
37
+ -- see the transpose note on :func:`generate_qubit_tomography`.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import numpy as np
43
+ from spacecore import Context, DenseArray, DenseVectorSpace, HermitianSpace, NumpyOps
44
+
45
+ from ...problem import DenseConstraintOp, SDPProblem
46
+
47
+
48
+ def generate_qubit_tomography(
49
+ M: DenseArray,
50
+ b_obs: DenseArray,
51
+ atol: float = 0.0,
52
+ rtol: float = 0.0,
53
+ enforce_herm: bool = True,
54
+ ctx: Context | str | None = None,
55
+ ) -> SDPProblem:
56
+ r"""Build the tomography feasibility SDP for observables ``M``.
57
+
58
+ The unknown is a density matrix :math:`X \in \operatorname{Herm}(d)` and
59
+ the returned problem is
60
+
61
+ .. math::
62
+
63
+ \min_{X \in \operatorname{Herm}(d)}\quad 0
64
+ \quad \text{s.t.} \quad
65
+ \operatorname{Tr}[M_i X] = b_i,\quad
66
+ \operatorname{Tr}[X] = 1,\quad
67
+ X \succeq 0.
68
+
69
+ The trace-one constraint is appended as one extra row measuring the
70
+ identity, so the constraint operator has ``m + 1`` rows.
71
+
72
+ **Mind the transpose.** A :class:`~sdplab.problem.DenseConstraintOp` pairs
73
+ its tensor with :math:`X` by Frobenius,
74
+ :math:`(\mathcal{A}X)_i = \sum_{pq} T_{i,pq} X_{pq} =
75
+ \operatorname{Tr}[T_i^{T} X]`. Handing it the observables directly would
76
+ therefore measure :math:`\operatorname{Tr}[M_i^{T} X]` -- for a Hermitian
77
+ observable the *conjugate* of the intended value, silently flipping the
78
+ sign of every measurement with a nonzero imaginary part (the Pauli
79
+ :math:`Y`, say). The stack is transposed here so that
80
+ :math:`(\mathcal{A}X)_i = \operatorname{Tr}[M_i X]`, which also makes the
81
+ operator's cvxpy encoding come out as the :math:`M_i` themselves.
82
+
83
+ Args:
84
+ M: Stack of Hermitian observables, shape ``(m, d, d)``.
85
+ b_obs: Observed expectation values :math:`b_i = \operatorname{Tr}[M_i X]`,
86
+ length ``m``. Taken as real -- an imaginary part would not be
87
+ attainable by a Hermitian observable on a Hermitian state.
88
+ atol: Absolute Hermitian membership tolerance.
89
+ rtol: Relative Hermitian membership tolerance.
90
+ enforce_herm: Whether the primal domain enforces Hermitian matrices.
91
+ ctx: Optional backend context for the returned problem. The problem is
92
+ assembled on NumPy (in a complex dtype when ``M`` is complex) and
93
+ converted, matching :func:`~sdplab.examples.generate_max_cut`.
94
+
95
+ Returns:
96
+ Feasibility :class:`~sdplab.problem.SDPProblem` with a zero cost.
97
+
98
+ Raises:
99
+ ValueError: If ``M`` is not a stack of square matrices, if ``b_obs``
100
+ does not match its length, or if any observable is not Hermitian.
101
+ """
102
+ M = np.asarray(M)
103
+ b_obs = np.asarray(b_obs).reshape(-1)
104
+
105
+ if M.ndim != 3 or M.shape[-1] != M.shape[-2]:
106
+ raise ValueError(f"M must have shape (m, d, d); got {M.shape}.")
107
+ if b_obs.shape[0] != M.shape[0]:
108
+ raise ValueError(
109
+ f"b_obs has length {b_obs.shape[0]}, but M has {M.shape[0]} observables."
110
+ )
111
+ if not np.allclose(M, np.conj(np.swapaxes(M, -1, -2)), atol=1e-10):
112
+ raise ValueError("every observable M[i] must be Hermitian.")
113
+
114
+ m, d = M.shape[0], M.shape[-1]
115
+ dtype = "complex128" if np.iscomplexobj(M) else "float64"
116
+ np_ctx = Context(NumpyOps(), dtype=dtype, check_level="none")
117
+
118
+ # Append Tr[X] = 1 as one extra measurement of the identity.
119
+ M_full = np.concatenate([M.astype(dtype), np.eye(d, dtype=dtype)[None]], axis=0)
120
+ b_full = np.concatenate([b_obs.real.astype("float64"), [1.0]])
121
+
122
+ dom = HermitianSpace(d, atol=atol, rtol=rtol, enforce_herm=enforce_herm, ctx=np_ctx)
123
+ cod = DenseVectorSpace((m + 1,), ctx=np_ctx)
124
+ A = DenseConstraintOp(np.swapaxes(M_full, -1, -2), dom, cod, np_ctx)
125
+
126
+ sdp = SDPProblem(dom.zeros(), A, b_full, ctx=np_ctx) # zero cost: pure feasibility
127
+
128
+ if ctx is not None:
129
+ sdp = sdp.convert(ctx)
130
+ return sdp
@@ -0,0 +1,31 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ r"""Random quantum optimal-transport instances: a coupling
16
+ :math:`\Gamma \in \operatorname{Herm}(d^N)` of minimal cost with prescribed
17
+ one-body marginals :math:`\operatorname{Tr}^k[\Gamma] = \gamma_k`.
18
+
19
+ The codomain is a stack of Hermitian blocks, so the dual is a tuple of matrices
20
+ rather than a vector of scalars. :func:`generate_random_qot` derives the
21
+ marginals from a reference coupling, so the returned ``(problem, state)``
22
+ carries a feasible point.
23
+ """
24
+
25
+ from ...special.qot import QOTConstraintOp
26
+ from ._random import generate_random_qot
27
+
28
+ __all__ = [
29
+ "QOTConstraintOp",
30
+ "generate_random_qot",
31
+ ]
@@ -0,0 +1,132 @@
1
+ # Copyright 2026 Pavlo Pelikh
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ r"""Random QOT problem generator.
16
+
17
+ Use this module when you want a self-contained QOT test problem. It creates a
18
+ random cost matrix, builds a valid primal density matrix, computes the
19
+ marginals from that density matrix, and returns an SDP whose constraints are
20
+ therefore feasible by construction.
21
+ """
22
+
23
+ from typing import Any, Optional, Tuple
24
+ import numpy as np
25
+
26
+ from spacecore import Context, NumpyOps
27
+ from ...special.qot import QOTConstraintOp
28
+ from ...problem import SDPProblem
29
+
30
+
31
+ def generate_random_qot(
32
+ d: int, N: int,
33
+ proportions: tuple[float, ...],
34
+ seed: Optional[int] = 0,
35
+ atol: float = 0.0,
36
+ rtol: float = 0.0,
37
+ enforce_herm: bool = True,
38
+ ctx: Context | str | None = None
39
+ ) -> Tuple[SDPProblem, Any]:
40
+ r"""
41
+ Generate a random dense QOT instance together with a feasible primal state.
42
+
43
+ The generated SDP has the form
44
+
45
+ .. math::
46
+
47
+ \min_\Gamma \quad \operatorname{Tr}[C \Gamma]
48
+ \quad \text{s.t.} \quad
49
+ \operatorname{Tr}^k[\Gamma] = \gamma_k,\quad
50
+ k = 0, ..., N - 1,\quad \Gamma \succeq 0.
51
+
52
+ The function first chooses a reference coupling ``Gamma`` and then defines
53
+ :math:`\gamma_k = \operatorname{Tr}^k[\Gamma]`. Because the right-hand side is computed
54
+ from ``Gamma``, the returned ``state`` is guaranteed to satisfy the
55
+ equality constraints.
56
+
57
+ This function samples a random Hermitian cost matrix on
58
+ :math:`(\mathbb{C}^d)^{\otimes N}`,
59
+ builds a reference density matrix as a convex combination of eigenvector projectors,
60
+ computes its one-body marginals through ``QOTConstraintOp``, and returns the
61
+ corresponding dense SDP problem plus the same state as a plain ``dom`` element.
62
+
63
+ The generated primal state is feasible for the constructed constraint data by design,
64
+ since the marginals are obtained by applying the constraint operator to that state.
65
+
66
+ Args:
67
+ d: Local Hilbert space dimension. The full state space is
68
+ :math:`(\mathbb{C}^d)^{\otimes N}`, so the ambient matrix size is
69
+ :math:`D = d^N`.
70
+ N: Number of subsystems.
71
+ proportions: Coefficients used to form the mixed state
72
+ :math:`\Gamma = \sum_i p_i \, |v_i\rangle \langle v_i|`,
73
+ where the :math:`v_i` are eigenvectors of the sampled cost matrix.
74
+ This is intended to be a convex combination, so the entries should
75
+ be nonnegative and sum to :math:`1`.
76
+ seed: Random seed used for NumPy sampling.
77
+ atol: Absolute tolerance passed to ``QOTConstraintOp``.
78
+ rtol: Relative tolerance passed to ``QOTConstraintOp``.
79
+ enforce_herm: Whether the constraint operator should enforce Hermitian outputs.
80
+ ctx: Target context for the returned SDP problem. If provided, the generated
81
+ problem is converted to this context before returning.
82
+
83
+ Returns:
84
+ - ``qot`` is an ``SDPProblem`` representing the dense QOT SDP with the
85
+ sampled Hermitian cost matrix and marginals induced by ``Gamma``.
86
+ - ``state`` is the same density matrix used to define the marginals, a
87
+ plain ``dom`` element in the returned problem's context.
88
+
89
+ Notes:
90
+ - The random cost matrix is sampled entrywise in real and imaginary parts and
91
+ then symmetrized to be Hermitian.
92
+ - The density matrix ``Gamma`` is built in the NumPy complex context
93
+ ``Context(NumpyOps(), dtype=np.complex128)`` before optional conversion.
94
+ """
95
+
96
+ D = d ** N
97
+
98
+ proportions = np.asarray(proportions, dtype=float)
99
+
100
+ if len(proportions) > D:
101
+ raise ValueError("len(proportions) cannot exceed D = d ** N")
102
+
103
+ if np.any(proportions < 0):
104
+ raise ValueError("proportions must be nonnegative")
105
+
106
+ if not np.isclose(np.sum(proportions), 1.0):
107
+ raise ValueError("proportions must sum to 1")
108
+
109
+ rng = np.random.default_rng(seed)
110
+ cost_re_im = rng.normal(size=(2, D, D))
111
+ cost_matrix = cost_re_im[0, :, :] + 1j * cost_re_im[1, :, :]
112
+ cost_matrix = (cost_matrix + cost_matrix.T.conj()) * .5
113
+
114
+ # Compute eigenvalues and eigenvectors
115
+ evals, evecs = np.linalg.eigh(cost_matrix)
116
+
117
+ # Compute ground state energy and density matrix
118
+ np_ctx = Context(NumpyOps(), dtype=np.complex128, check_level="none")
119
+ Gamma = sum(p * np.outer(v, v.conj()) for p, v in zip(proportions, evecs.T))
120
+ Gamma = (Gamma + Gamma.T.conj()) * .5
121
+ qot_op = QOTConstraintOp(d=d, N=N, atol=atol, rtol=rtol, enforce_herm=enforce_herm, ctx=np_ctx)
122
+ marginals = qot_op.apply(Gamma)
123
+
124
+ # Define QOT problem & convert to target ctx
125
+ qot = SDPProblem(cost_matrix, qot_op, marginals, ctx=np_ctx)
126
+ if ctx is not None:
127
+ qot = qot.convert(ctx)
128
+
129
+ # Move the example state onto the problem's context
130
+ state = qot.ctx.asarray(Gamma)
131
+
132
+ return qot, state