diff --git a/Doxyfile b/Doxyfile index dd4a247..3b761cf 100644 --- a/Doxyfile +++ b/Doxyfile @@ -2,7 +2,7 @@ # Run: doxygen Doxyfile PROJECT_NAME = "Entity Generator" -PROJECT_NUMBER = 4.1.2 +PROJECT_NUMBER = 4.2.0 PROJECT_BRIEF = "Composable, deterministic entity generation for C++23" PROJECT_LOGO = doc/entity-generator.png diff --git a/README.md b/README.md index dd09c01..6aa4f79 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ A small framework for generating entities from your own components, with depende - **Dependencies & Fields** — A component declares `depends_on()`: a request pulls in what it needs, orders it, and skips a component whose dependency was skipped. `field(key, fn)` builds a component in one expression, with a label, formatter, JSON mapper, validator and condition. +- **Recipes** — Components that draw from a list or a range are written as rows of a tab-separated file (`constant`, `choice`, `weighted`, `int`, `real`, with labels, dependencies and weights) and registered with `add_recipe(path)`, no recompiling. + - **Randomness & Replay** — Explicit per-call seeds and caller-owned deterministic sequences. Every entity and component stores its seed for full replay via seed signatures; `generate(request, e.seed())` reproduces an entity with the same request and setup. - **Identical Everywhere** — A seed gives the same entity on every compiler, standard library and CPU (GCC, Clang, MSVC, Emscripten; x86-64 and ARM64), checked by known-answer tests. Each component draws from its own stream, so adding a component does not change the others. diff --git a/dasmig/entitygen.hpp b/dasmig/entitygen.hpp index db7ee4c..affad8e 100644 --- a/dasmig/entitygen.hpp +++ b/dasmig/entitygen.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -1316,6 +1317,57 @@ class eg return add(std::make_unique>(std::move(f))); } + /// @brief Register the components a recipe declares. + /// + /// A recipe is a tab-separated file with a header line and one component + /// per row, so a component that only draws from data is written as data + /// instead of code (see add_recipe_from_string() for the columns). The + /// components are the ones the code makes with field(): the same + /// dependencies, ordering, weights and replay. An invalid row throws and + /// adds nothing. + /// + /// @param path The recipe file. + /// @return `*this` for chaining. + /// @throws core::tsv_error If the file cannot be read or a row is invalid. + /// @throws std::invalid_argument As add(std::unique_ptr). + eg& add_recipe(const std::filesystem::path& path) + { + return add_recipe_(core::tsv_document::load(path)); + } + + /// @brief Register the components a recipe in text declares. + /// + /// Columns (the header names them, in any order): + /// + /// | Column | | Meaning | + /// |---|---|---| + /// | `key` | required | The component key; dots make a path in the JSON | + /// | `kind` | required | `constant`, `choice`, `weighted`, `int` or `real` | + /// | `values` | required | What the kind draws from, below | + /// | `label` | optional | Display name; the key by default | + /// | `depends_on` | optional | Keys this component follows, separated by `;` | + /// | `weight` | optional | Inclusion probability, 0 to 1; 1 by default | + /// + /// The kinds: `constant` gives `values` as text; `choice` picks uniformly + /// from `a|b|c`; `weighted` picks from `a:3|b:1` (a value and its + /// relative weight, split at the last colon); `int` draws an integer from + /// `lo..hi` (both included); `real` draws a number from `lo..hi` (`hi` + /// excluded). Text kinds give strings in the JSON, `int` and `real` give + /// numbers. Items are not trimmed. A component with the key of an + /// existing one replaces it. The recipe's rows are part of the entity's + /// replay signature. + /// + /// @param text UTF-8 recipe text. + /// @param source Name used in errors. + /// @return `*this` for chaining. + /// @throws core::tsv_error If the text is malformed or a row is invalid + /// (an unknown kind, a bad range or weight, a repeated key). + /// @throws std::invalid_argument As add(std::unique_ptr). + eg& add_recipe_from_string(std::string_view text, std::string source = "recipe") + { + return add_recipe_(core::tsv_document::parse(text, std::move(source))); + } + /// @brief Register a component with a weight override. /// /// The override takes precedence over the component's own weight() method. @@ -2074,6 +2126,117 @@ class eg // Rejects keys the JSON could not hold: empty path segments, or a key // that is also the parent of another. + // Build every component of a recipe, then add them, so a bad row adds none. + eg& add_recipe_(const core::tsv_document& doc) + { + std::vector> built; + std::set seen; + for (const auto& row : doc.rows()) + { + const auto key = row.get("key"); + if (!seen.insert(key).second) throw row.error("key", "key repeated in the recipe"); + built.push_back(recipe_component_(row, key)); + } + for (const auto& comp : built) check_key(comp->key()); + for (auto& comp : built) add(std::move(comp)); + return *this; + } + + static std::unique_ptr recipe_component_(const core::tsv_row& row, const std::string& key) + { + const auto kind = row.get("kind"); + const auto values = row.get("values"); + const auto hash = core::fnv1a64(key + '\t' + kind + '\t' + values); + + if (kind == "constant") + { + return finish_recipe_(field(core::from_utf8(key), + [text = core::from_utf8(values)](const generation_context&) { return text; }), + row, key, hash); + } + if (kind == "choice") + { + std::vector choices; + for (const auto item : row.list("values", '|')) choices.push_back(core::from_utf8(item)); + return finish_recipe_(field(core::from_utf8(key), + [choices](const generation_context& ctx) + { return core::pick(ctx.random(), choices); }), + row, key, hash); + } + if (kind == "weighted") + { + std::vector choices; + std::vector weights; + for (const auto item : row.list("values", '|')) + { + const auto colon = item.rfind(':'); + if (colon == std::string_view::npos) + throw row.error("values", "expected value:weight, got '" + std::string{item} + "'"); + const auto weight = core::detail::parse_number(item.substr(colon + 1)); + if (!weight || !(*weight >= 0.0)) + throw row.error("values", "weight must be a number of 0 or more in '" + std::string{item} + "'"); + choices.push_back(core::from_utf8(item.substr(0, colon))); + weights.push_back(*weight); + } + if (!std::ranges::any_of(weights, [](double w) { return w > 0.0; })) + throw row.error("values", "at least one weight must be positive"); + return finish_recipe_(field(core::from_utf8(key), + [choices, dist = core::discrete{weights}](const generation_context& ctx) + { return choices[dist(ctx.random())]; }), + row, key, hash); + } + if (kind == "int" || kind == "real") + { + const std::string_view range = values; + const auto dots = range.find(".."); + if (dots == std::string_view::npos) + throw row.error("values", "expected lo..hi, got '" + values + "'"); + if (kind == "int") + { + const auto lo = core::detail::parse_number(range.substr(0, dots)); + const auto hi = core::detail::parse_number(range.substr(dots + 2)); + if (!lo || !hi || *lo > *hi) + throw row.error("values", "expected whole numbers lo..hi with lo <= hi, got '" + values + "'"); + return finish_recipe_(field(core::from_utf8(key), + [lo = *lo, hi = *hi](const generation_context& ctx) + { return core::uniform_int(ctx.random(), lo, hi); }), + row, key, hash); + } + const auto lo = core::detail::parse_number(range.substr(0, dots)); + const auto hi = core::detail::parse_number(range.substr(dots + 2)); + if (!lo || !hi || !(*lo <= *hi)) + throw row.error("values", "expected finite numbers lo..hi with lo <= hi, got '" + values + "'"); + return finish_recipe_(field(core::from_utf8(key), + [lo = *lo, hi = *hi](const generation_context& ctx) + { + const double v = core::uniform_real(ctx.random(), lo, hi); + return v >= hi && hi > lo ? std::nextafter(hi, lo) : v; + }), + row, key, hash); + } + throw row.error("kind", "expected constant, choice, weighted, int or real, got '" + kind + "'"); + } + + // Apply the columns every kind shares and wrap the field as a component. + template + static std::unique_ptr finish_recipe_(field_component f, const core::tsv_row& row, + const std::string& key, std::uint64_t hash) + { + const auto label = row.get_or("label", key); + std::vector depends; + if (row.has("depends_on")) + { + for (const auto item : row.list("depends_on")) depends.push_back(core::from_utf8(item)); + } + const auto weight = row.get_or("weight", 1.0); + if (!(weight >= 0.0 && weight <= 1.0)) throw row.error("weight", "must be between 0 and 1"); + return std::make_unique>(std::move(f) + .label(core::from_utf8(label)) + .depends_on(std::move(depends)) + .weight(weight) + .data_hash(hash)); + } + void check_key(const std::wstring& key) const { const auto path = core::to_utf8(key); diff --git a/doc/usage.md b/doc/usage.md index 04ff6d6..883a42c 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -17,6 +17,7 @@ This guide covers every feature of the entity-generator library in detail. For a
  • Batch Generation
  • Component Groups
  • Thread Safety
  • +
  • Recipes
  • Component Weights
  • Entity Serialization
  • Structured Serialization
  • @@ -389,6 +390,47 @@ for (int i = 0; i < 4; ++i) `eg` is move-constructible and move-assignable, so instances can be transferred between scopes. The `instance()` singleton is still available for single-threaded convenience. +## Recipes + +A component that only draws from data can be written as data. A recipe is a tab-separated file with one component per row; `add_recipe(path)` or `add_recipe_from_string(text)` registers them, and they behave like components made with `field()`: dependencies, ordering, weights, replay and JSON are the same. + +``` +key kind values label depends_on weight +planet weighted Terra:3|Mars:1 Planet +crew int 2..9 +mass real 1.5..2.5 +mood choice calm|tense planet +flag constant blue 0.5 +``` + +```cpp +dasmig::eg gen; +gen.add_recipe("ship.tsv"); +auto e = gen.generate({}, 42); +auto crew = e.get(L"crew"); +``` + +| Column | | Meaning | +|--------|---|---------| +| `key` | required | The component key; dots make a path in the JSON | +| `kind` | required | `constant`, `choice`, `weighted`, `int` or `real` | +| `values` | required | What the kind draws from | +| `label` | optional | Display name; the key by default | +| `depends_on` | optional | Keys this component follows, separated by `;` | +| `weight` | optional | Inclusion probability, 0 to 1; 1 by default | + +| Kind | `values` | Value | +|------|----------|-------| +| `constant` | any text | `std::wstring` | +| `choice` | `a\|b\|c`, uniform | `std::wstring` | +| `weighted` | `a:3\|b:1`, a value and its relative weight (split at the last colon) | `std::wstring` | +| `int` | `lo..hi`, both included | `std::int64_t` | +| `real` | `lo..hi`, `hi` excluded | `double` | + +Items are not trimmed. A row with an unknown kind, a bad range or weight, an empty value or a repeated key throws `core::tsv_error` naming the file, line and column, and nothing from the recipe is added; so does a key whose path clashes with an existing key (`std::invalid_argument`). A recipe component with the key of an existing one replaces it, so a recipe can override a default that code registered. Code fields can list recipe keys in `depends_on`, and recipe rows can list code keys. + +The text of each row (key, kind and values) is the component's data hash, so changing a recipe changes the replay signature of every entity that used it. Recipes cover the components that draw from a list or a range. A component that reads another's value, calls a generator or computes something stays in code. + ## Component Weights Components can declare an inclusion weight via the `weight()` virtual method (default `1.0`). Values range from `0.0` (never included) to `1.0` (always included). The generator rolls against the weight during generation; components that fail the roll are skipped. diff --git a/tests/tests.cpp b/tests/tests.cpp index 50b9acf..d5b86d1 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -2,7 +2,10 @@ #include "../dasmig/entitygen.hpp" #include "../dasmig/ext/stats_observer.hpp" #include +#include +#include #include +#include #include #include #include @@ -4838,4 +4841,178 @@ TEST_CASE("batch options: known answer of 10,000 entities", "[batch][options][ka g.for_each_entity({}, {.count = 10000, .threads = 4}, 1, [&](std::uint64_t, const dasmig::entity& e) { h = fnv1a(e.to_json(), h); }); REQUIRE(h == 0xa172668aaf7295d7ULL); -} \ No newline at end of file +} +// --------------------------------------------------------------------------- +// Recipes +// --------------------------------------------------------------------------- + +namespace +{ +constexpr auto recipe_header = "key\tkind\tvalues\tlabel\tdepends_on\tweight\n"; +} + +TEST_CASE("recipe: each kind draws what it declares", "[recipe]") +{ + dasmig::eg gen; + gen.add_recipe_from_string(std::string{recipe_header} + + "planet\tweighted\tTerra:3|Mars:1\tPlanet\t\t\n" + "crew\tint\t2..9\t\t\t\n" + "mass\treal\t1.5..2.5\t\t\t\n" + "mood\tchoice\tcalm|tense\t\t\t\n" + "flag\tconstant\tblue\t\t\t\n"); + REQUIRE(gen.size() == 5); + + std::map planets; + std::set moods; + std::set crews; + for (std::uint64_t seed = 0; seed < 4000; ++seed) + { + const auto e = gen.generate({}, seed); + ++planets[e.get(L"planet")]; + moods.insert(e.get(L"mood")); + const auto crew = e.get(L"crew"); + REQUIRE(crew >= 2); + REQUIRE(crew <= 9); + crews.insert(crew); + const auto mass = e.get(L"mass"); + REQUIRE(mass >= 1.5); + REQUIRE(mass < 2.5); + REQUIRE(e.get(L"flag") == L"blue"); + } + REQUIRE(moods == std::set{L"calm", L"tense"}); + REQUIRE(crews.size() == 8); + REQUIRE(planets.size() == 2); + REQUIRE(planets[L"Terra"] > 2 * planets[L"Mars"]); // 3:1 + REQUIRE(planets[L"Terra"] < 4 * planets[L"Mars"]); +} + +TEST_CASE("recipe: JSON types follow the kind and a label shows in the text", "[recipe]") +{ + dasmig::eg gen; + gen.add_recipe_from_string(std::string{recipe_header} + + "ship.name\tconstant\tNova\tShip\t\t\n" + "ship.crew\tint\t4..4\t\t\t\n"); + const auto e = gen.generate({}, 1); + const auto json = e.to_json(); + REQUIRE(json.find(R"("ship":{"name":"Nova","crew":4})") != std::string::npos); + REQUIRE(e.to_string().find(L"Ship: Nova") != std::wstring::npos); +} + +TEST_CASE("recipe: the same seed replays and the recipe is in the signature", "[recipe][signature]") +{ + const auto make = [](std::string_view values) { + dasmig::eg gen; + gen.add_recipe_from_string(std::string{recipe_header} + "crew\tint\t" + std::string{values} + "\t\t\t\n"); + return gen; + }; + const auto a = make("2..9").generate({}, 7); + const auto b = make("2..9").generate({}, 7); + REQUIRE(a.to_json() == b.to_json()); + REQUIRE(a.signature().data != 0); + REQUIRE(a.signature().data != make("2..10").generate({}, 7).signature().data); +} + +TEST_CASE("recipe: depends_on orders components and weight 0 skips them", "[recipe]") +{ + dasmig::eg gen; + gen.add_recipe_from_string(std::string{recipe_header} + + "late\tconstant\tz\t\tearly\t\n" + "early\tconstant\ta\t\t\t\n" + "gone\tconstant\tx\t\t\t0\n" + "after_gone\tconstant\ty\t\tgone\t\n"); + const auto e = gen.generate({}, 1); + REQUIRE(e.has(L"late")); + REQUIRE_FALSE(e.has(L"gone")); + REQUIRE_FALSE(e.has(L"after_gone")); // its dependency was skipped + const auto keys = e.keys(); + REQUIRE(std::ranges::find(keys, L"early") < std::ranges::find(keys, L"late")); +} + +TEST_CASE("recipe: code fields can depend on recipe components", "[recipe]") +{ + dasmig::eg gen; + gen.add_recipe_from_string(std::string{recipe_header} + "crew\tint\t3..3\t\t\t\n"); + gen.add(field(L"rations", [](const generation_context& ctx) { return ctx.get(L"crew") * 10; }) + .depends_on({L"crew"})); + REQUIRE(gen.generate({.component_keys = std::vector{L"rations"}}, 1).get(L"rations") == 30); +} + +TEST_CASE("recipe: a component with an existing key replaces it", "[recipe]") +{ + dasmig::eg gen; + gen.add(field(L"flag", [](const generation_context&) { return std::wstring{L"red"}; })); + gen.add_recipe_from_string(std::string{recipe_header} + "flag\tconstant\tblue\t\t\t\n"); + REQUIRE(gen.size() == 1); + REQUIRE(gen.generate({}, 1).get(L"flag") == L"blue"); +} + +TEST_CASE("recipe: a bad row throws with its location and adds nothing", "[recipe]") +{ + const auto bad = [](const std::string& row) { + dasmig::eg gen; + gen.add(field(L"keep", [](const generation_context&) { return 1; })); + const auto text = std::string{recipe_header} + "ok\tconstant\tfine\t\t\t\n" + row; + REQUIRE_THROWS_AS(gen.add_recipe_from_string(text, "test.tsv"), dasmig::core::tsv_error); + REQUIRE(gen.size() == 1); // not even the good row + }; + bad("x\tbogus\t1\t\t\t\n"); + bad("x\tint\t5..1\t\t\t\n"); + bad("x\tint\t1\t\t\t\n"); + bad("x\tint\ta..b\t\t\t\n"); + bad("x\treal\t2..1\t\t\t\n"); + bad("x\tweighted\ta|b\t\t\t\n"); + bad("x\tweighted\ta:0|b:0\t\t\t\n"); + bad("x\tweighted\ta:-1|b:2\t\t\t\n"); + bad("x\tconstant\t\t\t\t\n"); + bad("x\tconstant\t1\t\t\t1.5\n"); + bad("ok\tconstant\tagain\t\t\t\n"); + + dasmig::eg gen; + try + { + gen.add_recipe_from_string(std::string{recipe_header} + "x\tbogus\t1\t\t\t\n", "test.tsv"); + FAIL("expected a throw"); + } + catch (const dasmig::core::tsv_error& error) + { + REQUIRE(std::string{error.what()}.find("test.tsv:2") != std::string::npos); + REQUIRE(std::string{error.what()}.find("kind") != std::string::npos); + } +} + +TEST_CASE("recipe: a path that clashes with an existing key throws and adds nothing", "[recipe]") +{ + dasmig::eg gen; + gen.add(field(L"ship", [](const generation_context&) { return 1; })); + REQUIRE_THROWS_AS(gen.add_recipe_from_string(std::string{recipe_header} + + "fine\tconstant\t1\t\t\t\n" + "ship.name\tconstant\tNova\t\t\t\n"), + std::invalid_argument); + REQUIRE(gen.size() == 1); +} + +TEST_CASE("recipe: add_recipe reads a file", "[recipe]") +{ + const auto path = std::filesystem::temp_directory_path() / "dasmig-entity-recipe-test.tsv"; + { + std::ofstream out{path, std::ios::binary}; + out << recipe_header << "crew\tint\t2..9\t\t\t\n" << "name\tchoice\tÅsa|João\t\t\t\n"; + } + dasmig::eg gen; + gen.add_recipe(path); + std::filesystem::remove(path); + REQUIRE(gen.size() == 2); + const auto name = gen.generate({}, 3).get(L"name"); + REQUIRE((name == L"Åsa" || name == L"João")); + + REQUIRE_THROWS_AS(gen.add_recipe(path), dasmig::core::tsv_error); // gone +} + +TEST_CASE("recipe: batches equal the single calls", "[recipe][batch]") +{ + dasmig::eg gen; + gen.add_recipe_from_string(std::string{recipe_header} + "crew\tint\t2..99\t\t\t\n"); + const auto batch = gen.generate_batch({.count = 20}, {.count = 20, .threads = 3}, 5); + for (std::size_t i = 0; i < batch.size(); ++i) + REQUIRE(batch[i].to_json() == gen.generate({}, dasmig::core::item_seed(5, i)).to_json()); +}