Trait Generator 2.1.2
Personality traits from a weighted catalogue with conflicts, synergies and Big Five axes for C++23
Loading...
Searching...
No Matches
traitgen.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Diego Dasso Migotto
3
4#ifndef DASMIG_TRAITGEN_HPP
5#define DASMIG_TRAITGEN_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 <functional>
15#include <iterator>
16#include <map>
17#include <optional>
18#include <ostream>
19#include <span>
20#include <stdexcept>
21#include <string>
22#include <string_view>
23#include <system_error>
24#include <utility>
25#include <vector>
26
27#ifdef DASMIG_TRAIT_EMBED
28#include "traitgen_data.hpp"
29#endif
30
31/// @file traitgen.hpp
32/// @brief Trait generator library — personality traits from a weighted
33/// catalogue with conflicts, synergies and Big Five axes, for C++23.
34/// @author Diego Dasso Migotto (diegomigotto at hotmail dot com)
35/// @see See doc/usage.md for the narrative tutorial.
36
37DASMIG_CORE_REQUIRE(1, 1);
38
39namespace dasmig
40{
41
42// Members have {} initializers so designated initializers may leave them
43// out without -Wmissing-designated-field-initializers warnings.
44// NOLINTBEGIN(readability-redundant-member-init)
45
46/// @brief What to generate: how many traits, and traits to include or
47/// leave out.
49{
50 /// Number of traits, included ones counted. Fewer are returned when no
51 /// other trait is compatible with those already chosen.
52 std::size_t count{3};
53 /// Traits that must be present, in this order, before any is drawn.
54 /// They must exist and must not conflict with each other.
55 std::vector<std::string> include{};
56 /// Traits that must not be drawn. Unknown names are ignored.
57 std::vector<std::string> exclude{};
58};
59
60// NOLINTEND(readability-redundant-member-init)
61
62/// @brief Return type for personality generation: traits and the axis
63/// values they resolve to.
64///
65/// Supports implicit conversion to std::string (the trait labels),
66/// streaming via operator<< and JSON output via to_json().
68{
69 public:
70 /// @brief One trait of a personality.
71 struct trait
72 {
73 std::string key; ///< Catalogue key (`trait` column).
74 std::string label; ///< Display label.
75 std::string group; ///< Exclusive group; empty for none.
76 std::vector<std::string> tags; ///< Tags, in catalogue order.
77 };
78
79 /// @brief One axis of a personality.
80 struct axis
81 {
82 std::string key; ///< Axis key (`axis` column).
83 std::string label; ///< Display label.
84 double base{0.0}; ///< Value drawn before traits.
85 double value{0.0}; ///< Base plus the loadings of the traits.
86 };
87
88 std::vector<trait> traits; ///< Included traits first, then drawn ones.
89 std::vector<axis> axes; ///< Every axis, in catalogue order.
90
91 /// @brief Whether the personality has a trait.
92 /// @param key Trait key.
93 /// @return True when present.
94 [[nodiscard]] bool has(std::string_view key) const
95 {
96 return std::ranges::any_of(traits, [key](const trait& t) { return t.key == key; });
97 }
98
99 /// @brief The resolved value of an axis.
100 /// @param key Axis key.
101 /// @return The value, or empty for an unknown axis.
102 [[nodiscard]] std::optional<double> value(std::string_view key) const
103 {
104 const auto it = std::ranges::find(axes, key, &axis::key);
105 if (it == axes.end()) return std::nullopt;
106 return it->value;
107 }
108
109 /// @brief The tags of all traits, each once, in order of appearance.
110 /// @return The tags.
111 [[nodiscard]] std::vector<std::string> tags() const
112 {
113 std::vector<std::string> out;
114 for (const auto& t : traits)
115 {
116 for (const auto& tag : t.tags)
117 {
118 if (std::ranges::find(out, tag) == out.end()) out.push_back(tag);
119 }
120 }
121 return out;
122 }
123
124 /// @brief Retrieve the random seed used to generate this personality.
125 /// @return The call seed; pass it to get_personality(request, seed)
126 /// with the same request and data to replay this personality.
127 [[nodiscard]] std::uint64_t seed() const { return _signature.seed; }
128
129 /// @brief The seed and the data this personality was generated from.
130 /// @return The replay signature; `data` is trtg::data_hash() at the
131 /// time of generation.
132 [[nodiscard]] const core::seed_signature& signature() const
133 {
134 return _signature;
135 }
136
137 /// @brief Human-readable summary string.
138 /// @return The trait labels, comma separated.
139 [[nodiscard]] std::string to_string() const
140 {
141 std::string s;
142 for (const auto& t : traits)
143 {
144 if (!s.empty()) s += ", ";
145 s += t.label;
146 }
147 return s;
148 }
149
150 /// @brief Write this personality as a JSON object.
151 /// @param w Writer positioned where a value is allowed.
152 void to_json(core::json_writer& w) const
153 {
154 w.begin_object().key("traits").begin_array();
155 for (const auto& t : traits)
156 {
157 w.begin_object().field("key", t.key).field("label", t.label).key("group");
158 if (t.group.empty()) w.value(nullptr);
159 else w.value(t.group);
160 w.key("tags").begin_array();
161 for (const auto& tag : t.tags) w.value(tag);
162 w.end_array().end_object();
163 }
164 w.end_array().key("axes").begin_object();
165 for (const auto& a : axes) w.field(a.key, a.value);
166 w.end_object().key("signature");
167 _signature.to_json(w);
168 w.end_object();
169 }
170
171 /// @brief This personality as a JSON document.
172 /// @return Compact JSON, for example
173 /// `{"traits":[{"key":"brave","label":"Brave","group":"courage",
174 /// "tags":["combat","risk"]},...],"axes":{"openness":0.42,...},
175 /// "signature":{...}}`.
176 [[nodiscard]] std::string to_json() const
177 {
178 core::json_writer w;
179 to_json(w);
180 return w.str();
181 }
182
183 /// @brief Implicit conversion to std::string.
184 /// @return Summary string.
185 operator std::string() const // NOLINT(hicpp-explicit-conversions)
186 {
187 return to_string();
188 }
189
190 /// @brief Stream the summary to an output stream.
191 /// @param os Output stream.
192 /// @param p Personality to stream.
193 /// @return The stream.
194 friend std::ostream& operator<<(std::ostream& os, const personality& p)
195 {
196 os << p.to_string();
197 return os;
198 }
199
200 private:
201 core::seed_signature _signature;
202 friend class trtg;
203};
204
205/// @brief Personality generator: draws traits from a weighted catalogue,
206/// shaped by Big Five axes, conflicts and synergies.
207///
208/// The generation pipeline, each step drawing from its own stream split
209/// from the call seed:
210/// 1. Each axis draws a base value from normal(mean, sd) (stream
211/// `"axis:<key>"`; an axis with sd 0 takes its mean).
212/// 2. Each trait's weight is multiplied by its affinity,
213/// `exp(sum of loading * standardised base value)`, so a person high in
214/// an axis is likelier to have the traits that load on it.
215/// 3. Included traits are taken; then traits are drawn one at a time
216/// (stream `"traits"`), skipping traits already chosen, excluded,
217/// conflicting with a chosen trait or in a chosen trait's group, and
218/// multiplying weights by the synergy factors of the chosen traits.
219/// 4. Each axis value is its base plus the loadings of the chosen traits.
220///
221/// The catalogue is four tables (see table), extended by layers. Output is
222/// identical on every compiler and platform (dasmig-core).
223///
224/// Can be used as a singleton via instance() or constructed
225/// independently.
226///
227/// @par Thread safety
228/// Each instance is independent. Concurrent calls to get_personality()
229/// on the **same** instance require external synchronisation.
230class trtg
231{
232 public:
233 /// @brief The data tables.
234 enum class table : std::uint8_t
235 {
236 traits, ///< `traits.tsv`: key `trait`; label, weight, group, tags.
237 axes, ///< `axes.tsv`: key `axis`; label, mean, sd.
238 loadings, ///< `loadings.tsv`: key `trait` and `axis`; loading.
239 relations ///< `relations.tsv`: key `trait` and `other`; relation, factor.
240 };
241
242 /// @brief Default constructor — creates an empty generator with no
243 /// data.
244 trtg() = default;
245
246 trtg(const trtg&) = delete; ///< Not copyable.
247 trtg& operator=(const trtg&) = delete; ///< Not copyable.
248 trtg(trtg&&) noexcept = default; ///< Movable.
249 trtg& operator=(trtg&&) noexcept = default; ///< Movable.
250 ~trtg() = default; ///< Destructor.
251
252 /// @brief Access the global singleton instance.
253 ///
254 /// Loads the `lite` tier (or `full` if `lite` is missing) on first
255 /// access, from compiled-in data or an auto-probed `resources/`.
256 /// @return The singleton.
257 static trtg& instance()
258 {
259 static trtg inst{auto_probe_tag{}};
260 return inst;
261 }
262
263 // -- Generation -------------------------------------------------------
264
265 /// @brief Generate a random personality as requested.
266 /// Use trait_request{} for three traits without constraints.
267 /// @param request Count, and traits to include or leave out.
268 /// @return The personality.
269 /// @throws std::runtime_error If no data has been loaded.
270 /// @throws std::invalid_argument If an included trait is unknown,
271 /// repeated, excluded or in conflict with another included
272 /// trait, or more traits are included than requested.
273 [[nodiscard]] personality get_personality(const trait_request& request)
274 {
275 return generate_(request, draw_seed_());
276 }
277
278 /// @brief Generate a deterministic personality as requested.
279 /// @param request Count, and traits to include or leave out.
280 /// @param call_seed Seed; the same seed, request and data give the same
281 /// result.
282 /// @return The personality.
283 /// @throws std::runtime_error If no data has been loaded.
284 /// @throws std::invalid_argument As get_personality(const trait_request&).
285 [[nodiscard]] personality get_personality(const trait_request& request,
286 std::uint64_t call_seed) const
287 {
288 return generate_(request, call_seed);
289 }
290
291 // -- Batch generation -------------------------------------------------
292
293 /// @brief Generate a batch of personality results from a seed drawn like
294 /// get_personality() draws its own.
295 ///
296 /// Item `i` is `get_personality(request, core::item_seed(batch_seed, options.first + i))`
297 /// for the drawn batch seed; see the seeded overload.
298 ///
299 /// @param request What every item is generated from.
300 /// @param options Count, first index, threads and uniqueness.
301 /// @return The results, in index order.
302 [[nodiscard]] std::vector<personality> get_personality_batch(const trait_request& request,
303 const core::batch_options& options)
304 {
305 return get_personality_batch(request, options, draw_seed_());
306 }
307
308 /// @brief Generate a batch of personality results, deterministically.
309 ///
310 /// Item `i` is exactly `get_personality(request, core::item_seed(batch_seed,
311 /// options.first + i))`, so an item replays on its own, a range can be
312 /// generated directly, two batches that follow each other equal one, and
313 /// the output does not depend on `options.threads`.
314 ///
315 /// @param request What every item is generated from.
316 /// @param options Count, first index, threads and uniqueness (see
317 /// core::unique_rule; the rule must be made for `personality`).
318 /// @param batch_seed Seed of the whole batch.
319 /// @return The results, in index order.
320 /// @throws std::invalid_argument If the request or options are invalid.
321 /// @throws std::runtime_error If no data has been loaded, or a unique
322 /// batch runs out of attempts.
323 [[nodiscard]] std::vector<personality> get_personality_batch(const trait_request& request,
324 const core::batch_options& options,
325 std::uint64_t batch_seed) const
326 {
327 return core::run_batch(options, batch_seed, [&](std::uint64_t item_seed)
328 { return get_personality(request, item_seed); });
329 }
330
331 /// @brief Stream a batch of personality results to a sink without holding the
332 /// whole batch.
333 ///
334 /// Generates the same items as get_personality_batch() and calls
335 /// `sink(index, item)` in index order on the calling thread; `index`
336 /// includes `options.first`.
337 ///
338 /// @tparam Sink Callable taking `(std::uint64_t, personality&)`; for example
339 /// core::jsonl_writer or core::csv_writer.
340 /// @param request What every item is generated from.
341 /// @param options Count, first index, threads and uniqueness.
342 /// @param batch_seed Seed of the whole batch.
343 /// @param sink Receives the items.
344 template <typename Sink>
345 void for_each_personality(const trait_request& request, const core::batch_options& options,
346 std::uint64_t batch_seed, Sink&& sink) const
347 {
348 core::for_each_batch(options, batch_seed,
349 [&](std::uint64_t item_seed) { return get_personality(request, item_seed); },
350 std::forward<Sink>(sink));
351 }
352
353 /// @brief Stream a batch of personality results with a batch seed drawn like
354 /// get_personality() draws its own.
355 /// @tparam Sink Callable taking `(std::uint64_t, personality&)`.
356 /// @param request What every item is generated from.
357 /// @param options Count, first index, threads and uniqueness.
358 /// @param sink Receives the items.
359 template <typename Sink>
360 void for_each_personality(const trait_request& request, const core::batch_options& options,
361 Sink&& sink)
362 {
363 for_each_personality(request, options, draw_seed_(), std::forward<Sink>(sink));
364 }
365
366
367 // -- Catalogue --------------------------------------------------------
368
369 /// @brief Check whether any data has been loaded.
370 /// @return True when at least one trait is loaded.
371 [[nodiscard]] bool has_data() const { return !_model.traits.empty(); }
372
373 /// @brief Return the number of loaded traits.
374 /// @return The count.
375 [[nodiscard]] std::size_t trait_count() const { return _model.traits.size(); }
376
377 /// @brief Return the number of loaded axes.
378 /// @return The count.
379 [[nodiscard]] std::size_t axis_count() const { return _model.axes.size(); }
380
381 /// @brief Whether a trait is loaded.
382 /// @param key Trait key.
383 /// @return True when loaded.
384 [[nodiscard]] bool has_trait(std::string_view key) const
385 {
386 return _model.trait_index.contains(key);
387 }
388
389 /// @brief Loadings and relations that name a trait or axis that is not
390 /// loaded. They are ignored, so removing a trait needs no other
391 /// change; this lists them to catch misspelt names.
392 /// @return One description per ignored row, such as
393 /// `loadings.tsv:12: unknown axis 'opennes'`.
394 [[nodiscard]] const std::vector<std::string>& unresolved() const
395 {
396 return _model.unresolved;
397 }
398
399 // -- Data management --------------------------------------------------
400
401 /// @brief Load a resource directory, replacing all data and layers
402 /// loaded before.
403 ///
404 /// The directory must contain `traits.tsv`; `axes.tsv`, `loadings.tsv`
405 /// and `relations.tsv` are read when present (a missing one leaves its
406 /// table empty). A path without `traits.tsv` is ignored.
407 ///
408 /// @param dir Directory such as `resources/lite`.
409 /// @throws core::tsv_error If a file is malformed; the data is then
410 /// unchanged.
411 void load(const std::filesystem::path& dir)
412 {
413 std::error_code ec;
414 if (!std::filesystem::is_directory(dir, ec) ||
415 !std::filesystem::is_regular_file(dir / file_name_(table::traits), ec))
416 {
417 return;
418 }
419 apply_(read_directory_(dir), core::layer_mode::replace, true);
420 }
421
422 /// @brief Load a dataset tier, replacing all data and layers loaded
423 /// before.
424 ///
425 /// Uses the compiled-in copy when the library is built with
426 /// `DASMIG_TRAIT_EMBED`, otherwise searches `resources/`,
427 /// `../resources`, `trait-generator/resources` and
428 /// `../trait-generator/resources`.
429 ///
430 /// @param tier The tier.
431 /// @return False when the tier was not found.
432 /// @throws core::tsv_error If a file is malformed.
433 [[nodiscard]] bool load(dataset tier)
434 {
435 const std::string tier_dir{core::tier_name(tier)};
436 const auto marker = tier_dir + "/" + std::string{file_name_(table::traits)};
437 const auto root = core::find_resources(library_name_, marker);
438 documents docs;
439 for (const auto t : all_tables_)
440 {
441 docs.at(index_(t)) = core::load_resource(tier_dir + "/" + std::string{file_name_(t)},
442 embedded_files_(), root);
443 }
444 if (!docs.at(index_(table::traits))) return false;
445 apply_(std::move(docs), core::layer_mode::replace, true);
446 return true;
447 }
448
449 /// @brief Add data layers from a directory or a file.
450 ///
451 /// A directory may hold any of `traits.tsv`, `axes.tsv`,
452 /// `loadings.tsv` and `relations.tsv`; all are applied together or, if
453 /// any fails, none. A file must have one of those names.
454 ///
455 /// @param path Directory or file.
456 /// @param mode How the layers combine with the data loaded before.
457 /// @throws core::tsv_error If a file cannot be read or a row is
458 /// invalid; the data is then unchanged.
459 /// @throws std::invalid_argument If @p path is a file with another name.
460 void extend(const std::filesystem::path& path, core::layer_mode mode = core::layer_mode::override)
461 {
462 std::error_code ec;
463 if (std::filesystem::is_directory(path, ec))
464 {
465 apply_(read_directory_(path), mode, false);
466 return;
467 }
468 for (const auto t : all_tables_)
469 {
470 if (path.filename() == file_name_(t))
471 {
472 (void)extend(t, core::tsv_document::load(path), mode);
473 return;
474 }
475 }
476 throw std::invalid_argument("extend: expected traits.tsv, axes.tsv, loadings.tsv or "
477 "relations.tsv, got " + path.filename().string());
478 }
479
480 /// @brief Add a data layer to one table.
481 /// @param which The table.
482 /// @param doc TSV with that table's columns.
483 /// @param mode How the layer combines with the data loaded before.
484 /// @return What the layer did.
485 /// @throws core::tsv_error If a row is invalid; the data is unchanged.
486 const core::layer_info& extend(table which, core::tsv_document doc,
487 core::layer_mode mode = core::layer_mode::override)
488 {
489 documents docs;
490 docs.at(index_(which)).emplace(std::move(doc));
491 apply_(std::move(docs), mode, false);
492 return _tables.at(index_(which)).layers().back();
493 }
494
495 /// @brief Add a data layer to one table from TSV text (for example
496 /// from a WebAssembly host).
497 /// @param which The table.
498 /// @param text TSV with that table's columns.
499 /// @param source Name used in errors.
500 /// @param mode How the layer combines with the data loaded before.
501 /// @return What the layer did.
502 /// @throws core::tsv_error If a row is invalid; the data is unchanged.
503 const core::layer_info& extend_from_string(table which, std::string_view text, std::string source,
504 core::layer_mode mode = core::layer_mode::override)
505 {
506 return extend(which, core::tsv_document::parse(text, std::move(source)), mode);
507 }
508
509 /// @brief The layers in effect for one table, in order.
510 /// @param which The table.
511 /// @return The layer records.
512 [[nodiscard]] const std::vector<core::layer_info>& layers(table which) const
513 {
514 return _tables.at(index_(which)).layers();
515 }
516
517 /// @brief Hash of the loaded data (all four tables), recorded in each
518 /// result's signature().
519 /// @return The hash; 0 when nothing is loaded.
520 [[nodiscard]] std::uint64_t data_hash() const
521 {
522 std::uint64_t h = 0;
523 bool any = false;
524 for (const auto& t : _tables)
525 {
526 any = any || !t.layers().empty();
527 h = core::hash_combine(h, t.data_hash());
528 }
529 return any ? h : 0;
530 }
531
532 private:
533 // -- Internal data structures -----------------------------------------
534
535 static constexpr std::string_view library_name_ = "trait-generator";
536 static constexpr std::size_t table_count_ = 4;
537 static constexpr std::array<table, table_count_> all_tables_{
539 // Affinity exponents are clamped to this, so a large loading cannot
540 // overflow a weight.
541 static constexpr double max_exponent_ = 30.0;
542 static constexpr double default_synergy_ = 2.0;
543
544 using documents = std::array<std::optional<core::tsv_document>, table_count_>;
545 using tables = std::array<core::layered_table, table_count_>;
546
547 static constexpr std::size_t index_(table t) { return static_cast<std::size_t>(t); }
548
549 static constexpr std::string_view file_name_(table t)
550 {
551 switch (t)
552 {
553 case table::traits: return "traits.tsv";
554 case table::axes: return "axes.tsv";
555 case table::loadings: return "loadings.tsv";
556 case table::relations: return "relations.tsv";
557 }
558 return "";
559 }
560
561 static core::table_schema schema_(table t)
562 {
563 switch (t)
564 {
565 case table::traits:
566 return {.key = {"trait"}, .required = {"label", "weight"}, .optional = {"group", "tags"}};
567 case table::axes: return {.key = {"axis"}, .required = {"label", "mean", "sd"}, .optional = {}};
568 case table::loadings: return {.key = {"trait", "axis"}, .required = {"loading"}, .optional = {}};
569 case table::relations:
570 return {.key = {"trait", "other"}, .required = {"relation"}, .optional = {"factor"}};
571 }
572 return {};
573 }
574
575 static tables make_tables_()
576 {
577 return {core::layered_table{schema_(table::traits)}, core::layered_table{schema_(table::axes)},
578 core::layered_table{schema_(table::loadings)},
579 core::layered_table{schema_(table::relations)}};
580 }
581
582 struct trait_entry
583 {
584 personality::trait info;
585 double weight{0.0};
586 };
587
588 struct axis_entry
589 {
590 std::string key;
591 std::string label;
592 double mean{0.0};
593 double sd{0.0};
594 };
595
596 enum class relation_kind : std::uint8_t
597 {
598 conflict,
599 synergy
600 };
601
602 struct relation_entry
603 {
604 std::string trait;
605 std::string other;
606 relation_kind kind{relation_kind::conflict};
607 double factor{1.0};
608 };
609
610 // -- Row parsing (throws core::tsv_error located at the row) ------------
611
612 static std::string label_(const core::tsv_row& row)
613 {
614 auto label = row.get<std::string>("label");
615 if (label.empty()) throw row.error("label", "must not be empty");
616 return label;
617 }
618
619 static trait_entry parse_trait_(const core::tsv_row& row)
620 {
621 trait_entry e;
622 e.info.key = row.get<std::string>("trait");
623 e.info.label = label_(row);
624 e.weight = row.get<double>("weight");
625 if (e.weight < 0.0) throw row.error("weight", "must not be negative");
626 e.info.group = row.get_or<std::string>("group", "");
627 if (row.has("tags"))
628 {
629 for (const auto tag : row.list("tags"))
630 {
631 if (tag.empty()) throw row.error("tags", "empty tag");
632 e.info.tags.emplace_back(tag);
633 }
634 }
635 return e;
636 }
637
638 static axis_entry parse_axis_(const core::tsv_row& row)
639 {
640 axis_entry a{.key = row.get<std::string>("axis"),
641 .label = label_(row),
642 .mean = row.get<double>("mean"),
643 .sd = row.get<double>("sd")};
644 if (a.sd < 0.0) throw row.error("sd", "must not be negative");
645 return a;
646 }
647
648 static relation_entry parse_relation_(const core::tsv_row& row)
649 {
650 relation_entry r{.trait = row.get<std::string>("trait"), .other = row.get<std::string>("other")};
651 if (r.trait == r.other) throw row.error("other", "a trait cannot relate to itself");
652 const auto kind = row.get<std::string_view>("relation");
653 if (kind == "conflict")
654 {
655 r.kind = relation_kind::conflict;
656 if (row.has("factor")) throw row.error("factor", "a conflict takes no factor");
657 }
658 else if (kind == "synergy")
659 {
660 r.kind = relation_kind::synergy;
661 r.factor = row.get_or<double>("factor", default_synergy_);
662 if (!(r.factor > 0.0)) throw row.error("factor", "must be positive");
663 }
664 else
665 {
666 throw row.error("relation", "expected conflict or synergy, got '" + std::string{kind} + "'");
667 }
668 return r;
669 }
670
671 static void validate_(table t, const core::tsv_row& row)
672 {
673 switch (t)
674 {
675 case table::traits: (void)parse_trait_(row); break;
676 case table::axes: (void)parse_axis_(row); break;
677 case table::loadings: (void)row.get<double>("loading"); break;
678 case table::relations: (void)parse_relation_(row); break;
679 }
680 }
681
682 // -- State --------------------------------------------------------------
683
684 // The generation model, built from the tables by build_model_().
685 struct model
686 {
687 std::vector<trait_entry> traits; // in table row order
688 std::vector<axis_entry> axes; // in table row order
689 std::map<std::string, std::size_t, std::less<>> trait_index;
690 std::map<std::string, std::size_t, std::less<>> axis_index;
691 std::vector<std::vector<double>> loadings; // [trait][axis]
692 std::vector<std::vector<std::size_t>> conflicts; // [trait] -> traits, sorted
693 std::vector<std::vector<std::pair<std::size_t, double>>> synergies; // [trait] -> (trait, factor)
694 core::weighted_table<std::size_t> weights; // trait index, by weight
695 std::vector<std::string> unresolved;
696 };
697
698 tables _tables{make_tables_()};
699 model _model;
700
701 core::rng _engine{core::rng::from_entropy()};
702
703 struct auto_probe_tag {};
704
705 explicit trtg(auto_probe_tag /*tag*/)
706 {
707 if (!load(dataset::lite)) (void)load(dataset::full);
708 }
709
710 // -- Helpers ----------------------------------------------------------
711
712 static std::span<const core::embedded_file> embedded_files_()
713 {
714#ifdef DASMIG_TRAIT_EMBED
715 return embedded::traitgen::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 auto built = build_model_(next);
753 _tables = std::move(next);
754 _model = std::move(built);
755 }
756
757 // -- Building the model -------------------------------------------------
758
759 static std::string where_(const core::tsv_row& row)
760 {
761 return std::format("{}:{}", row.source(), row.line());
762 }
763
764 // The index of the trait named in a column, or empty (recorded in
765 // unresolved) when it is not loaded.
766 static std::optional<std::size_t> resolve_trait_(model& m, const core::tsv_row& row, std::string_view column)
767 {
768 const auto name = row[column];
769 const auto it = m.trait_index.find(name);
770 if (it != m.trait_index.end()) return it->second;
771 m.unresolved.push_back(std::format("{}: unknown trait '{}'", where_(row), name));
772 return std::nullopt;
773 }
774
775 static std::optional<std::size_t> resolve_axis_(model& m, const core::tsv_row& row)
776 {
777 const auto name = row["axis"];
778 const auto it = m.axis_index.find(name);
779 if (it != m.axis_index.end()) return it->second;
780 m.unresolved.push_back(std::format("{}: unknown axis '{}'", where_(row), name));
781 return std::nullopt;
782 }
783
784 // Throws core::tsv_error for a relation defined twice (in either order).
785 static void add_relations_(model& m, const core::layered_table& relations)
786 {
787 std::map<std::pair<std::string, std::string>, std::string> pairs; // unordered pair -> where
788 for (const auto& row : relations.rows())
789 {
790 const auto r = parse_relation_(row);
791 const auto [first, second] = std::minmax(r.trait, r.other);
792 const auto [it, inserted] = pairs.emplace(std::pair{first, second}, where_(row));
793 if (!inserted)
794 {
795 throw row.error("other", std::format("the relation between '{}' and '{}' is already "
796 "defined at {}", r.trait, r.other, it->second));
797 }
798 const auto a = resolve_trait_(m, row, "trait");
799 const auto b = resolve_trait_(m, row, "other");
800 if (!a || !b) continue;
801 if (r.kind == relation_kind::conflict)
802 {
803 m.conflicts[*a].push_back(*b);
804 m.conflicts[*b].push_back(*a);
805 }
806 else
807 {
808 m.synergies[*a].emplace_back(*b, r.factor);
809 m.synergies[*b].emplace_back(*a, r.factor);
810 }
811 }
812 }
813
814 // Traits in the same group conflict; conflict lists end sorted and
815 // without repeats.
816 static void add_group_conflicts_(model& m)
817 {
818 std::map<std::string, std::vector<std::size_t>, std::less<>> groups;
819 for (std::size_t i = 0; i < m.traits.size(); ++i)
820 {
821 if (!m.traits[i].info.group.empty()) groups[m.traits[i].info.group].push_back(i);
822 }
823 for (const auto& [name, members] : groups)
824 {
825 for (const auto i : members)
826 {
827 std::ranges::copy_if(members, std::back_inserter(m.conflicts[i]),
828 [i](std::size_t j) { return j != i; });
829 }
830 }
831 for (auto& c : m.conflicts)
832 {
833 std::ranges::sort(c);
834 const auto dup = std::ranges::unique(c);
835 c.erase(dup.begin(), dup.end());
836 }
837 }
838
839 static model build_model_(const tables& t)
840 {
841 model m;
842 for (const auto& row : t.at(index_(table::traits)).rows())
843 {
844 const std::size_t i = m.traits.size();
845 m.traits.push_back(parse_trait_(row));
846 m.trait_index.emplace(m.traits.back().info.key, i);
847 m.weights.add(i, m.traits.back().weight);
848 }
849 for (const auto& row : t.at(index_(table::axes)).rows())
850 {
851 m.axis_index.emplace(row.get<std::string>("axis"), m.axes.size());
852 m.axes.push_back(parse_axis_(row));
853 }
854 m.loadings.assign(m.traits.size(), std::vector<double>(m.axes.size(), 0.0));
855 for (const auto& row : t.at(index_(table::loadings)).rows())
856 {
857 const auto ti = resolve_trait_(m, row, "trait");
858 const auto ai = resolve_axis_(m, row);
859 if (ti && ai) m.loadings[*ti][*ai] = row.get<double>("loading");
860 }
861 m.conflicts.resize(m.traits.size());
862 m.synergies.resize(m.traits.size());
863 add_relations_(m, t.at(index_(table::relations)));
864 add_group_conflicts_(m);
865 return m;
866 }
867
868 // -- Generation ---------------------------------------------------------
869
870 std::uint64_t draw_seed_() { return _engine(); }
871
872 void require_data_() const
873 {
874 if (_model.traits.empty())
875 {
876 throw std::runtime_error("No trait data loaded. Call load() first.");
877 }
878 }
879
880 // The traits chosen so far, and what they rule out and favour.
881 class selection
882 {
883 public:
884 explicit selection(const model& m)
885 : _m{&m}, _blocked(m.traits.size(), 0), _boost(m.traits.size(), 1.0)
886 {
887 }
888
889 void block(std::size_t i) { _blocked[i] = 1; }
890
891 [[nodiscard]] bool blocked(std::size_t i) const { return _blocked[i] != 0; }
892
893 void choose(std::size_t i)
894 {
895 chosen.push_back(i);
896 _blocked[i] = 1;
897 for (const auto c : _m->conflicts[i]) _blocked[c] = 1;
898 for (const auto& [other, factor] : _m->synergies[i]) _boost[other] *= factor;
899 }
900
901 // The weight to draw trait i with, given its base weight and affinity.
902 [[nodiscard]] double weight(std::size_t i, double base, double affinity) const
903 {
904 return blocked(i) ? 0.0 : core::math::fenced(base * affinity * _boost[i]);
905 }
906
907 std::vector<std::size_t> chosen; // in the order chosen
908
909 private:
910 const model* _m;
911 std::vector<char> _blocked;
912 std::vector<double> _boost;
913 };
914
915 // Validate the request and take its included traits.
916 [[nodiscard]] selection start_(const trait_request& request) const
917 {
918 if (request.include.size() > request.count)
919 {
920 throw std::invalid_argument(std::format("{} traits included but only {} requested",
921 request.include.size(), request.count));
922 }
923 selection sel{_model};
924 for (const auto& key : request.exclude)
925 {
926 const auto it = _model.trait_index.find(key);
927 if (it != _model.trait_index.end()) sel.block(it->second);
928 }
929 for (const auto& key : request.include)
930 {
931 const auto it = _model.trait_index.find(key);
932 if (it == _model.trait_index.end())
933 {
934 throw std::invalid_argument(std::format("Unknown trait: {}", key));
935 }
936 if (sel.blocked(it->second))
937 {
938 throw std::invalid_argument(std::format(
939 "Included trait {} is repeated, excluded or conflicts with another included trait", key));
940 }
941 sel.choose(it->second);
942 }
943 return sel;
944 }
945
946 // Step 1: draw each axis's base value; returns the standardised values.
947 std::vector<double> draw_axes_(const core::rng& call, personality& p) const
948 {
949 std::vector<double> z(_model.axes.size(), 0.0);
950 p.axes.reserve(_model.axes.size());
951 for (std::size_t a = 0; a < _model.axes.size(); ++a)
952 {
953 const auto& ax = _model.axes[a];
954 double base = ax.mean;
955 if (ax.sd > 0.0)
956 {
957 auto r = call.split("axis:" + ax.key);
958 base = core::normal(r, ax.mean, ax.sd);
959 z[a] = (base - ax.mean) / ax.sd;
960 }
961 p.axes.push_back({.key = ax.key, .label = ax.label, .base = base, .value = base});
962 }
963 return z;
964 }
965
966 // Step 2: each trait's affinity, exp(sum of loading * z).
967 [[nodiscard]] std::vector<double> affinities_(const std::vector<double>& z) const
968 {
969 std::vector<double> affinity(_model.traits.size(), 1.0);
970 for (std::size_t i = 0; i < affinity.size(); ++i)
971 {
972 double s = 0.0;
973 for (std::size_t a = 0; a < z.size(); ++a) s += core::math::fenced(_model.loadings[i][a] * z[a]);
974 affinity[i] = core::math::exp(std::clamp(s, -max_exponent_, max_exponent_));
975 }
976 return affinity;
977 }
978
979 [[nodiscard]] personality generate_(const trait_request& request, std::uint64_t call_seed) const
980 {
981 require_data_();
982 auto sel = start_(request);
983
984 const core::rng call{call_seed};
985 personality p;
986 p._signature = {.seed = call_seed, .data = data_hash()};
987 const auto affinity = affinities_(draw_axes_(call, p));
988
989 // Step 3: draw the remaining traits.
990 auto r = call.split("traits");
991 while (sel.chosen.size() < request.count)
992 {
993 const auto pick = _model.weights.try_pick_index(
994 r, [&](const std::size_t& i, double w) { return sel.weight(i, w, affinity[i]); });
995 if (!pick) break;
996 sel.choose(*pick);
997 }
998
999 // Step 4: traits and resolved axis values.
1000 p.traits.reserve(sel.chosen.size());
1001 for (const auto i : sel.chosen)
1002 {
1003 p.traits.push_back(_model.traits[i].info);
1004 for (std::size_t a = 0; a < p.axes.size(); ++a) p.axes[a].value += _model.loadings[i][a];
1005 }
1006 return p;
1007 }
1008};
1009
1010} // namespace dasmig
1011
1012#endif // DASMIG_TRAITGEN_HPP
Return type for personality generation: traits and the axis values they resolve to.
Definition traitgen.hpp:68
bool has(std::string_view key) const
Whether the personality has a trait.
Definition traitgen.hpp:94
std::uint64_t seed() const
Retrieve the random seed used to generate this personality.
Definition traitgen.hpp:127
std::string to_string() const
Human-readable summary string.
Definition traitgen.hpp:139
std::string to_json() const
This personality as a JSON document.
Definition traitgen.hpp:176
const core::seed_signature & signature() const
The seed and the data this personality was generated from.
Definition traitgen.hpp:132
std::vector< trait > traits
Included traits first, then drawn ones.
Definition traitgen.hpp:88
std::vector< axis > axes
Every axis, in catalogue order.
Definition traitgen.hpp:89
void to_json(core::json_writer &w) const
Write this personality as a JSON object.
Definition traitgen.hpp:152
std::optional< double > value(std::string_view key) const
The resolved value of an axis.
Definition traitgen.hpp:102
friend std::ostream & operator<<(std::ostream &os, const personality &p)
Stream the summary to an output stream.
Definition traitgen.hpp:194
std::vector< std::string > tags() const
The tags of all traits, each once, in order of appearance.
Definition traitgen.hpp:111
Personality generator: draws traits from a weighted catalogue, shaped by Big Five axes,...
Definition traitgen.hpp:231
std::size_t trait_count() const
Return the number of loaded traits.
Definition traitgen.hpp:375
void for_each_personality(const trait_request &request, const core::batch_options &options, Sink &&sink)
Stream a batch of personality results with a batch seed drawn like get_personality() draws its own.
Definition traitgen.hpp:360
void for_each_personality(const trait_request &request, const core::batch_options &options, std::uint64_t batch_seed, Sink &&sink) const
Stream a batch of personality results to a sink without holding the whole batch.
Definition traitgen.hpp:345
void load(const std::filesystem::path &dir)
Load a resource directory, replacing all data and layers loaded before.
Definition traitgen.hpp:411
bool has_trait(std::string_view key) const
Whether a trait is loaded.
Definition traitgen.hpp:384
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).
Definition traitgen.hpp:503
bool has_data() const
Check whether any data has been loaded.
Definition traitgen.hpp:371
bool load(dataset tier)
Load a dataset tier, replacing all data and layers loaded before.
Definition traitgen.hpp:433
trtg(const trtg &)=delete
Not copyable.
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 traitgen.hpp:486
std::vector< personality > get_personality_batch(const trait_request &request, const core::batch_options &options, std::uint64_t batch_seed) const
Generate a batch of personality results, deterministically.
Definition traitgen.hpp:323
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 traitgen.hpp:460
const std::vector< std::string > & unresolved() const
Loadings and relations that name a trait or axis that is not loaded. They are ignored,...
Definition traitgen.hpp:394
trtg()=default
Default constructor — creates an empty generator with no data.
std::uint64_t data_hash() const
Hash of the loaded data (all four tables), recorded in each result's signature().
Definition traitgen.hpp:520
std::vector< personality > get_personality_batch(const trait_request &request, const core::batch_options &options)
Generate a batch of personality results from a seed drawn like get_personality() draws its own.
Definition traitgen.hpp:302
const std::vector< core::layer_info > & layers(table which) const
The layers in effect for one table, in order.
Definition traitgen.hpp:512
static trtg & instance()
Access the global singleton instance.
Definition traitgen.hpp:257
trtg(trtg &&) noexcept=default
Movable.
personality get_personality(const trait_request &request)
Generate a random personality as requested. Use trait_request{} for three traits without constraints.
Definition traitgen.hpp:273
std::size_t axis_count() const
Return the number of loaded axes.
Definition traitgen.hpp:379
table
The data tables.
Definition traitgen.hpp:235
@ relations
relations.tsv: key trait and other; relation, factor.
@ loadings
loadings.tsv: key trait and axis; loading.
@ axes
axes.tsv: key axis; label, mean, sd.
@ traits
traits.tsv: key trait; label, weight, group, tags.
trtg & operator=(const trtg &)=delete
Not copyable.
personality get_personality(const trait_request &request, std::uint64_t call_seed) const
Generate a deterministic personality as requested.
Definition traitgen.hpp:285
One axis of a personality.
Definition traitgen.hpp:81
double value
Base plus the loadings of the traits.
Definition traitgen.hpp:85
std::string key
Axis key (axis column).
Definition traitgen.hpp:82
std::string label
Display label.
Definition traitgen.hpp:83
double base
Value drawn before traits.
Definition traitgen.hpp:84
One trait of a personality.
Definition traitgen.hpp:72
std::string key
Catalogue key (trait column).
Definition traitgen.hpp:73
std::string group
Exclusive group; empty for none.
Definition traitgen.hpp:75
std::vector< std::string > tags
Tags, in catalogue order.
Definition traitgen.hpp:76
std::string label
Display label.
Definition traitgen.hpp:74
What to generate: how many traits, and traits to include or leave out.
Definition traitgen.hpp:49
std::vector< std::string > include
Definition traitgen.hpp:55
std::vector< std::string > exclude
Traits that must not be drawn. Unknown names are ignored.
Definition traitgen.hpp:57