Skip to content

API reference

Every name exported from the nncg namespace, grouped the way you reach for them: the one-call wrappers first, then the two solver families they wrap (the active-set loop and MPRGP), then the KKT certificate that both are judged by, and finally the inner solvers and the matrix-free Krylov core the active-set loop runs on.

Private helpers are omitted. The planted-optimum problem generators live outside the installed package, in the repository's tests/problems.py.

One-call wrappers

Logic-free shortcuts over the solver classes below: they wrap a plain array in a DenseOperator and resolve the inner string. Reach for these first.

nncg.api

One-call convenience entry points over the core solvers.

:func:solve_nnqp and :func:solve_nnqp_eq compose the three pieces of the core API — wrap a plain SPD array in DenseOperator, default-construct the inner solver from a bare string, bundle the outer-loop knobs into an :class:~nncg.solver.ActiveSetConfig — and delegate to :class:~nncg.solver.ActiveSetSolver. They hold no logic of their own: reach past them to ActiveSetSolver directly whenever you need to reuse a configured solver across problems, or an inner solver the string shortcut cannot express (inner=Nystrom(nystrom=NystromConfig(rank=20)) still works here, passed as an instance). :func:solve_nnqp_mprgp is the matching one-call wrapper over the projection-based :class:~nncg.mprgp.MPRGP solver for the same bound-constrained problem.

InnerKind = Literal['cg', 'jacobi', 'nystrom', 'global_nystrom', 'exact'] module-attribute

The bare-string shortcuts accepted for inner (keys of :data:_INNER).

solve_nnqp(a, b, *, inner='cg', warm=None, tol=1e-08, p_max=3, track=False, max_outer=None)

Minimise 1/2 x^T A x - b^T x over x >= 0 — the one-call entry point.

A thin convenience wrapper that composes the three pieces of the layered API for the common case: it wraps a plain SPD array in DenseOperator, default- constructs the inner solver from a bare string, and bundles the outer-loop knobs into an :class:ActiveSetConfig, then delegates to :meth:ActiveSetSolver.solve. It holds no logic of its own — reach past it to :class:ActiveSetSolver directly whenever you need to reuse a configured solver across problems, or an inner solver this shortcut cannot express.

Parameters:

Name Type Description Default
a SymmetricOperator | NDArray[float64]

The SPD quadratic term. A :class:cvx.linalg.SymmetricOperator is used as-is; a plain 2-D array is wrapped in DenseOperator. The matrix- free A = M^T M + ridge I path is not inferred from an M — pass GramOperator(M, ridge) explicitly for it.

required
b Vector

The linear term b.

required
inner InnerSolver | InnerKind

The inner solver for each free block, as an :class:nncg.inner.InnerSolver instance (fully configurable — e.g. Nystrom(nystrom=NystromConfig(rank=20))), or one of the shortcut strings "cg", "jacobi", "nystrom", "global_nystrom", "exact" for its default configuration.

'cg'
warm tuple[NDArray[bool_], Vector] | None

Optional (free_mask, x_prev) pair from a previous solve, forwarded to :meth:ActiveSetSolver.solve — see there for the warm-start semantics.

None
tol float

Threshold of the primal and dual KKT violator tests (ActiveSetConfig.tol).

1e-08
p_max int

Patience budget before a least-index Bland fallback pivot (ActiveSetConfig.p_max).

3
track bool

Record the visited free-set trajectory in Result.traj.

False
max_outer int | None

Optional cap on outer steps; when hit, the current iterate is returned with converged=False.

None

Returns:

Name Type Description
A Result

class:Result; converged is True iff the KKT system was satisfied

Result

to tol, which certifies the unique global minimiser.

Raises:

Type Description
TypeError

When a is neither a :class:cvx.linalg.SymmetricOperator nor an array wrappable by DenseOperator.

ValueError

When inner is a string outside the shortcut set, when the operator dimension does not match len(b), or on the inner solver's own conditions.

Examples:

The bound binds where the unconstrained minimiser would go negative. Here A^-1 b = [1, -1], so the second coordinate is clamped to zero:

>>> import numpy as np
>>> a = np.array([[2.0, 0.0], [0.0, 2.0]])
>>> b = np.array([2.0, -2.0])
>>> result = solve_nnqp(a, b)
>>> result.converged
True
>>> result.x.round(6).tolist()
[1.0, 0.0]
Source code in src/nncg/api.py
def solve_nnqp(
    a: SymmetricOperator | NDArray[np.float64],
    b: Vector,
    *,
    inner: InnerSolver | InnerKind = "cg",
    warm: tuple[NDArray[np.bool_], Vector] | None = None,
    tol: float = 1e-8,
    p_max: int = 3,
    track: bool = False,
    max_outer: int | None = None,
) -> Result:
    """Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` — the one-call entry point.

    A thin convenience wrapper that composes the three pieces of the layered API
    for the common case: it wraps a plain SPD array in ``DenseOperator``, default-
    constructs the inner solver from a bare string, and bundles the outer-loop
    knobs into an :class:`ActiveSetConfig`, then delegates to
    :meth:`ActiveSetSolver.solve`. It holds no logic of its own — reach past it to
    :class:`ActiveSetSolver` directly whenever you need to reuse a configured
    solver across problems, or an inner solver this shortcut cannot express.

    Args:
        a: The SPD quadratic term. A :class:`cvx.linalg.SymmetricOperator` is used
            as-is; a plain 2-D array is wrapped in ``DenseOperator``. The matrix-
            free ``A = M^T M + ridge I`` path is *not* inferred from an ``M`` —
            pass ``GramOperator(M, ridge)`` explicitly for it.
        b: The linear term ``b``.
        inner: The inner solver for each free block, as an
            :class:`nncg.inner.InnerSolver` instance (fully configurable — e.g.
            ``Nystrom(nystrom=NystromConfig(rank=20))``), or one of the shortcut
            strings ``"cg"``, ``"jacobi"``, ``"nystrom"``, ``"global_nystrom"``,
            ``"exact"`` for its
            default configuration.
        warm: Optional ``(free_mask, x_prev)`` pair from a previous solve, forwarded
            to :meth:`ActiveSetSolver.solve` — see there for the warm-start semantics.
        tol: Threshold of the primal and dual KKT violator tests
            (``ActiveSetConfig.tol``).
        p_max: Patience budget before a least-index Bland fallback pivot
            (``ActiveSetConfig.p_max``).
        track: Record the visited free-set trajectory in ``Result.traj``.
        max_outer: Optional cap on outer steps; when hit, the current iterate is
            returned with ``converged=False``.

    Returns:
        A :class:`Result`; ``converged`` is True iff the KKT system was satisfied
        to ``tol``, which certifies the unique global minimiser.

    Raises:
        TypeError: When ``a`` is neither a :class:`cvx.linalg.SymmetricOperator`
            nor an array wrappable by ``DenseOperator``.
        ValueError: When ``inner`` is a string outside the shortcut set, when the
            operator dimension does not match ``len(b)``, or on the inner solver's
            own conditions.

    Examples:
        The bound binds where the unconstrained minimiser would go negative. Here
        ``A^-1 b = [1, -1]``, so the second coordinate is clamped to zero:

        >>> import numpy as np
        >>> a = np.array([[2.0, 0.0], [0.0, 2.0]])
        >>> b = np.array([2.0, -2.0])
        >>> result = solve_nnqp(a, b)
        >>> result.converged
        True
        >>> result.x.round(6).tolist()
        [1.0, 0.0]
    """
    config = ActiveSetConfig(tol=tol, p_max=p_max, track=track, max_outer=max_outer)
    solver = ActiveSetSolver(inner=_resolve_inner(inner), config=config)
    return solver.solve(_as_operator(a), b, warm=warm)

solve_nnqp_eq(a, b, b_eq, c_eq, *, inner='cg', warm=None, tol=1e-08, p_max=3, track=False, max_outer=None)

Solve min 1/2 x^T A x - b^T x s.t. x >= 0 and B x = c — one call.

The equality-augmented companion to :func:solve_nnqp, with identical wrapping and configuration conventions; it delegates to :meth:ActiveSetSolver.solve_eq, where the per-free-set saddle system and the full-row-rank requirement on B are documented. The single normalisation 1^T x = beta is the p = 1 case.

Parameters:

Name Type Description Default
a SymmetricOperator | NDArray[float64]

The SPD quadratic term — a :class:cvx.linalg.SymmetricOperator, or a plain array wrapped in DenseOperator (see :func:solve_nnqp).

required
b Vector

The linear term b.

required
b_eq Matrix

Equality matrix B of shape (p, n), full row rank on the visited free sets.

required
c_eq Vector

Equality right-hand side c of shape (p,).

required
inner InnerSolver | InnerKind

The inner solver instance, or a shortcut string — see :func:solve_nnqp.

'cg'
warm tuple[NDArray[bool_], Vector] | None

Optional (free_mask, x_prev) pair, forwarded to :meth:ActiveSetSolver.solve_eq.

None
tol float

KKT violator tolerance (ActiveSetConfig.tol).

1e-08
p_max int

Bland-fallback patience budget (ActiveSetConfig.p_max).

3
track bool

Record the visited free-set trajectory in Result.traj.

False
max_outer int | None

Optional outer-step cap; converged=False when hit.

None

Returns:

Name Type Description
A Result

class:Result with the equality multipliers in lam. The reduced

Result

gradient underlying the dual test is s = A x - b - B^T lam.

Raises:

Type Description
TypeError

When a is neither a :class:cvx.linalg.SymmetricOperator nor an array wrappable by DenseOperator.

ValueError

When inner is a string outside the shortcut set, when the operator dimension does not match len(b), or on the inner solver's own conditions.

Examples:

The p = 1 normalisation 1^T x = 1 — the minimum-norm point on the simplex, here its centre:

>>> import numpy as np
>>> a = np.eye(2) * 2.0
>>> b = np.zeros(2)
>>> result = solve_nnqp_eq(a, b, np.array([[1.0, 1.0]]), np.array([1.0]))
>>> result.converged
True
>>> result.x.round(6).tolist()
[0.5, 0.5]
Source code in src/nncg/api.py
def solve_nnqp_eq(
    a: SymmetricOperator | NDArray[np.float64],
    b: Vector,
    b_eq: Matrix,
    c_eq: Vector,
    *,
    inner: InnerSolver | InnerKind = "cg",
    warm: tuple[NDArray[np.bool_], Vector] | None = None,
    tol: float = 1e-8,
    p_max: int = 3,
    track: bool = False,
    max_outer: int | None = None,
) -> Result:
    """Solve ``min 1/2 x^T A x - b^T x`` s.t. ``x >= 0`` and ``B x = c`` — one call.

    The equality-augmented companion to :func:`solve_nnqp`, with identical
    wrapping and configuration conventions; it delegates to
    :meth:`ActiveSetSolver.solve_eq`, where the per-free-set saddle system and the
    full-row-rank requirement on ``B`` are documented. The single normalisation
    ``1^T x = beta`` is the ``p = 1`` case.

    Args:
        a: The SPD quadratic term — a :class:`cvx.linalg.SymmetricOperator`, or a
            plain array wrapped in ``DenseOperator`` (see :func:`solve_nnqp`).
        b: The linear term ``b``.
        b_eq: Equality matrix ``B`` of shape ``(p, n)``, full row rank on the
            visited free sets.
        c_eq: Equality right-hand side ``c`` of shape ``(p,)``.
        inner: The inner solver instance, or a shortcut string — see
            :func:`solve_nnqp`.
        warm: Optional ``(free_mask, x_prev)`` pair, forwarded to
            :meth:`ActiveSetSolver.solve_eq`.
        tol: KKT violator tolerance (``ActiveSetConfig.tol``).
        p_max: Bland-fallback patience budget (``ActiveSetConfig.p_max``).
        track: Record the visited free-set trajectory in ``Result.traj``.
        max_outer: Optional outer-step cap; ``converged=False`` when hit.

    Returns:
        A :class:`Result` with the equality multipliers in ``lam``. The reduced
        gradient underlying the dual test is ``s = A x - b - B^T lam``.

    Raises:
        TypeError: When ``a`` is neither a :class:`cvx.linalg.SymmetricOperator`
            nor an array wrappable by ``DenseOperator``.
        ValueError: When ``inner`` is a string outside the shortcut set, when the
            operator dimension does not match ``len(b)``, or on the inner solver's
            own conditions.

    Examples:
        The ``p = 1`` normalisation ``1^T x = 1`` — the minimum-norm point on the
        simplex, here its centre:

        >>> import numpy as np
        >>> a = np.eye(2) * 2.0
        >>> b = np.zeros(2)
        >>> result = solve_nnqp_eq(a, b, np.array([[1.0, 1.0]]), np.array([1.0]))
        >>> result.converged
        True
        >>> result.x.round(6).tolist()
        [0.5, 0.5]
    """
    config = ActiveSetConfig(tol=tol, p_max=p_max, track=track, max_outer=max_outer)
    solver = ActiveSetSolver(inner=_resolve_inner(inner), config=config)
    return solver.solve_eq(_as_operator(a), b, b_eq, c_eq, warm=warm)

solve_nnqp_mprgp(a, b, *, x0=None, tol=1e-08, gamma=1.0, alpha_bar=None, max_iter=100000, seed=0)

Minimise 1/2 x^T A x - b^T x over x >= 0 by MPRGP — one call.

The projection-based companion to :func:solve_nnqp: it solves the same bound-constrained program with Dostál & Schöberl's MPRGP (:class:nncg.mprgp.MPRGP) instead of the active-set loop — matrix-free and factorisation-free, so it never forms or refactorises A. Like :func:solve_nnqp it wraps a plain SPD array in DenseOperator and bundles the knobs into an :class:nncg.mprgp.MPRGPConfig, then delegates to :meth:nncg.mprgp.MPRGP.solve. The equality-augmented variant is not covered — use :func:solve_nnqp_eq for B x = c.

Parameters:

Name Type Description Default
a SymmetricOperator | NDArray[float64]

The SPD quadratic term. A :class:cvx.linalg.SymmetricOperator is used as-is; a plain 2-D array is wrapped in DenseOperator (the matrix-free A = M^T M + ridge I path is not inferred — pass GramOperator(M, ridge) explicitly for it).

required
b Vector

The linear term b.

required
x0 Vector | None

Optional feasible warm start, projected onto x >= 0; None starts from the origin.

None
tol float

Relative projected-gradient stopping tolerance (MPRGPConfig.tol).

1e-08
gamma float

Proportioning constant Gamma > 0 (MPRGPConfig.gamma).

1.0
alpha_bar float | None

Fixed projected-gradient step in (0, 2/||A||]; None estimates 1/||A|| matrix-free (MPRGPConfig.alpha_bar).

None
max_iter int

Iteration cap; converged=False when hit (MPRGPConfig.max_iter).

100000
seed int

Seed of the power-iteration ||A|| estimate (MPRGPConfig.seed).

0

Returns:

Name Type Description
An MPRGPResult

class:nncg.mprgp.MPRGPResult; converged is True iff the

MPRGPResult

projected gradient fell below tol * ||b||, which certifies the unique

MPRGPResult

global minimiser.

Raises:

Type Description
TypeError

When a is neither a :class:cvx.linalg.SymmetricOperator nor an array wrappable by DenseOperator.

ValueError

When the operator dimension does not match len(b), when gamma is not strictly positive, or when alpha_bar is set but not strictly positive.

Examples:

The same program as the :func:solve_nnqp example, reached by projection instead of the active-set loop — same unique minimiser:

>>> import numpy as np
>>> a = np.array([[2.0, 0.0], [0.0, 2.0]])
>>> b = np.array([2.0, -2.0])
>>> result = solve_nnqp_mprgp(a, b)
>>> result.converged
True
>>> result.x.round(6).tolist()
[1.0, 0.0]
Source code in src/nncg/api.py
def solve_nnqp_mprgp(
    a: SymmetricOperator | NDArray[np.float64],
    b: Vector,
    *,
    x0: Vector | None = None,
    tol: float = 1e-8,
    gamma: float = 1.0,
    alpha_bar: float | None = None,
    max_iter: int = 100_000,
    seed: int = 0,
) -> MPRGPResult:
    """Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` by MPRGP — one call.

    The projection-based companion to :func:`solve_nnqp`: it solves the same
    bound-constrained program with Dostál & Schöberl's MPRGP
    (:class:`nncg.mprgp.MPRGP`) instead of the active-set loop — matrix-free and
    factorisation-free, so it never forms or refactorises ``A``. Like
    :func:`solve_nnqp` it wraps a plain SPD array in ``DenseOperator`` and bundles
    the knobs into an :class:`nncg.mprgp.MPRGPConfig`, then delegates to
    :meth:`nncg.mprgp.MPRGP.solve`. The equality-augmented variant is not covered
    — use :func:`solve_nnqp_eq` for ``B x = c``.

    Args:
        a: The SPD quadratic term. A :class:`cvx.linalg.SymmetricOperator` is used
            as-is; a plain 2-D array is wrapped in ``DenseOperator`` (the
            matrix-free ``A = M^T M + ridge I`` path is *not* inferred — pass
            ``GramOperator(M, ridge)`` explicitly for it).
        b: The linear term ``b``.
        x0: Optional feasible warm start, projected onto ``x >= 0``; ``None``
            starts from the origin.
        tol: Relative projected-gradient stopping tolerance
            (``MPRGPConfig.tol``).
        gamma: Proportioning constant ``Gamma > 0`` (``MPRGPConfig.gamma``).
        alpha_bar: Fixed projected-gradient step in ``(0, 2/||A||]``; ``None``
            estimates ``1/||A||`` matrix-free (``MPRGPConfig.alpha_bar``).
        max_iter: Iteration cap; ``converged=False`` when hit
            (``MPRGPConfig.max_iter``).
        seed: Seed of the power-iteration ``||A||`` estimate
            (``MPRGPConfig.seed``).

    Returns:
        An :class:`nncg.mprgp.MPRGPResult`; ``converged`` is True iff the
        projected gradient fell below ``tol * ||b||``, which certifies the unique
        global minimiser.

    Raises:
        TypeError: When ``a`` is neither a :class:`cvx.linalg.SymmetricOperator`
            nor an array wrappable by ``DenseOperator``.
        ValueError: When the operator dimension does not match ``len(b)``, when
            ``gamma`` is not strictly positive, or when ``alpha_bar`` is set but
            not strictly positive.

    Examples:
        The same program as the :func:`solve_nnqp` example, reached by projection
        instead of the active-set loop — same unique minimiser:

        >>> import numpy as np
        >>> a = np.array([[2.0, 0.0], [0.0, 2.0]])
        >>> b = np.array([2.0, -2.0])
        >>> result = solve_nnqp_mprgp(a, b)
        >>> result.converged
        True
        >>> result.x.round(6).tolist()
        [1.0, 0.0]
    """
    config = MPRGPConfig(tol=tol, gamma=gamma, alpha_bar=alpha_bar, max_iter=max_iter, seed=seed)
    return MPRGP(config=config).solve(_as_operator(a), b, x0=x0)

Active-set solver

The primal-dual active-set loop with the unconditional finite-termination guarantee — this package's subject. solve_eq adds the equality-augmented Bx = c variant via a p-by-p Schur complement.

nncg.solver

Non-negative conjugate gradients: the active-set / block-principal-pivoting loop.

Solves the strictly convex non-negative quadratic program

min_{x >= 0}  1/2 x^T A x - b^T x,        A symmetric positive definite,

and its equality-augmented variant with a general linear system B x = c, by wrapping a matrix-free inner solver in a primal-dual active-set outer loop. The working-set toggles are the principal pivots of the linear complementarity problem LCP(A, -b); guarding the fast block-pivot path with a least-index Bland fallback gives unconditional finite termination at the unique global minimiser — no non-degeneracy assumption (Theorem 5.1 of the accompanying paper). See https://github.com/Jebel-Quant/mean_variance_solvers.

:class:ActiveSetSolver is the outer loop and the entry point. It knows nothing about preconditioning: it asks its :class:nncg.inner.InnerSolver for a per-free-block solve and drives the pivots around it. The quadratic term enters as a :class:cvx.linalg.SymmetricOperator, accessed only through block products — wrap an explicit SPD array in DenseOperator, or pass GramOperator(M, ridge) for A = M^T M + ridge I so the n x n matrix is never formed.

ActiveSetConfig dataclass

Configuration of the active-set outer loop (:class:ActiveSetSolver).

Bundles the outer-loop knobs into one argument; the inner solver and its tolerances live in :class:nncg.inner.InnerSolver, and the warm start stays a separate argument.

Attributes:

Name Type Description
tol float

Threshold of the primal and dual KKT violator tests.

p_max int

Patience budget — non-improving batch steps tolerated before a least-index Bland fallback pivot. Any value gives finite termination.

