Skip to content

base

Base class for convex subproblem solvers.

This module defines the abstract interface that all convex solver implementations must follow for use within successive convexification algorithms.

Note

Solvers own both their optimization variables (create_variables()) and the lowering of any user .convex() constraints (lower_convex_constraints()). The default lower_convex_constraints refuses user .convex() constraints with a clear error — backends that accept them override it. This keeps openscvx.symbolic.lower backend-agnostic: it never branches on solver type, it just delegates.

See :class:openscvx.solvers.ptr_solver.PTRSolver for the PTR-specific interface every PTR backend implements.

ConvexSolver

Bases: ABC

Abstract base class for convex subproblem solvers.

This class defines the interface for solvers that handle the convex subproblems generated at each iteration of a successive convexification algorithm.

Subclasses must implement all abstract methods below.

The solver lifecycle has two phases:

Setup (called once):

  • create_variables: Create backend-specific variables
  • initialize: Build the problem structure using lowered constraints

Per-iteration (called each SCP iteration):

  • update_dynamics_linearization: Set linearization point and dynamics matrices
  • update_constraint_linearizations: Set constraint values and gradients
  • update_penalties: Set penalty weights
  • solve: Solve and return results
Example

Implementing a custom solver::

class MySolver(ConvexSolver):
    def create_variables(self, N, x_unified, u_unified, jax_constraints):
        self._vars = create_my_variables(N, x_unified, ...)

    def initialize(self, lowered, settings):
        self._prob = build_my_problem(self._vars, lowered, settings)

    def update_dynamics_linearization(self, **kwargs):
        # Set x_bar, u_bar, A_d, B_d, etc.
        ...

    def update_constraint_linearizations(self, **kwargs):
        # Set constraint function values and gradients
        ...

    def update_penalties(self, **kwargs):
        # Set lam_prox, lam_cost, lam_vc, lam_vb_nodal, lam_vb_cross
        ...

    def solve(self):
        self._prob.solve()
        return MyResult(...)
