Coverage for src/cvx/linalg/operators/composite.py: 100%
46 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 18:56 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 18:56 +0000
1"""Weighted sum of symmetric operators."""
3from __future__ import annotations
5from collections.abc import Sequence
7from ..core.exceptions import DimensionMismatchError
8from ..core.types import Matrix, Vector
9from .base import SymmetricOperator
11_EMPTY_TERMS_MESSAGE = "SumOperator needs at least one term"
12_NO_SOLVE_MESSAGE = "SumOperator has no direct free-block solve; run a Krylov method (e.g. CG) over apply_free"
13_NO_RCOND_MESSAGE = "SumOperator has no structural rcond_free estimate"
16class SumOperator(SymmetricOperator):
17 """A weighted sum of symmetric operators, ``A = sum_i coeff_i * op_i``.
19 Composes several :class:`SymmetricOperator` terms that act on the same
20 dimension into one operator whose products are the (weighted) sums of the
21 terms' products. This expresses forms a single backend cannot -- e.g. a
22 data term plus a structured target,
23 ``(1 - alpha) * GramOperator(M) + alpha * FactorOperator(...)`` -- while
24 every product stays matrix-free (each term applies its own).
26 **Forward-only.** A general sum has no cheap direct free-block solve or
27 structural conditioning estimate, so :meth:`solve_free` and
28 :meth:`rcond_free` raise :class:`NotImplementedError`. Feed
29 :meth:`apply_free` to a Krylov solver (conjugate gradients) instead -- which
30 is exactly how such a composite is used. :attr:`diag` *is* available (the
31 weighted sum of the terms' diagonals, provided every term exposes one) and
32 supplies the Jacobi preconditioner for that solve.
34 Args:
35 terms: A non-empty sequence of ``(coeff, operator)`` pairs. All operators
36 must share the same dimension ``n``.
38 Example:
39 >>> import numpy as np
40 >>> from cvx.linalg import DenseOperator, SumOperator
41 >>> P = DenseOperator(np.array([[2.0, 0.0], [0.0, 1.0]]))
42 >>> Q = DenseOperator(np.array([[1.0, 1.0], [1.0, 1.0]]))
43 >>> op = SumOperator([(0.5, P), (2.0, Q)])
44 >>> A = 0.5 * np.array([[2.0, 0.0], [0.0, 1.0]]) + 2.0 * np.ones((2, 2))
45 >>> np.allclose(op.matvec(np.array([1.0, -1.0])), A @ np.array([1.0, -1.0]))
46 True
47 >>> free = np.array([0, 1])
48 >>> np.allclose(op.apply_free(free, np.array([1.0, 1.0])), A @ np.ones(2))
49 True
50 >>> np.allclose(op.diag, np.diag(A)) # Jacobi preconditioner
51 True
52 """
54 def __init__(self, terms: Sequence[tuple[float, SymmetricOperator]]) -> None:
55 """Store the ``(coeff, operator)`` terms after checking they share a dimension."""
56 terms = list(terms)
57 if not terms:
58 raise ValueError(_EMPTY_TERMS_MESSAGE)
59 dims = {op.n for _, op in terms}
60 if len(dims) != 1:
61 raise DimensionMismatchError(min(dims), max(dims))
62 self._terms: list[tuple[float, SymmetricOperator]] = [(float(c), op) for c, op in terms]
63 self._n = dims.pop()
65 @property
66 def n(self) -> int:
67 """Dimension shared by the terms."""
68 return int(self._n)
70 @property
71 def diag(self) -> Vector:
72 """The weighted sum of the terms' diagonals.
74 Raises:
75 NotImplementedError: If any term does not expose its diagonal.
76 """
77 coeff, op = self._terms[0]
78 result: Vector = coeff * op.diag
79 for coeff, op in self._terms[1:]:
80 result = result + coeff * op.diag
81 return result
83 def matvec(self, x: Vector | Matrix) -> Vector | Matrix:
84 """Return ``A @ x = sum_i coeff_i * op_i.matvec(x)``."""
85 coeff, op = self._terms[0]
86 result = coeff * op.matvec(x)
87 for coeff, op in self._terms[1:]:
88 result = result + coeff * op.matvec(x)
89 return result
91 def restricted(self, free: object) -> SumOperator:
92 """Return the sum of the terms' restricted operators (same coefficients)."""
93 return SumOperator([(c, op.restricted(free)) for c, op in self._terms])
95 def block_matvec(self, rows: object, cols: object, v: Vector | Matrix) -> Vector | Matrix:
96 """Return ``A[rows, cols] @ v`` as the weighted sum of the terms' sub-block products."""
97 coeff, op = self._terms[0]
98 result = coeff * op.block_matvec(rows, cols, v)
99 for coeff, op in self._terms[1:]:
100 result = result + coeff * op.block_matvec(rows, cols, v)
101 return result
103 def solve_free(self, free: object, rhs: Vector | Matrix) -> Vector | Matrix:
104 """Not available: a sum has no cheap direct free-block solve.
106 Raises:
107 NotImplementedError: Always. Run a Krylov solver over :meth:`apply_free`.
108 """
109 raise NotImplementedError(_NO_SOLVE_MESSAGE)
111 def rcond_free(self, free: object) -> float:
112 """Not available: a sum has no structural conditioning estimate.
114 Raises:
115 NotImplementedError: Always. Estimate conditioning from the assembled
116 free block or a Krylov method if needed.
117 """
118 raise NotImplementedError(_NO_RCOND_MESSAGE)