track bool

Record the visited free-set trajectory in Result.traj.

max_outer int | None

Optional cap on outer steps; when hit, the current iterate is returned with converged=False.

Source code in src/nncg/solver.py
@dataclass(frozen=True)
class ActiveSetConfig:
    """Configuration of the active-set outer loop (:class:`ActiveSetSolver`).

    Bundles the outer-loop knobs into one argument; the inner solver and its
    tolerances live in :class:`nncg.inner.InnerSolver`, and the warm start stays
    a separate argument.

    Attributes:
        tol: Threshold of the primal and dual KKT violator tests.
        p_max: Patience budget — non-improving batch steps tolerated before a
            least-index Bland fallback pivot. Any value gives finite termination.
        track: Record the visited free-set trajectory in ``Result.traj``.
        max_outer: Optional cap on outer steps; when hit, the current iterate is
            returned with ``converged=False``.
    """

    tol: float = 1e-8
    p_max: int = 3
    track: bool = False
    max_outer: int | None = None

ActiveSetSolver dataclass

The primal-dual active-set outer loop for the non-negative quadratic program.

Holds the outer-loop :class:ActiveSetConfig and an :class:nncg.inner.InnerSolver, and drives the guarded block-pivot loop around the per-free-block solve the inner solver provides. It never touches a preconditioner — everything about CG/PCG/Nyström lives in inner.

Attributes:

Name Type Description
inner InnerSolver

The inner solver for each free block — e.g. :class:nncg.inner.CG (plain CG), :class:nncg.inner.Jacobi, :class:nncg.inner.Nystrom or :class:nncg.inner.Exact.

config ActiveSetConfig

Outer-loop configuration (violator tolerance, patience, trajectory tracking, outer-step cap).

Examples:

A enters as an operator, never as a bare array:

>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> from nncg import ActiveSetSolver, CG, kkt_violation
>>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
>>> b = np.array([2.0, -2.0])

The unconstrained minimiser would be (1, -1), so the bound binds on the second coordinate and the loop returns (1, 0) with that coordinate active:

>>> res = ActiveSetSolver(inner=CG()).solve(a, b)
>>> res.converged
True
>>> bool(np.allclose(res.x, [1.0, 0.0]))
True
>>> res.free.tolist()
[True, False]

converged is the KKT exit, which :func:nncg.kkt_violation scores independently — zero certifies the unique global minimiser:

>>> round(kkt_violation(a, b, res.x), 12)
0.0

Generic data never needs the Bland fallback; that it stayed dormant is reported rather than assumed:

>>> res.fallback
0

Swap the inner solver freely — the outer loop is unchanged, and on this problem so is the answer:

>>> from nncg import Exact
>>> direct = ActiveSetSolver(inner=Exact()).solve(a, b)
>>> bool(np.allclose(direct.x, res.x))
True
Source code in src/nncg/solver.py
@dataclass(frozen=True)
class ActiveSetSolver:
    """The primal-dual active-set outer loop for the non-negative quadratic program.

    Holds the outer-loop :class:`ActiveSetConfig` and an
    :class:`nncg.inner.InnerSolver`, and drives the guarded block-pivot loop
    around the per-free-block solve the inner solver provides. It never touches a
    preconditioner — everything about CG/PCG/Nyström lives in ``inner``.

    Attributes:
        inner: The inner solver for each free block — e.g. :class:`nncg.inner.CG`
            (plain CG), :class:`nncg.inner.Jacobi`, :class:`nncg.inner.Nystrom`
            or :class:`nncg.inner.Exact`.
        config: Outer-loop configuration (violator tolerance, patience,
            trajectory tracking, outer-step cap).

    Examples:
        ``A`` enters as an operator, never as a bare array:

        >>> import numpy as np
        >>> from cvx.linalg import DenseOperator
        >>> from nncg import ActiveSetSolver, CG, kkt_violation
        >>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
        >>> b = np.array([2.0, -2.0])

        The unconstrained minimiser would be ``(1, -1)``, so the bound binds on
        the second coordinate and the loop returns ``(1, 0)`` with that
        coordinate active:

        >>> res = ActiveSetSolver(inner=CG()).solve(a, b)
        >>> res.converged
        True
        >>> bool(np.allclose(res.x, [1.0, 0.0]))
        True
        >>> res.free.tolist()
        [True, False]

        ``converged`` is the KKT exit, which :func:`nncg.kkt_violation` scores
        independently — zero certifies the unique global minimiser:

        >>> round(kkt_violation(a, b, res.x), 12)
        0.0

        Generic data never needs the Bland fallback; that it stayed dormant is
        reported rather than assumed:

        >>> res.fallback
        0

        Swap the inner solver freely — the outer loop is unchanged, and on this
        problem so is the answer:

        >>> from nncg import Exact
        >>> direct = ActiveSetSolver(inner=Exact()).solve(a, b)
        >>> bool(np.allclose(direct.x, res.x))
        True
    """

    inner: InnerSolver
    config: ActiveSetConfig = field(default_factory=ActiveSetConfig)

    def solve(
        self,
        a: SymmetricOperator,
        b: Vector,
        warm: tuple[NDArray[np.bool_], Vector] | None = None,
    ) -> Result:
        """Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` by the active-set loop.

        Each free-block solve is delegated to :attr:`inner`; the reduced matrix
        is never materialised and ``A`` is never refactorised. The batch
        block-pivot fast path is guarded by a least-index Bland fallback, so
        termination at the unique global minimiser is unconditional.

        Args:
            a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`) —
                ``DenseOperator`` for an explicit array, ``GramOperator(M, ridge)``
                for ``A = M^T M + ridge I`` whose Gram matrix is never formed.
            b: The linear term ``b``.
            warm: Optional ``(free_mask, x_prev)`` pair from a previous solve.
                Starts the loop from that free set and warm-starts every inner
                solve from the newest iterate (the :class:`nncg.inner.Exact`
                inner solver is direct, so it has nothing to seed but still
                starts from the warm free set) — across a support-stable
                parameter step the loop then terminates in a single outer step.

        Returns:
            A :class:`Result`; ``converged`` is True iff the KKT system was
            satisfied to ``config.tol``, which certifies the unique global
            minimiser.

        Raises:
            TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
            ValueError: When the operator dimension does not match ``len(b)``, or
                on the inner solver's own conditions in
                :meth:`InnerSolver.solve`.
            NotImplementedError: When a diagonal-preconditioned inner solver
                (:class:`nncg.inner.Jacobi`) meets a backend without ``diag``
                (propagated from ``cvx.linalg``).
        """
        _require_operator(a, b)

        def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]:
            """Solve the reduced system ``A_F x_F = b_F`` with the chosen inner solver."""
            xf, k_step = self.inner.solve(a, idx, b[idx], x0)
            return xf, None, k_step

        def reduced_gradient(x: Vector, lam: Vector | None) -> Vector:  # noqa: ARG001
            """Return the reduced gradient ``s = A x - b``."""
            return a.matvec(x) - b

        return self._run(len(b), sub_solve, reduced_gradient, warm)

    def solve_eq(
        self,
        a: SymmetricOperator,
        b: Vector,
        b_eq: Matrix,
        c_eq: Vector,
        warm: tuple[NDArray[np.bool_], Vector] | None = None,
    ) -> Result:
        """Solve ``min 1/2 x^T A x - b^T x`` subject to ``x >= 0`` and ``B x = c``.

        On each free set the saddle system is solved by eliminating the
        multiplier ``lambda`` in R^p through the p-by-p Schur complement
        ``S = B_F A_F^{-1} B_F^T``: the ``p + 1`` right-hand sides share the
        operator ``A_F`` and are each one inner solve, then ``S lambda = c - B_F
        v0`` fixes the multipliers in closed form. The single normalisation
        ``1^T x = beta`` is the ``p = 1`` case. ``B`` must have full row rank on
        the visited free sets (automatic for ``p = 1``).

        Args:
            a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`).
            b: The linear term ``b``.
            b_eq: Equality matrix ``B`` of shape ``(p, n)``, full row rank.
            c_eq: Equality right-hand side ``c`` of shape ``(p,)``.
            warm: Optional ``(free_mask, x_prev)`` pair from a previous solve.
                Starts the loop from that free set and seeds the ``v0`` solve of
                every saddle step from the newest iterate; the ``v1`` columns are
                re-solved cold (their right-hand sides are the rows of ``B_F``,
                unrelated to ``x_prev``). Across a support-stable parameter step
                the loop then terminates in a single outer step.

        Returns:
            A :class:`Result` with the multipliers in ``lam``. The reduced
            gradient underlying the dual test is ``s = A x - b - B^T lam``.

        Raises:
            TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
            ValueError: When the operator dimension does not match ``len(b)``, or
                on the inner solver's own conditions in
                :meth:`InnerSolver.solve`.
            NotImplementedError: When a diagonal-preconditioned inner solver
                (:class:`nncg.inner.Jacobi`) meets a backend without ``diag``
                (propagated from ``cvx.linalg``).
        """
        _require_operator(a, b)

        def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]:
            """Solve the saddle system on the free set via the p-by-p Schur complement."""
            return _saddle_solve(self.inner, a, b, b_eq, c_eq, idx, x0)

        def reduced_gradient(x: Vector, lam: Vector | None) -> Vector:
            """Return the constrained reduced gradient ``s = A x - b - B^T lam``."""
            correction = b_eq.T @ lam if lam is not None else np.zeros_like(b)
            return a.matvec(x) - b - correction

        return self._run(len(b), sub_solve, reduced_gradient, warm)

    def _run(
        self,
        n: int,
        sub_solve: SubSolve,
        reduced_gradient: ReducedGradient,
        warm: tuple[NDArray[np.bool_], Vector] | None,
    ) -> Result:
        """Run the guarded primal-dual active-set loop.

        The driver owns everything the termination proof depends on: the primal
        and dual violator tests, the batch exchange with its patience counter,
        and the least-index Bland fallback. What is solved on each free set — a
        single reduced system (:meth:`solve`), or the equality-augmented saddle
        system (:meth:`solve_eq`) — enters through the ``sub_solve`` callback,
        with ``reduced_gradient`` supplying the matching dual test quantity. The
        thresholds (``tol``, ``p_max``, ``track``, ``max_outer``) are read from
        :attr:`config`.

        Args:
            n: Problem dimension.
            sub_solve: Callback ``(idx, x0) -> (x_F, lam, inner_iters)`` solving
                the subproblem on the free set ``idx``. ``x0`` is a warm inner
                guess restricted to ``idx`` (None on a cold start); ``lam`` are
                the equality multipliers (None for the bound-only problem).
            reduced_gradient: Callback ``(x, lam) -> s`` computing the reduced
                gradient that drives the dual violator test.
            warm: Optional ``(free_mask, x_prev)`` pair from a previous solve.
                Starts the loop from that free set and seeds every subproblem
                solve from the newest iterate.

        Returns:
            A :class:`Result`; ``lam`` is whatever the last subproblem returned.
        """
        cfg = self.config
        x, outer, inner_total, fallback, converged, free, lam, traj = _drive(
            cfg.tol, cfg.p_max, cfg.track, cfg.max_outer, n, sub_solve, reduced_gradient, warm
        )
        return Result(
            x=x,
            outer=outer,
            inner=inner_total,
            fallback=fallback,
            converged=converged,
            free=free,
            lam=lam,
            traj=traj,
        )