Source code in openscvx/solvers/base.py
class ConvexSolver(ABC):
    """Abstract base class for convex subproblem solvers.

    This class defines the interface for solvers that handle the convex
    subproblems generated at each iteration of a successive convexification
    algorithm.

    Subclasses must implement all abstract methods below.

    The solver lifecycle has two phases:

    **Setup (called once):**

    - create_variables: Create backend-specific variables
    - initialize: Build the problem structure using lowered constraints

    **Per-iteration (called each SCP iteration):**

    - update_dynamics_linearization: Set linearization point and dynamics matrices
    - update_constraint_linearizations: Set constraint values and gradients
    - update_penalties: Set penalty weights
    - solve: Solve and return results

    Example:
        Implementing a custom solver::

            class MySolver(ConvexSolver):
                def create_variables(self, N, x_unified, u_unified, jax_constraints):
                    self._vars = create_my_variables(N, x_unified, ...)

                def initialize(self, lowered, settings):
                    self._prob = build_my_problem(self._vars, lowered, settings)

                def update_dynamics_linearization(self, **kwargs):
                    # Set x_bar, u_bar, A_d, B_d, etc.
                    ...

                def update_constraint_linearizations(self, **kwargs):
                    # Set constraint function values and gradients
                    ...

                def update_penalties(self, **kwargs):
                    # Set lam_prox, lam_cost, lam_vc, lam_vb_nodal, lam_vb_cross
                    ...

                def solve(self):
                    self._prob.solve()
                    return MyResult(...)
    """

    @abstractmethod
    def create_variables(
        self,
        N: int,
        x_unified: "UnifiedState",
        u_unified: "UnifiedControl",
        jax_constraints: "LoweredJaxConstraints",
        dynamics_sparsity: Optional[tuple] = None,
        constraint_sparsity: Optional[list] = None,
    ) -> None:
        """Create backend-specific optimization variables.

        This method creates the optimization variables (decision variables and
        parameters) for this solver's backend. Called once during problem setup,
        before constraint lowering.

        The solver should store its variables on ``self`` for use in subsequent
        ``initialize()`` and ``solve()`` calls.

        Args:
            N: Number of discretization nodes
            x_unified: Unified state interface with dimensions and scaling bounds
            u_unified: Unified control interface with dimensions and scaling bounds
            jax_constraints: Lowered JAX constraints (for sizing linearization params)
            dynamics_sparsity: Optional tuple ``(A_d, B_d, C_d)`` of boolean
                ndarrays giving the discrete-time Jacobian sparsity patterns.
                ``A_d`` has shape ``(n_x, n_x)``; ``B_d`` and ``C_d`` have
                shape ``(n_x, n_u)``.
            constraint_sparsity: Optional list of ``(x_mask, u_mask)`` boolean
                1-D arrays, one per nodal constraint.
        """
        raise NotImplementedError

    def lower_convex_constraints(
        self,
        constraints: "ConstraintSet",
        parameters: Optional[Dict[str, Any]] = None,
    ) -> Tuple[List[Any], Dict[str, Any]]:
        """Lower user ``.convex()`` constraints into this backend's form.

        Called once by :func:`openscvx.symbolic.lower.lower_symbolic_problem`
        after ``create_variables()`` and before ``initialize()``.

        The default implementation refuses any user ``.convex()``
        constraints — appropriate for backends like
        :class:`openscvx.solvers.qpax_ptr_solver.QPAXPTRSolver` that don't
        accept second-order-cone constraints. Backends that do accept them
        (e.g. :class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver`)
        override this to invoke their backend-specific lowerer.

        Args:
            constraints: Categorized symbolic constraints. Only the
                ``nodal_convex`` / ``cross_node_convex`` lists matter here;
                non-convex constraints go through the JAX lowering pipeline.
            parameters: Optional dict of symbolic ``Parameter`` objects
                referenced by the constraints. May be ``None``.

        Returns:
            ``(lowered_list, parameter_map)``. The first is a list of
            backend-specific constraint objects (e.g. ``cp.Constraint``);
            the second maps parameter names to backend-specific parameter
            objects. Both are empty for the default refusal path.

        Raises:
            NotImplementedError: if the user defined any ``.convex()``
                constraints and this backend doesn't override.
        """
        n = len(constraints.nodal_convex) + len(constraints.cross_node_convex)
        if n:
            raise NotImplementedError(
                f"{type(self).__name__} does not support user-defined "
                f".convex() constraints ({n} defined). Drop the .convex() "
                "constraint or switch to a backend that supports them "
                "(e.g. openscvx.CVXPyPTRSolver)."
            )
        return [], {}

    @abstractmethod
    def initialize(
        self,
        lowered: "LoweredProblem",
        settings: "Config",
    ) -> None:
        """Build the convex subproblem structure.

        This method constructs the optimization problem once, using CVXPy
        Parameters (or equivalent) for values that change each iteration.
        Called once during problem setup, not at each SCP iteration.

        The solver should store its problem representation on ``self`` for use
        in subsequent ``solve()`` calls.

        Args:
            lowered: Lowered problem containing:
                - ``cvxpy_constraints``: Lowered convex constraints
                - ``jax_constraints``: JAX constraint functions
                - ``x_unified``, ``u_unified``: State/control interfaces
            settings: Configuration object with solver settings
        """
        raise NotImplementedError

    @abstractmethod
    def update_dynamics_linearization(self, **kwargs) -> None:
        """Update dynamics linearization point and matrices.

        Called at each SCP iteration before ``solve()`` to set the current
        linearization point and discretized dynamics matrices.

        The specific parameters depend on the solver implementation.
        See concrete solver classes for expected arguments.
        """
        raise NotImplementedError

    @abstractmethod
    def update_constraint_linearizations(self, **kwargs) -> None:
        """Update linearized constraint values and gradients.

        Called at each SCP iteration before ``solve()`` to set constraint
        function values and gradients at the current linearization point.

        The specific parameters depend on the solver implementation.
        See concrete solver classes for expected arguments.
        """
        raise NotImplementedError

    @abstractmethod
    def update_penalties(self, **kwargs) -> None:
        """Update SCP penalty weights.

        Called at each SCP iteration before ``solve()`` to set the current
        penalty weights for trust region, virtual control, and virtual buffer.

        The specific parameters depend on the solver implementation.
        See concrete solver classes for expected arguments.
        """
        raise NotImplementedError

    def update_proximal_terms(self) -> None:
        """Update optional proximal-expansion parameters.

        Called after ``update_penalties()`` and before ``solve()``. Solvers
        that formulate trust-region penalties via expanded quadratic forms can
        override this hook to refresh any derived coefficients.

        Default implementation is a no-op.
        """
        return None

    @abstractmethod
    def update_boundary_conditions(self, **kwargs) -> None:
        """Update boundary condition parameters.

        Called once during algorithm initialization to set initial and terminal
        state constraints.

        The specific parameters depend on the solver implementation.
        See concrete solver classes for expected arguments.
        """
        raise NotImplementedError

    @abstractmethod
    def get_stats(self) -> dict:
        """Get solver statistics for diagnostics and printing.

        Returns:
            Dict containing solver statistics. Expected keys:
                - ``n_variables``: Total number of optimization variables
                - ``n_parameters``: Total number of parameters
                - ``n_constraints``: Total number of constraints
        """
        raise NotImplementedError

    @abstractmethod
    def solve(self) -> Any:
        """Solve the convex subproblem and return results.

        Called at each SCP iteration after updating linearization and penalties.
        Returns a solver-specific result object containing the solution.

        Returns:
            Solver-specific result object (e.g., ``PTRSolveResult`` for PTR).
        """
        raise NotImplementedError

    @abstractmethod
    def citation(self) -> List[str]:
        """Return BibTeX citations for this solver.

        Implementations should return a list of BibTeX entry strings for the
        papers that should be cited when using this solver.

        Returns:
            List of BibTeX citation strings.
        """
        raise NotImplementedError
