From eba3bcc5c5117478d8bf321977f47e2295a027fa Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sat, 11 Apr 2026 16:50:58 -0400 Subject: [PATCH 1/5] claude: Add dual-path conformance testing (basic/non_basic mode) All conformance binaries now read a `mode` field from JSON params. When mode is "non_basic", generators are wrapped with a trivial filter to lose their schema, forcing the compositional fallback path. --- tests/conformance/cpp/metrics.h | 16 ++++++++++++++ tests/conformance/cpp/test_binary.cpp | 6 +++++- tests/conformance/cpp/test_booleans.cpp | 19 +++++++++++++++-- tests/conformance/cpp/test_floats.cpp | 5 ++++- tests/conformance/cpp/test_hashmaps.cpp | 23 ++++++++++++++------- tests/conformance/cpp/test_integers.cpp | 5 ++++- tests/conformance/cpp/test_lists.cpp | 7 +++++-- tests/conformance/cpp/test_sampled_from.cpp | 5 ++++- tests/conformance/cpp/test_text.cpp | 5 ++++- 9 files changed, 75 insertions(+), 16 deletions(-) diff --git a/tests/conformance/cpp/metrics.h b/tests/conformance/cpp/metrics.h index b5b466a..dcc8734 100644 --- a/tests/conformance/cpp/metrics.h +++ b/tests/conformance/cpp/metrics.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -36,4 +37,19 @@ namespace conformance { } } + // Wrap a generator in a trivial filter so it loses its schema, + // forcing the compositional fallback path. + template + hegel::generators::Generator + make_non_basic(hegel::generators::Generator gen) { + return gen.filter([](const T&) { return true; }); + } + + inline std::string get_mode(const nlohmann::json& args) { + if (args.contains("mode") && args["mode"].is_string()) { + return args["mode"].get(); + } + return "basic"; + } + } // namespace conformance diff --git a/tests/conformance/cpp/test_binary.cpp b/tests/conformance/cpp/test_binary.cpp index c83af40..2a16632 100644 --- a/tests/conformance/cpp/test_binary.cpp +++ b/tests/conformance/cpp/test_binary.cpp @@ -22,13 +22,17 @@ int main(int argc, char* argv[]) { args["max_size"].is_null() ? std::nullopt : std::optional(args["max_size"].get()); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( [=]() { auto gen = hegel::generators::binary( {.min_size = min_size, .max_size = max_size}); - std::vector value = hegel::draw(gen); + std::vector value = + mode == "non_basic" + ? hegel::draw(conformance::make_non_basic(gen)) + : hegel::draw(gen); conformance::write_metrics({{"length", value.size()}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_booleans.cpp b/tests/conformance/cpp/test_booleans.cpp index 20f67ba..532da53 100644 --- a/tests/conformance/cpp/test_booleans.cpp +++ b/tests/conformance/cpp/test_booleans.cpp @@ -1,13 +1,28 @@ +#include +#include + #include "hegel/hegel.h" #include "metrics.h" +#include "../../../src/json_impl.h" +using hegel::internal::json::ImplUtil; + int main(int argc, char* argv[]) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " ''" << std::endl; + return 1; + } + + auto args = ImplUtil::raw(hegel::internal::json::json::parse(argv[1])); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( - []() { + [=]() { auto gen = hegel::generators::booleans(); - auto value = hegel::draw(gen); + auto value = mode == "non_basic" + ? hegel::draw(conformance::make_non_basic(gen)) + : hegel::draw(gen); conformance::write_metrics({{"value", value}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_floats.cpp b/tests/conformance/cpp/test_floats.cpp index 3adf74c..185dcf5 100644 --- a/tests/conformance/cpp/test_floats.cpp +++ b/tests/conformance/cpp/test_floats.cpp @@ -34,6 +34,7 @@ int main(int argc, char* argv[]) { args["allow_infinity"].is_null() ? std::nullopt : std::optional(args["allow_infinity"].get()); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( @@ -46,7 +47,9 @@ int main(int argc, char* argv[]) { .allow_nan = allow_nan, .allow_infinity = allow_infinity, }); - auto value = hegel::draw(gen); + auto value = mode == "non_basic" + ? hegel::draw(conformance::make_non_basic(gen)) + : hegel::draw(gen); conformance::write_metrics({ {"value", value}, {"is_nan", std::isnan(value)}, diff --git a/tests/conformance/cpp/test_hashmaps.cpp b/tests/conformance/cpp/test_hashmaps.cpp index 293053c..0eb58a4 100644 --- a/tests/conformance/cpp/test_hashmaps.cpp +++ b/tests/conformance/cpp/test_hashmaps.cpp @@ -24,6 +24,7 @@ int main(int argc, char* argv[]) { int max_key = args["max_key"].get(); int min_value = args["min_value"].get(); int max_value = args["max_value"].get(); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( @@ -31,11 +32,15 @@ int main(int argc, char* argv[]) { nlohmann::json metrics; if (key_type == "integer") { + auto key_gen = hegel::generators::integers( + {.min_value = min_key, .max_value = max_key}); + auto val_gen = hegel::generators::integers( + {.min_value = min_value, .max_value = max_value}); auto gen = hegel::generators::dictionaries( - hegel::generators::integers( - {.min_value = min_key, .max_value = max_key}), - hegel::generators::integers( - {.min_value = min_value, .max_value = max_value}), + mode == "non_basic" ? conformance::make_non_basic(key_gen) + : key_gen, + mode == "non_basic" ? conformance::make_non_basic(val_gen) + : val_gen, {.min_size = min_size, .max_size = max_size}); auto dict = hegel::draw(gen); @@ -60,10 +65,14 @@ int main(int argc, char* argv[]) { } } else { // string keys + auto text_gen = hegel::generators::text(); + auto val_gen = hegel::generators::integers( + {.min_value = min_value, .max_value = max_value}); auto gen = hegel::generators::dictionaries( - hegel::generators::text(), - hegel::generators::integers( - {.min_value = min_value, .max_value = max_value}), + mode == "non_basic" ? conformance::make_non_basic(text_gen) + : text_gen, + mode == "non_basic" ? conformance::make_non_basic(val_gen) + : val_gen, {.min_size = min_size, .max_size = max_size}); auto dict = hegel::draw(gen); diff --git a/tests/conformance/cpp/test_integers.cpp b/tests/conformance/cpp/test_integers.cpp index ad2a5e2..d36778e 100644 --- a/tests/conformance/cpp/test_integers.cpp +++ b/tests/conformance/cpp/test_integers.cpp @@ -23,13 +23,16 @@ int main(int argc, char* argv[]) { args["max_value"].is_null() ? std::nullopt : std::optional(args["max_value"].get()); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( [=]() { auto gen = hegel::generators::integers( {.min_value = min_value, .max_value = max_value}); - auto value = hegel::draw(gen); + auto value = mode == "non_basic" + ? hegel::draw(conformance::make_non_basic(gen)) + : hegel::draw(gen); conformance::write_metrics({{"value", value}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_lists.cpp b/tests/conformance/cpp/test_lists.cpp index cd3eab1..956917d 100644 --- a/tests/conformance/cpp/test_lists.cpp +++ b/tests/conformance/cpp/test_lists.cpp @@ -29,13 +29,16 @@ int main(int argc, char* argv[]) { args["max_value"].is_null() ? std::nullopt : std::optional(args["max_value"].get()); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( [=]() { + auto elem_gen = hegel::generators::integers( + {.min_value = min_value, .max_value = max_value}); auto gen = hegel::generators::vectors( - hegel::generators::integers( - {.min_value = min_value, .max_value = max_value}), + mode == "non_basic" ? conformance::make_non_basic(elem_gen) + : elem_gen, {.min_size = min_size, .max_size = max_size}); auto vec = hegel::draw(gen); diff --git a/tests/conformance/cpp/test_sampled_from.cpp b/tests/conformance/cpp/test_sampled_from.cpp index 08038f7..e749c59 100644 --- a/tests/conformance/cpp/test_sampled_from.cpp +++ b/tests/conformance/cpp/test_sampled_from.cpp @@ -16,12 +16,15 @@ int main(int argc, char* argv[]) { auto args = ImplUtil::raw(hegel::internal::json::json::parse(argv[1])); std::vector options = args["options"].get>(); + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( [&]() { auto gen = hegel::generators::sampled_from(options); - auto value = hegel::draw(gen); + auto value = mode == "non_basic" + ? hegel::draw(conformance::make_non_basic(gen)) + : hegel::draw(gen); conformance::write_metrics({{"value", value}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_text.cpp b/tests/conformance/cpp/test_text.cpp index fd57632..ff644a4 100644 --- a/tests/conformance/cpp/test_text.cpp +++ b/tests/conformance/cpp/test_text.cpp @@ -98,6 +98,7 @@ int main(int argc, char* argv[]) { args["exclude_characters"].get()) : std::nullopt; + std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( @@ -112,7 +113,9 @@ int main(int argc, char* argv[]) { .exclude_categories = exclude_categories, .include_characters = include_characters, .exclude_characters = exclude_characters}); - auto value = hegel::draw(gen); + auto value = mode == "non_basic" + ? hegel::draw(conformance::make_non_basic(gen)) + : hegel::draw(gen); auto cps = extract_codepoints(value); nlohmann::json cp_array = nlohmann::json::array(); for (auto cp : cps) { From fb434ee90e69ce78f1a27e074361312f0f4cccd6 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sat, 11 Apr 2026 17:07:19 -0400 Subject: [PATCH 2/5] claude: Simplify list conformance test to report raw elements Report the raw vector of integers instead of computing size/min_element/max_element in the binary. nlohmann/json natively serializes std::vector as a JSON array. --- tests/conformance/cpp/test_lists.cpp | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tests/conformance/cpp/test_lists.cpp b/tests/conformance/cpp/test_lists.cpp index 956917d..e047d81 100644 --- a/tests/conformance/cpp/test_lists.cpp +++ b/tests/conformance/cpp/test_lists.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -42,15 +41,7 @@ int main(int argc, char* argv[]) { {.min_size = min_size, .max_size = max_size}); auto vec = hegel::draw(gen); - - nlohmann::json metrics = {{"size", vec.size()}}; - if (!vec.empty()) { - metrics["min_element"] = - *std::min_element(vec.begin(), vec.end()); - metrics["max_element"] = - *std::max_element(vec.begin(), vec.end()); - } - conformance::write_metrics(metrics); + conformance::write_metrics({{"elements", vec}}); }, {.test_cases = test_cases}); From 488e6e9ad230618adc7953be247d411e0aec8acc Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sat, 11 Apr 2026 17:37:05 -0400 Subject: [PATCH 3/5] claude: Remove mode support from scalar conformance binaries Only test_lists and test_hashmaps need dual-path (basic/non_basic) testing. Scalar generators do not benefit from it, so revert them to simple hegel::draw(gen). --- tests/conformance/cpp/test_binary.cpp | 6 +----- tests/conformance/cpp/test_booleans.cpp | 19 ++----------------- tests/conformance/cpp/test_floats.cpp | 5 +---- tests/conformance/cpp/test_integers.cpp | 5 +---- tests/conformance/cpp/test_sampled_from.cpp | 5 +---- tests/conformance/cpp/test_text.cpp | 5 +---- 6 files changed, 7 insertions(+), 38 deletions(-) diff --git a/tests/conformance/cpp/test_binary.cpp b/tests/conformance/cpp/test_binary.cpp index 2a16632..c83af40 100644 --- a/tests/conformance/cpp/test_binary.cpp +++ b/tests/conformance/cpp/test_binary.cpp @@ -22,17 +22,13 @@ int main(int argc, char* argv[]) { args["max_size"].is_null() ? std::nullopt : std::optional(args["max_size"].get()); - std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( [=]() { auto gen = hegel::generators::binary( {.min_size = min_size, .max_size = max_size}); - std::vector value = - mode == "non_basic" - ? hegel::draw(conformance::make_non_basic(gen)) - : hegel::draw(gen); + std::vector value = hegel::draw(gen); conformance::write_metrics({{"length", value.size()}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_booleans.cpp b/tests/conformance/cpp/test_booleans.cpp index 532da53..20f67ba 100644 --- a/tests/conformance/cpp/test_booleans.cpp +++ b/tests/conformance/cpp/test_booleans.cpp @@ -1,28 +1,13 @@ -#include -#include - #include "hegel/hegel.h" #include "metrics.h" -#include "../../../src/json_impl.h" -using hegel::internal::json::ImplUtil; - int main(int argc, char* argv[]) { - if (argc < 2) { - std::cerr << "Usage: " << argv[0] << " ''" << std::endl; - return 1; - } - - auto args = ImplUtil::raw(hegel::internal::json::json::parse(argv[1])); - std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( - [=]() { + []() { auto gen = hegel::generators::booleans(); - auto value = mode == "non_basic" - ? hegel::draw(conformance::make_non_basic(gen)) - : hegel::draw(gen); + auto value = hegel::draw(gen); conformance::write_metrics({{"value", value}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_floats.cpp b/tests/conformance/cpp/test_floats.cpp index 185dcf5..3adf74c 100644 --- a/tests/conformance/cpp/test_floats.cpp +++ b/tests/conformance/cpp/test_floats.cpp @@ -34,7 +34,6 @@ int main(int argc, char* argv[]) { args["allow_infinity"].is_null() ? std::nullopt : std::optional(args["allow_infinity"].get()); - std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( @@ -47,9 +46,7 @@ int main(int argc, char* argv[]) { .allow_nan = allow_nan, .allow_infinity = allow_infinity, }); - auto value = mode == "non_basic" - ? hegel::draw(conformance::make_non_basic(gen)) - : hegel::draw(gen); + auto value = hegel::draw(gen); conformance::write_metrics({ {"value", value}, {"is_nan", std::isnan(value)}, diff --git a/tests/conformance/cpp/test_integers.cpp b/tests/conformance/cpp/test_integers.cpp index d36778e..ad2a5e2 100644 --- a/tests/conformance/cpp/test_integers.cpp +++ b/tests/conformance/cpp/test_integers.cpp @@ -23,16 +23,13 @@ int main(int argc, char* argv[]) { args["max_value"].is_null() ? std::nullopt : std::optional(args["max_value"].get()); - std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( [=]() { auto gen = hegel::generators::integers( {.min_value = min_value, .max_value = max_value}); - auto value = mode == "non_basic" - ? hegel::draw(conformance::make_non_basic(gen)) - : hegel::draw(gen); + auto value = hegel::draw(gen); conformance::write_metrics({{"value", value}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_sampled_from.cpp b/tests/conformance/cpp/test_sampled_from.cpp index e749c59..08038f7 100644 --- a/tests/conformance/cpp/test_sampled_from.cpp +++ b/tests/conformance/cpp/test_sampled_from.cpp @@ -16,15 +16,12 @@ int main(int argc, char* argv[]) { auto args = ImplUtil::raw(hegel::internal::json::json::parse(argv[1])); std::vector options = args["options"].get>(); - std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( [&]() { auto gen = hegel::generators::sampled_from(options); - auto value = mode == "non_basic" - ? hegel::draw(conformance::make_non_basic(gen)) - : hegel::draw(gen); + auto value = hegel::draw(gen); conformance::write_metrics({{"value", value}}); }, {.test_cases = test_cases}); diff --git a/tests/conformance/cpp/test_text.cpp b/tests/conformance/cpp/test_text.cpp index ff644a4..fd57632 100644 --- a/tests/conformance/cpp/test_text.cpp +++ b/tests/conformance/cpp/test_text.cpp @@ -98,7 +98,6 @@ int main(int argc, char* argv[]) { args["exclude_characters"].get()) : std::nullopt; - std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); hegel::hegel( @@ -113,9 +112,7 @@ int main(int argc, char* argv[]) { .exclude_categories = exclude_categories, .include_characters = include_characters, .exclude_characters = exclude_characters}); - auto value = mode == "non_basic" - ? hegel::draw(conformance::make_non_basic(gen)) - : hegel::draw(gen); + auto value = hegel::draw(gen); auto cps = extract_codepoints(value); nlohmann::json cp_array = nlohmann::json::array(); for (auto cp : cps) { From 8bbaea6e55094ff6680f14d7e8c8e00e8918a7e3 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Tue, 14 Apr 2026 17:30:12 -0400 Subject: [PATCH 4/5] claude: bump hegel-core to 0.4.1 for dual-path conformance --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 564f033..9d18144 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,7 @@ jobs: python-version: '3.13' - name: Install hegel - run: pip install hegel-core==0.4.0 + run: pip install hegel-core==0.4.1 - name: Configure CMake run: cmake -B build From cbfeb4945fec56e48fe12788650ea89c567e53b1 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Tue, 14 Apr 2026 17:50:40 -0400 Subject: [PATCH 5/5] claude: fix vectors/dictionaries fallback uniqueness The compositional fallback paths of `vectors` and `dictionaries` did not honor uniqueness when element/key generators lacked a schema. This was exposed by the new dual-path conformance tests: - `vectors({.unique = true})` could return a non-unique vector when the element generator had been transformed (e.g. via `.filter()`). - `dictionaries` could loop indefinitely if the key generator kept returning the same value (for instance while Hypothesis was exploring its zero/minimum inputs). Both fallbacks now cap attempts and reject the test case via `hegel::internal::assume` when the generator cannot satisfy the requested size with unique values. `test_lists.cpp` now also threads the `unique` parameter through to the `vectors` generator, so basic- mode list conformance actually exercises the unique path. --- RELEASE.md | 10 +++++ include/hegel/generators/collections.h | 56 ++++++++++++++++++++------ tests/conformance/cpp/test_lists.cpp | 3 +- 3 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c97f2f1 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,10 @@ +RELEASE_TYPE: patch + +Fix the compositional fallback path of `vectors` and `dictionaries` to +correctly honor uniqueness when element/key generators lack a schema. +Previously the fallback for `vectors({.unique = true})` produced a +potentially non-unique vector, and `dictionaries` could loop for an +unbounded number of attempts when the key generator repeatedly returned +duplicates. Both now cap attempts and reject the test case via +`hegel::internal::assume` when the generator cannot satisfy the +requested size with unique values. diff --git a/include/hegel/generators/collections.h b/include/hegel/generators/collections.h index 70de91b..583ff75 100644 --- a/include/hegel/generators/collections.h +++ b/include/hegel/generators/collections.h @@ -5,6 +5,7 @@ * @brief Collection generator functions: vectors, sets, dictionaries, tuples */ +#include #include #include #include @@ -12,6 +13,7 @@ #include "hegel/core.h" #include "hegel/generators/numeric.h" +#include "hegel/internal.h" namespace hegel::generators { @@ -91,12 +93,33 @@ namespace hegel::generators { auto length_gen = integers( {.min_value = params.min_size, .max_value = max_size}); + bool unique = params.unique; return from_function>( - [elements, length_gen](TestCaseData* data) { + [elements, length_gen, unique](TestCaseData* data) { size_t len = length_gen.do_draw(data); std::vector result; result.reserve(len); + if constexpr (requires(T a, T b) { a == b; }) { + if (unique) { + size_t max_attempts = len * 10 + 10; + for (size_t attempts = 0; + result.size() < len && attempts < max_attempts; + ++attempts) { + T elem = elements.do_draw(data); + if (std::find(result.begin(), result.end(), elem) == + result.end()) { + result.push_back(std::move(elem)); + } + } + // If the element generator can't produce enough unique + // values (e.g. it's heavily constrained), reject this + // test case rather than returning a short vector. + hegel::internal::assume(result.size() == len); + return result; + } + } + for (size_t i = 0; i < len; ++i) { result.push_back(elements.do_draw(data)); } @@ -217,19 +240,28 @@ namespace hegel::generators { auto length_gen = integers( {.min_value = params.min_size, .max_value = max_size}); - return from_function>( - [keys, values, length_gen](TestCaseData* data) { - size_t len = length_gen.do_draw(data); - std::map result; - - while (result.size() < len) { - K key = keys.do_draw(data); - V value = values.do_draw(data); - result[std::move(key)] = std::move(value); + return from_function>([keys, values, + length_gen](TestCaseData* data) { + size_t len = length_gen.do_draw(data); + std::map result; + + size_t max_attempts = len * 10 + 10; + for (size_t attempts = 0; + result.size() < len && attempts < max_attempts; ++attempts) { + K key = keys.do_draw(data); + if (result.find(key) != result.end()) { + continue; } + V value = values.do_draw(data); + result.emplace(std::move(key), std::move(value)); + } + // If the key generator can't produce enough unique keys + // (e.g. it's heavily constrained), reject this test case + // rather than returning a smaller map. + hegel::internal::assume(result.size() == len); - return result; - }); + return result; + }); } /// @cond INTERNAL diff --git a/tests/conformance/cpp/test_lists.cpp b/tests/conformance/cpp/test_lists.cpp index e047d81..1008e56 100644 --- a/tests/conformance/cpp/test_lists.cpp +++ b/tests/conformance/cpp/test_lists.cpp @@ -28,6 +28,7 @@ int main(int argc, char* argv[]) { args["max_value"].is_null() ? std::nullopt : std::optional(args["max_value"].get()); + bool unique = args["unique"].get(); std::string mode = conformance::get_mode(args); int test_cases = conformance::get_test_cases(); @@ -38,7 +39,7 @@ int main(int argc, char* argv[]) { auto gen = hegel::generators::vectors( mode == "non_basic" ? conformance::make_non_basic(elem_gen) : elem_gen, - {.min_size = min_size, .max_size = max_size}); + {.min_size = min_size, .max_size = max_size, .unique = unique}); auto vec = hegel::draw(gen); conformance::write_metrics({{"elements", vec}});