Repository: RobotLocomotion/drake (C++)

Issue:
Projected Gradient Descent Solver Interface
Projected gradient descent (PGD) is a strategy for solving constrained optimization problems -- one alternates between taking a step down the gradient of the objective function, and projecting onto the feasible set. In some contexts, this optimization strategy is preferable to using a second order solver like SNOPT or IPOPT. Having a native PGD solver in Drake would particularly be useful if we want to use it for the rounding stage of GcsTrajectoryOptimization.

I'm assigning to myself for now, but probably won't be able to get to this for some time. If someone wants it sooner, I'm happy to hand it off. Minimum features to include:

- User can specify solver used for the projection step
- User can manually specify a gradient function, instead of computing with autodiff/symbolic
- Backtracking line search

Additional features that might be nice:

- Specifying the affine hull in closed-form (can simplify the projection, or render it moot altogether)
- Momentum, and various other tricks that can lead to faster convergence

cc @shrutigarg914 @RussTedrake 


Discussion: cc @hongkai-dai if you have any feedback on how to do this, I'd appreciate it!
I suppose one implementation would look like this
```cc
class PGDSolver {
 private:
  // Project x to the constraint set of prog
  VectorXd CalcProjection(const MathematicalProgram& prog, const Eigen::VectorXd& x) const;
  // Compute the descent direction of the cost of prog
  // If the user provides a direction (stored as a function pointer in `solver_options`, then use that direction. Otherwise, call autodiff to compute the direction.
  VectorXd CalcDescentDirection(const MathematicalProgram& prog, const Eigen::VectorXd& x, const SolverOptions& solver_options) const {
    if (solver_options.descent_direction_fun == nullptr) {
      AutoDiffVecXd x_ad = InitializeAutoDiff(x);
      AutoDiffVecXd y_ad;
      prog.cost().evaluate(x_ad, &y_ad);
      return ExtractGradient(y_ad);
    } else {
      return solver_options.descent_direction_fun(x);
    }
  }
  // Line search along the descent direction
  double LineSearch(const MathematicalProgram& prog, const VectorXd& x, const VectorXd& descent_direction) const;

  void Solve(const MathematicalProgram& prog, const VectorXd& x_init, const SolverOptions& solver_options, MathematicalProgramResult* result) const {
  x = x_init;
  while not_converged  {
    const VectorXd descent_direction = CalcDescentDirection(prog, x, solver_options);
    double eta = LineSearch(prog, x, descent_direction);
    x = CalcProjection(prog, x + eta * descent_direction);
  }
  }
};
```

Does this make sense to you?

I think one caveat of implementing PGDSolver as a sub-class of MathematicalProgramSolverInterface, is that MathematicalProgramSolverInterface is a const class after construction (its Solve() function is a const function). So you cannot store any information inside PGDSolver class. On the other hand, I often found that when I implement a solver, I want to store some information in the class for debugging/testing purpose, and also store some intermediate information that could be useful for the next iteration of the optimization.

So an alternative approach is to implement two classes, one `ProjectedGradientDescent` class that does all the mathematical computation and store the intermediate results, and another `PGDSolver` class that derives from `MathematicalProgramSolverInterface` class. `PGDSolver` class is a const class that doesn't store any information. It just calls `ProjectedGradientDescent`

```cc
class ProjectedGradientDescent {
  public:
    ProjectedGradientDescent(const MathematicalProgram& prog, std::function<...> descent_direction_fun);
    Eigen::VectorXd Projection(const VectorXd& x);
    double LineSearch(const VectorXd& x, const VectorXd& descent_direction);
    Eigen::VectorXd CalcDescentDirection(const VectorXd& x);
    Solve(const VectorXd& x, some_hyper_parameters, ..., some_return_values_like_primal_and_dual_solution);
  private:
    const MathematicalProgram* prog_;
};

const PGDSolver : public MathematicalProgramSolverInterface {
  public Solve(const MathematicalProgram& prog, const VectorXd& x_init, const SolverOptions& solver_options, MathematicalProgramResult* result) const {
    ProjectedGradientDescent(prog, ...);
    prog.Solver(x_init, ...,);
    // Fill in result 
  }
}
```
In this way, we can unit-test `ProjectedGradientDescent` public functions.