solve(a, b, warm=None)

Minimise 1/2 x^T A x - b^T x over x >= 0 by the active-set loop.

Each free-block solve is delegated to :attr:inner; the reduced matrix is never materialised and A is never refactorised. The batch block-pivot fast path is guarded by a least-index Bland fallback, so termination at the unique global minimiser is unconditional.

Parameters:

Name Type Description Default
a SymmetricOperator

The SPD operator A (a :class:cvx.linalg.SymmetricOperator) — DenseOperator for an explicit array, GramOperator(M, ridge) for A = M^T M + ridge I whose Gram matrix is never formed.

required
b Vector

The linear term b.

required
warm tuple[NDArray[bool_], Vector] | None

Optional (free_mask, x_prev) pair from a previous solve. Starts the loop from that free set and warm-starts every inner solve from the newest iterate (the :class:nncg.inner.Exact inner solver is direct, so it has nothing to seed but still starts from the warm free set) — across a support-stable parameter step the loop then terminates in a single outer step.

None

Returns:

Name Type Description
A Result

class:Result; converged is True iff the KKT system was

Result

satisfied to config.tol, which certifies the unique global

Result

minimiser.

Raises:

Type Description
TypeError

When a is not a :class:cvx.linalg.SymmetricOperator.

ValueError

When the operator dimension does not match len(b), or on the inner solver's own conditions in :meth:InnerSolver.solve.

NotImplementedError

When a diagonal-preconditioned inner solver (:class:nncg.inner.Jacobi) meets a backend without diag (propagated from cvx.linalg).

Source code in src/nncg/solver.py
def solve(
    self,
    a: SymmetricOperator,
    b: Vector,
    warm: tuple[NDArray[np.bool_], Vector] | None = None,
) -> Result:
    """Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` by the active-set loop.

    Each free-block solve is delegated to :attr:`inner`; the reduced matrix
    is never materialised and ``A`` is never refactorised. The batch
    block-pivot fast path is guarded by a least-index Bland fallback, so
    termination at the unique global minimiser is unconditional.

    Args:
        a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`) —
            ``DenseOperator`` for an explicit array, ``GramOperator(M, ridge)``
            for ``A = M^T M + ridge I`` whose Gram matrix is never formed.
        b: The linear term ``b``.
        warm: Optional ``(free_mask, x_prev)`` pair from a previous solve.
            Starts the loop from that free set and warm-starts every inner
            solve from the newest iterate (the :class:`nncg.inner.Exact`
            inner solver is direct, so it has nothing to seed but still
            starts from the warm free set) — across a support-stable
            parameter step the loop then terminates in a single outer step.

    Returns:
        A :class:`Result`; ``converged`` is True iff the KKT system was
        satisfied to ``config.tol``, which certifies the unique global
        minimiser.

    Raises:
        TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
        ValueError: When the operator dimension does not match ``len(b)``, or
            on the inner solver's own conditions in
            :meth:`InnerSolver.solve`.
        NotImplementedError: When a diagonal-preconditioned inner solver
            (:class:`nncg.inner.Jacobi`) meets a backend without ``diag``
            (propagated from ``cvx.linalg``).
    """
    _require_operator(a, b)

    def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]:
        """Solve the reduced system ``A_F x_F = b_F`` with the chosen inner solver."""
        xf, k_step = self.inner.solve(a, idx, b[idx], x0)
        return xf, None, k_step

    def reduced_gradient(x: Vector, lam: Vector | None) -> Vector:  # noqa: ARG001
        """Return the reduced gradient ``s = A x - b``."""
        return a.matvec(x) - b

    return self._run(len(b), sub_solve, reduced_gradient, warm)

solve_eq(a, b, b_eq, c_eq, warm=None)

Solve min 1/2 x^T A x - b^T x subject to x >= 0 and B x = c.

On each free set the saddle system is solved by eliminating the multiplier lambda in R^p through the p-by-p Schur complement S = B_F A_F^{-1} B_F^T: the p + 1 right-hand sides share the operator A_F and are each one inner solve, then S lambda = c - B_F v0 fixes the multipliers in closed form. The single normalisation 1^T x = beta is the p = 1 case. B must have full row rank on the visited free sets (automatic for p = 1).

Parameters:

Name Type Description Default
a SymmetricOperator

The SPD operator A (a :class:cvx.linalg.SymmetricOperator).

required
b Vector

The linear term b.

required
b_eq Matrix

Equality matrix B of shape (p, n), full row rank.

required
c_eq Vector

Equality right-hand side c of shape (p,).

required
warm tuple[NDArray[bool_], Vector] | None

Optional (free_mask, x_prev) pair from a previous solve. Starts the loop from that free set and seeds the v0 solve of every saddle step from the newest iterate; the v1 columns are re-solved cold (their right-hand sides are the rows of B_F, unrelated to x_prev). Across a support-stable parameter step the loop then terminates in a single outer step.

None

Returns:

Name Type Description
A Result

class:Result with the multipliers in lam. The reduced

Result

gradient underlying the dual test is s = A x - b - B^T lam.

Raises:

Type Description
TypeError

When a is not a :class:cvx.linalg.SymmetricOperator.

ValueError

When the operator dimension does not match len(b), or on the inner solver's own conditions in :meth:InnerSolver.solve.

NotImplementedError

When a diagonal-preconditioned inner solver (:class:nncg.inner.Jacobi) meets a backend without diag (propagated from cvx.linalg).

Source code in src/nncg/solver.py
def solve_eq(
    self,
    a: SymmetricOperator,
    b: Vector,
    b_eq: Matrix,
    c_eq: Vector,
    warm: tuple[NDArray[np.bool_], Vector] | None = None,
) -> Result:
    """Solve ``min 1/2 x^T A x - b^T x`` subject to ``x >= 0`` and ``B x = c``.

    On each free set the saddle system is solved by eliminating the
    multiplier ``lambda`` in R^p through the p-by-p Schur complement
    ``S = B_F A_F^{-1} B_F^T``: the ``p + 1`` right-hand sides share the
    operator ``A_F`` and are each one inner solve, then ``S lambda = c - B_F
    v0`` fixes the multipliers in closed form. The single normalisation
    ``1^T x = beta`` is the ``p = 1`` case. ``B`` must have full row rank on
    the visited free sets (automatic for ``p = 1``).

    Args:
        a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`).
        b: The linear term ``b``.
        b_eq: Equality matrix ``B`` of shape ``(p, n)``, full row rank.
        c_eq: Equality right-hand side ``c`` of shape ``(p,)``.
        warm: Optional ``(free_mask, x_prev)`` pair from a previous solve.
            Starts the loop from that free set and seeds the ``v0`` solve of
            every saddle step from the newest iterate; the ``v1`` columns are
            re-solved cold (their right-hand sides are the rows of ``B_F``,
            unrelated to ``x_prev``). Across a support-stable parameter step
            the loop then terminates in a single outer step.

    Returns:
        A :class:`Result` with the multipliers in ``lam``. The reduced
        gradient underlying the dual test is ``s = A x - b - B^T lam``.

    Raises:
        TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
        ValueError: When the operator dimension does not match ``len(b)``, or
            on the inner solver's own conditions in
            :meth:`InnerSolver.solve`.
        NotImplementedError: When a diagonal-preconditioned inner solver
            (:class:`nncg.inner.Jacobi`) meets a backend without ``diag``
            (propagated from ``cvx.linalg``).
    """
    _require_operator(a, b)

    def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]:
        """Solve the saddle system on the free set via the p-by-p Schur complement."""
        return _saddle_solve(self.inner, a, b, b_eq, c_eq, idx, x0)

    def reduced_gradient(x: Vector, lam: Vector | None) -> Vector:
        """Return the constrained reduced gradient ``s = A x - b - B^T lam``."""
        correction = b_eq.T @ lam if lam is not None else np.zeros_like(b)
        return a.matvec(x) - b - correction

    return self._run(len(b), sub_solve, reduced_gradient, warm)

InnerSolver

Bases: Protocol

The inner-solver interface the active-set loop depends on (dependency inversion).

Structural (a :class:typing.Protocol): anything with a matching :meth:solve is an inner solver, so implementations need neither import nor subclass this — this module (the high-level loop) owns the interface, and the implementations depend on it, not the other way round. The built-ins live in :mod:nncg.inner (:class:~nncg.inner.CG, :class:~nncg.inner.Jacobi, :class:~nncg.inner.Nystrom, :class:~nncg.inner.Exact); further ones — Clarabel- or KKT-equation-based — live in Jebel-Quant/mean_variance_solvers.

Source code in src/nncg/solver.py
class InnerSolver(Protocol):
    """The inner-solver interface the active-set loop depends on (dependency inversion).

    Structural (a :class:`typing.Protocol`): anything with a matching
    :meth:`solve` is an inner solver, so implementations need neither import
    nor subclass this — this module (the high-level loop) owns the interface, and
    the implementations depend on it, not the other way round. The built-ins live
    in :mod:`nncg.inner` (:class:`~nncg.inner.CG`, :class:`~nncg.inner.Jacobi`,
    :class:`~nncg.inner.Nystrom`, :class:`~nncg.inner.Exact`); further ones —
    Clarabel- or KKT-equation-based — live in Jebel-Quant/mean_variance_solvers.
    """

    def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
        """Solve the free-block system ``A[F, F] y = rhs``, warm-started at ``x0``.

        Returns the free-block solution and the inner iteration count (each
        direct solve counts as one). Called once per outer step by the
        bound-constrained loop, and once per ``p + 1`` right-hand side per outer
        step by the equality-augmented loop.
        """
        ...

solve(op, idx, rhs, x0)