citation() -> List[str] abstractmethod

Return BibTeX citations for this solver.

Implementations should return a list of BibTeX entry strings for the papers that should be cited when using this solver.

Returns:

Type Description
List[str]

List of BibTeX citation strings.

Source code in openscvx/solvers/base.py
@abstractmethod
def citation(self) -> List[str]:
    """Return BibTeX citations for this solver.

    Implementations should return a list of BibTeX entry strings for the
    papers that should be cited when using this solver.

    Returns:
        List of BibTeX citation strings.
    """
    raise NotImplementedError
create_variables(N: int, x_unified: UnifiedState, u_unified: UnifiedControl, jax_constraints: LoweredJaxConstraints, dynamics_sparsity: Optional[tuple] = None, constraint_sparsity: Optional[list] = None) -> None abstractmethod

Create backend-specific optimization variables.

This method creates the optimization variables (decision variables and parameters) for this solver's backend. Called once during problem setup, before constraint lowering.

The solver should store its variables on self for use in subsequent initialize() and solve() calls.

Parameters:

Name Type Description Default
N int

Number of discretization nodes

required
x_unified UnifiedState

Unified state interface with dimensions and scaling bounds

required
u_unified UnifiedControl

Unified control interface with dimensions and scaling bounds

required
jax_constraints LoweredJaxConstraints

Lowered JAX constraints (for sizing linearization params)

required
dynamics_sparsity Optional[tuple]

Optional tuple (A_d, B_d, C_d) of boolean ndarrays giving the discrete-time Jacobian sparsity patterns. A_d has shape (n_x, n_x); B_d and C_d have shape (n_x, n_u).

None
constraint_sparsity Optional[list]

Optional list of (x_mask, u_mask) boolean 1-D arrays, one per nodal constraint.

