diff --git a/solvers/BUILD.bazel b/solvers/BUILD.bazel index 4c66c2027823..cc4f34ac525b 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"], @@ -2027,6 +2041,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..a488fa864715 --- /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_) { + // Pedantically, 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..cfd73247034c --- /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 as 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 any 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