Also, one more question, we typically will compute the dual solution for the problem, how do you get the dual of the constraints through projected gradient descent?
What sort of information would you want to be storing between solves? I guess having some way of caching the projection program across solves could be useful, but we could handle that as an optional argument. I think we could implement something similar to VisualizationCallback if we wanted to debug intermediate iterations, but I don't have a great sense of what's that been like in your past experiences.

No idea about dual variables -- Shruti and I haven't needed them, so we never thought about them. Is that a requirement, or could we leave it as a TODO?
> What sort of information would you want to be storing between solves? 

One example is that we might want to cache the active constraints in the previous projection operation, then the same constraint might have a high probability to be active in the next iteration.

If you prefer using a single `PGDSolver` class instead of two separate classes, then I would recommend creating two header files, one as `pgd_solver.h` and one as `pgd_solver_internal.h`. The `pgd_solver.h` declare the public interface, and `pgd_solver_internal.h` declares the functions such as `CalcProjection`, `LineSearch` and `DescentDirection`, then we can test these functions in a unit test `pgd_solver_internal_test.cc`. One example is in `ipopt_solver.h` and `ipopt_solver_internal.h`. There are several complicated functions in `ipopt_solver_internal.h` (such as `get_nlp_info()`) that I want to test, but don't want to expose to the user.
> No idea about dual variables -- Shruti and I haven't needed them, so we never thought about them. Is that a requirement, or could we leave it as a TODO?

Yes, leaving it as a TODO is fine.
> One example is that we might want to cache the active constraints in the previous projection operation, then the same constraint might have a high probability to be active in the next iteration.

But all of this is within the scope of a single call to `Solve`, right? So that sort of information could be stored as a variable local to the method until the solve completes, and then discarded.

> If you prefer using a single PGDSolver class instead of two separate classes, then I would recommend creating two header files, one as pgd_solver.h and one as pgd_solver_internal.h. The pgd_solver.h declare the public interface, and pgd_solver_internal.h declares the functions such as CalcProjection, LineSearch and DescentDirection, then we can test these functions in a unit test pgd_solver_internal_test.cc. One example is in ipopt_solver.h and ipopt_solver_internal.h. There are several complicated functions in ipopt_solver_internal.h (such as get_nlp_info()) that I want to test, but don't want to expose to the user.

Great idea! I like this design a lot.
> But all of this is within the scope of a single call to Solve, right? So that sort of information could be stored as a variable local to the method until the solve completes, and then discarded.

Yes, that is true, we do not have to create a new class for storing the intermediate information.



Fix (patch):
diff --git a/solvers/BUILD.bazel b/solvers/BUILD.bazel
index 5304314a8e00..47f8ee88ba17 100644
--- a/solvers/BUILD.bazel
+++ b/solvers/BUILD.bazel
@@ -64,6 +64,7 @@ drake_cc_package_library(
         ":non_convex_optimization_util",
         ":osqp_solver",
         ":program_attribute",
+        ":projected_gradient_descent_solver",
         ":rotation_constraint",
         ":scs_clarabel_common",
         ":scs_solver",
@@ -1180,6 +1181,21 @@ drake_cc_library(
     ],
 )
 
+drake_cc_library(
+    name = "projected_gradient_descent_solver",
+    srcs = ["projected_gradient_descent_solver.cc"],
+    hdrs = ["projected_gradient_descent_solver.h"],
+    deps = [
+        ":solver_base",
+    ],
+    implementation_deps = [
+        ":choose_best_solver",
+        ":mathematical_program",
+        ":solve",
+        "//math:gradient",
+    ],
+)
+
 # === test/ ===
 
 drake_cc_googletest(
@@ -2148,4 +2164,21 @@ drake_cc_googletest(
     ],
 )
 