None
Source code in openscvx/solvers/base.py
@abstractmethod
def create_variables(
    self,
    N: int,
    x_unified: "UnifiedState",
    u_unified: "UnifiedControl",
    jax_constraints: "LoweredJaxConstraints",
    dynamics_sparsity: Optional[tuple] = None,
    constraint_sparsity: Optional[list] = None,
) -> None:
    """Create backend-specific optimization variables.

    This method creates the optimization variables (decision variables and
    parameters) for this solver's backend. Called once during problem setup,
    before constraint lowering.

    The solver should store its variables on ``self`` for use in subsequent
    ``initialize()`` and ``solve()`` calls.

    Args:
        N: Number of discretization nodes
        x_unified: Unified state interface with dimensions and scaling bounds
        u_unified: Unified control interface with dimensions and scaling bounds
        jax_constraints: Lowered JAX constraints (for sizing linearization params)
        dynamics_sparsity: Optional tuple ``(A_d, B_d, C_d)`` of boolean
            ndarrays giving the discrete-time Jacobian sparsity patterns.
            ``A_d`` has shape ``(n_x, n_x)``; ``B_d`` and ``C_d`` have
            shape ``(n_x, n_u)``.
        constraint_sparsity: Optional list of ``(x_mask, u_mask)`` boolean
            1-D arrays, one per nodal constraint.
    """
    raise NotImplementedError
get_stats() -> dict abstractmethod

Get solver statistics for diagnostics and printing.

Returns:

Type Description
dict

Dict containing solver statistics. Expected keys: - n_variables: Total number of optimization variables - n_parameters: Total number of parameters - n_constraints: Total number of constraints

Source code in openscvx/solvers/base.py
@abstractmethod
def get_stats(self) -> dict:
    """Get solver statistics for diagnostics and printing.

    Returns:
        Dict containing solver statistics. Expected keys:
            - ``n_variables``: Total number of optimization variables
            - ``n_parameters``: Total number of parameters
            - ``n_constraints``: Total number of constraints
    """
    raise NotImplementedError
initialize(lowered: LoweredProblem, settings: Config) -> None abstractmethod

Build the convex subproblem structure.

This method constructs the optimization problem once, using CVXPy Parameters (or equivalent) for values that change each iteration. Called once during problem setup, not at each SCP iteration.

The solver should store its problem representation on self for use in subsequent solve() calls.

Parameters:

Name Type Description Default
lowered LoweredProblem

Lowered problem containing: - cvxpy_constraints: Lowered convex constraints - jax_constraints: JAX constraint functions - x_unified, u_unified: State/control interfaces

required
settings Config

Configuration object with solver settings

required
Source code in openscvx/solvers/base.py
@abstractmethod
def initialize(
    self,
    lowered: "LoweredProblem",
    settings: "Config",
) -> None:
    """Build the convex subproblem structure.

    This method constructs the optimization problem once, using CVXPy
    Parameters (or equivalent) for values that change each iteration.
    Called once during problem setup, not at each SCP iteration.

    The solver should store its problem representation on ``self`` for use
    in subsequent ``solve()`` calls.

    Args:
        lowered: Lowered problem containing:
            - ``cvxpy_constraints``: Lowered convex constraints
            - ``jax_constraints``: JAX constraint functions
            - ``x_unified``, ``u_unified``: State/control interfaces
        settings: Configuration object with solver settings
    """
    raise NotImplementedError
lower_convex_constraints(constraints: ConstraintSet, parameters: Optional[Dict[str, Any]] = None) -> Tuple[List[Any], Dict[str, Any]]

Lower user .convex() constraints into this backend's form.

Called once by :func:openscvx.symbolic.lower.lower_symbolic_problem after create_variables() and before initialize().

The default implementation refuses any user .convex() constraints — appropriate for backends like :class:openscvx.solvers.qpax_ptr_solver.QPAXPTRSolver that don't accept second-order-cone constraints. Backends that do accept them (e.g. :class:openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver) override this to invoke their backend-specific lowerer.

Parameters:

Name Type Description Default
constraints ConstraintSet

Categorized symbolic constraints. Only the nodal_convex / cross_node_convex lists matter here; non-convex constraints go through the JAX lowering pipeline.

required
parameters Optional[Dict[str, Any]]

Optional dict of symbolic Parameter objects referenced by the constraints. May be None.

None

Returns:

Type Description
List[Any]

(lowered_list, parameter_map). The first is a list of

Dict[str, Any]

backend-specific constraint objects (e.g. cp.Constraint);

Tuple[List[Any], Dict[str, Any]]

the second maps parameter names to backend-specific parameter

