ncpolopt 0.1.0__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.
ncpolopt/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """ncpolopt: sparse SDP relaxations of polynomial optimization problems.
2
+
3
+ Builds semidefinite programming (SDP) relaxations -- the NPA hierarchy
4
+ for noncommuting operators, the Lasserre hierarchy for commuting
5
+ variables -- and solves them with pluggable backends (cvxpy, MOSEK,
6
+ cvxopt, SDPA).
7
+
8
+ The package models an optimization problem as a :class:`Problem` of
9
+ operators or variables, adds an objective and constraints, then builds a
10
+ :class:`~ncpolopt.relaxation.NpaRelaxation` at a chosen hierarchy level
11
+ and solves it to obtain an immutable :class:`~ncpolopt.solution.Solution`.
12
+
13
+ Quickstart (a small noncommutative problem, see the README for details)::
14
+
15
+ import ncpolopt as nc
16
+
17
+ X = nc.generate_operators("x", 2, hermitian=True)
18
+ problem = nc.Problem(
19
+ X,
20
+ objective=X[0] * X[1] + X[1] * X[0],
21
+ inequalities=[-X[1] ** 2 + X[1] + 0.5],
22
+ substitutions={X[0] ** 2: X[0]},
23
+ )
24
+ solution = problem.solve(level=2)
25
+ print(solution.primal) # -0.75
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from importlib.metadata import PackageNotFoundError, version
31
+
32
+ from ._logging import setup_logging
33
+ from .chordal import find_clique_index, find_variable_cliques, sliding_cliques
34
+ from .expressions import flatten
35
+ from .hierarchies import MoroderHierarchy, RdmHierarchy, SteeringHierarchy
36
+ from .moment import MomentEntry, MomentExpr
37
+ from .monomials import get_all_monomials, get_monomials
38
+ from .physics import (
39
+ Probability,
40
+ bosonic_constraints,
41
+ correlator,
42
+ define_objective_with_I,
43
+ fermionic_constraints,
44
+ generate_measurements,
45
+ get_neighbors,
46
+ get_next_neighbors,
47
+ maximum_violation,
48
+ pauli_constraints,
49
+ projective_measurement_constraints,
50
+ )
51
+ from .problem import Problem
52
+ from .relaxation import NpaRelaxation
53
+ from .sdpa_writer import read_sdpa_out, write_dat_s
54
+ from .solution import Solution
55
+ from .solvers.base import SolverKind, SolverSettings
56
+ from .variables import generate_operators, generate_variables, get_support
57
+
58
+ try:
59
+ __version__ = version("ncpolopt")
60
+ except PackageNotFoundError: # pragma: no cover - editable installs always provide it
61
+ __version__ = "0.0.0"
62
+
63
+ __all__ = [
64
+ "MomentEntry",
65
+ "MomentExpr",
66
+ "MoroderHierarchy",
67
+ "NpaRelaxation",
68
+ "Probability",
69
+ "Problem",
70
+ "RdmHierarchy",
71
+ "Solution",
72
+ "SolverKind",
73
+ "SolverSettings",
74
+ "SteeringHierarchy",
75
+ "__version__",
76
+ "bosonic_constraints",
77
+ "correlator",
78
+ "define_objective_with_I",
79
+ "fermionic_constraints",
80
+ "find_clique_index",
81
+ "find_variable_cliques",
82
+ "flatten",
83
+ "generate_measurements",
84
+ "generate_operators",
85
+ "generate_variables",
86
+ "get_all_monomials",
87
+ "get_monomials",
88
+ "get_neighbors",
89
+ "get_next_neighbors",
90
+ "get_support",
91
+ "maximum_violation",
92
+ "pauli_constraints",
93
+ "projective_measurement_constraints",
94
+ "read_sdpa_out",
95
+ "setup_logging",
96
+ "sliding_cliques",
97
+ "write_dat_s",
98
+ ]
ncpolopt/__main__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Command-line entry point for ncpolopt.
2
+
3
+ Prints the package version, the platform it runs on, and the SDP solvers
4
+ that are currently usable. The actual problem building happens through the
5
+ Python API; the CLI exists so ``pip install ncpolopt`` provides a useful
6
+ ``ncpolopt`` command out of the box, and ``python -m ncpolopt`` works too.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import platform
13
+ import sys
14
+
15
+ from . import __version__
16
+ from ._logging import setup_logging
17
+ from .solvers.registry import available_solvers
18
+
19
+
20
+ def main() -> None:
21
+ """Run the CLI: report version, platform and available solvers."""
22
+ parser = argparse.ArgumentParser(prog="ncpolopt", description=__doc__)
23
+ parser.add_argument("--version", action="version", version=__version__)
24
+ args = parser.parse_args() # noqa: F841 - kept for future subcommands
25
+ setup_logging()
26
+ print(f"ncpolopt {__version__}")
27
+ print(f"Python {platform.python_version()} on {platform.platform()}")
28
+ solvers = available_solvers()
29
+ if solvers:
30
+ print("Available solvers: " + ", ".join(s.name for s in solvers))
31
+ else:
32
+ print("No SDP solver found. Install one, e.g. `pip install ncpolopt[cvxpy]`.")
33
+ sys.exit(0)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
ncpolopt/_logging.py ADDED
@@ -0,0 +1,62 @@
1
+ """Logging helpers shared by all ncpolopt modules.
2
+
3
+ The package never prints to stdout directly; every module obtains a named
4
+ logger through :func:`module_logger` and exposes a user-facing ``verbose``
5
+ level that maps to standard logging severities.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ _LOG_LEVELS: dict[int, int] = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}
13
+
14
+
15
+ def module_logger(name: str) -> logging.Logger:
16
+ """Return the logger for a module, quiet by default.
17
+
18
+ The returned logger inherits the root handler configuration; call
19
+ :func:`setup_logging` once from an entry point to enable output.
20
+
21
+ Args:
22
+ name: The ``__name__`` of the calling module.
23
+
24
+ Returns:
25
+ A logger that only emits warnings unless the package is configured
26
+ for verbose output.
27
+ """
28
+ return logging.getLogger(name)
29
+
30
+
31
+ def verbosity_to_level(verbose: int) -> int:
32
+ """Map the package's ``verbose`` integer to a logging severity.
33
+
34
+ Args:
35
+ verbose: 0 (warnings only), 1 (info) or 2 (debug). Higher values
36
+ are clamped to debug.
37
+
38
+ Returns:
39
+ The matching ``logging`` level constant.
40
+ """
41
+ return _LOG_LEVELS.get(verbose, logging.DEBUG)
42
+
43
+
44
+ def setup_logging(level: int = logging.INFO) -> None:
45
+ """Configure the root logger with a console handler.
46
+
47
+ Idempotent: calling it repeatedly does not stack duplicate handlers.
48
+ The handler carries the ``"ncpolopt"`` name so tests (and the function
49
+ itself) can recognize and detach it.
50
+
51
+ Args:
52
+ level: The logging severity to emit, e.g. ``logging.INFO``.
53
+ """
54
+ root = logging.getLogger()
55
+ root.setLevel(level)
56
+ for handler in root.handlers:
57
+ if getattr(handler, "name", None) == "ncpolopt":
58
+ return
59
+ handler = logging.StreamHandler()
60
+ handler.name = "ncpolopt"
61
+ handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
62
+ root.addHandler(handler)
@@ -0,0 +1,304 @@
1
+ """Typed description of the diagonal block structure of an SDP relaxation.
2
+
3
+ Ported from the legacy ``SdpRelaxation._calculate_block_structure`` method.
4
+ The old implementation tracked
5
+ the layout in two parallel arrays -- ``block_struct`` (block sizes) and
6
+ ``localizing_monomial_sets`` (an indexed list padded with ``None`` entries,
7
+ with each equality's localizing basis duplicated) -- and constraint
8
+ processing recovered a block's basis by index arithmetic between the two.
9
+ This module replaces the pair with a single typed list of :class:`BlockSpec`
10
+ objects; each spec carries the metadata the construction layer needs, so no
11
+ parallel bookkeeping survives.
12
+
13
+ The block order is: parameter blocks, moment matrix blocks, extra moment
14
+ matrix blocks, then the constraint blocks (inequalities, moment inequalities,
15
+ equalities, moment equalities) in the same order the constraints are
16
+ processed downstream.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from enum import Enum
23
+ from typing import Any
24
+
25
+ from sympy import S
26
+
27
+ from .expressions import convert_relational, flatten
28
+ from .moment import MomentExpr, as_moment_expr
29
+ from .monomials import ncdegree, pick_monomials_up_to_degree, unique
30
+ from .variables import find_variable_set
31
+
32
+
33
+ class BlockKind(Enum):
34
+ """The role of a diagonal block in the SDP relaxation.
35
+
36
+ Attributes:
37
+ PARAMETER: One free 1x1 variable per problem parameter.
38
+ MOMENT: The moment matrix over a monomial basis.
39
+ LOCALIZING: The localizing matrix of an inequality constraint.
40
+ SCALAR_INEQUALITY: A 1x1 inequality block over moments.
41
+ SCALAR_EQUALITY: A 1x1 equality block; equalities are enforced by
42
+ pairs of such blocks with opposite signs.
43
+ COPY: An extra moment matrix copying the first moment block.
44
+ NEW: An unconstrained extra moment matrix block.
45
+ """
46
+
47
+ PARAMETER = "parameter"
48
+ MOMENT = "moment"
49
+ LOCALIZING = "localizing"
50
+ SCALAR_INEQUALITY = "scalar_inequality"
51
+ SCALAR_EQUALITY = "scalar_equality"
52
+ COPY = "copy"
53
+ NEW = "new"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class BlockSpec:
58
+ """Metadata of one diagonal block of the SDP.
59
+
60
+ Attributes:
61
+ kind: The role of the block.
62
+ size: Number of rows (and columns) of the block.
63
+ constraint: The constraint expression the block enforces, in the
64
+ normalized (``>= 0``) form and negated for the second half of an
65
+ equality pair. Only set for localizing and scalar blocks.
66
+ localizing_set: The monomials of the localizing basis. Only set for
67
+ localizing and scalar equality blocks.
68
+ monomial_set_index: Index of the monomial set for moment blocks.
69
+ impose_ppt: Apply the partial transpose to this block after
70
+ construction (extra moment matrices).
71
+ """
72
+
73
+ kind: BlockKind
74
+ size: int
75
+ constraint: Any = None
76
+ localizing_set: tuple[Any, ...] = ()
77
+ monomial_set_index: int = -1
78
+ impose_ppt: bool = False
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class BlockStructure:
83
+ """The ordered block layout of an SDP relaxation.
84
+
85
+ Attributes:
86
+ blocks: Block specs in the order they appear on the block diagonal.
87
+ constraint_to_blocks: Maps each constraint expression to the indices
88
+ of the blocks enforcing it. Inequalities map to a single block
89
+ index; equalities to the pair ``(first, first + half)`` covering
90
+ the + and - copies. Relational inputs are additionally registered
91
+ under their converted (``>= 0``) form.
92
+ warnings: Non-fatal build warnings, e.g. constraints whose degree
93
+ exceeds the relaxation level.
94
+ """
95
+
96
+ blocks: tuple[BlockSpec, ...] = ()
97
+ constraint_to_blocks: dict[Any, tuple[int, ...]] = field(default_factory=dict)
98
+ warnings: tuple[str, ...] = ()
99
+
100
+ @property
101
+ def constraint_starting_block(self) -> int:
102
+ """Index of the first constraint block (after the moment matrices)."""
103
+ for index, block in enumerate(self.blocks):
104
+ if block.kind in (
105
+ BlockKind.LOCALIZING,
106
+ BlockKind.SCALAR_INEQUALITY,
107
+ BlockKind.SCALAR_EQUALITY,
108
+ ):
109
+ return index
110
+ return len(self.blocks)
111
+
112
+
113
+ def _block_size(monomials: list[Any]) -> int:
114
+ """Size of a moment block over a monomial set (possibly rectangular).
115
+
116
+ A rectangular set ``[A, B]`` spans the tensor product basis ``A x B``,
117
+ so its block is ``len(A) * len(B)`` rows tall. The old code returned
118
+ ``len(A)`` here and left the generation code to produce the product --
119
+ an inconsistency the Moroder hierarchy patched by overriding the block
120
+ size. The product size is fixed at the source instead.
121
+ """
122
+ if len(monomials) > 0 and isinstance(monomials[0], list):
123
+ return len(monomials[0]) * len(monomials[1])
124
+ return len(monomials)
125
+
126
+
127
+ def compute_block_structure(
128
+ *,
129
+ variables: Any,
130
+ monomial_sets: list[list[Any]],
131
+ level: int,
132
+ inequalities: list[Any] | None,
133
+ equalities: list[Any] | None,
134
+ momentinequalities: list[Any] | None,
135
+ momentequalities: list[Any] | None,
136
+ parameters: list[Any] | None,
137
+ extramomentmatrices: list[list[str]] | None,
138
+ removeequalities: bool,
139
+ localizing_monomials: list[list[Any]] | None,
140
+ ) -> BlockStructure:
141
+ """Compute the ordered block layout of the SDP relaxation.
142
+
143
+ The localizing basis of a polynomial constraint of degree d at level l
144
+ consists of all basis monomials of degree up to (2l - d)/2; constraints
145
+ exceeding degree 2l yield a warning and the trivial basis.
146
+
147
+ Args:
148
+ variables: The problem variables (or list of variable lists).
149
+ monomial_sets: One monomial basis per moment matrix block.
150
+ level: The relaxation level.
151
+ inequalities: Polynomial inequality constraints.
152
+ equalities: Polynomial equality constraints.
153
+ momentinequalities: Inequalities over moment expressions.
154
+ momentequalities: Equalities over moment expressions.
155
+ parameters: Parameter variables, each becoming its own 1x1 block.
156
+ extramomentmatrices: Per extra moment matrix, the option strings
157
+ ``"copy"`` and/or ``"ppt"``.
158
+ removeequalities: If True, equalities are eliminated algebraically
159
+ and produce no blocks.
160
+ localizing_monomials: Per-constraint override of the localizing
161
+ basis; None entries request the automatic basis.
162
+
163
+ Returns:
164
+ The block structure with constraint-to-block lookup.
165
+ """
166
+ blocks: list[BlockSpec] = []
167
+ constraint_to_blocks: dict[Any, tuple[int, ...]] = {}
168
+ warnings: list[str] = []
169
+
170
+ # Parameter blocks precede everything else; each parameter becomes its
171
+ # own SDP variable, so its block is just a free 1x1 matrix.
172
+ if parameters is not None:
173
+ blocks.extend(BlockSpec(BlockKind.PARAMETER, 1) for _ in parameters)
174
+
175
+ # One moment matrix block per monomial set.
176
+ for index, monomials in enumerate(monomial_sets):
177
+ blocks.append(
178
+ BlockSpec(BlockKind.MOMENT, _block_size(monomials), monomial_set_index=index)
179
+ )
180
+
181
+ # Extra moment matrices (copy of the first block, or unconstrained new
182
+ # blocks) repeat the base block sizes.
183
+ if extramomentmatrices is not None:
184
+ for options in extramomentmatrices:
185
+ kind = BlockKind.COPY if "copy" in options else BlockKind.NEW
186
+ impose_ppt = "ppt" in options
187
+ for index, monomials in enumerate(monomial_sets):
188
+ blocks.append(
189
+ BlockSpec(
190
+ kind,
191
+ _block_size(monomials),
192
+ monomial_set_index=index,
193
+ impose_ppt=impose_ppt,
194
+ )
195
+ )
196
+
197
+ # Constraint blocks, in the same order the constraints are processed
198
+ # downstream: inequalities, moment inequalities, equalities, moment
199
+ # equalities.
200
+ constraints = list(flatten([inequalities]))
201
+ n_inequalities = len(inequalities) if inequalities is not None else 0
202
+ n_polynomial_inequalities = n_inequalities
203
+ if momentinequalities is not None:
204
+ constraints += list(momentinequalities)
205
+ n_inequalities += len(momentinequalities)
206
+ if not removeequalities and equalities is not None:
207
+ constraints += list(flatten([equalities]))
208
+ # Moment equalities are NOT part of the generic list: they get their
209
+ # scalar block pair from the dedicated loop below, and adding them here
210
+ # would double their blocks.
211
+
212
+ for k, raw_constraint in enumerate(constraints):
213
+ constraint = as_moment_expr(raw_constraint)
214
+ if isinstance(constraint, MomentExpr):
215
+ # Moment constraints act on 1x1 blocks with the trivial basis.
216
+ localizing_set = [S.One]
217
+ elif k < n_polynomial_inequalities or k >= n_inequalities:
218
+ # A genuine polynomial constraint (inequality or equality):
219
+ # compute its localizing basis from the relaxation degree.
220
+ if constraint.is_Relational:
221
+ constraint = convert_relational(constraint)
222
+ order = ncdegree(constraint)
223
+ if order > 2 * level:
224
+ warnings.append(
225
+ f"A constraint has degree {order}. Either choose a higher "
226
+ "level relaxation or ensure that a mixed-order relaxation "
227
+ "has the necessary monomials"
228
+ )
229
+ localization_order = (2 * level - order) // 2
230
+ if level == -1:
231
+ localization_order = 0
232
+ if (
233
+ localizing_monomials is not None
234
+ and localizing_monomials[k] is not None
235
+ ):
236
+ localizing_set = localizing_monomials[k]
237
+ else:
238
+ index = find_variable_set(variables, constraint)
239
+ localizing_set = pick_monomials_up_to_degree(
240
+ monomial_sets[index], localization_order
241
+ )
242
+ if len(localizing_set) == 0:
243
+ localizing_set = [S.One]
244
+ else:
245
+ localizing_set = [S.One]
246
+ localizing_set = unique(localizing_set)
247
+ ln = len(localizing_set)
248
+ if k < n_inequalities:
249
+ # Inequality block: the localizing matrix over the basis.
250
+ kind = (
251
+ BlockKind.LOCALIZING
252
+ if not isinstance(constraint, MomentExpr)
253
+ else BlockKind.SCALAR_INEQUALITY
254
+ )
255
+ block_index = len(blocks)
256
+ blocks.append(
257
+ BlockSpec(
258
+ kind,
259
+ ln,
260
+ constraint=constraint,
261
+ localizing_set=tuple(localizing_set),
262
+ )
263
+ )
264
+ constraint_to_blocks[constraint] = (block_index,)
265
+ if constraint is not raw_constraint:
266
+ constraint_to_blocks[raw_constraint] = (block_index,)
267
+ else:
268
+ # Equality: ln*(ln+1) one-by-one blocks in two halves. The first
269
+ # half enforces M_y(+eq), the second M_y(-eq); together they pin
270
+ # every entry of the localizing matrix to zero. The lookup entry
271
+ # (first, first + half) mirrors the old _constraint_to_block_index
272
+ # convention used by get_dual.
273
+ half = ln * (ln + 1) // 2
274
+ first = len(blocks)
275
+ blocks.extend(
276
+ BlockSpec(
277
+ BlockKind.SCALAR_EQUALITY,
278
+ 1,
279
+ constraint=constraint * sign,
280
+ localizing_set=tuple(localizing_set),
281
+ )
282
+ for sign in (1, -1)
283
+ for _ in range(half)
284
+ )
285
+ constraint_to_blocks[constraint] = (first, first + half)
286
+ if constraint is not raw_constraint:
287
+ constraint_to_blocks[raw_constraint] = (first, first + half)
288
+
289
+ # Moment equalities: two 1x1 blocks per constraint (+ and -).
290
+ if not removeequalities and momentequalities is not None:
291
+ for meq in momentequalities:
292
+ first = len(blocks)
293
+ blocks.extend(
294
+ BlockSpec(
295
+ BlockKind.SCALAR_EQUALITY,
296
+ 1,
297
+ constraint=meq * sign,
298
+ localizing_set=(S.One,),
299
+ )
300
+ for sign in (1, -1)
301
+ )
302
+ constraint_to_blocks[meq] = (first, first + 1)
303
+
304
+ return BlockStructure(tuple(blocks), constraint_to_blocks, tuple(warnings))