+drake_cc_googletest(
+    name = "projected_gradient_descent_solver_test",
+    deps = [
+        ":equality_constrained_qp_solver",
+        ":linear_program_examples",
+        ":mathematical_program",
+        ":mathematical_program_test_util",
+        ":optimization_examples",
+        ":projected_gradient_descent_solver",
+        ":quadratic_program_examples",
+        ":second_order_cone_program_examples",
+        ":solve",
+        "//common/test_utilities:eigen_matrix_compare",
+        "//common/test_utilities:expect_throws_message",
+    ],
+)
+
 add_lint_tests()
diff --git a/solvers/projected_gradient_descent_solver.cc b/solvers/projected_gradient_descent_solver.cc
new file mode 100644
index 000000000000..e9f6318c5ac4
--- /dev/null
+++ b/solvers/projected_gradient_descent_solver.cc
@@ -0,0 +1,306 @@
+#include "drake/solvers/projected_gradient_descent_solver.h"
+
+#include "drake/math/autodiff.h"
+#include "drake/math/autodiff_gradient.h"
+#include "drake/solvers/choose_best_solver.h"
+#include "drake/solvers/mathematical_program.h"
+#include "drake/solvers/solve.h"
+
+namespace drake {
+namespace solvers {
+
+using Eigen::MatrixXd;
+using Eigen::VectorXd;
+using Eigen::VectorXi;
+
+namespace {
+constexpr const char kConvergenceTolOptionName[] = "ConvergenceTol";
+constexpr const char kMaxIterationsOptionName[] = "MaxIterations";
+constexpr const char kBacktrackingCOptionName[] = "BacktrackingC";
+constexpr const char kBacktrackingTauOptionName[] = "BacktrackingTau";
+constexpr const char kBacktrackingAlpha0OptionName[] = "BacktrackingAlpha0";
+
+struct KnownOptions {
+  double convergence_tol{
+      ProjectedGradientDescentSolver::kDefaultConvergenceTol};
+  int max_iterations{ProjectedGradientDescentSolver::kDefaultMaxIterations};
+  double backtracking_c{ProjectedGradientDescentSolver::kDefaultBacktrackingC};
+  double backtracking_tau{
+      ProjectedGradientDescentSolver::kDefaultBacktrackingTau};
+  double backtracking_alpha_0{
+      ProjectedGradientDescentSolver::kDefaultBacktrackingAlpha0};
+};
+
+void Serialize(internal::SpecificOptions* archive,
+               // NOLINTNEXTLINE(runtime/references) to match Serialize concept.
+               KnownOptions& options) {
+  archive->Visit(
+      MakeNameValue(kConvergenceTolOptionName, &options.convergence_tol));
+  archive->Visit(
+      MakeNameValue(kMaxIterationsOptionName, &options.max_iterations));
+  archive->Visit(
+      MakeNameValue(kBacktrackingCOptionName, &options.backtracking_c));
+  archive->Visit(
+      MakeNameValue(kBacktrackingTauOptionName, &options.backtracking_tau));
+  archive->Visit(MakeNameValue(kBacktrackingAlpha0OptionName,
+                               &options.backtracking_alpha_0));
+}
+
+KnownOptions ParseOptions(internal::SpecificOptions* options) {
+  KnownOptions result;
+  options->CopyToSerializableStruct(&result);
+  if (result.convergence_tol <= 0) {
+    throw std::invalid_argument(
+        "ConvergenceTol should be a non-negative number.");
+  }
+  if (result.max_iterations < 1) {
+    throw std::invalid_argument("MaxIterations must be at least one.");
+  }
+  if (result.backtracking_c >= 1.0 || result.backtracking_c <= 0.0) {
+    throw std::invalid_argument("BacktrackingC must be between 0 and 1.");
+  }
+  if (result.backtracking_tau >= 1.0 || result.backtracking_tau <= 0.0) {
+    throw std::invalid_argument("BacktrackingTau must be between 0 and 1.");
+  }
+  if (result.backtracking_alpha_0 <= 0) {
+    throw std::invalid_argument(
+        "BacktrackingAlpha0 should be a non-negative number.");
+  }
+  return result;
+}
+
+// Given a (square, symmetric) matrix Q, compute b and c such that
+// AddQuadraticErrorCost(Q, x_desired, x) is equivalent to
+// AddQuadraticCost(2*Q, b, c, x).
+void ConvertQuadraticErrorCost(const VectorXd& x_desired, const MatrixXd& Q,
+                               VectorXd* b, double* c) {
+  *b = -Q * x_desired;
+  *c = x_desired.dot(Q * x_desired);
+}
+
+}  // namespace
+
+ProjectedGradientDescentSolver::ProjectedGradientDescentSolver()
+    : SolverBase(id(), &is_available, &is_enabled, &ProgramAttributesSatisfied),
+      custom_gradient_function_(std::nullopt),
+      custom_projection_function_(std::nullopt),
+      projection_solver_interface_(nullptr) {}
+ProjectedGradientDescentSolver::~ProjectedGradientDescentSolver() = default;
+
+void ProjectedGradientDescentSolver::DoSolve2(
+    const MathematicalProgram& prog, const VectorXd& initial_guess,
+    internal::SpecificOptions* options,
+    MathematicalProgramResult* result) const {
+  const KnownOptions parsed_options = ParseOptions(options);
+
+  // First, we replace any nan values in the initial guess with zero.
+  VectorXd x_current = initial_guess;
+  for (int i = 0; i < x_current.size(); ++i) {
+    if (std::isnan(x_current[i])) {
+      x_current[i] = 0.0;
+    }
+  }
+
+  std::vector<Binding<Cost>> costs = prog.GetAllCosts();
+
+  // Next, we construct an auxiliary MathematicalProgram, for use in the
+  // projection step. We clone prog, remove all costs, and add a quadratic
+  // cost penalizing distance to the initial guess. (We then later update
+  // the coefficients of this cost at each iteration.)
+  std::unique_ptr<MathematicalProgram> projection_prog_ptr = prog.Clone();
+  for (const auto& cost : costs) {
+    projection_prog_ptr->RemoveCost(cost);
+  }
+  // We parse the quadratic error cost into a standard-form quadratic cost to
+  // match the use later in the program.
+  MatrixXd projection_cost_Q = MatrixXd::Identity(
+      projection_prog_ptr->num_vars(), projection_prog_ptr->num_vars());
+  VectorXd projection_cost_b;
+  double projection_cost_c;
+  ConvertQuadraticErrorCost(x_current, projection_cost_Q, &projection_cost_b,
+                            &projection_cost_c);
+  auto projection_cost = projection_prog_ptr->AddQuadraticCost(
+      2 * projection_cost_Q, projection_cost_b, projection_cost_c,
+      projection_prog_ptr->decision_variables());
+
+  // Here, we create two lambdas, which we can evaluate to obtain the descent
+  // direction and projection.
+  std::function<double(const VectorXd&)> cost_function =
+      [&](const VectorXd& x) {
+        double cost = 0.0;
+        for (int i = 0; i < ssize(costs); ++i) {
+          VectorXd y = prog.EvalBinding(costs[i], x);
+          DRAKE_ASSERT(y.size() == 1);
+          cost += y[0];
+        }
+        return cost;
+      };
+  std::function<VectorXd(const VectorXd&)> gradient_function =
+      custom_gradient_function_.value_or([&](const VectorXd& x) {
+        AutoDiffVecXd x_ad = math::InitializeAutoDiff(x);
+        VectorXd gradient = VectorXd::Zero(x.size());
+        for (int i = 0; i < ssize(costs); ++i) {
+          AutoDiffVecXd y_ad = prog.EvalBinding(costs[i], x_ad);
+          Eigen::RowVectorXd gradient_part = math::ExtractGradient(y_ad);
+          DRAKE_ASSERT(gradient_part.size() == gradient.size());
+          gradient += gradient_part.transpose();
+        }
+        return gradient;
+      });
+
+  // TODO(cohnt): Allow user to specify an initial guess strategy, for cases
+  // where the feasible set is nonconvex. (For example, we may want to use the
+  // previous feasible iterate, or some other strategy.)
+  std::unique_ptr<solvers::SolverInterface> projection_solver_interface;
+  if (!projection_solver_interface_) {
+    const SolverId solver_id = ChooseBestSolver(prog);
+    projection_solver_interface = MakeSolver(solver_id);
+  }
+  std::function<bool(const VectorXd&, VectorXd*)> projection_function =
+      custom_projection_function_.value_or([&](const VectorXd& x, VectorXd* y) {
+        // Update the quadratic error cost.
+        ConvertQuadraticErrorCost(x, projection_cost_Q, &projection_cost_b,
+                                  &projection_cost_c);
+        projection_cost.evaluator()->UpdateCoefficients(
+            projection_cost_Q, projection_cost_b, projection_cost_c);
+
+        // Solve the program. We use the target value x as the initial guess.
+        MathematicalProgramResult projection_result;
+        if (projection_solver_interface_) {
+          projection_solver_interface_->Solve(
+              *projection_prog_ptr, x /* initial_guess */,
+              std::nullopt /* solver_options */, &projection_result);
+        } else {
+          DRAKE_DEMAND(projection_solver_interface != nullptr);
+          projection_solver_interface->Solve(
+              *projection_prog_ptr, x /* initial_guess */,
+              std::nullopt /* solver_options */, &projection_result);
+        }
+
+        DRAKE_DEMAND(y != nullptr);
+        *y = projection_result.get_x_val();
+        return projection_result.is_success();
+      });
+
+  const double convergence_tol_squared =
+      parsed_options.convergence_tol * parsed_options.convergence_tol;
+
+  // Finally, we perform the projected gradient descent loop.
+  // TODO(cohnt): Should we project to feasibility before starting the first
+  // iteration?
+  bool converged = false;
+  bool failed = false;
+  for (int iteration_count = 0; iteration_count < parsed_options.max_iterations;
+       ++iteration_count) {
+    // Gradient step.
+    VectorXd descent_direction = -gradient_function(x_current);
+
+    // Line search (using the Armijo condition). If the feasible set is convex
+    // and the objective function has a Lipschitz gradient, then a step
+    // satisfying the Armijo condition guarantees a decrease in the objective
+    // function, even after the projection step. This is true for any choice of
+    // C, alpha_0, and tau -- regardless of the Lipschitz constant.
+    const double c = parsed_options.backtracking_c;
+    const double tau = parsed_options.backtracking_tau;
+    double alpha = parsed_options.backtracking_alpha_0;
+    // TODO(cohnt): To handle cases where the objective function isn't Lipschitz
+    // or the feasible set isn't convex, we should allow the user to impose more
+    // general convergence criteria and/or line search strategies.
+
+    double m = descent_direction.dot(descent_direction);
+    double t = c * m;
+    double old_cost = cost_function(x_current);
+    // TODO(cohnt): Allow user to set the maximum number of line search steps.
+    for (int line_search_steps = 0;
+         line_search_steps < kDefaultMaxLineSearchSteps; ++line_search_steps) {
+      double new_cost = cost_function(x_current + alpha * descent_direction);
+      if (old_cost - new_cost >= alpha * t) {
+        break;
+      } else {
+        alpha *= tau;
+      }
+    }
+
+    // Now project back to feasibility.
+    VectorXd projected_value;
+    bool projection_succeeded = projection_function(
+        x_current + alpha * descent_direction, &projected_value);
+    VectorXd step = projected_value - x_current;
+    x_current = projected_value;
+
+    // Check that the projection step succeeded.
+    if (!projection_succeeded) {
+      failed = true;
+      break;
+    }
+
+    if (step.dot(step) < convergence_tol_squared) {
+      converged = true;
+      break;
+    }
+  }
+
+  // Populate results.
+  result->set_x_val(x_current);
+  result->set_optimal_cost(cost_function(x_current));
+  if (converged) {
+    result->set_solution_result(SolutionResult::kSolutionFound);
+  } else if (failed) {
+    result->set_solution_result(SolutionResult::kInfeasibleConstraints);
+  } else {
+    result->set_solution_result(SolutionResult::kIterationLimit);
+  }
+}
+
+std::string ProjectedGradientDescentSolver::ConvergenceTolOptionName() {
+  return kConvergenceTolOptionName;
+}
+
+std::string ProjectedGradientDescentSolver::MaxIterationsOptionName() {
+  return kMaxIterationsOptionName;
+}
+
+std::string ProjectedGradientDescentSolver::BacktrackingCOptionName() {
+  return kBacktrackingCOptionName;
+}
+
+std::string ProjectedGradientDescentSolver::BacktrackingTauOptionName() {
+  return kBacktrackingTauOptionName;
+}
+
+std::string ProjectedGradientDescentSolver::BacktrackingAlpha0OptionName() {
+  return kBacktrackingAlpha0OptionName;
+}
+
+SolverId ProjectedGradientDescentSolver::id() {
+  static const never_destroyed<SolverId> singleton{"PGD"};
+  return singleton.access();
+}
+
+bool ProjectedGradientDescentSolver::is_available() {
+  return true;
+}
+
+bool ProjectedGradientDescentSolver::is_enabled() {
+  return true;
+}
+
+bool ProjectedGradientDescentSolver::ProgramAttributesSatisfied(
+    const MathematicalProgram& prog) {
+  static const never_destroyed<ProgramAttributes> solver_capabilities(
+      std::initializer_list<ProgramAttribute>{
+          ProgramAttribute::kGenericConstraint,
+          ProgramAttribute::kLinearEqualityConstraint,
+          ProgramAttribute::kLinearConstraint,
+          ProgramAttribute::kQuadraticConstraint,
+          ProgramAttribute::kLorentzConeConstraint,
+          ProgramAttribute::kRotatedLorentzConeConstraint,
+          ProgramAttribute::kGenericCost, ProgramAttribute::kLinearCost,
+          ProgramAttribute::kL2NormCost, ProgramAttribute::kQuadraticCost,
+          ProgramAttribute::kCallback});
+  return AreRequiredAttributesSupported(prog.required_capabilities(),
+                                        solver_capabilities.access());
+}
+
+}  // namespace solvers
+}  // namespace drake
diff --git a/solvers/projected_gradient_descent_solver.h b/solvers/projected_gradient_descent_solver.h
new file mode 100644
index 000000000000..edc1d1b1dccd
--- /dev/null
+++ b/solvers/projected_gradient_descent_solver.h
@@ -0,0 +1,129 @@
+#pragma once
+
+#include <optional>
+#include <string>
+
+#include "drake/common/drake_copyable.h"
+#include "drake/solvers/solver_base.h"
+
+namespace drake {
+namespace solvers {
+
+/**
+ * Solves a nonlinear program via the projected gradient descent algorithm. The
+ * gradient is determined by differentiation of the costs, or the user can
+ * supply a custom gradient function. The projection step is itself an
+ * optimization problem in general, but the user can supply a custom projection
+ * function. The user can also specify a specific solver interface to be used
+ * to solve the projection problem.
+ *
+ * The solver terminates if
+ * - the projection step fails to find a feasible solution,
+ * - the 2-norm distance between subsequent iterates is less than the
+ * user-specified tolerance (see \ref
+ * ProjectedGradientDescentSolver::ConvergenceTolOptionName
+ * "ConvergenceTolOptionName"), or
+ * - a maximum number of iterations have been run (see \ref
+ * ProjectedGradientDescentSolver::MaxIterationsOptionName
+ * "MaxIterationsOptionName").
+ *
+ * @experimental
+ */
+class ProjectedGradientDescentSolver final : public SolverBase {
+ public:
+  DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(ProjectedGradientDescentSolver);
+
+  ProjectedGradientDescentSolver();
+  ~ProjectedGradientDescentSolver() final;
+
+  /** Specify a custom gradient function. Otherwise, this solver will
+   * differentiate through the costs in the MathematicalProgram it's used to
+   * solve. */
+  void SetCustomGradientFunction(
+      const std::function<Eigen::VectorXd(const Eigen::VectorXd&)>&
+          custom_gradient_function) {
+    custom_gradient_function_ = custom_gradient_function;
+  }
+
+  /** Specify a custom projection function. Otherwise, this solver will attempt
+   * to solve the L2 projection onto the feasible set of the MathematicalProgram
+   * it's used to solve. The projection function should return a boolean value
+   * indicating success or failure. It should take in two arguments: the point
+   * we are trying to stay close to, and an output argument where the projected
+   * value will be placed. */
+  void SetCustomProjectionFunction(
+      const std::function<bool(const Eigen::VectorXd&, Eigen::VectorXd*)>&
+          custom_projection_function) {
+    custom_projection_function_ = custom_projection_function;
+  }
+
+  /** Specify a solver interface to be used when solving the L2 projection onto
+   * the feasible set of the MathematicalProgram it's being used to solve. */
+  void SetProjectionSolverInterface(
+      const SolverInterface* projection_solver_interface) {
+    projection_solver_interface_ = projection_solver_interface;
+  }
+
+  /**
+   * @returns string key for SolverOptions to set the threshold used to
+   * determine convergence. It must be positive.
+   */
+  static std::string ConvergenceTolOptionName();
+
+  /**
+   * @returns string key for SolverOptions to set the maximum number of
+   * iterations. It must be a positive integer.
+   */
+  static std::string MaxIterationsOptionName();
+
+  /**
+   * @returns string key for SolverOptions to set the value of c to use for the
+   * backtracking line search. Must be between 0 and 1.
+   */
+  static std::string BacktrackingCOptionName();
+
+  /**
+   * @returns string key for SolverOptions to set the value of tau to use for
+   * the backtracking line search. Must be between 0 and 1.
+   */
+  static std::string BacktrackingTauOptionName();
+
+  /**
+   * @returns string key for SolverOptions to set the value of alpha_0 to use
+   * for the backtracking line search. Must be positive.
+   */
+  static std::string BacktrackingAlpha0OptionName();
+
+  /// @name Static versions of the instance methods with similar names.
+  //@{
+  static SolverId id();
+  static bool is_available();
+  static bool is_enabled();
+  static bool ProgramAttributesSatisfied(const MathematicalProgram&);
+  //@}
+
+  // A using-declaration adds these methods into our class's Doxygen.
+  using SolverBase::Solve;
+
+  static constexpr double kDefaultConvergenceTol = 1e-12;
+  static constexpr int kDefaultMaxIterations = 1000;
+  static constexpr double kDefaultBacktrackingC = 0.5;
+  static constexpr double kDefaultBacktrackingTau = 0.5;
+  static constexpr double kDefaultBacktrackingAlpha0 = 0.1;
+
+  static constexpr int kDefaultMaxLineSearchSteps = 100;
+
+ private:
+  void DoSolve2(const MathematicalProgram&, const Eigen::VectorXd&,
+                internal::SpecificOptions*,
+                MathematicalProgramResult*) const final;
+
+  std::optional<std::function<Eigen::VectorXd(const Eigen::VectorXd&)>>
+      custom_gradient_function_;
+  std::optional<std::function<bool(const Eigen::VectorXd&, Eigen::VectorXd*)>>
+      custom_projection_function_;
+  const SolverInterface* projection_solver_interface_;
+};
+
+}  // namespace solvers
+}  // namespace drake