Tuple[List[Any], Dict[str, Any]]

objects. Both are empty for the default refusal path.

Raises:

Type Description
NotImplementedError

if the user defined any .convex() constraints and this backend doesn't override.

Source code in openscvx/solvers/base.py
def lower_convex_constraints(
    self,
    constraints: "ConstraintSet",
    parameters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Any], Dict[str, Any]]:
    """Lower user ``.convex()`` constraints into this backend's form.

    Called once by :func:`openscvx.symbolic.lower.lower_symbolic_problem`
    after ``create_variables()`` and before ``initialize()``.

    The default implementation refuses any user ``.convex()``
    constraints — appropriate for backends like
    :class:`openscvx.solvers.qpax_ptr_solver.QPAXPTRSolver` that don't
    accept second-order-cone constraints. Backends that do accept them
    (e.g. :class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver`)
    override this to invoke their backend-specific lowerer.

    Args:
        constraints: Categorized symbolic constraints. Only the
            ``nodal_convex`` / ``cross_node_convex`` lists matter here;
            non-convex constraints go through the JAX lowering pipeline.
        parameters: Optional dict of symbolic ``Parameter`` objects
            referenced by the constraints. May be ``None``.

    Returns:
        ``(lowered_list, parameter_map)``. The first is a list of
        backend-specific constraint objects (e.g. ``cp.Constraint``);
        the second maps parameter names to backend-specific parameter
        objects. Both are empty for the default refusal path.

    Raises:
        NotImplementedError: if the user defined any ``.convex()``
            constraints and this backend doesn't override.
    """
    n = len(constraints.nodal_convex) + len(constraints.cross_node_convex)
    if n:
        raise NotImplementedError(
            f"{type(self).__name__} does not support user-defined "
            f".convex() constraints ({n} defined). Drop the .convex() "
            "constraint or switch to a backend that supports them "
            "(e.g. openscvx.CVXPyPTRSolver)."
        )
    return [], {}
solve() -> Any abstractmethod

Solve the convex subproblem and return results.

Called at each SCP iteration after updating linearization and penalties. Returns a solver-specific result object containing the solution.

Returns:

Type Description
Any

Solver-specific result object (e.g., PTRSolveResult for PTR).

Source code in openscvx/solvers/base.py
@abstractmethod
def solve(self) -> Any:
    """Solve the convex subproblem and return results.

    Called at each SCP iteration after updating linearization and penalties.
    Returns a solver-specific result object containing the solution.

    Returns:
        Solver-specific result object (e.g., ``PTRSolveResult`` for PTR).
    """
    raise NotImplementedError
update_boundary_conditions(**kwargs) -> None abstractmethod

Update boundary condition parameters.

Called once during algorithm initialization to set initial and terminal state constraints.

The specific parameters depend on the solver implementation. See concrete solver classes for expected arguments.

Source code in openscvx/solvers/base.py
@abstractmethod
def update_boundary_conditions(self, **kwargs) -> None:
    """Update boundary condition parameters.

    Called once during algorithm initialization to set initial and terminal
    state constraints.

    The specific parameters depend on the solver implementation.
    See concrete solver classes for expected arguments.
    """
    raise NotImplementedError
update_constraint_linearizations(**kwargs) -> None abstractmethod

Update linearized constraint values and gradients.

Called at each SCP iteration before solve() to set constraint function values and gradients at the current linearization point.

The specific parameters depend on the solver implementation. See concrete solver classes for expected arguments.

Source code in openscvx/solvers/base.py
@abstractmethod
def update_constraint_linearizations(self, **kwargs) -> None:
    """Update linearized constraint values and gradients.

    Called at each SCP iteration before ``solve()`` to set constraint
    function values and gradients at the current linearization point.

    The specific parameters depend on the solver implementation.
    See concrete solver classes for expected arguments.
    """
    raise NotImplementedError
update_dynamics_linearization(**kwargs) -> None abstractmethod

Update dynamics linearization point and matrices.

Called at each SCP iteration before solve() to set the current linearization point and discretized dynamics matrices.