Solve the free-block system A[F, F] y = rhs, warm-started at x0.

Returns the free-block solution and the inner iteration count (each direct solve counts as one). Called once per outer step by the bound-constrained loop, and once per p + 1 right-hand side per outer step by the equality-augmented loop.

Source code in src/nncg/solver.py
def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
    """Solve the free-block system ``A[F, F] y = rhs``, warm-started at ``x0``.

    Returns the free-block solution and the inner iteration count (each
    direct solve counts as one). Called once per outer step by the
    bound-constrained loop, and once per ``p + 1`` right-hand side per outer
    step by the equality-augmented loop.
    """
    ...

Result dataclass

Outcome of an active-set solve.

Attributes:

Name Type Description
x Vector

The minimiser (or the final iterate if converged is False).

outer int

Number of outer active-set steps taken.

inner int

Total inner (CG/PCG) iterations across all outer steps; each direct inner solve counts as one.

fallback int

Number of least-index Bland fallback pivots taken.

converged bool

True when the KKT exit was reached; False when an max_outer cap stopped the loop first.

free NDArray[bool_]

Boolean mask of the final free set.

lam Vector | None

Multipliers of the equality constraints (equality-augmented solves only; None otherwise).

traj list[tuple[int, ...]] | None

The sequence of visited free sets as index tuples when trajectory tracking was requested; None otherwise.

Source code in src/nncg/solver.py
@dataclass(frozen=True)
class Result:
    """Outcome of an active-set solve.

    Attributes:
        x: The minimiser (or the final iterate if ``converged`` is False).
        outer: Number of outer active-set steps taken.
        inner: Total inner (CG/PCG) iterations across all outer steps; each
            direct inner solve counts as one.
        fallback: Number of least-index Bland fallback pivots taken.
        converged: True when the KKT exit was reached; False when an
            ``max_outer`` cap stopped the loop first.
        free: Boolean mask of the final free set.
        lam: Multipliers of the equality constraints (equality-augmented
            solves only; None otherwise).
        traj: The sequence of visited free sets as index tuples when
            trajectory tracking was requested; None otherwise.
    """

    x: Vector
    outer: int
    inner: int
    fallback: int
    converged: bool
    free: NDArray[np.bool_]
    lam: Vector | None = None
    traj: list[tuple[int, ...]] | None = None

MPRGP

A standalone matrix-free projection solver for the same bound-constrained problem (Dostál & Schöberl) — conjugate-gradient, expansion and proportioning steps under the proportioning test, no factorisation. A first-order alternative to the active-set loop; bound constraints only.

nncg.mprgp

MPRGP: modified proportioning with reduced gradient projections.

A matrix-free, projection-based alternative outer solver for the same strictly convex non-negative quadratic program the active-set loop targets,

min_{x >= 0}  1/2 x^T A x - b^T x,        A symmetric positive definite.

Where :class:nncg.solver.ActiveSetSolver toggles a working set and solves an unconstrained system on each free block, MPRGP (Dostál & Schöberl, 2005) never factorises anything: it interleaves three cheap first-order moves, each costing one or two Hessian products,

  • a conjugate-gradient step that minimises within the current face while it stays feasible (the free set unchanged),
  • an expansion step that walks to the nearest bound and takes one fixed-step projected-gradient move to add constraints to the active set, and
  • a proportioning step along the chopped gradient that removes constraints from the active set,

switched by the proportioning test ||beta(x)||^2 <= gamma^2 phi~(x)^T phi(x). With the projected-gradient step bounded by alpha_bar in (0, 2/||A||] the iteration converges for any feasible start, and — because it identifies the active set of the minimiser in finitely many steps and then reduces to plain CG on the optimal face — it terminates finitely in exact arithmetic. A enters only through :meth:cvx.linalg.SymmetricOperator.matvec, so the n x n matrix is never formed; ||A|| for the step bound is estimated matrix-free by power iteration.

This is the bound-constrained solver; the equality-augmented variant B x = c is out of scope here (it needs an augmented-Lagrangian outer wrap, SMALBE/SMALSE around MPRGP) — use :meth:nncg.solver.ActiveSetSolver.solve_eq for that.

Reference: Z. Dostál and J. Schöberl, "Minimizing quadratic functions subject to bound constraints with the rate of convergence and finite termination", Comput. Optim. Appl. 30 (2005), 23-43.

Iterate = tuple[Vector, Vector, Vector] module-attribute

The MPRGP iteration state (x, g, p): iterate, gradient A x - b, CG direction.

Every move consumes one state and returns the next, so the loop in :func:_mprgp carries no other mutable numerics — only the counters the result reports.

MatVec = Callable[[Vector], Vector] module-attribute

The action v -> A v of the SPD operator — MPRGP's only access to A.

MPRGP dataclass

The MPRGP solver for the non-negative quadratic program.

A matrix-free, factorisation-free alternative to :class:nncg.solver.ActiveSetSolver on the bound-constrained problem min_{x>=0} 1/2 x^T A x - b^T x: it interleaves conjugate-gradient, expansion and proportioning steps under the proportioning test, never forming or factorising A. Holds only its :class:MPRGPConfig; the operator and right-hand side are passed to :meth:solve.

Attributes:

Name Type Description
config MPRGPConfig

Solver configuration (tolerance, proportioning constant, projected-gradient step, iteration cap, seed).

Examples:

The same operator interface as the active-set loop:

>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> from nncg import MPRGP, MPRGPConfig, kkt_violation
>>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
>>> b = np.array([2.0, -2.0])
>>> res = MPRGP().solve(a, b)
>>> res.converged
True
>>> bool(np.allclose(res.x, [1.0, 0.0]))
True
>>> round(kkt_violation(a, b, res.x), 12)
0.0

The counts break the run down by move, and always sum to iterations; hessian_products is the honest matrix-free cost — at least one product per step, plus the initial gradient:

>>> res.iterations == res.cg_steps + res.expansion_steps + res.proportioning_steps
True
>>> res.hessian_products > res.iterations
True

Nothing is factorised, so the whole configuration is a handful of scalars — here a tighter tolerance and an explicit projected-gradient step, which skips the power-iteration estimate of 1/||A||:

>>> tight = MPRGP(config=MPRGPConfig(tol=1e-12, alpha_bar=0.5)).solve(a, b)
>>> tight.converged
True
>>> bool(np.allclose(tight.x, [1.0, 0.0]))
True
Source code in src/nncg/mprgp.py
@dataclass(frozen=True)
class MPRGP:
    """The MPRGP solver for the non-negative quadratic program.

    A matrix-free, factorisation-free alternative to
    :class:`nncg.solver.ActiveSetSolver` on the bound-constrained problem
    ``min_{x>=0} 1/2 x^T A x - b^T x``: it interleaves conjugate-gradient,
    expansion and proportioning steps under the proportioning test, never forming
    or factorising ``A``. Holds only its :class:`MPRGPConfig`; the operator and
    right-hand side are passed to :meth:`solve`.

    Attributes:
        config: Solver configuration (tolerance, proportioning constant,
            projected-gradient step, iteration cap, seed).

    Examples:
        The same operator interface as the active-set loop:

        >>> import numpy as np
        >>> from cvx.linalg import DenseOperator
        >>> from nncg import MPRGP, MPRGPConfig, kkt_violation
        >>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
        >>> b = np.array([2.0, -2.0])
        >>> res = MPRGP().solve(a, b)
        >>> res.converged
        True
        >>> bool(np.allclose(res.x, [1.0, 0.0]))
        True
        >>> round(kkt_violation(a, b, res.x), 12)
        0.0

        The counts break the run down by move, and always sum to
        ``iterations``; ``hessian_products`` is the honest matrix-free cost —
        at least one product per step, plus the initial gradient:

        >>> res.iterations == res.cg_steps + res.expansion_steps + res.proportioning_steps
        True
        >>> res.hessian_products > res.iterations
        True

        Nothing is factorised, so the whole configuration is a handful of
        scalars — here a tighter tolerance and an explicit projected-gradient
        step, which skips the power-iteration estimate of ``1/||A||``:

        >>> tight = MPRGP(config=MPRGPConfig(tol=1e-12, alpha_bar=0.5)).solve(a, b)
        >>> tight.converged
        True
        >>> bool(np.allclose(tight.x, [1.0, 0.0]))
        True
    """

    config: MPRGPConfig = _DEFAULT_MPRGP

    def solve(self, a: SymmetricOperator, b: Vector, x0: Vector | None = None) -> MPRGPResult:
        """Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` by MPRGP.

        Args:
            a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`) —
                ``DenseOperator`` for an explicit array, ``GramOperator(M, ridge)``
                for ``A = M^T M + ridge I`` whose Gram matrix is never formed.
            b: The linear term ``b``.
            x0: Optional feasible warm start; it is projected onto ``x >= 0`` and
                the iteration begins there. ``None`` starts from the origin.

        Returns:
            An :class:`MPRGPResult`; ``converged`` is True iff the projected
            gradient fell below ``config.tol * ||b||``, which certifies the unique
            global minimiser.

        Raises:
            TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
            ValueError: When the operator dimension does not match ``len(b)``, or
                when ``config.alpha_bar`` is set but not strictly positive.
        """
        _require_operator(a, b)
        cfg = self.config
        alpha_bar = _resolve_alpha_bar(a, cfg.alpha_bar, cfg.seed)
        return _mprgp(a.matvec, b, x0, alpha_bar, cfg.gamma, cfg.tol, cfg.max_iter)

solve(a, b, x0=None)

Minimise 1/2 x^T A x - b^T x over x >= 0 by MPRGP.

Parameters:

Name Type Description Default
a SymmetricOperator

The SPD operator A (a :class:cvx.linalg.SymmetricOperator) — DenseOperator for an explicit array, GramOperator(M, ridge) for A = M^T M + ridge I whose Gram matrix is never formed.

required
b Vector

The linear term b.

required
x0 Vector | None

Optional feasible warm start; it is projected onto x >= 0 and the iteration begins there. None starts from the origin.

None

Returns:

Name Type Description
An MPRGPResult

class:MPRGPResult; converged is True iff the projected

MPRGPResult

gradient fell below config.tol * ||b||, which certifies the unique

MPRGPResult

global minimiser.

Raises:

Type Description
TypeError

When a is not a :class:cvx.linalg.SymmetricOperator.

ValueError

When the operator dimension does not match len(b), or when config.alpha_bar is set but not strictly positive.

