Birth Generator 3.2.2
Procedural birth date generation for C++23
Loading...
Searching...
No Matches
birthgen.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2020-2026 Diego Dasso Migotto
3
4#ifndef DASMIG_BIRTHGEN_HPP
5#define DASMIG_BIRTHGEN_HPP
6
7#include "core.hpp"
8#include <algorithm>
9#include <array>
10#include <chrono>
11#include <cstddef>
12#include <cstdint>
13#include <filesystem>
14#include <format>
15#include <map>
16#include <optional>
17#include <ostream>
18#include <span>
19#include <stdexcept>
20#include <string>
21#include <string_view>
22#include <system_error>
23#include <utility>
24#include <vector>
25
26#ifdef DASMIG_BIRTH_EMBED
27#include "birthgen_data.hpp"
28#endif
29
30/// @file birthgen.hpp
31/// @brief Birth generator library — procedural birthday generation for C++23.
32/// @author Diego Dasso Migotto (diegomigotto at hotmail dot com)
33/// @see See doc/usage.md for the narrative tutorial.
34
35DASMIG_CORE_REQUIRE(1, 2);
36
37namespace dasmig
38{
39
40/// @brief Strong type for a requested birth year.
41struct year_t
42{
43 std::uint16_t value; ///< The year.
44
45 /// @brief Construct from a year.
46 /// @param v The year.
47 constexpr explicit year_t(std::uint16_t v) noexcept : value{v} {}
48};
49
50/// @brief An inclusive range of ages in completed years.
52{
53 std::uint8_t min; ///< Youngest age.
54 std::uint8_t max; ///< Oldest age.
55};
56
57/// @brief All per-call birth filters and settings.
58///
59/// An omitted country selects a random country. Omitted sex, year and ages
60/// use the country's demographic distributions. A fixed year and an age
61/// range are mutually exclusive. Replay requires the same request and data.
63{
64 /// @brief 1 July 2023, the date of the UN population estimates.
65 static constexpr std::chrono::year_month_day default_reference_date{
66 std::chrono::year{2023}, std::chrono::July, std::chrono::day{1}};
67
68 /// @brief ISO 3166-1 alpha-2 code, or random.
69 std::optional<std::string> country_code{}; // NOLINT(readability-redundant-member-init)
70 /// @brief Fixed biological sex, or drawn.
71 std::optional<dasmig::sex> bio_sex{}; // NOLINT(readability-redundant-member-init)
72 /// @brief Fixed birth year, or derived from age.
73 std::optional<year_t> year{}; // NOLINT(readability-redundant-member-init)
74 /// @brief Inclusive ages; values above 100 clamp to 100.
75 std::optional<age_range> ages{}; // NOLINT(readability-redundant-member-init)
76 bool weighted{true}; ///< Population-weighted (true) or uniform country.
77 std::chrono::year_month_day reference_date{default_reference_date}; ///< Date ages are computed at.
78};
79
80/// @brief Return type for birth generation.
81///
82/// Supports implicit conversion to std::string (ISO 8601 date),
83/// streaming via operator<< and JSON output via to_json().
84class birth
85{
86 public:
87 std::string country_code; ///< ISO 3166-1 alpha-2 code.
88 std::uint16_t year{0}; ///< Birth year.
89 std::uint8_t month{0}; ///< Birth month (1–12).
90 std::uint8_t day{0}; ///< Birth day (1–31).
91 std::uint8_t age{0}; ///< Age in completed years at the reference date.
92 sex bio_sex{sex::male}; ///< Biological sex.
93 std::uint8_t weekday{0}; ///< Day of week (0=Sun, 1=Mon, …, 6=Sat).
94 double le_remaining{0.0}; ///< Estimated years of life remaining.
95 std::string cohort; ///< Generational cohort label.
96
97 /// @brief Retrieve the random seed used to generate this birth.
98 /// @return The call seed; pass it to get_birth(request, seed) with the
99 /// original request and data to generate the same birth again.
100 [[nodiscard]] std::uint64_t seed() const { return _signature.seed; }
101
102 /// @brief The seed and the data this birth was generated from.
103 /// @return The replay signature; `data` is bthg::data_hash() at the
104 /// time of generation.
105 [[nodiscard]] const core::seed_signature& signature() const { return _signature; }
106
107 /// @brief ISO 8601 date string (YYYY-MM-DD).
108 /// @return The date.
109 [[nodiscard]] std::string date_string() const
110 {
111 return std::format("{:04d}-{:02d}-{:02d}", static_cast<int>(year),
112 static_cast<int>(month), static_cast<int>(day));
113 }
114
115 /// @brief Write this birth as a JSON object.
116 /// @param w Writer positioned where a value is allowed.
117 void to_json(core::json_writer& w) const
118 {
119 w.begin_object()
120 .field("country_code", country_code)
121 .field("date", date_string())
122 .field("year", static_cast<int>(year))
123 .field("month", static_cast<int>(month))
124 .field("day", static_cast<int>(day))
125 .field("age", static_cast<int>(age))
126 .field("sex", core::sex_name(bio_sex))
127 .field("weekday", static_cast<int>(weekday))
128 .field("le_remaining", le_remaining)
129 .field("cohort", cohort)
130 .key("signature");
131 _signature.to_json(w);
132 w.end_object();
133 }
134
135 /// @brief This birth as a JSON document.
136 /// @return Compact JSON.
137 [[nodiscard]] std::string to_json() const
138 {
139 core::json_writer w;
140 to_json(w);
141 return w.str();
142 }
143
144 /// @brief Implicit conversion to std::string.
145 /// @return ISO 8601 date string.
146 operator std::string() const // NOLINT(hicpp-explicit-conversions)
147 {
148 return date_string();
149 }
150
151 /// @brief Stream the ISO date to an output stream.
152 /// @param os Output stream.
153 /// @param b Birth to stream.
154 /// @return The stream.
155 friend std::ostream& operator<<(std::ostream& os, const birth& b)
156 {
157 os << b.date_string();
158 return os;
159 }
160
161 private:
162 core::seed_signature _signature;
163 friend class bthg;
164};
165
166/// @brief Birth generator that produces demographically plausible
167/// birthdays from country age pyramids.
168///
169/// The generation pipeline, each step drawing from its own stream split
170/// from the call seed (so fixing the sex, year or age range leaves the
171/// other steps' draws unchanged):
172/// 1. Sex, weighted by the country's male:female population.
173/// 2. Age in completed years, from the country's age pyramid for that sex
174/// (optionally restricted to an age range), or derived from a fixed year.
175/// 3. Month, from the country's seasonal weights.
176/// 4. Day, uniform within the month and placed so that the age at the
177/// reference date is exact; weekend days are redrawn with a probability
178/// that grows with the country's caesarean rate.
179/// 5. Weekday, remaining life expectancy and generational cohort.
180///
181/// Ages are relative to a fixed reference date (default 2023-07-01, the
182/// date of the population estimates), not the clock, so a seed gives the
183/// same birth every time. Output is identical on every compiler and
184/// platform (dasmig-core).
185///
186/// @par Thread safety
187/// Each instance is independent. Concurrent calls to get_birth() on the
188/// **same** instance require external synchronisation.
189class bthg
190{
191 public:
192 /// @brief The three data tables.
193 enum class table : std::uint8_t
194 {
195 countries, ///< `countries.tsv`: names, life expectancy, caesarean rate.
196 age_pyramid, ///< `age_pyramid.tsv`: population by single age and sex.
197 monthly_births ///< `monthly_births.tsv`: seasonal birth weights.
198 };
199
200 /// @brief The default reference date: 1 July 2023, the date the UN
201 /// World Population Prospects estimates in the data refer to.
202 static constexpr std::chrono::year_month_day default_reference_date{
204
205 /// @brief Default constructor — creates an empty generator with no data.
206 bthg() = default;
207
208 bthg(const bthg&) = delete; ///< Not copyable.
209 bthg& operator=(const bthg&) = delete; ///< Not copyable.
210 bthg(bthg&&) noexcept = default; ///< Movable.
211 bthg& operator=(bthg&&) noexcept = default; ///< Movable.
212 ~bthg() = default; ///< Destructor.
213
214 /// @brief Access the global singleton instance.
215 ///
216 /// Loads the `lite` tier (or `full` if `lite` is missing) on first
217 /// access, from compiled-in data or an auto-probed `resources/`.
218 /// @return The singleton.
219 static bthg& instance()
220 {
221 static bthg inst{auto_probe_tag{}};
222 return inst;
223 }
224
225 // -- Generation -------------------------------------------------------
226
227 /// @brief Generate a random birth using per-call filters and settings.
228 ///
229 /// Draws a call seed from the instance's entropy-initialized random engine.
230 /// Use get_birth(request, seed) for deterministic generation.
231 /// @param request All filters and settings for this call.
232 /// @return The birth, including its seed for replay with the same request.
233 /// @throws std::runtime_error If no data has been loaded.
234 /// @throws std::invalid_argument If the country is unknown, the reference
235 /// date or age range is invalid, or both year and ages are specified.
236 [[nodiscard]] birth get_birth(const birth_request& request)
237 {
238 return get_birth(request, draw_seed_());
239 }
240
241 /// @brief Generate a deterministic birth using per-call filters and settings.
242 ///
243 /// Does not advance the internal random engine. Replay with the same
244 /// request, seed and data.
245 /// @param request Filters and settings for this call.
246 /// @param call_seed Seed.
247 /// @return The birth.
248 /// @throws std::runtime_error If no data has been loaded.
249 /// @throws std::invalid_argument If the country is unknown, the reference
250 /// date or age range is invalid, or both year and ages are specified.
251 [[nodiscard]] birth get_birth(const birth_request& request, std::uint64_t call_seed) const
252 {
253 validate_reference_(request.reference_date);
254 if (request.ages && request.ages->min > request.ages->max)
255 {
256 throw std::invalid_argument("age_range: min must be <= max");
257 }
258 if (request.year && request.ages)
259 {
260 throw std::invalid_argument("birth_request: year and ages are mutually exclusive");
261 }
262 const auto& e = request.country_code ? lookup_entry_(*request.country_code)
263 : random_entry_(call_seed, request.weighted);
264 return generate_(e, call_seed, request);
265 }
266
267 // -- Batch generation -------------------------------------------------
268
269 /// @brief Generate a batch of birth results from a seed drawn like
270 /// get_birth() draws its own.
271 ///
272 /// Item `i` is `get_birth(request, core::item_seed(batch_seed, options.first + i))`
273 /// for the drawn batch seed; see the seeded overload.
274 ///
275 /// @param request What every item is generated from.
276 /// @param options Count, first index, threads and uniqueness.
277 /// @return The results, in index order.
278 [[nodiscard]] std::vector<birth> get_birth_batch(const birth_request& request,
279 const core::batch_options& options)
280 {
281 return get_birth_batch(request, options, draw_seed_());
282 }
283
284 /// @brief Generate a batch of birth results, deterministically.
285 ///
286 /// Item `i` is exactly `get_birth(request, core::item_seed(batch_seed,
287 /// options.first + i))`, so an item replays on its own, a range can be
288 /// generated directly, two batches that follow each other equal one, and
289 /// the output does not depend on `options.threads`.
290 ///
291 /// @param request What every item is generated from.
292 /// @param options Count, first index, threads and uniqueness (see
293 /// core::unique_rule; the rule must be made for `birth`).
294 /// @param batch_seed Seed of the whole batch.
295 /// @return The results, in index order.
296 /// @throws std::invalid_argument If the request or options are invalid.
297 /// @throws std::runtime_error If no data has been loaded, or a unique
298 /// batch runs out of attempts.
299 [[nodiscard]] std::vector<birth> get_birth_batch(const birth_request& request,
300 const core::batch_options& options,
301 std::uint64_t batch_seed) const
302 {
303 return core::run_batch(options, batch_seed, [&](std::uint64_t item_seed)
304 { return get_birth(request, item_seed); });
305 }
306
307 /// @brief Stream a batch of birth results to a sink without holding the
308 /// whole batch.
309 ///
310 /// Generates the same items as get_birth_batch() and calls
311 /// `sink(index, item)` in index order on the calling thread; `index`
312 /// includes `options.first`.
313 ///
314 /// @tparam Sink Callable taking `(std::uint64_t, birth&)`; for example
315 /// core::jsonl_writer or core::csv_writer.
316 /// @param request What every item is generated from.
317 /// @param options Count, first index, threads and uniqueness.
318 /// @param batch_seed Seed of the whole batch.
319 /// @param sink Receives the items.
320 template <typename Sink>
321 void for_each_birth(const birth_request& request, const core::batch_options& options,
322 std::uint64_t batch_seed, Sink&& sink) const
323 {
324 core::for_each_batch(options, batch_seed,
325 [&](std::uint64_t item_seed) { return get_birth(request, item_seed); },
326 std::forward<Sink>(sink));
327 }
328
329 /// @brief Stream a batch of birth results with a batch seed drawn like
330 /// get_birth() draws its own.
331 /// @tparam Sink Callable taking `(std::uint64_t, birth&)`.
332 /// @param request What every item is generated from.
333 /// @param options Count, first index, threads and uniqueness.
334 /// @param sink Receives the items.
335 template <typename Sink>
336 void for_each_birth(const birth_request& request, const core::batch_options& options,
337 Sink&& sink)
338 {
339 for_each_birth(request, options, draw_seed_(), std::forward<Sink>(sink));
340 }
341
342
343 // -- Data management --------------------------------------------------
344
345 /// @brief Check whether any country can be generated.
346 /// @return True when at least one country has an age pyramid and life
347 /// expectancy.
348 [[nodiscard]] bool has_data() const { return !_entries.empty(); }
349
350 /// @brief Number of countries that can be generated.
351 /// @return The count.
352 [[nodiscard]] std::size_t country_count() const { return _entries.size(); }
353
354 /// @brief The countries that can be generated, in the order of
355 /// `countries.tsv`.
356 /// @return ISO 3166-1 alpha-2 codes; empty when no data is loaded.
357 [[nodiscard]] std::vector<std::string> countries() const
358 {
359 std::vector<std::string> out;
360 out.reserve(_entries.size());
361 for (const auto& e : _entries) out.push_back(e.cca2);
362 return out;
363 }
364
365 /// @brief A country's population by single age, as the generator draws ages.
366 ///
367 /// Relative weights, not normalised: they are the rows of
368 /// `age_pyramid.tsv` (population by age 0 to 100 and sex), so two
369 /// countries' weights are comparable only within a country.
370 /// @param cca2 ISO 3166-1 alpha-2 code.
371 /// @param bio_sex The sex.
372 /// @return 101 weights, index = age in completed years.
373 /// @throws std::runtime_error If no data is loaded.
374 /// @throws std::invalid_argument If the country has no data.
375 [[nodiscard]] const std::vector<double>& age_weights(std::string_view cca2, sex bio_sex) const
376 {
377 const auto& e = lookup_entry_(cca2);
378 return bio_sex == sex::male ? e.male_weights : e.female_weights;
379 }
380
381 /// @brief A country's life expectancy at birth, the figure `le_remaining`
382 /// is computed from.
383 /// @param cca2 ISO 3166-1 alpha-2 code.
384 /// @param bio_sex The sex.
385 /// @return Years.
386 /// @throws std::runtime_error If no data is loaded.
387 /// @throws std::invalid_argument If the country has no data.
388 [[nodiscard]] double life_expectancy(std::string_view cca2, sex bio_sex) const
389 {
390 const auto& e = lookup_entry_(cca2);
391 return bio_sex == sex::male ? e.le_male : e.le_female;
392 }
393
394 /// @brief The generational cohort of a birth year, as `birth::cohort`
395 /// labels it (see the table in the usage guide).
396 /// @param year The birth year.
397 /// @return `Greatest Generation` up to 1927, then `Silent Generation`,
398 /// `Baby Boomer`, `Generation X`, `Millennial`, `Generation Z`,
399 /// and `Generation Alpha` from 2013.
400 [[nodiscard]] static std::string_view cohort_of(int year) { return cohort_label_(year); }
401
402 /// @brief Load data from a resource directory, replacing all data and
403 /// layers loaded before.
404 ///
405 /// Reads `countries.tsv`, `age_pyramid.tsv` and `monthly_births.tsv`;
406 /// a missing file leaves its table empty. A path that is not a
407 /// directory, or has no `countries.tsv`, is ignored.
408 ///
409 /// @param dir Directory such as `resources/lite`.
410 /// @throws core::tsv_error If a file is malformed; the data is then
411 /// unchanged.
412 void load(const std::filesystem::path& dir)
413 {
414 std::error_code ec;
415 if (!std::filesystem::is_directory(dir, ec) ||
416 !std::filesystem::is_regular_file(dir / file_name_(table::countries), ec))
417 {
418 return;
419 }
420 apply_(read_directory_(dir), core::layer_mode::replace, true);
421 }
422
423 /// @brief Load a dataset tier, replacing all data and layers loaded
424 /// before.
425 ///
426 /// Uses the compiled-in copy when the library is built with
427 /// `DASMIG_BIRTH_EMBED`, otherwise searches `resources/`,
428 /// `../resources`, `birth-generator/resources` and
429 /// `../birth-generator/resources`.
430 ///
431 /// @param tier The tier.
432 /// @return False when the tier was not found.
433 /// @throws core::tsv_error If a file is malformed.
434 [[nodiscard]] bool load(dataset tier)
435 {
436 const std::string tier_dir{core::tier_name(tier)};
437 const auto marker = tier_dir + "/" + std::string{file_name_(table::countries)};
438 const auto root = core::find_resources(library_name_, marker);
439 documents docs;
440 for (const auto t : all_tables_)
441 {
442 docs.at(index_(t)) = core::load_resource(tier_dir + "/" + std::string{file_name_(t)},
443 embedded_files_(), root);
444 }
445 if (!docs.at(index_(table::countries))) return false;
446 apply_(std::move(docs), core::layer_mode::replace, true);
447 return true;
448 }
449
450 /// @brief Add data layers from a directory or a file.
451 ///
452 /// A directory may hold any of `countries.tsv`, `age_pyramid.tsv` and
453 /// `monthly_births.tsv`; all are applied together or, if any fails,
454 /// none. A file must have one of those names. Rows are keyed by
455 /// `cca2`; see Extending Data in doc/usage.md.
456 ///
457 /// @param path Directory or file.
458 /// @param mode How the layers combine with the data loaded before.
459 /// @throws core::tsv_error If a file cannot be read or a row is
460 /// invalid; the data is then unchanged.
461 /// @throws std::invalid_argument If @p path is a file with another name.
462 void extend(const std::filesystem::path& path, core::layer_mode mode = core::layer_mode::override)
463 {
464 std::error_code ec;
465 if (std::filesystem::is_directory(path, ec))
466 {
467 apply_(read_directory_(path), mode, false);
468 return;
469 }
470 for (const auto t : all_tables_)
471 {
472 if (path.filename() == file_name_(t))
473 {
474 extend(t, core::tsv_document::load(path), mode);
475 return;
476 }
477 }
478 throw std::invalid_argument("extend: expected countries.tsv, age_pyramid.tsv or "
479 "monthly_births.tsv, got " + path.filename().string());
480 }
481
482 /// @brief Add a data layer to one table.
483 /// @param which The table.
484 /// @param doc TSV with that table's columns.
485 /// @param mode How the layer combines with the data loaded before.
486 /// @return What the layer did.
487 /// @throws core::tsv_error If a row is invalid; the data is unchanged.
488 const core::layer_info& extend(table which, core::tsv_document doc,
489 core::layer_mode mode = core::layer_mode::override)
490 {
491 documents docs;
492 docs.at(index_(which)).emplace(std::move(doc));
493 apply_(std::move(docs), mode, false);
494 return _tables.at(index_(which)).layers().back();
495 }
496
497 /// @brief Add a data layer to one table from TSV text.
498 /// @param which The table.
499 /// @param text TSV with that table's columns.
500 /// @param source Name used in errors.
501 /// @param mode How the layer combines with the data loaded before.
502 /// @return What the layer did.
503 /// @throws core::tsv_error If a row is invalid; the data is unchanged.
504 const core::layer_info& extend_from_string(table which, std::string_view text, std::string source,
505 core::layer_mode mode = core::layer_mode::override)
506 {
507 return extend(which, core::tsv_document::parse(text, std::move(source)), mode);
508 }
509
510 /// @brief The layers in effect for one table, in order.
511 /// @param which The table.
512 /// @return The layer records.
513 [[nodiscard]] const std::vector<core::layer_info>& layers(table which) const
514 {
515 return _tables.at(index_(which)).layers();
516 }
517
518 /// @brief Hash of the loaded data (all three tables), recorded in each
519 /// result's signature().
520 /// @return The hash; 0 when nothing is loaded.
521 [[nodiscard]] std::uint64_t data_hash() const
522 {
523 std::uint64_t h = 0;
524 bool any = false;
525 for (const auto& t : _tables)
526 {
527 any = any || !t.layers().empty();
528 h = core::hash_combine(h, t.data_hash());
529 }
530 return any ? h : 0;
531 }
532
533 private:
534 static constexpr std::string_view library_name_ = "birth-generator";
535 static constexpr std::size_t max_age_ = 100;
536 static constexpr std::size_t table_count_ = 3;
537 static constexpr std::array<table, table_count_> all_tables_{
539 static constexpr std::array<std::string_view, 12> month_columns_{
540 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"};
541
542 using documents = std::array<std::optional<core::tsv_document>, table_count_>;
543 using tables = std::array<core::layered_table, table_count_>;
544
545 static constexpr std::size_t index_(table t) { return static_cast<std::size_t>(t); }
546
547 static constexpr std::string_view file_name_(table t)
548 {
549 switch (t)
550 {
551 case table::countries: return "countries.tsv";
552 case table::age_pyramid: return "age_pyramid.tsv";
553 case table::monthly_births: return "monthly_births.tsv";
554 }
555 return "";
556 }
557
558 static const std::vector<std::string>& pyramid_columns_()
559 {
560 static const std::vector<std::string> columns = [] {
561 std::vector<std::string> c;
562 for (const char s : {'m', 'f'})
563 {
564 for (std::size_t a = 0; a <= max_age_; ++a) c.push_back(std::string(1, s) + std::to_string(a));
565 }
566 return c;
567 }();
568 return columns;
569 }
570
571 static core::table_schema schema_(table t)
572 {
573 switch (t)
574 {
575 case table::countries:
576 return {.key = {"cca2"},
577 .required = {"name", "csection_rate"},
578 .optional = {"cca3", "region", "subregion", "latitude", "independent",
579 "le_male", "le_female"}};
580 case table::age_pyramid: return {.key = {"cca2"}, .required = pyramid_columns_(), .optional = {}};
582 return {.key = {"cca2"},
583 .required = {month_columns_.begin(), month_columns_.end()},
584 .optional = {}};
585 }
586 return {};
587 }
588
589 static tables make_tables_()
590 {
591 return {core::layered_table{schema_(table::countries)},
592 core::layered_table{schema_(table::age_pyramid)},
593 core::layered_table{schema_(table::monthly_births)}};
594 }
595
596 // -- Row parsing (also the validation of layers) ------------------------
597
598 struct country_row
599 {
600 std::string cca2;
601 std::string name;
602 std::optional<double> le_male;
603 std::optional<double> le_female;
604 double csection_rate{0.0};
605 };
606
607 static country_row parse_country_(const core::tsv_row& row)
608 {
609 country_row c;
610 c.cca2 = row.get<std::string>("cca2");
611 c.name = row.get<std::string>("name");
612 c.csection_rate = row.get<double>("csection_rate");
613 if (c.csection_rate < 0.0 || c.csection_rate > 1.0)
614 {
615 throw row.error("csection_rate", "must be between 0 and 1");
616 }
617 for (const auto& [column, value] : {std::pair{"le_male", &c.le_male}, std::pair{"le_female", &c.le_female}})
618 {
619 if (!row.has(column)) continue;
620 const auto v = row.get<double>(column);
621 if (!(v > 0.0)) throw row.error(column, "must be positive");
622 *value = v;
623 }
624 return c;
625 }
626
627 // Weights by age, males then females.
628 struct pyramid_row
629 {
630 std::vector<double> male;
631 std::vector<double> female;
632 };
633
634 static pyramid_row parse_pyramid_(const core::tsv_row& row)
635 {
636 pyramid_row p;
637 const auto& columns = pyramid_columns_();
638 for (std::size_t i = 0; i < columns.size(); ++i)
639 {
640 const auto v = row.get<double>(columns[i]);
641 if (v < 0.0) throw row.error(columns[i], "must not be negative");
642 (i <= max_age_ ? p.male : p.female).push_back(v);
643 }
644 for (const auto* weights : {&p.male, &p.female})
645 {
646 double total = 0.0;
647 for (const double w : *weights) total += w;
648 if (!(total > 0.0))
649 {
650 throw row.error(weights == &p.male ? "m0" : "f0", "every age of this sex is 0");
651 }
652 }
653 return p;
654 }
655
656 static std::vector<double> parse_monthly_(const core::tsv_row& row)
657 {
658 std::vector<double> w;
659 double total = 0.0;
660 for (const auto column : month_columns_)
661 {
662 const auto v = row.get<double>(column);
663 if (v < 0.0) throw row.error(column, "must not be negative");
664 w.push_back(v);
665 total += v;
666 }
667 if (!(total > 0.0)) throw row.error("jan", "every month is 0");
668 return w;
669 }
670
671 static void validate_(table t, const core::tsv_row& row)
672 {
673 switch (t)
674 {
675 case table::countries: (void)parse_country_(row); break;
676 case table::age_pyramid: (void)parse_pyramid_(row); break;
677 case table::monthly_births: (void)parse_monthly_(row); break;
678 }
679 }
680
681 // -- Assembled data ------------------------------------------------------
682
683 struct entry
684 {
685 std::string cca2;
686 std::string name;
687 double le_male{0.0};
688 double le_female{0.0};
689 double csection_rate{0.0};
690 double total_male{0.0};
691 double total_female{0.0};
692 std::vector<double> male_weights; // by age 0..100
693 std::vector<double> female_weights; // by age 0..100
694 core::discrete male_age{1.0};
695 core::discrete female_age{1.0};
696 core::discrete month{1.0};
697 };
698
699 tables _tables{make_tables_()};
700 std::vector<entry> _entries; // countries-table order
701 std::map<std::string, std::size_t, std::less<>> _index;
702 core::discrete _country_dist{1.0};
703 core::rng _engine{core::rng::from_entropy()};
704
705 struct auto_probe_tag {};
706
707 explicit bthg(auto_probe_tag /*tag*/)
708 {
709 if (!load(dataset::lite)) (void)load(dataset::full);
710 }
711
712 static std::span<const core::embedded_file> embedded_files_()
713 {
714#ifdef DASMIG_BIRTH_EMBED
715 return embedded::birthgen::files;
716#else
717 return {};
718#endif
719 }
720
721 static documents read_directory_(const std::filesystem::path& dir)
722 {
723 documents docs;
724 for (const auto t : all_tables_)
725 {
726 const auto file = dir / file_name_(t);
727 std::error_code ec;
728 if (std::filesystem::is_regular_file(file, ec))
729 {
730 docs.at(index_(t)) = core::tsv_document::load(file);
731 }
732 }
733 return docs;
734 }
735
736 // Apply layers to copies of the tables, then commit: all or nothing.
737 // With @p reset (load), tables without a document are emptied.
738 void apply_(documents docs, core::layer_mode mode, bool reset)
739 {
740 tables next = _tables;
741 for (const auto t : all_tables_)
742 {
743 auto& doc = docs.at(index_(t));
744 auto& tbl = next.at(index_(t));
745 if (!doc)
746 {
747 if (reset) tbl.clear();
748 continue;
749 }
750 (void)tbl.extend(std::move(*doc), mode, [t](const core::tsv_row& row) { validate_(t, row); });
751 }
752 rebuild_(next);
753 _tables = std::move(next);
754 }
755
756 // A country is generated when it has a countries row with life
757 // expectancy and an age pyramid; monthly weights default to uniform.
758 void rebuild_(const tables& t)
759 {
760 std::map<std::string, const core::tsv_row*, std::less<>> pyramids;
761 std::map<std::string, const core::tsv_row*, std::less<>> months;
762 for (const auto& row : t.at(index_(table::age_pyramid)).rows()) pyramids.emplace(row["cca2"], &row);
763 for (const auto& row : t.at(index_(table::monthly_births)).rows()) months.emplace(row["cca2"], &row);
764
765 std::vector<entry> entries;
766 std::map<std::string, std::size_t, std::less<>> index;
767 std::vector<double> population;
768 for (const auto& row : t.at(index_(table::countries)).rows())
769 {
770 auto c = parse_country_(row);
771 const auto p = pyramids.find(c.cca2);
772 if (p == pyramids.end() || !c.le_male || !c.le_female) continue;
773 auto pyramid = parse_pyramid_(*p->second);
774 entry e;
775 e.cca2 = std::move(c.cca2);
776 e.name = std::move(c.name);
777 e.le_male = *c.le_male;
778 e.le_female = *c.le_female;
779 e.csection_rate = c.csection_rate;
780 for (const double w : pyramid.male) e.total_male += w;
781 for (const double w : pyramid.female) e.total_female += w;
782 e.male_age = core::discrete{pyramid.male};
783 e.female_age = core::discrete{pyramid.female};
784 e.male_weights = std::move(pyramid.male);
785 e.female_weights = std::move(pyramid.female);
786 const auto m = months.find(e.cca2);
787 e.month = m == months.end() ? core::discrete{std::vector<double>(12, 1.0)}
788 : core::discrete{parse_monthly_(*m->second)};
789 population.push_back(e.total_male + e.total_female);
790 index.emplace(e.cca2, entries.size());
791 entries.push_back(std::move(e));
792 }
793 _country_dist = population.empty() ? core::discrete{1.0} : core::discrete{population};
794 _entries = std::move(entries);
795 _index = std::move(index);
796 }
797
798 // -- Generation ----------------------------------------------------------
799
800 std::uint64_t draw_seed_() { return _engine(); }
801
802 static void validate_reference_(std::chrono::year_month_day date)
803 {
804 if (!date.ok())
805 {
806 throw std::invalid_argument("reference_date: not a valid date");
807 }
808 }
809
810 void require_data_() const
811 {
812 if (_entries.empty())
813 {
814 throw std::runtime_error("No birth data loaded. Call load() first.");
815 }
816 }
817
818 [[nodiscard]] const entry& lookup_entry_(std::string_view cca2) const
819 {
820 require_data_();
821 const auto it = _index.find(cca2);
822 if (it == _index.end())
823 {
824 throw core::unknown_country_error(cca2);
825 }
826 return _entries[it->second];
827 }
828
829 [[nodiscard]] const entry& random_entry_(std::uint64_t call_seed, bool weighted) const
830 {
831 require_data_();
832 auto r = core::rng{call_seed}.split("country");
833 const auto idx = weighted ? _country_dist(r)
834 : static_cast<std::size_t>(core::bounded(r, _entries.size()));
835 return _entries[idx];
836 }
837
838 static unsigned days_in_(int year, unsigned month)
839 {
840 return static_cast<unsigned>(
841 std::chrono::year_month_day_last{std::chrono::year{year} / std::chrono::month{month} /
842 std::chrono::last}
843 .day());
844 }
845
846 // Whether month/day falls after the reference date's month/day.
847 [[nodiscard]] static bool after_reference_(unsigned month, unsigned day,
848 std::chrono::year_month_day reference)
849 {
850 const auto ref_m = static_cast<unsigned>(reference.month());
851 const auto ref_d = static_cast<unsigned>(reference.day());
852 return month > ref_m || (month == ref_m && day > ref_d);
853 }
854
855 // Draw a day in @p month such that the person is @p age at the reference
856 // date (or born in @p fixed_year), redrawing weekend days with
857 // probability csection_rate / 2, at most three times.
858 struct placed
859 {
860 int year;
861 unsigned day;
862 };
863
864 [[nodiscard]] static placed place_(core::rng& r, const entry& e, unsigned month, unsigned age,
865 std::optional<year_t> fixed_year,
866 std::chrono::year_month_day reference)
867 {
868 const int ref_year = static_cast<int>(reference.year());
869 auto draw = [&]() -> placed {
870 if (fixed_year)
871 {
872 const int y = fixed_year->value;
873 return {.year = y, .day = core::uniform_int(r, 1U, days_in_(y, month))};
874 }
875 for (;;)
876 {
877 const auto d = core::uniform_int(r, 1U, 31U);
878 const int y = ref_year - static_cast<int>(age) -
879 (after_reference_(month, d, reference) ? 1 : 0);
880 if (d <= days_in_(y, month)) return {.year = y, .day = d};
881 }
882 };
883 placed p = draw();
884 static constexpr int max_weekend_redraws = 3;
885 for (int attempt = 0; attempt < max_weekend_redraws; ++attempt)
886 {
887 const std::chrono::weekday wd{std::chrono::sys_days{
888 std::chrono::year{p.year} / std::chrono::month{month} / std::chrono::day{p.day}}};
889 const bool weekend = wd.iso_encoding() >= 6;
890 if (!weekend || !core::bernoulli(r, core::math::fenced(e.csection_rate * 0.5))) break;
891 p = draw();
892 }
893 return p;
894 }
895
896 [[nodiscard]] birth generate_(const entry& e, std::uint64_t call_seed, const birth_request& opts) const
897 {
898 const core::rng call{call_seed};
899 birth b;
900 b._signature = {.seed = call_seed, .data = data_hash()};
901 b.country_code = e.cca2;
902
903 // 1. Sex, weighted by the male:female population.
904 if (opts.bio_sex)
905 {
906 b.bio_sex = *opts.bio_sex;
907 }
908 else
909 {
910 auto r = call.split("sex");
911 const double female_share = e.total_female / (e.total_male + e.total_female);
912 b.bio_sex = core::bernoulli(r, female_share) ? sex::female : sex::male;
913 }
914 const bool male = b.bio_sex == sex::male;
915
916 // 2. Age (unless the year is fixed), from the pyramid or a range of it.
917 unsigned age = 0;
918 if (!opts.year)
919 {
920 auto r = call.split("age");
921 const auto& weights = male ? e.male_weights : e.female_weights;
922 const auto top = static_cast<std::uint8_t>(max_age_);
923 const auto range = opts.ages.value_or(age_range{.min = 0, .max = top});
924 const auto age_min = std::min(range.min, top);
925 const auto age_max = std::min(range.max, top);
926 if (age_min == 0 && age_max == max_age_)
927 {
928 age = static_cast<unsigned>((male ? e.male_age : e.female_age)(r));
929 }
930 else
931 {
932 const std::vector<double> slice(weights.begin() + age_min,
933 weights.begin() + age_max + 1);
934 double total = 0.0;
935 for (const double w : slice) total += w;
936 const auto offset = total > 0.0 ? core::discrete{slice}(r)
937 : static_cast<std::size_t>(core::bounded(r, slice.size()));
938 age = age_min + static_cast<unsigned>(offset);
939 }
940 }
941
942 // 3. Month, from the seasonal weights.
943 {
944 auto r = call.split("month");
945 b.month = static_cast<std::uint8_t>(e.month(r) + 1);
946 }
947
948 // 4. Day (and year), with the weekend deficit.
949 {
950 auto r = call.split("day");
951 const auto p = place_(r, e, b.month, age, opts.year, opts.reference_date);
952 b.year = static_cast<std::uint16_t>(p.year);
953 b.day = static_cast<std::uint8_t>(p.day);
954 if (opts.year)
955 {
956 const int completed = static_cast<int>(opts.reference_date.year()) - p.year -
957 (after_reference_(b.month, p.day, opts.reference_date) ? 1 : 0);
958 age = static_cast<unsigned>(std::clamp(completed, 0, static_cast<int>(max_age_)));
959 }
960 }
961 b.age = static_cast<std::uint8_t>(age);
962
963 // 5. Weekday, remaining life expectancy, cohort.
964 const std::chrono::weekday wd{std::chrono::sys_days{
965 std::chrono::year{b.year} / std::chrono::month{b.month} / std::chrono::day{b.day}}};
966 b.weekday = static_cast<std::uint8_t>(wd.c_encoding());
967 const double le = male ? e.le_male : e.le_female;
968 b.le_remaining = std::max(0.0, le - static_cast<double>(b.age));
969 b.cohort = std::string{cohort_label_(b.year)};
970 return b;
971 }
972
973 static std::string_view cohort_label_(int year)
974 {
975 if (year <= 1927) return "Greatest Generation";
976 if (year <= 1945) return "Silent Generation";
977 if (year <= 1964) return "Baby Boomer";
978 if (year <= 1980) return "Generation X";
979 if (year <= 1996) return "Millennial";
980 if (year <= 2012) return "Generation Z";
981 return "Generation Alpha";
982 }
983};
984
985} // namespace dasmig
986
987#endif // DASMIG_BIRTHGEN_HPP
Return type for birth generation.
Definition birthgen.hpp:85
std::uint8_t day
Birth day (1–31).
Definition birthgen.hpp:90
double le_remaining
Estimated years of life remaining.
Definition birthgen.hpp:94
std::uint64_t seed() const
Retrieve the random seed used to generate this birth.
Definition birthgen.hpp:100
std::uint8_t month
Birth month (1–12).
Definition birthgen.hpp:89
const core::seed_signature & signature() const
The seed and the data this birth was generated from.
Definition birthgen.hpp:105
sex bio_sex
Biological sex.
Definition birthgen.hpp:92
std::string cohort
Generational cohort label.
Definition birthgen.hpp:95
friend std::ostream & operator<<(std::ostream &os, const birth &b)
Stream the ISO date to an output stream.
Definition birthgen.hpp:155
std::uint8_t age
Age in completed years at the reference date.
Definition birthgen.hpp:91
std::string date_string() const
ISO 8601 date string (YYYY-MM-DD).
Definition birthgen.hpp:109
std::uint16_t year
Birth year.
Definition birthgen.hpp:88
std::string country_code
ISO 3166-1 alpha-2 code.
Definition birthgen.hpp:87
void to_json(core::json_writer &w) const
Write this birth as a JSON object.
Definition birthgen.hpp:117
std::string to_json() const
This birth as a JSON document.
Definition birthgen.hpp:137
std::uint8_t weekday
Day of week (0=Sun, 1=Mon, …, 6=Sat).
Definition birthgen.hpp:93
Birth generator that produces demographically plausible birthdays from country age pyramids.
Definition birthgen.hpp:190
double life_expectancy(std::string_view cca2, sex bio_sex) const
A country's life expectancy at birth, the figure le_remaining is computed from.
Definition birthgen.hpp:388
const std::vector< core::layer_info > & layers(table which) const
The layers in effect for one table, in order.
Definition birthgen.hpp:513
std::vector< birth > get_birth_batch(const birth_request &request, const core::batch_options &options)
Generate a batch of birth results from a seed drawn like get_birth() draws its own.
Definition birthgen.hpp:278
table
The three data tables.
Definition birthgen.hpp:194
@ age_pyramid
age_pyramid.tsv: population by single age and sex.
@ countries
countries.tsv: names, life expectancy, caesarean rate.
@ monthly_births
monthly_births.tsv: seasonal birth weights.
birth get_birth(const birth_request &request, std::uint64_t call_seed) const
Generate a deterministic birth using per-call filters and settings.
Definition birthgen.hpp:251
bthg & operator=(const bthg &)=delete
Not copyable.
void for_each_birth(const birth_request &request, const core::batch_options &options, Sink &&sink)
Stream a batch of birth results with a batch seed drawn like get_birth() draws its own.
Definition birthgen.hpp:336
bool load(dataset tier)
Load a dataset tier, replacing all data and layers loaded before.
Definition birthgen.hpp:434
const std::vector< double > & age_weights(std::string_view cca2, sex bio_sex) const
A country's population by single age, as the generator draws ages.
Definition birthgen.hpp:375
std::vector< std::string > countries() const
The countries that can be generated, in the order of countries.tsv.
Definition birthgen.hpp:357
std::size_t country_count() const
Number of countries that can be generated.
Definition birthgen.hpp:352
void extend(const std::filesystem::path &path, core::layer_mode mode=core::layer_mode::override)
Add data layers from a directory or a file.
Definition birthgen.hpp:462
static std::string_view cohort_of(int year)
The generational cohort of a birth year, as birth::cohort labels it (see the table in the usage guide...
Definition birthgen.hpp:400
const core::layer_info & extend_from_string(table which, std::string_view text, std::string source, core::layer_mode mode=core::layer_mode::override)
Add a data layer to one table from TSV text.
Definition birthgen.hpp:504
bool has_data() const
Check whether any country can be generated.
Definition birthgen.hpp:348
bthg()=default
Default constructor — creates an empty generator with no data.
const core::layer_info & extend(table which, core::tsv_document doc, core::layer_mode mode=core::layer_mode::override)
Add a data layer to one table.
Definition birthgen.hpp:488
bthg(bthg &&) noexcept=default
Movable.
bthg(const bthg &)=delete
Not copyable.
void load(const std::filesystem::path &dir)
Load data from a resource directory, replacing all data and layers loaded before.
Definition birthgen.hpp:412
birth get_birth(const birth_request &request)
Generate a random birth using per-call filters and settings.
Definition birthgen.hpp:236
static constexpr std::chrono::year_month_day default_reference_date
The default reference date: 1 July 2023, the date the UN World Population Prospects estimates in the ...
Definition birthgen.hpp:202
void for_each_birth(const birth_request &request, const core::batch_options &options, std::uint64_t batch_seed, Sink &&sink) const
Stream a batch of birth results to a sink without holding the whole batch.
Definition birthgen.hpp:321
static bthg & instance()
Access the global singleton instance.
Definition birthgen.hpp:219
std::uint64_t data_hash() const
Hash of the loaded data (all three tables), recorded in each result's signature().
Definition birthgen.hpp:521
std::vector< birth > get_birth_batch(const birth_request &request, const core::batch_options &options, std::uint64_t batch_seed) const
Generate a batch of birth results, deterministically.
Definition birthgen.hpp:299
An inclusive range of ages in completed years.
Definition birthgen.hpp:52
std::uint8_t min
Youngest age.
Definition birthgen.hpp:53
std::uint8_t max
Oldest age.
Definition birthgen.hpp:54
All per-call birth filters and settings.
Definition birthgen.hpp:63
std::optional< year_t > year
Fixed birth year, or derived from age.
Definition birthgen.hpp:73
std::optional< age_range > ages
Inclusive ages; values above 100 clamp to 100.
Definition birthgen.hpp:75
std::optional< dasmig::sex > bio_sex
Fixed biological sex, or drawn.
Definition birthgen.hpp:71
static constexpr std::chrono::year_month_day default_reference_date
1 July 2023, the date of the UN population estimates.
Definition birthgen.hpp:65
std::optional< std::string > country_code
ISO 3166-1 alpha-2 code, or random.
Definition birthgen.hpp:69
std::chrono::year_month_day reference_date
Date ages are computed at.
Definition birthgen.hpp:77
bool weighted
Population-weighted (true) or uniform country.
Definition birthgen.hpp:76
Strong type for a requested birth year.
Definition birthgen.hpp:42
std::uint16_t value
The year.
Definition birthgen.hpp:43
constexpr year_t(std::uint16_t v) noexcept
Construct from a year.
Definition birthgen.hpp:47