The specific parameters depend on the solver implementation. See concrete solver classes for expected arguments.

Source code in openscvx/solvers/base.py
@abstractmethod
def update_dynamics_linearization(self, **kwargs) -> None:
    """Update dynamics linearization point and matrices.

    Called at each SCP iteration before ``solve()`` to set the current
    linearization point and discretized dynamics matrices.

    The specific parameters depend on the solver implementation.
    See concrete solver classes for expected arguments.
    """
    raise NotImplementedError
update_penalties(**kwargs) -> None abstractmethod

Update SCP penalty weights.

Called at each SCP iteration before solve() to set the current penalty weights for trust region, virtual control, and virtual buffer.

The specific parameters depend on the solver implementation. See concrete solver classes for expected arguments.

Source code in openscvx/solvers/base.py
@abstractmethod
def update_penalties(self, **kwargs) -> None:
    """Update SCP penalty weights.

    Called at each SCP iteration before ``solve()`` to set the current
    penalty weights for trust region, virtual control, and virtual buffer.

    The specific parameters depend on the solver implementation.
    See concrete solver classes for expected arguments.
    """
    raise NotImplementedError
update_proximal_terms() -> None

Update optional proximal-expansion parameters.

Called after update_penalties() and before solve(). Solvers that formulate trust-region penalties via expanded quadratic forms can override this hook to refresh any derived coefficients.

Default implementation is a no-op.

Source code in openscvx/solvers/base.py
def update_proximal_terms(self) -> None:
    """Update optional proximal-expansion parameters.

    Called after ``update_penalties()`` and before ``solve()``. Solvers
    that formulate trust-region penalties via expanded quadratic forms can
    override this hook to refresh any derived coefficients.

    Default implementation is a no-op.
    """
    return None

PTRSolverSpec

Bases: BaseModel

Validates PTR solver configuration from dict/YAML input.

The backend discriminator selects which concrete PTR backend to build: "cvxpy" (the default, :class:openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver), "qpax" (:class:openscvx.solvers.qpax_ptr_solver.QPAXPTRSolver), or "moreau" (:class:openscvx.solvers.moreau_ptr_solver.MoreauPTRSolver).

Backend-specific fields mirror the constructor parameters of each class. Fields that don't belong to the active backend are a configuration error and raise ValidationError.

CVXPy-only

cvx_solver, cvxpygen, cvxpygen_override.

QPAX-only

solver_tol.

Moreau-only

verbose, device, tol_gap_abs, tol_feas.

Shared between QPAX and Moreau

max_iter.

All backends

solver_args (escape hatch for settings not covered by named fields).

Warning

Enabling cvxpygen currently disables sparse parameter declarations. cvxpygen does not yet support the N-D sparsity indices used by OpenSCvx's tiled parameters, so all parameters are created as dense when code generation is active.