Source code in src/nncg/mprgp.py
def solve(self, a: SymmetricOperator, b: Vector, x0: Vector | None = None) -> MPRGPResult:
    """Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` by MPRGP.

    Args:
        a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`) —
            ``DenseOperator`` for an explicit array, ``GramOperator(M, ridge)``
            for ``A = M^T M + ridge I`` whose Gram matrix is never formed.
        b: The linear term ``b``.
        x0: Optional feasible warm start; it is projected onto ``x >= 0`` and
            the iteration begins there. ``None`` starts from the origin.

    Returns:
        An :class:`MPRGPResult`; ``converged`` is True iff the projected
        gradient fell below ``config.tol * ||b||``, which certifies the unique
        global minimiser.

    Raises:
        TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
        ValueError: When the operator dimension does not match ``len(b)``, or
            when ``config.alpha_bar`` is set but not strictly positive.
    """
    _require_operator(a, b)
    cfg = self.config
    alpha_bar = _resolve_alpha_bar(a, cfg.alpha_bar, cfg.seed)
    return _mprgp(a.matvec, b, x0, alpha_bar, cfg.gamma, cfg.tol, cfg.max_iter)

MPRGPConfig dataclass

Configuration of the MPRGP solver (:class:MPRGP).

Attributes:

Name Type Description
tol float

Relative stopping tolerance on the projected gradient — the loop exits when ||phi(x) + beta(x)|| <= tol * ||b|| (||b|| replaced by 1 when b = 0), which certifies the KKT conditions.

gamma float

Proportioning constant Gamma > 0 balancing expansion against proportioning. 1.0 is the standard, near-optimal choice; larger values expand more eagerly, smaller ones release more eagerly.

alpha_bar float | None

Fixed projected-gradient step, which must satisfy 0 < alpha_bar <= 2/||A|| for convergence. None estimates the safe default 1/||A|| by matrix-free power iteration.

max_iter int

Iteration cap; the current iterate is returned with converged=False when it is hit.

seed int

Seed of the power-iteration ||A|| estimate (only used when alpha_bar is None), fixed so a solve is reproducible.

Raises:

Type Description
ValueError

When gamma is not strictly positive.

Source code in src/nncg/mprgp.py
@dataclass(frozen=True)
class MPRGPConfig:
    """Configuration of the MPRGP solver (:class:`MPRGP`).

    Attributes:
        tol: Relative stopping tolerance on the projected gradient — the loop
            exits when ``||phi(x) + beta(x)|| <= tol * ||b||`` (``||b||`` replaced
            by ``1`` when ``b = 0``), which certifies the KKT conditions.
        gamma: Proportioning constant ``Gamma > 0`` balancing expansion against
            proportioning. ``1.0`` is the standard, near-optimal choice; larger
            values expand more eagerly, smaller ones release more eagerly.
        alpha_bar: Fixed projected-gradient step, which must satisfy
            ``0 < alpha_bar <= 2/||A||`` for convergence. ``None`` estimates the
            safe default ``1/||A||`` by matrix-free power iteration.
        max_iter: Iteration cap; the current iterate is returned with
            ``converged=False`` when it is hit.
        seed: Seed of the power-iteration ``||A||`` estimate (only used when
            ``alpha_bar is None``), fixed so a solve is reproducible.

    Raises:
        ValueError: When ``gamma`` is not strictly positive.
    """

    tol: float = 1e-8
    gamma: float = 1.0
    alpha_bar: float | None = None
    max_iter: int = 100_000
    seed: int = 0

    def __post_init__(self) -> None:
        """Validate that the proportioning constant is strictly positive."""
        if self.gamma <= 0.0:
            msg = f"gamma must be strictly positive; got {self.gamma:.2e}"
            raise ValueError(msg)

__post_init__()

Validate that the proportioning constant is strictly positive.

Source code in src/nncg/mprgp.py
def __post_init__(self) -> None:
    """Validate that the proportioning constant is strictly positive."""
    if self.gamma <= 0.0:
        msg = f"gamma must be strictly positive; got {self.gamma:.2e}"
        raise ValueError(msg)

MPRGPResult dataclass

Outcome of an MPRGP solve.

The iteration counts are broken out by move because they carry the algorithm's signature: expansion and proportioning steps are the ones that change the active set, while a run of conjugate-gradient steps is plain CG on a fixed face. hessian_products is the honest cost of a matrix-free method — one product per CG or proportioning step, two per expansion step, plus one for the initial gradient.

Attributes:

Name Type Description
x Vector

The minimiser (or the final iterate if converged is False).

iterations int

Total MPRGP steps taken (the sum of the three move counts).

hessian_products int

Number of operator matrix-vector products consumed.

cg_steps int

Conjugate-gradient (minimisation-within-the-face) steps.

expansion_steps int

Expansion (bound-hitting projected-gradient) steps.

proportioning_steps int

Proportioning (constraint-releasing) steps.

converged bool

True when the projected-gradient stopping test was met; False when max_iter stopped the loop first.

free NDArray[bool_]

Boolean mask of the final free set (x > 0).

Source code in src/nncg/mprgp.py
@dataclass(frozen=True)
class MPRGPResult:
    """Outcome of an MPRGP solve.

    The iteration counts are broken out by move because they carry the algorithm's
    signature: expansion and proportioning steps are the ones that change the
    active set, while a run of conjugate-gradient steps is plain CG on a fixed
    face. ``hessian_products`` is the honest cost of a matrix-free method — one
    product per CG or proportioning step, two per expansion step, plus one for the
    initial gradient.

    Attributes:
        x: The minimiser (or the final iterate if ``converged`` is False).
        iterations: Total MPRGP steps taken (the sum of the three move counts).
        hessian_products: Number of operator matrix-vector products consumed.
        cg_steps: Conjugate-gradient (minimisation-within-the-face) steps.
        expansion_steps: Expansion (bound-hitting projected-gradient) steps.
        proportioning_steps: Proportioning (constraint-releasing) steps.
        converged: True when the projected-gradient stopping test was met; False
            when ``max_iter`` stopped the loop first.
        free: Boolean mask of the final free set (``x > 0``).
    """

    x: Vector
    iterations: int
    hessian_products: int
    cg_steps: int
    expansion_steps: int
    proportioning_steps: int
    converged: bool
    free: NDArray[np.bool_]

KKT certificate

nncg.certificate

The KKT certificate for the non-negative quadratic program and its shared precondition.

:func:kkt_violation scores how far a candidate is from the unique global minimiser of min_{x>=0} 1/2 x'Ax - b'x — zero certifies optimality — and is the load-bearing check the paper's numerical study reports against. :func:_require_operator is the one operator/right-hand-side precondition shared by the certificate and both :class:nncg.solver.ActiveSetSolver entry points.

kkt_violation(a, b, x)

Maximum violation of the KKT system of min_{x>=0} 1/2 x'Ax - b'x.

Parameters:

Name Type Description Default
a SymmetricOperator

The SPD operator A (a :class:cvx.linalg.SymmetricOperator).

required
b Vector

The linear term b.

required
x Vector

Candidate solution.

required

Returns:

Type Description
float

max of the negativity violations of x and of the reduced

float

gradient s = A x - b, and of the complementarity products

float

|x_i s_i|. Zero certifies the unique global minimiser.

Examples:

Note that a must be an operator — a bare array raises TypeError:

>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
>>> b = np.array([2.0, -2.0])

The minimiser certifies at zero, while the origin does not:

>>> round(kkt_violation(a, b, np.array([1.0, 0.0])), 12)
0.0
>>> kkt_violation(a, b, np.zeros(2)) > 0
True
Source code in src/nncg/certificate.py
def kkt_violation(a: SymmetricOperator, b: Vector, x: Vector) -> float:
    """Maximum violation of the KKT system of ``min_{x>=0} 1/2 x'Ax - b'x``.

    Args:
        a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`).
        b: The linear term ``b``.
        x: Candidate solution.

    Returns:
        ``max`` of the negativity violations of ``x`` and of the reduced
        gradient ``s = A x - b``, and of the complementarity products
        ``|x_i s_i|``. Zero certifies the unique global minimiser.

    Examples:
        Note that ``a`` must be an operator — a bare array raises ``TypeError``:

        >>> import numpy as np
        >>> from cvx.linalg import DenseOperator
        >>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
        >>> b = np.array([2.0, -2.0])

        The minimiser certifies at zero, while the origin does not:

        >>> round(kkt_violation(a, b, np.array([1.0, 0.0])), 12)
        0.0
        >>> kkt_violation(a, b, np.zeros(2)) > 0
        True
    """
    _require_operator(a, b)
    s = a.matvec(x) - b
    # The leading 0.0 is a floor on a quantity that is mathematically non-negative
    # already, and it is there for the sign of zero rather than the magnitude: when
    # every term is zero, `np.max(-s, initial=0.0)` may hand back -0.0 (its reduce
    # path picks a different one of the two equal zeros on linux than on macOS), and
    # `-0.0` compares equal to `0.0` but does not print the same. Python's `max`
    # replaces its running best only on a strict `>`, so the +0.0 seeded here
    # survives and the certificate reports one platform-independent zero.
    return float(
        max(
            0.0,
            np.max(-x, initial=0.0),
            np.max(-s, initial=0.0),
            np.max(np.abs(x * s), initial=0.0),
        )
    )

Inner solvers

The pluggable free-block solvers the active-set loop delegates to. Pass an instance to ActiveSetSolver(inner=...) to tune one; the string shortcuts on the wrappers take defaults only.

nncg.inner

Inner solvers: one free-block system A[F, F] y = rhs per active-set step.

Each concrete inner solver provides solve(op, idx, rhs, x0) -> (y, iters), solving the free-block system A[F, F] y = rhs (and so satisfies the :class:nncg.solver.InnerSolver interface). This is the only module that knows about preconditioning: the built-in solvers are the identity/Jacobi/Nyström- preconditioned CG variants (:class:CG, :class:Jacobi, :class:Nystrom, :class:GlobalNystrom) and the direct :class:Exact. The operator-derived builders they run on — the free-block matvec and the diagonal/Nyström preconditioners — live in :mod:nncg.preconditioners. Further inner solvers — e.g. Clarabel- or KKT-equation-based — live in Jebel-Quant/mean_variance_solvers and satisfy the same structural interface.

Examples:

The inner solver is the one thing that varies between these runs — the outer loop, and the minimiser it certifies, are the same:

>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> from nncg import CG, ActiveSetSolver, Exact, Jacobi
>>> a = DenseOperator(np.diag([1.0, 2.0, 4.0, 8.0]))
>>> b = np.array([1.0, 2.0, -4.0, -8.0])
>>> for inner in (CG(), Jacobi(), Exact()):
...     res = ActiveSetSolver(inner=inner).solve(a, b)
...     print(type(inner).__name__, res.converged, np.allclose(res.x, [1.0, 1.0, 0.0, 0.0]))
CG True True
Jacobi True True
Exact True True

The direct solver counts one inner "iteration" per solve, so it never spends more than the number of outer steps:

>>> direct = ActiveSetSolver(inner=Exact()).solve(a, b)
>>> direct.inner <= direct.outer
True

The Nyström solvers sketch the free block at :attr:NystromConfig.rank, so they belong on a problem larger than that rank and with a decaying spectrum — here three orders of geometric decay, with a planted optimum on the even coordinates:

>>> from nncg import GlobalNystrom, Nystrom
>>> d = 10.0 ** -np.linspace(0.0, 3.0, 40)
>>> x_star = np.where(np.arange(40) % 2 == 0, 1.0, 0.0)
>>> b = d * x_star - (1.0 - x_star)
>>> for inner in (Nystrom(), GlobalNystrom()):
...     res = ActiveSetSolver(inner=inner).solve(DenseOperator(np.diag(d)), b)
...     print(type(inner).__name__, res.converged, np.allclose(res.x, x_star))
Nystrom True True
GlobalNystrom True True

CG dataclass

Plain matrix-free conjugate gradients (the identity preconditioner).

Attributes:

Name Type Description
krylov KrylovConfig

Tolerance and iteration cap of the CG solves (tol defaults to 1e-10).

Source code in src/nncg/inner.py
@dataclass(frozen=True)
class CG:
    """Plain matrix-free conjugate gradients (the identity preconditioner).

    Attributes:
        krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``).
    """

    krylov: KrylovConfig = field(default_factory=_default_krylov)

    def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
        """Solve the free block ``A[F, F] y = rhs`` by plain CG."""
        return _pcg_block(op, idx, rhs, x0, self.krylov, None)

solve(op, idx, rhs, x0)

Solve the free block A[F, F] y = rhs by plain CG.

Source code in src/nncg/inner.py
def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
    """Solve the free block ``A[F, F] y = rhs`` by plain CG."""
    return _pcg_block(op, idx, rhs, x0, self.krylov, None)

Exact dataclass

Direct free-block solve via op.solve_free (one "iteration" per solve).

