Interest Generator 3.0.1
Religion, hobbies and favourite media by country, age and sex for C++23
Loading...
Searching...
No Matches
interestgen.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Diego Dasso Migotto
3
4#ifndef DASMIG_INTERESTGEN_HPP
5#define DASMIG_INTERESTGEN_HPP
6
7#include "core.hpp"
8#include <algorithm>
9#include <array>
10#include <cstddef>
11#include <cstdint>
12#include <filesystem>
13#include <format>
14#include <map>
15#include <optional>
16#include <ostream>
17#include <span>
18#include <stdexcept>
19#include <string>
20#include <string_view>
21#include <system_error>
22#include <limits>
23#include <utility>
24#include <vector>
25
26#ifdef DASMIG_INTEREST_EMBED
27#include "interestgen_data.hpp"
28#endif
29
30/// @file interestgen.hpp
31/// @brief Interest Generator library — religion, hobbies and favourite media by country, age and sex 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 One hobby or favourite genre in a result.
42{
43 std::string key; ///< Catalogue key (the `key` column of `interests.tsv`).
44 std::string label; ///< Display label.
45 std::string category; ///< Grouping such as "sport" or "film"; may be empty.
46};
47
48/// @brief Per-call generation choices. Every field has a default.
49///
50/// Pass an empty request for a random country, unknown age and sex,
51/// three hobbies and two genres per medium. Requests do not change
52/// the generator's configuration.
54{
55 std::string_view country{}; ///< ISO 3166-1 alpha-2 code; empty draws a country (uniform over those loaded).
56 std::optional<std::uint8_t> age{}; ///< Age in completed years; unknown applies no age factor or age limit.
57 std::optional<sex> person_sex{}; ///< Sex; unknown applies no sex factor.
58 std::size_t hobbies{3}; ///< How many hobbies to draw.
59 std::size_t media{2}; ///< How many genres to draw in each of film, games, books and music.
60};
61
62/// @brief Return type for interest generation.
63///
64/// Supports implicit conversion to std::string (the religion), streaming
65/// via operator<< and JSON output via to_json().
67{
68 public:
69 std::string country_code; ///< ISO 3166-1 alpha-2 code of the country drawn from.
70 std::string religion; ///< Religion label (the `religion` column).
71 std::string religion_group; ///< Major group of the religion (the `group` column).
72 std::vector<interest_item> hobbies; ///< Hobbies, in the order they were drawn.
73 std::vector<interest_item> films; ///< Favourite film genres.
74 std::vector<interest_item> games; ///< Favourite game genres.
75 std::vector<interest_item> books; ///< Favourite book genres.
76 std::vector<interest_item> music; ///< Favourite music genres.
77
78 /// @brief Retrieve the random seed used to generate this interest.
79 /// @return The call seed; pass it, with the same request, to the seeded
80 /// get_interest() overload to generate the same interest again.
81 [[nodiscard]] std::uint64_t seed() const { return _signature.seed; }
82
83 /// @brief The seed and the data this interest was generated from.
84 /// @return The replay signature; `data` is intg::data_hash() at the
85 /// time of generation.
86 [[nodiscard]] const core::seed_signature& signature() const
87 {
88 return _signature;
89 }
90
91 /// @brief Human-readable summary string.
92 /// @return The religion.
93 [[nodiscard]] std::string to_string() const { return religion; }
94
95 /// @brief Write this interest as a JSON object.
96 /// @param w Writer positioned where a value is allowed.
97 void to_json(core::json_writer& w) const
98 {
99 w.begin_object()
100 .field("country_code", country_code)
101 .field("religion", religion)
102 .field("religion_group", religion_group);
103 write_items_(w, "hobbies", hobbies);
104 write_items_(w, "films", films);
105 write_items_(w, "games", games);
106 write_items_(w, "books", books);
107 write_items_(w, "music", music);
108 w.key("signature");
109 _signature.to_json(w);
110 w.end_object();
111 }
112
113 /// @brief This interest as a JSON document.
114 /// @return Compact JSON, for example
115 /// `{"country_code":"BR","religion":"...","religion_group":"...","hobbies":[{"key":"...","label":"..."}],...,"signature":{...}}`.
116 [[nodiscard]] std::string to_json() const
117 {
118 core::json_writer w;
119 to_json(w);
120 return w.str();
121 }
122
123 /// @brief Implicit conversion to std::string.
124 /// @return Summary string.
125 operator std::string() const // NOLINT(hicpp-explicit-conversions)
126 {
127 return to_string();
128 }
129
130 /// @brief Stream the summary to an output stream.
131 /// @param os Output stream.
132 /// @param v The interest to stream.
133 /// @return The stream.
134 friend std::ostream& operator<<(std::ostream& os, const interest& v)
135 {
136 os << v.to_string();
137 return os;
138 }
139
140 private:
141 static void write_items_(core::json_writer& w, std::string_view name,
142 const std::vector<interest_item>& items)
143 {
144 w.key(name).begin_array();
145 for (const auto& i : items)
146 {
147 w.begin_object().field("key", i.key).field("label", i.label).end_object();
148 }
149 w.end_array();
150 }
151
152 core::seed_signature _signature;
153 friend class intg;
154};
155
156/// @brief One religion's share of a country, as loaded.
158{
159 std::string religion; ///< Label, for example "Roman Catholic" (`full`) or "Christian" (`lite`).
160 std::string group; ///< Major group: christian, muslim, hindu, buddhist, jewish, unaffiliated, other, or a group a layer adds.
161 double weight{0.0}; ///< Share of the population in percent; only the ratios matter.
162 int year{0}; ///< Survey year, or 0 when unknown.
163};
164
165/// @brief Interest Generator: draws a person's religion, hobbies and
166/// favourite film, game, book and music genres.
167///
168/// The country (when not given) comes from the `"country"` stream of the
169/// call seed, the religion from `"religion"`, and each kind of item from a
170/// stream of its own (`"hobby"`, `"film"`, `"game"`, `"book"`, `"music"`),
171/// so fields added later leave these unchanged. Output is identical on
172/// every compiler and platform (dasmig-core).
173///
174/// An item's weight for a person is its base `weight` multiplied by factors
175/// for the person's sex, age band, country (or else region) and religion
176/// group, for the item and for its activity class; a missing factor is 1.
177/// Items are drawn without replacement.
178///
179/// A country chosen at random is uniform over the countries loaded; the
180/// data has no population column. Pass a country chosen by country-generator
181/// for a population-weighted world.
182///
183/// Can be used as a singleton via instance() or constructed
184/// independently.
185///
186/// @par Thread safety
187/// Each instance is independent. Concurrent calls to get_interest() on the
188/// **same** instance require external synchronisation.
189class intg
190{
191 public:
192 /// @brief The data tables.
193 enum class table : std::uint8_t
194 {
195 religion, ///< `religion.tsv`: key `country_code` and `religion`; group, weight, year, basis.
196 interests, ///< `interests.tsv`: key `kind` and `key`; label, weight, class, category, tags, min_age, max_age.
197 affinity, ///< `affinity.tsv`: key `target` (an item key, or `class:` and a class), `dimension` and `value`; factor, basis.
198 age_bands, ///< `age_bands.tsv`: key `band`; min_age, max_age.
199 regions ///< `regions.tsv`: key `country_code`; region.
200 };
201
202 /// @brief Default constructor — creates an empty generator with no
203 /// data.
204 intg() : _tables{make_tables_()} {}
205
206 intg(const intg&) = delete; ///< Not copyable.
207 intg& operator=(const intg&) = delete; ///< Not copyable.
208 intg(intg&&) noexcept = default; ///< Movable.
209 intg& operator=(intg&&) noexcept = default; ///< Movable.
210 ~intg() = default; ///< Destructor.
211
212 /// @brief Access the global singleton instance.
213 ///
214 /// Loads the `lite` tier (or `full` if `lite` is missing) on first
215 /// access, from compiled-in data or an auto-probed `resources/`.
216 /// @return The singleton.
217 static intg& instance()
218 {
219 static intg inst{auto_probe_tag{}};
220 return inst;
221 }
222
223 // -- Generation -------------------------------------------------------
224
225 /// @brief Generate a random interest for a request.
226 /// @param request Country, age, sex and counts. Use `{}` for defaults.
227 /// @return The interest.
228 /// @throws std::runtime_error If no data has been loaded.
229 /// @throws std::invalid_argument If the country has no data, or every
230 /// share of it has weight 0 (or, with no country, no country has
231 /// a positive weight).
232 [[nodiscard]] interest get_interest(const interest_request& request)
233 {
234 return generate_(request, draw_seed_());
235 }
236
237 /// @brief Generate a deterministic interest for a request.
238 /// @param request Country, age, sex and counts. Use `{}` for defaults.
239 /// @param call_seed Seed; the same seed, request and data give the same
240 /// interest, independent of prior calls. Does not touch the
241 /// instance's random engine.
242 /// @return The interest.
243 /// @throws std::runtime_error If no data has been loaded.
244 /// @throws std::invalid_argument As for the unseeded overload.
245 [[nodiscard]] interest get_interest(const interest_request& request,
246 std::uint64_t call_seed) const
247 {
248 return generate_(request, call_seed);
249 }
250
251 // -- Batch generation -------------------------------------------------
252
253 /// @brief Generate a batch of interest results from a seed drawn like
254 /// get_interest() draws its own.
255 ///
256 /// Item `i` is `get_interest(request, core::item_seed(batch_seed, options.first + i))`
257 /// for the drawn batch seed; see the seeded overload.
258 ///
259 /// @param request What every item is generated from.
260 /// @param options Count, first index, threads and uniqueness.
261 /// @return The results, in index order.
262 [[nodiscard]] std::vector<interest> get_interest_batch(const interest_request& request,
263 const core::batch_options& options)
264 {
265 return get_interest_batch(request, options, draw_seed_());
266 }
267
268 /// @brief Generate a batch of interest results, deterministically.
269 ///
270 /// Item `i` is exactly `get_interest(request, core::item_seed(batch_seed,
271 /// options.first + i))`, so an item replays on its own, a range can be
272 /// generated directly, two batches that follow each other equal one, and
273 /// the output does not depend on `options.threads`.
274 ///
275 /// @param request What every item is generated from.
276 /// @param options Count, first index, threads and uniqueness (see
277 /// core::unique_rule; the rule must be made for `interest`).
278 /// @param batch_seed Seed of the whole batch.
279 /// @return The results, in index order.
280 /// @throws std::invalid_argument If the request or options are invalid.
281 /// @throws std::runtime_error If no data has been loaded, or a unique
282 /// batch runs out of attempts.
283 [[nodiscard]] std::vector<interest> get_interest_batch(const interest_request& request,
284 const core::batch_options& options,
285 std::uint64_t batch_seed) const
286 {
287 return core::run_batch(options, batch_seed, [&](std::uint64_t item_seed)
288 { return get_interest(request, item_seed); });
289 }
290
291 /// @brief Stream a batch of interest results to a sink without holding the
292 /// whole batch.
293 ///
294 /// Generates the same items as get_interest_batch() and calls
295 /// `sink(index, item)` in index order on the calling thread; `index`
296 /// includes `options.first`.
297 ///
298 /// @tparam Sink Callable taking `(std::uint64_t, interest&)`; for example
299 /// core::jsonl_writer or core::csv_writer.
300 /// @param request What every item is generated from.
301 /// @param options Count, first index, threads and uniqueness.
302 /// @param batch_seed Seed of the whole batch.
303 /// @param sink Receives the items.
304 template <typename Sink>
305 void for_each_interest(const interest_request& request, const core::batch_options& options,
306 std::uint64_t batch_seed, Sink&& sink) const
307 {
308 core::for_each_batch(options, batch_seed,
309 [&](std::uint64_t item_seed) { return get_interest(request, item_seed); },
310 std::forward<Sink>(sink));
311 }
312
313 /// @brief Stream a batch of interest results with a batch seed drawn like
314 /// get_interest() draws its own.
315 /// @tparam Sink Callable taking `(std::uint64_t, interest&)`.
316 /// @param request What every item is generated from.
317 /// @param options Count, first index, threads and uniqueness.
318 /// @param sink Receives the items.
319 template <typename Sink>
320 void for_each_interest(const interest_request& request, const core::batch_options& options,
321 Sink&& sink)
322 {
323 for_each_interest(request, options, draw_seed_(), std::forward<Sink>(sink));
324 }
325
326
327 // -- Queries ----------------------------------------------------------
328
329 /// @brief Check whether religion data has been loaded.
330 /// @return True when at least one religion row is loaded.
331 [[nodiscard]] bool has_data() const { return _tables.at(index_(table::religion)).size() > 0; }
332
333 /// @brief Return the number of countries with religion data.
334 /// @return The count.
335 [[nodiscard]] std::size_t country_count() const { return _model.countries.size(); }
336
337 /// @brief Return the number of hobbies and genres loaded, all kinds.
338 /// @return The count.
339 [[nodiscard]] std::size_t item_count() const
340 {
341 std::size_t n = 0;
342 for (const auto& v : _model.items) n += v.size();
343 return n;
344 }
345
346 /// @brief The religion shares loaded for a country.
347 /// @param cca2 ISO 3166-1 alpha-2 country code.
348 /// @return The shares in table order; empty when the country has none.
349 [[nodiscard]] std::span<const religion_share> shares(std::string_view cca2) const
350 {
351 const auto it = _model.countries.find(cca2);
352 if (it == _model.countries.end()) return {};
353 return it->second.shares;
354 }
355
356 /// @brief Affinity rows whose target is neither an item key nor
357 /// `class:` and an activity class that is loaded. They are ignored, so removing an
358 /// item needs no other change; this lists them to catch misspelt
359 /// names.
360 /// @return One description per ignored row, such as
361 /// `affinity.tsv:12: unknown target 'footbal'`.
362 [[nodiscard]] const std::vector<std::string>& unresolved() const
363 {
364 return _model.unresolved;
365 }
366
367 // -- Data management --------------------------------------------------
368
369 /// @brief Load a resource directory, replacing all data and layers
370 /// loaded before.
371 ///
372 /// The directory must contain `religion.tsv`; the other tables are read
373 /// when present (a missing one leaves its table empty, so a directory
374 /// with only `religion.tsv` draws no hobbies or media). A path without
375 /// `religion.tsv` is ignored.
376 ///
377 /// @param dir Directory such as `resources/lite`.
378 /// @throws core::tsv_error If a file is malformed; the data is then
379 /// unchanged.
380 void load(const std::filesystem::path& dir)
381 {
382 std::error_code ec;
383 if (!std::filesystem::is_directory(dir, ec) ||
384 !std::filesystem::is_regular_file(dir / file_name_(table::religion), ec))
385 {
386 return;
387 }
388 apply_(read_directory_(dir), core::layer_mode::replace, true);
389 }
390
391 /// @brief Load a dataset tier, replacing all data and layers loaded
392 /// before.
393 ///
394 /// Uses the compiled-in copy when the library is built with
395 /// `DASMIG_INTEREST_EMBED`, otherwise searches `resources/`,
396 /// `../resources`, `interest-generator/resources` and
397 /// `../interest-generator/resources`.
398 ///
399 /// @param tier The tier.
400 /// @return False when the tier was not found.
401 /// @throws core::tsv_error If a file is malformed.
402 [[nodiscard]] bool load(dataset tier)
403 {
404 const std::string tier_dir{core::tier_name(tier)};
405 const auto marker = tier_dir + "/" + std::string{file_name_(table::religion)};
406 const auto root = core::find_resources(library_name_, marker);
407 documents docs;
408 for (const auto t : all_tables_)
409 {
410 docs.at(index_(t)) = core::load_resource(tier_dir + "/" + std::string{file_name_(t)},
411 embedded_files_(), root);
412 }
413 if (!docs.at(index_(table::religion))) return false;
414 apply_(std::move(docs), core::layer_mode::replace, true);
415 return true;
416 }
417
418 /// @brief Add data layers from a directory or a file.
419 ///
420 /// A directory may hold any of `religion.tsv`, `interests.tsv`,
421 /// `affinity.tsv`, `age_bands.tsv` and `regions.tsv`; all are applied
422 /// together or, if any fails, none. A file must have one of those
423 /// names.
424 ///
425 /// @param path Directory or file.
426 /// @param mode How the layers combine with the data loaded before.
427 /// @throws core::tsv_error If a file cannot be read or a row is
428 /// invalid; the data is then unchanged.
429 /// @throws std::invalid_argument If @p path is a file with another name.
430 void extend(const std::filesystem::path& path, core::layer_mode mode = core::layer_mode::override)
431 {
432 std::error_code ec;
433 if (std::filesystem::is_directory(path, ec))
434 {
435 apply_(read_directory_(path), mode, false);
436 return;
437 }
438 for (const auto t : all_tables_)
439 {
440 if (path.filename() == file_name_(t))
441 {
442 (void)extend(t, core::tsv_document::load(path), mode);
443 return;
444 }
445 }
446 throw std::invalid_argument("extend: expected religion.tsv, interests.tsv, affinity.tsv, "
447 "age_bands.tsv or regions.tsv, got " + path.filename().string());
448 }
449
450 /// @brief Add a data layer to one table.
451 ///
452 /// Rows are keyed by the table's key columns (see table). In the default
453 /// override mode a row replaces the loaded row with the same key and new
454 /// keys are added; a row with `disabled` set to 1 removes its key.
455 ///
456 /// @param which The table.
457 /// @param doc TSV with that table's columns.
458 /// @param mode How the layer combines with the data loaded before.
459 /// @return What the layer did (rows added, replaced, removed, warnings).
460 /// @throws core::tsv_error If a row is invalid; the data is unchanged.
461 const core::layer_info& extend(table which, core::tsv_document doc,
462 core::layer_mode mode = core::layer_mode::override)
463 {
464 documents docs;
465 docs.at(index_(which)).emplace(std::move(doc));
466 apply_(std::move(docs), mode, false);
467 return _tables.at(index_(which)).layers().back();
468 }
469
470 /// @brief Add a data layer to one table from TSV text (for example
471 /// from a WebAssembly host).
472 /// @param which The table.
473 /// @param text TSV with that table's columns.
474 /// @param source Name used in errors.
475 /// @param mode How the layer combines with the data loaded before.
476 /// @return What the layer did.
477 /// @throws core::tsv_error If a row is invalid; the data is unchanged.
478 const core::layer_info& extend_from_string(table which, std::string_view text, std::string source,
479 core::layer_mode mode = core::layer_mode::override)
480 {
481 return extend(which, core::tsv_document::parse(text, std::move(source)), mode);
482 }
483
484 /// @brief The layers in effect for one table, in order.
485 /// @param which The table.
486 /// @return The layer records.
487 [[nodiscard]] const std::vector<core::layer_info>& layers(table which) const
488 {
489 return _tables.at(index_(which)).layers();
490 }
491
492 /// @brief Hash of the loaded data (all tables), recorded in each
493 /// result's signature().
494 /// @return The hash; 0 when nothing is loaded.
495 [[nodiscard]] std::uint64_t data_hash() const
496 {
497 std::uint64_t h = 0;
498 bool any = false;
499 for (const auto& t : _tables)
500 {
501 any = any || !t.layers().empty();
502 h = core::hash_combine(h, t.data_hash());
503 }
504 return any ? h : 0;
505 }
506
507 private:
508 // -- Tables -------------------------------------------------------------
509
510 static constexpr std::string_view library_name_ = "interest-generator";
511 static constexpr std::size_t table_count_ = 5;
512 static constexpr std::array<table, table_count_> all_tables_{
514
515 using documents = std::array<std::optional<core::tsv_document>, table_count_>;
516 using tables = std::array<core::layered_table, table_count_>;
517
518 static constexpr std::size_t index_(table t) { return static_cast<std::size_t>(t); }
519
520 static constexpr std::string_view file_name_(table t)
521 {
522 switch (t)
523 {
524 case table::religion: return "religion.tsv";
525 case table::interests: return "interests.tsv";
526 case table::affinity: return "affinity.tsv";
527 case table::age_bands: return "age_bands.tsv";
528 case table::regions: return "regions.tsv";
529 }
530 return "";
531 }
532
533 static core::table_schema schema_(table t)
534 {
535 switch (t)
536 {
537 case table::religion:
538 return {.key = {"country_code", "religion"}, .required = {"group", "weight"}, .optional = {"year", "basis"}};
539 case table::interests:
540 return {.key = {"kind", "key"},
541 .required = {"label", "weight"},
542 .optional = {"class", "category", "tags", "min_age", "max_age"}};
543 case table::affinity:
544 return {.key = {"target", "dimension", "value"}, .required = {"factor"}, .optional = {"basis"}};
545 case table::age_bands: return {.key = {"band"}, .required = {"min_age", "max_age"}, .optional = {}};
546 case table::regions: return {.key = {"country_code"}, .required = {"region"}, .optional = {}};
547 }
548 return {};
549 }
550
551 static tables make_tables_()
552 {
553 return {core::layered_table{schema_(table::religion)}, core::layered_table{schema_(table::interests)},
554 core::layered_table{schema_(table::affinity)}, core::layered_table{schema_(table::age_bands)},
555 core::layered_table{schema_(table::regions)}};
556 }
557
558 // -- Parsed rows --------------------------------------------------------
559
560 static constexpr std::size_t kind_count_ = 5;
561 static constexpr std::array<std::string_view, kind_count_> kind_names_{"hobby", "film", "game", "book", "music"};
562 static constexpr std::array<std::string_view, kind_count_> kind_streams_{"hobby", "film", "game", "book", "music"};
563
564 enum class dimension : std::uint8_t { sex, age_band, region, country, religion };
565 static constexpr std::array<std::string_view, 5> dimension_names_{"sex", "age_band", "region", "country", "religion"};
566 static constexpr std::size_t dimension_count_ = dimension_names_.size();
567
568 // Marks a value no factor uses, and an item without a class.
569 static constexpr std::uint32_t none_ = std::numeric_limits<std::uint32_t>::max();
570
571 // A target's factors (an item key or "class:<cls>"): per dimension, the
572 // value ids it has a factor for. Rows are few per target, so a scan wins.
573 using target_factors = std::array<std::vector<std::pair<std::uint32_t, double>>, dimension_count_>;
574
575 struct item_data
576 {
577 std::string key;
578 std::string label;
579 std::string cls;
580 std::string category;
581 double weight{0.0};
582 int min_age{0};
583 int max_age{255};
584 };
585
586 struct band_data
587 {
588 std::string band;
589 int min_age{0};
590 int max_age{0};
591 };
592
593 struct country_data
594 {
595 std::vector<religion_share> shares; // in table row order
596 core::weighted_table<std::size_t> weights; // share index, by weight
597 };
598
599 struct model
600 {
601 std::map<std::string, country_data, std::less<>> countries;
602 std::vector<std::string> codes; // countries with a positive total, sorted
603 std::array<std::vector<item_data>, kind_count_> items;
604 // Affinity factors, resolved at load so a draw does no string work:
605 // each dimension's values get an id, each target its factors by id,
606 // and each item its key's and class's target (none_ when absent).
607 std::array<std::map<std::string, std::uint32_t, std::less<>>, dimension_count_> value_ids;
608 std::vector<target_factors> targets;
609 std::array<std::vector<std::array<std::uint32_t, 2>>, kind_count_> item_targets;
610 // Every item of a kind, by index; a draw passes each person's weights.
611 std::array<core::weighted_table<std::size_t>, kind_count_> item_tables;
612 std::vector<band_data> bands; // in table row order
613 std::map<std::string, std::string, std::less<>> region_of;
614 std::vector<std::string> unresolved;
615 };
616
617 static std::size_t kind_of_(const core::tsv_row& row)
618 {
619 const auto name = row["kind"];
620 for (std::size_t i = 0; i < kind_count_; ++i)
621 {
622 if (kind_names_[i] == name) return i;
623 }
624 throw row.error("kind", std::format("expected hobby, film, game, book or music, got '{}'", name));
625 }
626
627 static dimension dimension_of_(const core::tsv_row& row)
628 {
629 const auto name = row["dimension"];
630 for (std::size_t i = 0; i < dimension_names_.size(); ++i)
631 {
632 if (dimension_names_[i] == name) return static_cast<dimension>(i);
633 }
634 throw row.error("dimension", std::format("expected sex, age_band, region, country or religion, got '{}'", name));
635 }
636
637 // Each parse_ function checks a row and throws core::tsv_error located at
638 // it; the same function validates a layer and builds the model.
639 static std::pair<std::string, religion_share> parse_share_(const core::tsv_row& row)
640 {
641 std::string cca2 = row.get<std::string>("country_code");
642 if (!core::is_alpha2(cca2)) throw row.error("country_code", "expected two capital letters (ISO 3166-1 alpha-2)");
643 religion_share s;
644 s.religion = row.get<std::string>("religion");
645 s.group = row.get<std::string>("group");
646 s.weight = row.get<double>("weight");
647 if (!(s.weight >= 0.0)) throw row.error("weight", "must not be negative");
648 s.year = row.get_or<int>("year", 0);
649 return {std::move(cca2), std::move(s)};
650 }
651
652 static std::pair<std::size_t, item_data> parse_item_(const core::tsv_row& row)
653 {
654 const std::size_t kind = kind_of_(row);
655 item_data i;
656 i.key = row.get<std::string>("key");
657 i.label = row.get<std::string>("label");
658 i.weight = row.get<double>("weight");
659 if (!(i.weight >= 0.0)) throw row.error("weight", "must not be negative");
660 i.cls = row.get_or<std::string>("class", "");
661 i.category = row.get_or<std::string>("category", "");
662 i.min_age = row.get_or<int>("min_age", 0);
663 i.max_age = row.get_or<int>("max_age", 255);
664 if (i.min_age < 0) throw row.error("min_age", "must not be negative");
665 if (i.max_age < i.min_age) throw row.error("max_age", "must not be below min_age");
666 return {kind, std::move(i)};
667 }
668
669 struct affinity_data
670 {
671 std::string target;
672 dimension dim{dimension::sex};
673 std::string value;
674 double factor{1.0};
675 };
676
677 static affinity_data parse_affinity_(const core::tsv_row& row)
678 {
679 affinity_data a;
680 a.target = row.get<std::string>("target");
681 a.dim = dimension_of_(row);
682 a.value = row.get<std::string>("value");
683 if (a.dim == dimension::sex && !core::parse_sex(a.value))
684 {
685 throw row.error("value", std::format("expected male or female, got '{}'", a.value));
686 }
687 a.factor = row.get<double>("factor");
688 if (!(a.factor >= 0.0)) throw row.error("factor", "must not be negative");
689 return a;
690 }
691
692 static band_data parse_band_(const core::tsv_row& row)
693 {
694 band_data b;
695 b.band = row.get<std::string>("band");
696 b.min_age = row.get<int>("min_age");
697 b.max_age = row.get<int>("max_age");
698 if (b.min_age < 0) throw row.error("min_age", "must not be negative");
699 if (b.max_age < b.min_age) throw row.error("max_age", "must not be below min_age");
700 return b;
701 }
702
703 static std::pair<std::string, std::string> parse_region_(const core::tsv_row& row)
704 {
705 return {row.get<std::string>("country_code"), row.get<std::string>("region")};
706 }
707
708 static void validate_(table t, const core::tsv_row& row)
709 {
710 switch (t)
711 {
712 case table::religion: (void)parse_share_(row); break;
713 case table::interests: (void)parse_item_(row); break;
714 case table::affinity: (void)parse_affinity_(row); break;
715 case table::age_bands: (void)parse_band_(row); break;
716 case table::regions: (void)parse_region_(row); break;
717 }
718 }
719
720 // -- State ----------------------------------------------------------------
721
722 tables _tables;
723 model _model;
724 core::rng _engine{core::rng::from_entropy()};
725
726 struct auto_probe_tag {};
727
728 explicit intg(auto_probe_tag /*tag*/) : _tables{make_tables_()}
729 {
730 if (!load(dataset::lite)) (void)load(dataset::full);
731 }
732
733 // -- Helpers ----------------------------------------------------------
734
735 static std::span<const core::embedded_file> embedded_files_()
736 {
737#ifdef DASMIG_INTEREST_EMBED
738 return embedded::interestgen::files;
739#else
740 return {};
741#endif
742 }
743
744 std::uint64_t draw_seed_() { return _engine(); }
745
746 static documents read_directory_(const std::filesystem::path& dir)
747 {
748 documents docs;
749 for (const auto t : all_tables_)
750 {
751 const auto file = dir / file_name_(t);
752 std::error_code ec;
753 if (std::filesystem::is_regular_file(file, ec))
754 {
755 docs.at(index_(t)) = core::tsv_document::load(file);
756 }
757 }
758 return docs;
759 }
760
761 // Apply layers to copies of the tables, then commit: all or nothing.
762 // With @p reset (load), tables without a document are emptied.
763 void apply_(documents docs, core::layer_mode mode, bool reset)
764 {
765 tables next = _tables;
766 for (const auto t : all_tables_)
767 {
768 auto& doc = docs.at(index_(t));
769 auto& tbl = next.at(index_(t));
770 if (!doc)
771 {
772 if (reset) tbl.clear();
773 continue;
774 }
775 (void)tbl.extend(std::move(*doc), mode, [t](const core::tsv_row& row) { validate_(t, row); });
776 }
777 auto built = build_model_(next);
778 _tables = std::move(next);
779 _model = std::move(built);
780 }
781
782 static model build_model_(const tables& tbls)
783 {
784 model m;
785 for (const auto& row : tbls.at(index_(table::religion)).rows())
786 {
787 auto [cca2, share] = parse_share_(row);
788 auto& c = m.countries[cca2];
789 c.weights.add(c.shares.size(), share.weight);
790 c.shares.push_back(std::move(share));
791 }
792 for (const auto& [cca2, c] : m.countries)
793 {
794 if (c.weights.total_weight() > 0.0) m.codes.push_back(cca2);
795 }
796
797 std::map<std::string, bool, std::less<>> known; // item keys and classes
798 for (const auto& row : tbls.at(index_(table::interests)).rows())
799 {
800 auto [kind, item] = parse_item_(row);
801 known[item.key] = true;
802 if (!item.cls.empty()) known["class:" + item.cls] = true;
803 m.item_tables.at(kind).add(m.items.at(kind).size(), item.weight);
804 m.items.at(kind).push_back(std::move(item));
805 }
806 // A later row for the same target, dimension and value replaces an
807 // earlier one.
808 std::map<std::string, std::array<std::map<std::string, double, std::less<>>, dimension_count_>, std::less<>>
809 affinity;
810 for (const auto& row : tbls.at(index_(table::affinity)).rows())
811 {
812 const auto a = parse_affinity_(row);
813 if (!known.contains(a.target))
814 {
815 m.unresolved.push_back(std::format("{}:{}: unknown target '{}'", row.source(), row.line(), a.target));
816 continue;
817 }
818 affinity[a.target].at(static_cast<std::size_t>(a.dim))[a.value] = a.factor;
819 }
820 std::map<std::string_view, std::uint32_t, std::less<>> target_ids;
821 for (const auto& [target, dims] : affinity)
822 {
823 target_ids.emplace(target, static_cast<std::uint32_t>(m.targets.size()));
824 auto& factors = m.targets.emplace_back();
825 for (std::size_t d = 0; d < dimension_count_; ++d)
826 {
827 auto& ids = m.value_ids.at(d);
828 for (const auto& [value, factor] : dims.at(d))
829 {
830 const auto id = ids.try_emplace(value, static_cast<std::uint32_t>(ids.size())).first->second;
831 factors.at(d).emplace_back(id, factor);
832 }
833 }
834 }
835 const auto target_id = [&](std::string_view target) {
836 const auto it = target_ids.find(target);
837 return it == target_ids.end() ? none_ : it->second;
838 };
839 for (std::size_t k = 0; k < kind_count_; ++k)
840 {
841 for (const auto& item : m.items.at(k))
842 {
843 m.item_targets.at(k).push_back(
844 {target_id(item.key), item.cls.empty() ? none_ : target_id("class:" + item.cls)});
845 }
846 }
847 for (const auto& row : tbls.at(index_(table::age_bands)).rows()) m.bands.push_back(parse_band_(row));
848 for (const auto& row : tbls.at(index_(table::regions)).rows())
849 {
850 auto [cca2, region] = parse_region_(row);
851 m.region_of[cca2] = std::move(region);
852 }
853 return m;
854 }
855
856 void require_data_() const
857 {
858 if (!has_data())
859 {
860 throw std::runtime_error("No interest data loaded. Call load() first.");
861 }
862 }
863
864 // The person an item weight is computed for.
865 struct person
866 {
867 std::string cca2;
868 std::string region; // empty when unknown
869 std::string sex; // "male", "female" or empty
870 std::string band; // empty when unknown
871 std::string religion; // group of the drawn religion
872 std::optional<int> age;
873 std::array<std::uint32_t, dimension_count_> ids{}; // value ids, from resolve_ids_()
874 };
875
876 // The person's value id in each dimension: none_ for an empty value
877 // (no factor applies) or a value no factor uses. The country is looked
878 // up even when empty, as a country row always was.
879 void resolve_ids_(person& p) const
880 {
881 const auto id = [this](dimension d, std::string_view value, bool empty_matches) {
882 if (value.empty() && !empty_matches) return none_;
883 const auto& ids = _model.value_ids.at(static_cast<std::size_t>(d));
884 const auto it = ids.find(value);
885 return it == ids.end() ? none_ : it->second;
886 };
887 p.ids = {id(dimension::sex, p.sex, false), id(dimension::age_band, p.band, false),
888 id(dimension::region, p.region, false), id(dimension::country, p.cca2, true),
889 id(dimension::religion, p.religion, false)};
890 }
891
892 [[nodiscard]] static const double* find_factor_(const target_factors& t, dimension d, const person& p)
893 {
894 const auto i = static_cast<std::size_t>(d);
895 const auto id = p.ids.at(i);
896 if (id == none_) return nullptr;
897 for (const auto& [value, factor] : t.at(i))
898 {
899 if (value == id) return &factor;
900 }
901 return nullptr;
902 }
903
904 // The weight times the key's and then the class's factors for sex, age
905 // band, religion and place (a country row replaces the region row of the
906 // same target). A missing factor is 1, so it is skipped: multiplying by
907 // 1.0 is exact, and the order of the others is kept, so the weight is
908 // the same bits as when every factor was looked up by name.
909 [[nodiscard]] double item_weight_(std::size_t kind, std::size_t index, const person& p) const
910 {
911 double w = _model.items.at(kind)[index].weight;
912 for (const auto t : _model.item_targets.at(kind)[index])
913 {
914 if (t == none_) continue;
915 const auto& factors = _model.targets[t];
916 for (const auto d : {dimension::sex, dimension::age_band, dimension::religion})
917 {
918 if (const auto* f = find_factor_(factors, d, p)) w *= *f;
919 }
920 if (const auto* c = find_factor_(factors, dimension::country, p)) w *= *c;
921 else if (const auto* r = find_factor_(factors, dimension::region, p)) w *= *r;
922 }
923 return w;
924 }
925
926 // Draw up to n items of one kind without replacement. The table of every
927 // item was built at load; this person's weight replaces each base weight,
928 // and an item outside its ages or already drawn weighs 0. A 0 adds
929 // nothing to the cumulative sums and is never picked, so the draws are
930 // those of a table of the eligible items alone. @p weights is scratch.
931 void draw_items_(std::size_t kind, std::size_t n, const person& p, core::rng& r,
932 std::vector<double>& weights, std::vector<interest_item>& out) const
933 {
934 if (n == 0) return;
935 const auto& items = _model.items.at(kind);
936 weights.assign(items.size(), 0.0);
937 for (std::size_t i = 0; i < items.size(); ++i)
938 {
939 const auto& item = items[i];
940 if (p.age && (*p.age < item.min_age || *p.age > item.max_age)) continue;
941 weights[i] = item_weight_(kind, i, p);
942 }
943 const auto& table = _model.item_tables.at(kind);
944 const auto adjust = [&weights](const std::size_t& i, double /*base*/) { return weights[i]; };
945 for (std::size_t k = 0; k < n; ++k)
946 {
947 const auto picked = table.try_pick_index(r, adjust);
948 if (!picked) break;
949 const std::size_t i = table.entries()[*picked].value;
950 weights[i] = 0.0;
951 out.push_back({.key = items[i].key, .label = items[i].label, .category = items[i].category});
952 }
953 }
954
955 [[nodiscard]] interest generate_(const interest_request& req, std::uint64_t call_seed) const
956 {
957 require_data_();
958 const core::rng call{call_seed};
959
960 std::string cca2{req.country};
961 if (cca2.empty())
962 {
963 if (_model.codes.empty())
964 {
965 throw std::invalid_argument("No country has a religion share above 0.");
966 }
967 auto cr = call.split("country");
968 cca2 = _model.codes[static_cast<std::size_t>(core::bounded(cr, _model.codes.size()))];
969 }
970 const auto it = _model.countries.find(cca2);
971 if (it == _model.countries.end())
972 {
973 throw core::unknown_country_error(cca2);
974 }
975 const auto& c = it->second;
976 if (!(c.weights.total_weight() > 0.0))
977 {
978 throw std::invalid_argument(std::format("Every religion share of {} has weight 0.", cca2));
979 }
980
981 auto rr = call.split("religion");
982 const auto& share = c.shares[c.weights.pick(rr)];
983
984 interest v;
985 v._signature = {.seed = call_seed, .data = data_hash()};
986 v.country_code = cca2;
987 v.religion = share.religion;
988 v.religion_group = share.group;
989
990 person p;
991 p.cca2 = cca2;
992 p.religion = share.group;
993 p.age = req.age ? std::optional<int>{*req.age} : std::nullopt;
994 if (const auto rg = _model.region_of.find(cca2); rg != _model.region_of.end()) p.region = rg->second;
995 if (req.person_sex) p.sex = std::string{core::sex_name(*req.person_sex)};
996 if (p.age && !_model.bands.empty())
997 {
998 const auto b = std::ranges::find_if(_model.bands, [&](const band_data& x) {
999 return *p.age >= x.min_age && *p.age <= x.max_age;
1000 });
1001 if (b != _model.bands.end()) p.band = b->band;
1002 // Younger than every band: too young for hobbies and media.
1003 else if (std::ranges::all_of(_model.bands, [&](const band_data& x) { return *p.age < x.min_age; }))
1004 {
1005 return v;
1006 }
1007 }
1008
1009 resolve_ids_(p);
1010 std::array<std::vector<interest_item>*, kind_count_> out{&v.hobbies, &v.films, &v.games, &v.books, &v.music};
1011 std::vector<double> weights;
1012 for (std::size_t k = 0; k < kind_count_; ++k)
1013 {
1014 auto r = call.split(kind_streams_[k]);
1015 draw_items_(k, k == 0 ? req.hobbies : req.media, p, r, weights, *out.at(k));
1016 }
1017 return v;
1018 }
1019};
1020
1021} // namespace dasmig
1022
1023#endif // DASMIG_INTERESTGEN_HPP
Return type for interest generation.
std::vector< interest_item > hobbies
Hobbies, in the order they were drawn.
std::uint64_t seed() const
Retrieve the random seed used to generate this interest.
void to_json(core::json_writer &w) const
Write this interest as a JSON object.
std::vector< interest_item > films
Favourite film genres.
std::vector< interest_item > games
Favourite game genres.
std::string religion
Religion label (the religion column).
std::string religion_group
Major group of the religion (the group column).
std::vector< interest_item > books
Favourite book genres.
std::string country_code
ISO 3166-1 alpha-2 code of the country drawn from.
const core::seed_signature & signature() const
The seed and the data this interest was generated from.
std::string to_json() const
This interest as a JSON document.
std::vector< interest_item > music
Favourite music genres.
std::string to_string() const
Human-readable summary string.
friend std::ostream & operator<<(std::ostream &os, const interest &v)
Stream the summary to an output stream.
Interest Generator: draws a person's religion, hobbies and favourite film, game, book and music genre...
std::size_t item_count() const
Return the number of hobbies and genres loaded, all kinds.
void extend(const std::filesystem::path &path, core::layer_mode mode=core::layer_mode::override)
Add data layers from a directory or a file.
intg(const intg &)=delete
Not copyable.
const std::vector< std::string > & unresolved() const
Affinity rows whose target is neither an item key nor class: and an activity class that is loaded....
intg()
Default constructor — creates an empty generator with no data.
void for_each_interest(const interest_request &request, const core::batch_options &options, Sink &&sink)
Stream a batch of interest results with a batch seed drawn like get_interest() draws its own.
bool load(dataset tier)
Load a dataset tier, replacing all data and layers loaded before.
interest get_interest(const interest_request &request)
Generate a random interest for a request.
std::size_t country_count() const
Return the number of countries with religion data.
table
The data tables.
@ regions
regions.tsv: key country_code; region.
@ age_bands
age_bands.tsv: key band; min_age, max_age.
@ affinity
affinity.tsv: key target (an item key, or class: and a class), dimension and value; factor,...
@ interests
interests.tsv: key kind and key; label, weight, class, category, tags, min_age, max_age.
@ religion
religion.tsv: key country_code and religion; group, weight, year, basis.
std::uint64_t data_hash() const
Hash of the loaded data (all tables), recorded in each result's signature().
intg(intg &&) noexcept=default
Movable.
bool has_data() const
Check whether religion data has been loaded.
interest get_interest(const interest_request &request, std::uint64_t call_seed) const
Generate a deterministic interest for a request.
void for_each_interest(const interest_request &request, const core::batch_options &options, std::uint64_t batch_seed, Sink &&sink) const
Stream a batch of interest results to a sink without holding the whole batch.
std::vector< interest > get_interest_batch(const interest_request &request, const core::batch_options &options, std::uint64_t batch_seed) const
Generate a batch of interest results, deterministically.
static intg & instance()
Access the global singleton instance.
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.
std::vector< interest > get_interest_batch(const interest_request &request, const core::batch_options &options)
Generate a batch of interest results from a seed drawn like get_interest() draws its own.
const std::vector< core::layer_info > & layers(table which) const
The layers in effect for one table, in order.
intg & operator=(const intg &)=delete
Not copyable.
void load(const std::filesystem::path &dir)
Load a resource directory, replacing all data and layers loaded before.
std::span< const religion_share > shares(std::string_view cca2) const
The religion shares loaded for a country.
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 (for example from a WebAssembly host).
One hobby or favourite genre in a result.
std::string label
Display label.
std::string key
Catalogue key (the key column of interests.tsv).
std::string category
Grouping such as "sport" or "film"; may be empty.
Per-call generation choices. Every field has a default.
std::size_t media
How many genres to draw in each of film, games, books and music.
std::string_view country
ISO 3166-1 alpha-2 code; empty draws a country (uniform over those loaded).
std::optional< sex > person_sex
Sex; unknown applies no sex factor.
std::size_t hobbies
How many hobbies to draw.
std::optional< std::uint8_t > age
Age in completed years; unknown applies no age factor or age limit.
One religion's share of a country, as loaded.
double weight
Share of the population in percent; only the ratios matter.
std::string religion
Label, for example "Roman Catholic" (full) or "Christian" (lite).
int year
Survey year, or 0 when unknown.
std::string group
Major group: christian, muslim, hindu, buddhist, jewish, unaffiliated, other, or a group a layer adds...