Source code in openscvx/solvers/base.py
class PTRSolverSpec(BaseModel):
    """Validates PTR solver configuration from dict/YAML input.

    The ``backend`` discriminator selects which concrete PTR backend to build:
    ``"cvxpy"`` (the default,
    :class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver`),
    ``"qpax"`` (:class:`openscvx.solvers.qpax_ptr_solver.QPAXPTRSolver`),
    or ``"moreau"``
    (:class:`openscvx.solvers.moreau_ptr_solver.MoreauPTRSolver`).

    Backend-specific fields mirror the constructor parameters of each class.
    Fields that don't belong to the active backend are a configuration error
    and raise ``ValidationError``.

    CVXPy-only:
        ``cvx_solver``, ``cvxpygen``, ``cvxpygen_override``.

    QPAX-only:
        ``solver_tol``.

    Moreau-only:
        ``verbose``, ``device``, ``tol_gap_abs``, ``tol_feas``.

    Shared between QPAX and Moreau:
        ``max_iter``.

    All backends:
        ``solver_args`` (escape hatch for settings not covered by named fields).

    !!! warning
        Enabling ``cvxpygen`` currently disables sparse parameter declarations.
        cvxpygen does not yet support the N-D sparsity indices used by
        OpenSCvx's tiled parameters, so all parameters are created as dense
        when code generation is active.
    """

    type: Literal["PTRSolver"] = "PTRSolver"
    backend: Literal["cvxpy", "qpax", "moreau"] = "cvxpy"

    # CVXPy-only
    cvx_solver: Optional[str] = None
    cvxpygen: bool = False
    cvxpygen_override: bool = False

    # QPAX-specific
    solver_tol: Optional[float] = None

    # Shared between QPAX and Moreau
    max_iter: Optional[int] = None

    # Moreau-specific
    verbose: Optional[bool] = None
    device: Optional[str] = None
    tol_gap_abs: Optional[float] = None
    tol_feas: Optional[float] = None

    # Escape hatch for all backends
    solver_args: Optional[Dict[str, Any]] = None

    model_config = ConfigDict(extra="forbid")

    @model_validator(mode="after")
    def _check_backend_fields(self):
        if self.backend in ("qpax", "moreau"):
            offenders = [
                name
                for name, value in (
                    ("cvx_solver", self.cvx_solver),
                    ("cvxpygen", self.cvxpygen),
                    ("cvxpygen_override", self.cvxpygen_override),
                )
                if value
            ]
            if offenders:
                raise ValueError(
                    f"{offenders} only valid for backend='cvxpy'; "
                    "remove these fields or set backend='cvxpy'."
                )
        if self.backend == "cvxpy":
            offenders = [
                name
                for name, value in (
                    ("solver_tol", self.solver_tol),
                    ("max_iter", self.max_iter),
                    ("verbose", self.verbose),
                    ("device", self.device),
                    ("tol_gap_abs", self.tol_gap_abs),
                    ("tol_feas", self.tol_feas),
                )
                if value is not None
            ]
            if offenders:
                raise ValueError(
                    f"{offenders} not valid for backend='cvxpy'; pass these "
                    "via solver_args or switch to the qpax / moreau backend."
                )
        if self.backend == "qpax":
            offenders = [
                name
                for name, value in (
                    ("verbose", self.verbose),
                    ("device", self.device),
                    ("tol_gap_abs", self.tol_gap_abs),
                    ("tol_feas", self.tol_feas),
                )
                if value is not None
            ]
            if offenders:
                raise ValueError(
                    f"{offenders} only valid for backend='moreau'; "
                    "remove these fields or set backend='moreau'."
                )
        if self.backend == "moreau" and self.solver_tol is not None:
            raise ValueError(
                "solver_tol is only valid for backend='qpax'; "
                "remove this field or set backend='qpax'."
            )
        return self

    def build(self) -> ConvexSolver:
        # Local imports keep CVXPy / qpax / moreau out of the import path until
        # the corresponding backend is actually requested.
        if self.backend == "cvxpy":
            from .cvxpy_ptr_solver import CVXPyPTRSolver

            return CVXPyPTRSolver(
                cvx_solver=self.cvx_solver or "QOCO",
                solver_args=self.solver_args,
                cvxpygen=self.cvxpygen,
                cvxpygen_override=self.cvxpygen_override,
            )
        if self.backend == "moreau":
            from .moreau_ptr_solver import MoreauPTRSolver

            kwargs: Dict[str, Any] = {}
            if self.max_iter is not None:
                kwargs["max_iter"] = self.max_iter
            if self.verbose is not None:
                kwargs["verbose"] = self.verbose
            if self.device is not None:
                kwargs["device"] = self.device
            if self.tol_gap_abs is not None:
                kwargs["tol_gap_abs"] = self.tol_gap_abs
            if self.tol_feas is not None:
                kwargs["tol_feas"] = self.tol_feas
            if self.solver_args is not None:
                kwargs["solver_args"] = self.solver_args
            return MoreauPTRSolver(**kwargs)

        from .qpax_ptr_solver import QPAXPTRSolver

        kwargs = {}
        if self.solver_tol is not None:
            kwargs["solver_tol"] = self.solver_tol
        if self.max_iter is not None:
            kwargs["max_iter"] = self.max_iter
        if self.solver_args is not None:
            kwargs["solver_args"] = self.solver_args
        return QPAXPTRSolver(**kwargs)