From 10ffba67348419d05bfdf65567dc30183ce91219 Mon Sep 17 00:00:00 2001 From: Jeremy Nimmer Date: Tue, 15 Oct 2024 13:57:06 -0700 Subject: [PATCH] [solvers] Add SpecificOptions sugar for solvers to map their options In the near future, we anticipate changing the SolverOptions API in support of loading and saving (i.e., serialization). That's especially troublesome for our solver back-ends that consume its information, given the low-level and hodge-podge ways in which they hunt for and apply their specific options. This commit introduces a higher-level intermediary between solver back-ends and the program's options. The goal is that SolverOptions will solely be a user-facing aspect of defining a program; the solver back-ends will never touch SolverOptions anymore, instead using the SpecificOptions sugar to map our options API into the back-end. The new API is designed to improve uniformity of common errors, such as unknown names or wrongly-typed values. The new API also lays the groundwork for more efficient processing, as future work. It removes the need for the "Merge" (copy) operation in the hot path -- since it only provides a *view* of the options, it can easily keep track of several dictionaries and query them in order, with no copying. --- solvers/BUILD.bazel | 22 ++ solvers/common_solver_option.h | 14 + solvers/specific_options.cc | 311 +++++++++++++++++++++ solvers/specific_options.h | 172 ++++++++++++ solvers/test/specific_options_test.cc | 388 ++++++++++++++++++++++++++ 5 files changed, 907 insertions(+) create mode 100644 solvers/specific_options.cc create mode 100644 solvers/specific_options.h create mode 100644 solvers/test/specific_options_test.cc diff --git a/solvers/BUILD.bazel b/solvers/BUILD.bazel index 0cf73085313d..da987402ac52 100644 --- a/solvers/BUILD.bazel +++ b/solvers/BUILD.bazel @@ -80,6 +80,7 @@ drake_cc_package_library( ":solver_type_converter", ":sos_basis_generator", ":sparse_and_dense_matrix", + ":specific_options", ":unrevised_lemke_solver", ], ) @@ -314,6 +315,19 @@ drake_cc_library( ], ) +drake_cc_library( + name = "specific_options", + srcs = ["specific_options.cc"], + hdrs = ["specific_options.h"], + deps = [ + ":solver_id", + ":solver_options", + "//common:name_value", + "//common:overloaded", + "//common:string_container", + ], +) + drake_cc_library( name = "indeterminate", srcs = ["indeterminate.cc"], @@ -2026,6 +2040,14 @@ drake_cc_googletest( ], ) +drake_cc_googletest( + name = "specific_options_test", + deps = [ + ":specific_options", + "//common/test_utilities:expect_throws_message", + ], +) + drake_cc_googletest( name = "augmented_lagrangian_test", deps = [ diff --git a/solvers/common_solver_option.h b/solvers/common_solver_option.h index 257ae2e8aefd..f2494b06191e 100644 --- a/solvers/common_solver_option.h +++ b/solvers/common_solver_option.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include "drake/common/fmt_ostream.h" @@ -56,6 +58,18 @@ enum class CommonSolverOption { std::ostream& operator<<(std::ostream& os, CommonSolverOption common_solver_option); + +namespace internal { + +/* Aggregated values for CommonSolverOption, for Drake-internal use only. */ +struct CommonSolverOptionValues { + std::string print_file_name; + bool print_to_console{false}; + std::string standalone_reproduction_file_name; + std::optional max_threads; +}; + +} // namespace internal } // namespace solvers } // namespace drake diff --git a/solvers/specific_options.cc b/solvers/specific_options.cc new file mode 100644 index 000000000000..6e5a1259e8d3 --- /dev/null +++ b/solvers/specific_options.cc @@ -0,0 +1,311 @@ +#include "drake/solvers/specific_options.h" + +#include +#include +#include +#include + +#include "drake/common/overloaded.h" + +namespace drake { +namespace solvers { +namespace internal { + +using OptionValue = SolverOptions::OptionValue; + +SpecificOptions::SpecificOptions(const SolverId* id, + const SolverOptions* all_options) + : id_{id}, all_options_{all_options} { + DRAKE_DEMAND(id != nullptr); + DRAKE_DEMAND(all_options != nullptr); +} + +SpecificOptions::~SpecificOptions() = default; + +void SpecificOptions::Respell( + const std::function*)>& respell) { + DRAKE_DEMAND(respell != nullptr); + DRAKE_DEMAND(respelled_.empty()); + respell( + CommonSolverOptionValues{ + .print_file_name = all_options_->get_print_file_name(), + .print_to_console = all_options_->get_print_to_console(), + .standalone_reproduction_file_name = + all_options_->get_standalone_reproduction_file_name(), + .max_threads = all_options_->get_max_threads(), + }, + &respelled_); +} + +template +std::optional SpecificOptions::Pop(std::string_view key) { + if (popped_.contains(key)) { + return std::nullopt; + } + const auto& typed_options = all_options_->template GetOptions(*id_); + // TODO(jwnimmer-tri) Nix this string copy after we fix SolverOptions to use + // sensible representation choices. + if (auto iter = typed_options.find(std::string{key}); + iter != typed_options.end()) { + popped_.emplace(key); + return iter->second; + } + if (auto iter = respelled_.find(key); iter != respelled_.end()) { + const OptionValue& value = iter->second; + if (std::holds_alternative(value)) { + popped_.emplace(key); + return std::get(value); + } + throw std::logic_error(fmt::format( + "{}: internal error: option {} was respelled to the wrong type", + id_->name(), key)); + } + return {}; +} + +template std::optional SpecificOptions::Pop(std::string_view); +template std::optional SpecificOptions::Pop(std::string_view); +template std::optional SpecificOptions::Pop(std::string_view); + +void SpecificOptions::CopyToCallbacks( + const std::function& set_double, + const std::function& set_int, + const std::function& + set_string) const { + // Bail out early when we have no options at all for this solver. + const std::unordered_map& options_double = + all_options_->GetOptionsDouble(*id_); + const std::unordered_map& options_int = + all_options_->GetOptionsInt(*id_); + const std::unordered_map& options_str = + all_options_->GetOptionsStr(*id_); + if (options_double.empty() && options_int.empty() && options_str.empty() && + respelled_.empty()) { + return; + } + + // Wrap the solver's set_{type} callbacks with error-reporting sugar, and + // logic to promote integers to doubles. + auto on_double = [this, &set_double](const std::string& key, double value) { + if (popped_.contains(key)) { + return; + } + if (set_double != nullptr) { + set_double(key, value); + return; + } + throw std::logic_error(fmt::format( + "{}: floating-point options are not supported; the option {}={} is " + "invalid", + id_->name(), key, value)); + }; + auto on_int = [this, &set_int, &set_double](const std::string& key, + int value) { + if (popped_.contains(key)) { + return; + } + if (set_int != nullptr) { + set_int(key, value); + return; + } + if (set_double != nullptr) { + set_double(key, value); + return; + } + throw std::logic_error(fmt::format( + "{}: integer and floating-point options are not supported; the option " + "{}={} is invalid", + id_->name(), key, value)); + }; + auto on_string = [this, &set_string](const std::string& key, + const std::string& value) { + if (popped_.contains(key)) { + return; + } + if (set_string != nullptr) { + set_string(key, value); + return; + } + throw std::logic_error(fmt::format( + "{}: string options are not supported; the option {}='{}' is invalid", + id_->name(), key, value)); + }; + + // Handle solver-specific options. + for (const auto& [key, value] : options_double) { + on_double(key, value); + } + for (const auto& [key, value] : options_int) { + on_int(key, value); + } + for (const auto& [key, value] : options_str) { + on_string(key, value); + } + + // Handle any respelled options, being careful not to set anything that has + // already been set. + for (const auto& [respelled_key, boxed_value] : respelled_) { + // Pedantially, lambdas cannot capture a structured binding so we need to + // make a local variable that we can capture. + const auto& key = respelled_key; + std::visit( + overloaded{[&key, &on_double, &options_double](double value) { + if (!options_double.contains(key)) { + on_double(key, value); + } + }, + [&key, &on_int, &options_int](int value) { + if (!options_int.contains(key)) { + on_int(key, value); + } + }, + [&key, &on_string, &options_str](const std::string& value) { + if (!options_str.contains(key)) { + on_string(key, value); + } + }}, + boxed_value); + } +} + +void SpecificOptions::InitializePending() { + pending_keys_.clear(); + for (const auto& [key, _] : all_options_->GetOptionsDouble(*id_)) { + pending_keys_.insert(key); + } + for (const auto& [key, _] : all_options_->GetOptionsInt(*id_)) { + pending_keys_.insert(key); + } + for (const auto& [key, _] : all_options_->GetOptionsStr(*id_)) { + pending_keys_.insert(key); + } + for (const auto& [key, _] : respelled_) { + pending_keys_.insert(key); + } + for (const auto& key : popped_) { + pending_keys_.erase(key); + } +} + +void SpecificOptions::CheckNoPending() const { + // Identify any unsupported names (i.e., leftovers in `pending_`). + if (!pending_keys_.empty()) { + std::vector unknown_names; + for (const auto& name : pending_keys_) { + unknown_names.push_back(name); + } + std::sort(unknown_names.begin(), unknown_names.end()); + throw std::logic_error(fmt::format( + "{}: the following solver option names were not recognized: {}", + id_->name(), fmt::join(unknown_names, ", "))); + } +} + +std::optional SpecificOptions::PrepareToCopy(const char* name) { + DRAKE_DEMAND(name != nullptr); + const std::unordered_map& options_double = + all_options_->GetOptionsDouble(*id_); + // TODO(jwnimmer-tri) Nix these string copies after we fix SolverOptions to + // use sensible representation choices. + if (auto iter = options_double.find(std::string{name}); + iter != options_double.end()) { + pending_keys_.erase(iter->first); + return iter->second; + } + const std::unordered_map& options_int = + all_options_->GetOptionsInt(*id_); + if (auto iter = options_int.find(std::string{name}); + iter != options_int.end()) { + pending_keys_.erase(iter->first); + return iter->second; + } + const std::unordered_map& options_str = + all_options_->GetOptionsStr(*id_); + if (auto iter = options_str.find(std::string{name}); + iter != options_str.end()) { + pending_keys_.erase(iter->first); + return iter->second; + } + if (auto iter = respelled_.find(name); iter != respelled_.end()) { + pending_keys_.erase(iter->first); + return iter->second; + } + return {}; +} + +template +void SpecificOptions::CopyFloatingPointOption(const char* name, T* output) { + DRAKE_DEMAND(output != nullptr); + if (auto boxed_value = PrepareToCopy(name)) { + if (std::holds_alternative(*boxed_value)) { + *output = std::get(*boxed_value); + return; + } + if (std::holds_alternative(*boxed_value)) { + *output = std::get(*boxed_value); + return; + } + throw std::logic_error( + fmt::format("{}: Expected a floating-point value for option {}", + id_->name(), name)); + } +} +template void SpecificOptions::CopyFloatingPointOption(const char*, double*); +template void SpecificOptions::CopyFloatingPointOption(const char*, float*); + +template +void SpecificOptions::CopyIntegralOption(const char* name, T* output) { + DRAKE_DEMAND(output != nullptr); + if (auto boxed_value = PrepareToCopy(name)) { + if (std::holds_alternative(*boxed_value)) { + const int value = std::get(*boxed_value); + if constexpr (std::is_same_v) { + *output = value; + } else if constexpr (std::is_same_v) { + if (!(value == 0 || value == 1)) { + throw std::logic_error(fmt::format( + "{}: Expected a boolean value (0 or 1) for int option {}={}", + id_->name(), name, value)); + } + *output = value; + } else { + static_assert(std::is_same_v); + if (value < 0) { + throw std::logic_error(fmt::format( + "{}: Expected a non-negative value for unsigned int option {}={}", + id_->name(), name, value)); + } + if (static_cast(value) > + static_cast(std::numeric_limits::max())) { + throw std::logic_error(fmt::format( + "{}: Too-large value for uint32 option {}={}", + id_->name(), name, value)); + } + *output = value; + } + return; + } + throw std::logic_error(fmt::format( + "{}: Expected an integer value for option {}", id_->name(), name)); + } +} +template void SpecificOptions::CopyIntegralOption(const char*, int*); +template void SpecificOptions::CopyIntegralOption(const char*, bool*); +template void SpecificOptions::CopyIntegralOption(const char*, uint32_t*); + +void SpecificOptions::CopyStringOption(const char* name, std::string* output) { + DRAKE_DEMAND(output != nullptr); + if (auto boxed_value = PrepareToCopy(name)) { + if (std::holds_alternative(*boxed_value)) { + *output = std::get(*boxed_value); + return; + } + throw std::logic_error(fmt::format( + "{}: Expected a string value for option {}", id_->name(), name)); + } +} + +} // namespace internal +} // namespace solvers +} // namespace drake diff --git a/solvers/specific_options.h b/solvers/specific_options.h new file mode 100644 index 000000000000..6b8f16de2296 --- /dev/null +++ b/solvers/specific_options.h @@ -0,0 +1,172 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "drake/common/drake_assert.h" +#include "drake/common/drake_copyable.h" +#include "drake/common/name_value.h" +#include "drake/common/string_unordered_map.h" +#include "drake/common/string_unordered_set.h" +#include "drake/solvers/solver_id.h" +#include "drake/solvers/solver_options.h" + +namespace drake { +namespace solvers { +namespace internal { + +/* Helper class that propagates options for a specific solver from Drake's +generic SolverOptions struct into the specific solver's options API. + +This class is intended a short-lived helper (i.e., a local variable on the call +stack). It aliases an immutable list of overall SolverOptions, and provides +convenient tools to map those options into whatever back-end is necessary. Two +different mechanisms are offered, depending on whether the solver's option names +are static or dynamic: CopyToCallbacks or CopyToSerializableStruct. + +(Method 1 - dynamic) CopyToCallbacks: This method is appropriate for solver +back-ends that offer an API like "set option via string name". The back-end +provides callbacks for each supported data type (i.e., double, int, string), and +this class loops over the stored options and invokes the callbacks. It is up to +the solver back-end to detect unknown or mis-typed options and throw. + +(Method 2 - static) CopyToSerializableStruct: This method is appropriate for +solvers that offer a statically typed `struct MyOptions { ... }` which needs to +be filled in. The caller provides a Serialize() wrapper around that struct, and +this class directly writes the fields and reports errors for unknown names and +mismatched data types. + +With both methods, there are also some ahead-of-time operations that are often +useful: + +- Respell() can project CommonSolverOption values into the back-end vocabulary + so that only the back-end specific names need to be implemented during options + handling. + +- Pop() can yank options that require special handling, so that they will not + participate in the Method 1 or 2 copying / callbacks. + +For examples of use, refer to all of the existing Drake solver wrappers. */ +class SpecificOptions { + public: + DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(SpecificOptions); + + /* Creates a converter that reads the subset of `all_options` destined for the + given `id`. Both arguments are aliased, so must outlive this object. */ + SpecificOptions(const SolverId* id, const SolverOptions* all_options); + + ~SpecificOptions(); + + /* The `respell` callback will be used to respell the CommonSolverOption + values. Any options returned (via the output argument) will be handled as if + they were solver-specific options, at a lower priority than any solver- + specific options the user already provided for those names (in our + constructor). It is OK for the callback to have side-effects; it will be + invoked exactly once and is not retained. As such, this can also serve as a + way to inject default values (by outputting them from this callback). + @pre This function may be called at most once (per converter object). */ + void Respell( + const std::function* /* respelled */)>& + respell); + + /* Returns and effectively removes the value of the option named `key`. (To be + specific -- `all_options` remain unchanged; instead, the `key` is memorized + and future calls to either of the `CopyTo...` functions will skip over it.) + If the option was not set, returns nullopt. When checking if an option was + set, this checks `all_options` for our `id` as well as any options added + during a Respell(). + @tparam T must be one of: double, int, std::string. */ + template + std::optional Pop(std::string_view key); + + /* Helper for "Method 1 - dynamic", per our class overview. Converts options + when the solver offers a generic key-value API where options are passed by + string name. Any of the `set_...` arguments can be nullptr, which implies + that the back-end does not support any options of that type. */ + void CopyToCallbacks( + const std::function& + set_double, + const std::function& set_int, + const std::function& set_string) const; + + /* Helper for "Method 2 - static", per our class overview. Converts options + when the solver uses an options struct with names known at compile time. The + `Output` struct must obey Drake's Serialize() protocol, for us to interrogate + the option names. + @param [in,out] result The solver's specific options struct; any options not + mentioned in `solver_options` will remain unchanged. */ + template + void CopyToSerializableStruct(Result* result) { + DRAKE_DEMAND(result != nullptr); + InitializePending(); + Serialize(this, *result); + CheckNoPending(); + } + + /* (Internal use only) Helper function for CopyToSerializableStruct(). */ + template + void Visit(const NameValue& x) { + if constexpr (std::is_floating_point_v) { + CopyFloatingPointOption(x.name(), x.value()); + } else if constexpr (std::is_integral_v) { + CopyIntegralOption(x.name(), x.value()); + } else if constexpr (std::is_same_v) { + CopyStringOption(x.name(), x.value()); + } else { + static_assert(std::is_void_v, "Unsupported template argument T"); + } + } + + private: + /* Helper function for CopyToSerializableStruct(). Sets pending_keys_ to the + union of all keys from all_options_ (for our id_) and respelled_, excluding + anything already popped_. */ + void InitializePending(); + + /* Helper function for CopyToSerializableStruct(). If pending_keys_ is + non-empty, throws an error about the unhandled options. */ + void CheckNoPending() const; + + /* Helper function for CopyToSerializableStruct(). Finds the option with the + given name, removes it from pending_keys_, and returns it. */ + std::optional PrepareToCopy(const char* name); + + /* Output helper functions for CopyToSerializableStruct(). + Each one sets its `output` argument to the option value for the given name, + and removes the name from pending_keys_. If the name is not in options, does + nothing (output keeps its prior value). */ + //@{ + template + void CopyFloatingPointOption(const char* name, T* output); + template + void CopyIntegralOption(const char* name, T* output); + void CopyStringOption(const char* name, std::string* output); + //@} + + // The solver we're operating on behalf of (as passed to our constructor). + const SolverId* const id_; + + // The full options for all solvers (as passed to our constructor). + const SolverOptions* const all_options_; + + // The result of the Respell() callback (or empty, if never called). + string_unordered_map respelled_; + + // Items that have already been popped. + string_unordered_set popped_; + + // Temporary storage during CopyToSerializableStruct() of option names that + // are set but haven't yet been processed by Visit(). + std::unordered_set pending_keys_; +}; + +} // namespace internal +} // namespace solvers +} // namespace drake diff --git a/solvers/test/specific_options_test.cc b/solvers/test/specific_options_test.cc new file mode 100644 index 000000000000..e001a0fda0a4 --- /dev/null +++ b/solvers/test/specific_options_test.cc @@ -0,0 +1,388 @@ +#include "drake/solvers/specific_options.h" + +#include + +#include +#include + +#include "drake/common/never_destroyed.h" +#include "drake/common/string_map.h" +#include "drake/common/test_utilities/expect_throws_message.h" + +namespace drake { +namespace solvers { +namespace internal { +namespace { + +using testing::UnorderedElementsAre; + +const SolverId& GetSolverId() { + static const never_destroyed result("test_id"); + return result.access(); +} + +GTEST_TEST(SpecificOptionsTest, BasicPop) { + const SolverId& solver_id = GetSolverId(); + SolverOptions solver_options; + solver_options.SetOption(solver_id, "some_default", 1234.5); + SpecificOptions dut(&solver_id, &solver_options); + + // Popping with the wrong type is a no-op. + EXPECT_EQ(dut.Pop("some_default"), std::nullopt); + + // Popping with the correct type retrieves it. + EXPECT_EQ(dut.Pop("some_default"), 1234.5); + + // It's gone now. + EXPECT_EQ(dut.Pop("some_default"), std::nullopt); + EXPECT_NO_THROW(dut.CopyToCallbacks({}, {}, {})); +} + +GTEST_TEST(SpecificOptionsTest, RespellThenPop) { + const SolverId& solver_id = GetSolverId(); + const SolverOptions solver_options; + SpecificOptions dut(&solver_id, &solver_options); + + // Add an option via respelling. + dut.Respell([](const CommonSolverOptionValues& common, + string_unordered_map* respelled) { + ASSERT_TRUE(respelled != nullptr); + respelled->emplace("some_default", 1234.5); + }); + + // Popping with the correct type retrieves it. + EXPECT_EQ(dut.Pop("some_default"), 1234.5); + + // It's gone now. + EXPECT_EQ(dut.Pop("some_default"), std::nullopt); + EXPECT_NO_THROW(dut.CopyToCallbacks({}, {}, {})); +} + +GTEST_TEST(SpecificOptionsTest, RespellThenPopWrongType) { + const SolverId& solver_id = GetSolverId(); + const SolverOptions solver_options; + SpecificOptions dut(&solver_id, &solver_options); + + // Add an option via respelling. + dut.Respell([](const CommonSolverOptionValues& common, + string_unordered_map* respelled) { + ASSERT_TRUE(respelled != nullptr); + respelled->emplace("some_default", 1234.5); + }); + + // The solver back-end is not allowed to mismatch its own types. + DRAKE_EXPECT_THROWS_MESSAGE(dut.Pop("some_default"), + ".*respelled.*wrong type.*"); +} + +GTEST_TEST(SpecificOptionsTest, CopyToCallbacksNoop) { + // This is a code-coverage test to cover the early return no-op. + const SolverId& solver_id = GetSolverId(); + const SolverOptions solver_options; + SpecificOptions dut(&solver_id, &solver_options); + auto fail = [](const auto&...) { + GTEST_FAIL(); + }; + dut.CopyToCallbacks(fail, fail, fail); +} + +GTEST_TEST(SpecificOptionsTest, CopyToCallbacksEarlyReturn) { + // This is a code-coverage test for the early return no-op. + const SolverId& solver_id = GetSolverId(); + const SolverOptions solver_options; + SpecificOptions dut(&solver_id, &solver_options); + auto fail = [](const auto&...) { + GTEST_FAIL(); + }; + dut.CopyToCallbacks(fail, fail, fail); +} + +GTEST_TEST(SpecificOptionsTest, CopyToCallbacksIntPromotesToDouble) { + const SolverId& solver_id = GetSolverId(); + SolverOptions solver_options; + solver_options.SetOption(solver_id, "my_double", 3); + SpecificOptions dut(&solver_id, &solver_options); + auto fail = [](const auto&...) { + GTEST_FAIL(); + }; + string_map values_double; + dut.CopyToCallbacks( + /* set_double = */ + [&values_double](const std::string& key, double value) { + values_double.emplace(key, value); + }, + /* set_int = */ nullptr, /* set_string = */ fail); + EXPECT_THAT(values_double, UnorderedElementsAre(std::pair{"my_double", 3.0})); +} + +GTEST_TEST(SpecificOptionsTest, CopyToCallbacksNoDouble) { + const SolverId& solver_id = GetSolverId(); + SolverOptions solver_options; + solver_options.SetOption(solver_id, "my_double", 1.5); + SpecificOptions dut(&solver_id, &solver_options); + auto fail = [](const auto&...) { + GTEST_FAIL(); + }; + DRAKE_EXPECT_THROWS_MESSAGE( + dut.CopyToCallbacks(/* set_double = */ nullptr, + /* set_int = */ fail, /* set_string = */ fail), + ".*floating-point.*not supported.*"); +} + +GTEST_TEST(SpecificOptionsTest, CopyToCallbacksNoDoubleNorInt) { + const SolverId& solver_id = GetSolverId(); + SolverOptions solver_options; + solver_options.SetOption(solver_id, "my_int", 3); + SpecificOptions dut(&solver_id, &solver_options); + auto fail = [](const auto&...) { + GTEST_FAIL(); + }; + DRAKE_EXPECT_THROWS_MESSAGE( + dut.CopyToCallbacks(/* set_double = */ nullptr, /* set_int = */ nullptr, + /* set_string = */ fail), + ".*integer and floating.point.*not supported.*"); +} + +GTEST_TEST(SpecificOptionsTest, CopyToCallbacksString) { + const SolverId& solver_id = GetSolverId(); + SolverOptions solver_options; + solver_options.SetOption(solver_id, "my_string", "hello"); + SpecificOptions dut(&solver_id, &solver_options); + auto fail = [](const auto&...) { + GTEST_FAIL(); + }; + DRAKE_EXPECT_THROWS_MESSAGE( + dut.CopyToCallbacks(/* set_double = */ fail, /* set_int = */ fail, + /* set_string = */ nullptr), + ".*string.*not supported.*"); +} + +SolverOptions MakeFullFledgedSolverOptions() { + const SolverId& id = GetSolverId(); + SolverOptions result; + + // These options should be processed. + result.SetOption(id, "my_double", 1.5); + result.SetOption(id, "my_int", 3); + result.SetOption(id, "my_string", "hello"); + + // These will be popped off before processing. + result.SetOption(id, "double_to_pop", 0.5); + result.SetOption(id, "int_to_pop", 2); + result.SetOption(id, "string_to_pop", "popped"); + + // Set all common options to non-default values. + result.SetOption(CommonSolverOption::kPrintFileName, "print.log"); + result.SetOption(CommonSolverOption::kPrintToConsole, 1); + result.SetOption(CommonSolverOption::kStandaloneReproductionFileName, + "repro.txt"); + result.SetOption(CommonSolverOption::kMaxThreads, 4); + + // These options are for a different solver, so will be ignored. + const SolverId some_other_solver_id("ignored"); + result.SetOption(some_other_solver_id, "double_ignored", 0.0); + result.SetOption(some_other_solver_id, "int_ignored", 0); + result.SetOption(some_other_solver_id, "string_ignored", ""); + + return result; +} + +GTEST_TEST(SpecificOptionsTest, CopyToCallbacksTypicalWorkflow) { + const SolverId& solver_id = GetSolverId(); + const SolverOptions solver_options = MakeFullFledgedSolverOptions(); + + SpecificOptions dut(&solver_id, &solver_options); + + // Pop things which exist. + EXPECT_EQ(dut.Pop("double_to_pop"), 0.5); + EXPECT_EQ(dut.Pop("int_to_pop"), 2); + EXPECT_EQ(dut.Pop("string_to_pop"), "popped"); + + // Now they are gone. + EXPECT_EQ(dut.Pop("double_to_pop"), std::nullopt); + EXPECT_EQ(dut.Pop("int_to_pop"), std::nullopt); + EXPECT_EQ(dut.Pop("string_to_pop"), std::nullopt); + + // Pop things which do not exist. + EXPECT_EQ(dut.Pop("no_such_double"), std::nullopt); + EXPECT_EQ(dut.Pop("no_such_int"), std::nullopt); + EXPECT_EQ(dut.Pop("no_such_string"), std::nullopt); + + // Respell. + dut.Respell([](const CommonSolverOptionValues& common, + string_unordered_map* respelled) { + ASSERT_TRUE(respelled != nullptr); + respelled->emplace("print_file_name", common.print_file_name); + respelled->emplace("print_to_console", common.print_to_console); + respelled->emplace("standalone_reproduction_file_name", + common.standalone_reproduction_file_name); + respelled->emplace("max_threads", common.max_threads.value_or(256)); + respelled->emplace("some_default", 1234.5); + }); + + // Extract the options via callbacks. + string_map values_double; + string_map values_int; + string_map values_string; + dut.CopyToCallbacks( + [&values_double](const std::string& key, double value) { + values_double.emplace(key, value); + }, + [&values_int](const std::string& key, int value) { + values_int.emplace(key, value); + }, + [&values_string](const std::string& key, const std::string& value) { + values_string.emplace(key, value); + }); + + EXPECT_THAT(values_double, + UnorderedElementsAre(std::pair{"my_double", 1.5}, + std::pair{"some_default", 1234.5})); + EXPECT_THAT(values_int, // BR + UnorderedElementsAre(std::pair{"my_int", 3}, + std::pair{"print_to_console", 1}, + std::pair{"max_threads", 4})); + EXPECT_THAT(values_string, + UnorderedElementsAre( + std::pair{"my_string", "hello"}, + std::pair{"print_file_name", "print.log"}, + std::pair{"standalone_reproduction_file_name", "repro.txt"})); +} + +struct OptionsStruct { + template + void Serialize(Archive* a) { + a->Visit(DRAKE_NVP(my_double)); + a->Visit(DRAKE_NVP(my_int)); + a->Visit(DRAKE_NVP(my_string)); + a->Visit(DRAKE_NVP(print_file_name)); + a->Visit(DRAKE_NVP(print_to_console)); + a->Visit(DRAKE_NVP(standalone_reproduction_file_name)); + a->Visit(DRAKE_NVP(max_threads)); + a->Visit(DRAKE_NVP(some_default)); + a->Visit(DRAKE_NVP(unchanged)); + } + + double my_double{}; + int my_int{}; + std::string my_string; + std::string print_file_name; + bool print_to_console{}; + std::string standalone_reproduction_file_name; + uint32_t max_threads{}; + double some_default{}; + std::string unchanged{"original_value"}; +}; + +// NOLINTNEXTLINE(runtime/references) to match Serialize concept. +void Serialize(internal::SpecificOptions* archive, OptionsStruct& options) { + options.Serialize(archive); +} + +GTEST_TEST(SpecificOptionsTest, CopyToSerializableStructTypicalWorkflow) { + const SolverId& solver_id = GetSolverId(); + const SolverOptions solver_options = MakeFullFledgedSolverOptions(); + + SpecificOptions dut(&solver_id, &solver_options); + + // Pop things which exist. + EXPECT_EQ(dut.Pop("double_to_pop"), 0.5); + EXPECT_EQ(dut.Pop("int_to_pop"), 2); + EXPECT_EQ(dut.Pop("string_to_pop"), "popped"); + + // Now they are gone. + EXPECT_EQ(dut.Pop("double_to_pop"), std::nullopt); + EXPECT_EQ(dut.Pop("int_to_pop"), std::nullopt); + EXPECT_EQ(dut.Pop("string_to_pop"), std::nullopt); + + // Pop things which do not exist. + EXPECT_EQ(dut.Pop("no_such_double"), std::nullopt); + EXPECT_EQ(dut.Pop("no_such_int"), std::nullopt); + EXPECT_EQ(dut.Pop("no_such_string"), std::nullopt); + + // Respell. + dut.Respell([](const CommonSolverOptionValues& common, + string_unordered_map* respelled) { + ASSERT_TRUE(respelled != nullptr); + respelled->emplace("print_file_name", common.print_file_name); + respelled->emplace("print_to_console", common.print_to_console); + respelled->emplace("standalone_reproduction_file_name", + common.standalone_reproduction_file_name); + respelled->emplace("max_threads", common.max_threads.value_or(-1)); + respelled->emplace("some_default", 1234.5); + }); + + // Extract the options via serialization. + OptionsStruct options_struct; + dut.CopyToSerializableStruct(&options_struct); + EXPECT_EQ(options_struct.my_double, 1.5); + EXPECT_EQ(options_struct.some_default, 1234.5); + EXPECT_EQ(options_struct.my_int, 3); + EXPECT_EQ(options_struct.print_to_console, 1); + EXPECT_EQ(options_struct.max_threads, 4); + EXPECT_EQ(options_struct.my_string, "hello"); + EXPECT_EQ(options_struct.print_file_name, "print.log"); + EXPECT_EQ(options_struct.standalone_reproduction_file_name, "repro.txt"); +} + +template +OptionsStruct CopyOneOptionToSerializableStruct(const std::string& key, + const T& value) { + const SolverId& solver_id = GetSolverId(); + SolverOptions solver_options; + solver_options.SetOption(solver_id, key, value); + SpecificOptions dut(&solver_id, &solver_options); + OptionsStruct options_struct; + dut.CopyToSerializableStruct(&options_struct); + return options_struct; +} + +GTEST_TEST(SpecificOptionsTest, StructIntValuePromotionToDoubleMemberField) { + const OptionsStruct options_struct = + CopyOneOptionToSerializableStruct("my_double", 2); + EXPECT_EQ(options_struct.my_double, 2.0); +} + +GTEST_TEST(SpecificOptionsTest, StructNoSuchField) { + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("problematic_double", 0.5), + ".*not recognized.*problematic_double.*"); + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("problematic_int", 1), + ".*not recognized.*problematic_int.*"); + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("problematic_string", "foo"), + ".*not recognized.*problematic_string.*"); +} + +GTEST_TEST(SpecificOptionsTest, StructWrongType) { + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("my_double", "foo"), + ".*floating.point.*my_double.*"); + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("my_int", "foo"), + ".*integer.*my_int.*"); + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("my_string", 0.5), + ".*string.*my_string.*"); +} + +GTEST_TEST(SpecificOptionsTest, StructBadBool) { + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("print_to_console", -1), + ".*(0 or 1).*print_to_console.*"); + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("print_to_console", 2), + ".*(0 or 1).*print_to_console.*"); +} + +GTEST_TEST(SpecificOptionsTest, StructBadUint32) { + DRAKE_EXPECT_THROWS_MESSAGE( + CopyOneOptionToSerializableStruct("max_threads", -1), + ".*non-negative.*max_threads.*"); +} + +} // namespace +} // namespace internal +} // namespace solvers +} // namespace drake