Suits backends whose solve_free is structured and cheap (e.g. FactorOperator's Woodbury solve at O(|F| r^2)). It ignores warm starts.

The rcond_free conditioning guard depends only on the free block, not the right-hand side, so it is estimated at most once per free set: :meth:nncg.solver.ActiveSetSolver.solve_eq drives p + 1 solves through the same free set per outer step, and the (up to O(|F|^3)) estimate must not be paid p + 1 times over. The last verified (operator, idx) is memoised in a private single slot — keyed on operator identity so the memo can never carry a stale verdict across operators, and excluded from equality/repr so Exact stays a value.

On the plain :meth:~nncg.solver.ActiveSetSolver.solve path every outer step visits a different free set, so the memo never hits and the guard is paid on every step — where, for a dense free block, the O(|F|^3) eigendecomposition can cost several times the Cholesky solve it precedes. The guard is also redundant when solve_free already fails loudly on a rank-deficient block (e.g. cvx.linalg.cholesky_solve's Cholesky→LU fallback). Set check_conditioning=False to skip it and let solve_free surface any singularity itself.

Attributes:

Name Type Description
check_conditioning bool

Estimate rcond_free and raise on a numerically singular free block before each (new) solve. Default True; set False to trade the diagnostic for the raw structured solve.

Source code in src/nncg/inner.py
@dataclass(frozen=True)
class Exact:
    """Direct free-block solve via ``op.solve_free`` (one "iteration" per solve).

    Suits backends whose ``solve_free`` is structured and cheap (e.g.
    ``FactorOperator``'s Woodbury solve at ``O(|F| r^2)``). It ignores warm starts.

    The ``rcond_free`` conditioning guard depends only on the free block, not the
    right-hand side, so it is estimated at most once per free set:
    :meth:`nncg.solver.ActiveSetSolver.solve_eq` drives ``p + 1`` solves through
    the *same* free set per outer step, and the (up to ``O(|F|^3)``) estimate must
    not be paid ``p + 1`` times over. The last verified ``(operator, idx)`` is
    memoised in a private single slot — keyed on operator identity so the memo can
    never carry a stale verdict across operators, and excluded from equality/repr
    so ``Exact`` stays a value.

    On the plain :meth:`~nncg.solver.ActiveSetSolver.solve` path every outer step
    visits a *different* free set, so the memo never hits and the guard is paid on
    every step — where, for a dense free block, the ``O(|F|^3)`` eigendecomposition
    can cost several times the Cholesky solve it precedes. The guard is also
    redundant when ``solve_free`` already fails loudly on a rank-deficient block
    (e.g. ``cvx.linalg.cholesky_solve``'s Cholesky→LU fallback). Set
    ``check_conditioning=False`` to skip it and let ``solve_free`` surface any
    singularity itself.

    Attributes:
        check_conditioning: Estimate ``rcond_free`` and raise on a numerically
            singular free block before each (new) solve. Default ``True``; set
            ``False`` to trade the diagnostic for the raw structured solve.
    """

    check_conditioning: bool = True
    _checked_op: SymmetricOperator | None = field(default=None, compare=False, repr=False)
    _checked_idx: NDArray[np.int_] | None = field(default=None, compare=False, repr=False)

    def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:  # noqa: ARG002
        """Solve the free block ``A[F, F] y = rhs`` directly, guarding its conditioning once per free set."""
        if self.check_conditioning and not _same_free_block(self._checked_op, self._checked_idx, op, idx):
            _raise_if_singular(op, idx)
            object.__setattr__(self, "_checked_op", op)
            object.__setattr__(self, "_checked_idx", idx)
        return op.solve_free(idx, rhs), 1

solve(op, idx, rhs, x0)

Solve the free block A[F, F] y = rhs directly, guarding its conditioning once per free set.

Source code in src/nncg/inner.py
def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:  # noqa: ARG002
    """Solve the free block ``A[F, F] y = rhs`` directly, guarding its conditioning once per free set."""
    if self.check_conditioning and not _same_free_block(self._checked_op, self._checked_idx, op, idx):
        _raise_if_singular(op, idx)
        object.__setattr__(self, "_checked_op", op)
        object.__setattr__(self, "_checked_idx", idx)
    return op.solve_free(idx, rhs), 1

GlobalNystrom dataclass

Nyström-preconditioned CG sketched once on the full operator, then masked per free block.

:class:Nystrom resketches A[F, F] from scratch on every outer step — the rank + oversample matrix-free products, a QR, a small Cholesky and an SVD, all paid again each time the free set changes. This class instead sketches the full operator A once: restricting a rank-rank factorization to a principal submatrix is exact ((U diag(lam) U^T)[F, F] = U_F diag(lam) U_F^T for U_F = U[F, :]), so masking rows of the one global basis gives a valid free-block preconditioner with no further matrix-free products against A — only a small rank x rank factorization per free block (see :func:nncg.preconditioners._masked_nystrom). This amortises well when the same operator is solved repeatedly (a parameter sweep, successive warm starts) or the active-set loop takes many outer steps; the trade is a preconditioner not adapted to each free block's own local spectrum, so it can take a few more CG iterations than a freshly-sketched :class:Nystrom on a small or spectrally unusual free block.

The global sketch is memoised in a private single slot, keyed on operator identity so the cache can never carry a stale sketch across operators (mirrors :class:Exact's conditioning memo) — excluded from equality/repr so this class stays a value.

Attributes:

Name Type Description
krylov KrylovConfig

Tolerance and iteration cap of the CG solves (tol defaults to 1e-10).

nystrom NystromConfig

Sketch rank, oversampling, shift and seed of the global sketch (see :class:nncg.preconditioners.NystromConfig).

Source code in src/nncg/inner.py
@dataclass(frozen=True)
class GlobalNystrom:
    """Nyström-preconditioned CG sketched once on the full operator, then masked per free block.

    :class:`Nystrom` resketches ``A[F, F]`` from scratch on every outer step —
    the `rank + oversample` matrix-free products, a QR, a small Cholesky and an
    SVD, all paid again each time the free set changes. This class instead
    sketches the *full* operator ``A`` once: restricting a rank-``rank``
    factorization to a principal submatrix is exact
    (``(U diag(lam) U^T)[F, F] = U_F diag(lam) U_F^T`` for ``U_F = U[F, :]``),
    so masking rows of the one global basis gives a valid free-block
    preconditioner with no further matrix-free products against ``A`` — only a
    small ``rank x rank`` factorization per free block (see
    :func:`nncg.preconditioners._masked_nystrom`). This amortises well when the
    same operator is solved repeatedly (a parameter sweep, successive warm
    starts) or the active-set loop takes many outer steps; the trade is a
    preconditioner not adapted to each free block's own local spectrum, so it
    can take a few more CG iterations than a freshly-sketched :class:`Nystrom`
    on a small or spectrally unusual free block.

    The global sketch is memoised in a private single slot, keyed on operator
    *identity* so the cache can never carry a stale sketch across operators
    (mirrors :class:`Exact`'s conditioning memo) — excluded from equality/repr
    so this class stays a value.

    Attributes:
        krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``).
        nystrom: Sketch rank, oversampling, shift and seed of the global sketch
            (see :class:`nncg.preconditioners.NystromConfig`).
    """

    krylov: KrylovConfig = field(default_factory=_default_krylov)
    nystrom: NystromConfig = field(default_factory=NystromConfig)
    _checked_op: SymmetricOperator | None = field(default=None, compare=False, repr=False)
    _sketch: GlobalNystromSketch | None = field(default=None, compare=False, repr=False)

    def _ensure_sketch(self, op: SymmetricOperator) -> GlobalNystromSketch:
        """Return the memoised global sketch of ``op``, (re)building it on a new operator."""
        sketch = self._sketch
        if self._checked_op is not op or sketch is None:
            sketch = _global_nystrom_sketch(op, self.nystrom)
            object.__setattr__(self, "_sketch", sketch)
            object.__setattr__(self, "_checked_op", op)
        return sketch

    def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
        """Solve the free block ``A[F, F] y = rhs`` by CG preconditioned from the masked global sketch."""
        precond = _masked_nystrom(self._ensure_sketch(op), idx) if idx.size else None
        return _pcg_block(op, idx, rhs, x0, self.krylov, precond)

solve(op, idx, rhs, x0)

Solve the free block A[F, F] y = rhs by CG preconditioned from the masked global sketch.

Source code in src/nncg/inner.py
def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
    """Solve the free block ``A[F, F] y = rhs`` by CG preconditioned from the masked global sketch."""
    precond = _masked_nystrom(self._ensure_sketch(op), idx) if idx.size else None
    return _pcg_block(op, idx, rhs, x0, self.krylov, precond)

Jacobi dataclass

Jacobi-preconditioned CG — runs at the operator's condition number, a bad diagonal scaling removed.

Attributes:

Name Type Description
krylov KrylovConfig

Tolerance and iteration cap of the CG solves (tol defaults to 1e-10).

Source code in src/nncg/inner.py
@dataclass(frozen=True)
class Jacobi:
    """Jacobi-preconditioned CG — runs at the operator's condition number, a bad diagonal scaling removed.

    Attributes:
        krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``).
    """

    krylov: KrylovConfig = field(default_factory=_default_krylov)

    def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
        """Solve the free block ``A[F, F] y = rhs`` by Jacobi-preconditioned CG."""
        return _pcg_block(op, idx, rhs, x0, self.krylov, _jacobi(op, idx))

solve(op, idx, rhs, x0)

Solve the free block A[F, F] y = rhs by Jacobi-preconditioned CG.

Source code in src/nncg/inner.py
def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
    """Solve the free block ``A[F, F] y = rhs`` by Jacobi-preconditioned CG."""
    return _pcg_block(op, idx, rhs, x0, self.krylov, _jacobi(op, idx))

Nystrom dataclass

Randomized Nyström-preconditioned CG — for free blocks with a steeply decaying spectrum.

Attributes:

Name Type Description
krylov KrylovConfig

Tolerance and iteration cap of the CG solves (tol defaults to 1e-10).

nystrom NystromConfig

Sketch rank, oversampling, shift and seed of the low-rank preconditioner (see :class:nncg.preconditioners.NystromConfig).

Source code in src/nncg/inner.py
@dataclass(frozen=True)
class Nystrom:
    """Randomized Nyström-preconditioned CG — for free blocks with a steeply decaying spectrum.

    Attributes:
        krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``).
        nystrom: Sketch rank, oversampling, shift and seed of the low-rank
            preconditioner (see :class:`nncg.preconditioners.NystromConfig`).
    """

    krylov: KrylovConfig = field(default_factory=_default_krylov)
    nystrom: NystromConfig = field(default_factory=NystromConfig)

    def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
        """Solve the free block ``A[F, F] y = rhs`` by Nyström-preconditioned CG (plain CG on an empty block)."""
        precond = _nystrom(op, idx, self.nystrom) if idx.size else None
        return _pcg_block(op, idx, rhs, x0, self.krylov, precond)

solve(op, idx, rhs, x0)

Solve the free block A[F, F] y = rhs by Nyström-preconditioned CG (plain CG on an empty block).

Source code in src/nncg/inner.py
def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]:
    """Solve the free block ``A[F, F] y = rhs`` by Nyström-preconditioned CG (plain CG on an empty block)."""
    precond = _nystrom(op, idx, self.nystrom) if idx.size else None
    return _pcg_block(op, idx, rhs, x0, self.krylov, precond)

NystromConfig dataclass

Tuning knobs for the Nyström preconditioner of :class:nncg.inner.Nystrom.

Attributes:

Name Type Description
rank int

Target sketch rank — the number of leading eigenpairs captured, clamped to the free-block dimension.

oversample int

Extra sketch columns drawn for accuracy before truncating back to rank (the standard randomized-SVD oversampling).

shift float | None

Explicit scalar tail eigenvalue, or None for the default (the largest eigenvalue the sketch does not capture).

seed int | None

Seed for the Gaussian test matrix; fixed by default so a solve is reproducible. None draws a fresh one.

Raises:

Type Description
ValueError

When rank < 1.

Source code in src/nncg/preconditioners.py
@dataclass(frozen=True)
class NystromConfig:
    """Tuning knobs for the Nyström preconditioner of :class:`nncg.inner.Nystrom`.

    Attributes:
        rank: Target sketch rank — the number of leading eigenpairs captured,
            clamped to the free-block dimension.
        oversample: Extra sketch columns drawn for accuracy before truncating
            back to ``rank`` (the standard randomized-SVD oversampling).
        shift: Explicit scalar tail eigenvalue, or ``None`` for the default
            (the largest eigenvalue the sketch does not capture).
        seed: Seed for the Gaussian test matrix; fixed by default so a solve is
            reproducible. ``None`` draws a fresh one.

    Raises:
        ValueError: When ``rank < 1``.
    """

    rank: int = 10
    oversample: int = 10
    shift: float | None = None
    seed: int | None = 0

    def __post_init__(self) -> None:
        """Validate that the sketch rank is a positive integer."""
        if self.rank < 1:
            msg = f"NystromConfig.rank must be a positive integer; got {self.rank}"
            raise ValueError(msg)

__post_init__()

Validate that the sketch rank is a positive integer.

Source code in src/nncg/preconditioners.py
def __post_init__(self) -> None:
    """Validate that the sketch rank is a positive integer."""
    if self.rank < 1:
        msg = f"NystromConfig.rank must be a positive integer; got {self.rank}"
        raise ValueError(msg)

Krylov core

The in-house matrix-free CG and Jacobi-preconditioned CG, warm-startable. This is the package's core contribution — the inner solvers drive it rather than you calling it directly.

nncg.krylov

Matrix-free (preconditioned) conjugate gradients — the Krylov core.

:func:pcg solves an SPD system accessed only through a mat-vec callable — the matrix is never required explicitly — and takes its preconditioner as a callable r -> M^{-1} r (config.precond=None recovers plain CG). It is operator-agnostic: the preconditioner builders that turn a :class:cvx.linalg.SymmetricOperator into such a callable live in :mod:nncg.inner, alongside the inner solvers that use them. Convergence is governed by the spectral condition number of M^{-1} A at the O(sqrt(kappa)) Krylov rate.

KrylovConfig dataclass

Options for a preconditioned CG solve (:func:pcg).

Bundles the solve knobs into one argument so :func:pcg keeps a short signature (matrix and right-hand side, then the config).

Attributes:

Name Type Description
precond Preconditioner | None

The action r -> M^{-1} r of an SPD preconditioner, applied once per iteration; None is the identity, so PCG reduces to plain CG.

tol float

Relative residual stopping tolerance ||b - A x|| / ||b||.

maxit int

Iteration cap; the current iterate is returned when it is hit.

x0 Vector | None

Optional warm start. The initial residual is b - A x0, so a good guess cuts the iteration count by the log of the initial error.

Source code in src/nncg/krylov.py
@dataclass(frozen=True)
class KrylovConfig:
    """Options for a preconditioned CG solve (:func:`pcg`).

    Bundles the solve knobs into one argument so :func:`pcg` keeps a short
    signature (matrix and right-hand side, then the config).

    Attributes:
        precond: The action ``r -> M^{-1} r`` of an SPD preconditioner, applied
            once per iteration; ``None`` is the identity, so PCG reduces to
            plain CG.
        tol: Relative residual stopping tolerance ``||b - A x|| / ||b||``.
        maxit: Iteration cap; the current iterate is returned when it is hit.
        x0: Optional warm start. The initial residual is ``b - A x0``, so a good
            guess cuts the iteration count by the log of the initial error.
    """

    precond: Preconditioner | None = None
    tol: float = 1e-8
    maxit: int = 100_000
    x0: Vector | None = None

pcg(matvec, rhs, config=_DEFAULT_KRYLOV)

Solve an SPD system by preconditioned conjugate gradients.

PCG converges at the condition number of M^{-1} A rather than of A, where the preconditioner M^{-1} (config.precond) enters only as the action r -> M^{-1} r; config.precond=None is the identity, so PCG reduces to plain CG. The inner solvers in :mod:nncg.inner build suitable preconditioners from an operator (diagonal Jacobi, randomized Nyström).

Parameters:

Name Type Description Default
matvec MatVec

The action v -> A v of an SPD operator.

required
rhs Vector

Right-hand side b.

required
config KrylovConfig

Preconditioner, tolerance, iteration cap and warm start of the solve (see :class:KrylovConfig).

_DEFAULT_KRYLOV

Returns:

Type Description
tuple[Vector, int]

The approximate solution and the number of iterations taken.

Source code in src/nncg/krylov.py
def pcg(
    matvec: MatVec,
    rhs: Vector,
    config: KrylovConfig = _DEFAULT_KRYLOV,
) -> tuple[Vector, int]:
    """Solve an SPD system by preconditioned conjugate gradients.

    PCG converges at the condition number of ``M^{-1} A`` rather than of ``A``,
    where the preconditioner ``M^{-1}`` (``config.precond``) enters only as the
    action ``r -> M^{-1} r``; ``config.precond=None`` is the identity, so PCG
    reduces to plain CG. The inner solvers in :mod:`nncg.inner` build suitable
    preconditioners from an operator (diagonal Jacobi, randomized Nyström).

    Args:
        matvec: The action ``v -> A v`` of an SPD operator.
        rhs: Right-hand side ``b``.
        config: Preconditioner, tolerance, iteration cap and warm start of the
            solve (see :class:`KrylovConfig`).

    Returns:
        The approximate solution and the number of iterations taken.
    """
    precond = _resolve_precond(config.precond)
    tol, maxit = config.tol, config.maxit
    bnorm = float(np.linalg.norm(rhs))
    if bnorm == 0.0:
        return np.zeros_like(rhs), 0
    x, r = _pcg_start(matvec, rhs, config.x0)
    z = precond(r)
    p = z.copy()
    rz = float(r @ z)
    # A warm start that already solves the system to tolerance leaves r == 0,
    # so p == 0 and the search-direction curvature p @ ap vanishes; returning
    # here avoids the 0/0 in the alpha step and reports zero iterations.
    if float(np.linalg.norm(r)) / bnorm <= tol:
        return x, 0
    for it in range(1, maxit + 1):
        ap = matvec(p)
        alpha = rz / float(p @ ap)
        x += alpha * p
        r -= alpha * ap
        if float(np.linalg.norm(r)) / bnorm <= tol:
            return x, it
        z = precond(r)
        rz_new = float(r @ z)
        p = z + (rz_new / rz) * p
        rz = rz_new
    return x, maxit