@forwardimpact/libsyntheticgen 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Entity generation — builds orgs, departments, teams, people, projects.
3
+ *
4
+ * @module libuniverse/engine/entities
5
+ */
6
+
7
+ import {
8
+ GREEK_NAMES,
9
+ MANAGER_NAMES,
10
+ toGithubUsername,
11
+ toEmail,
12
+ } from "./names.js";
13
+
14
+ /**
15
+ * Build all entities from AST and RNG.
16
+ * @param {import('../dsl/parser.js').UniverseAST} ast
17
+ * @param {import('./rng.js').SeededRNG} rng
18
+ * @returns {{ orgs: object[], departments: object[], teams: object[], people: object[], projects: object[] }}
19
+ */
20
+ export function buildEntities(ast, rng) {
21
+ const domain = ast.domain;
22
+ const orgs = ast.orgs.map((o) => ({
23
+ ...o,
24
+ iri: `https://${domain}/org/${o.id}`,
25
+ }));
26
+ const departments = ast.departments.map((d) => ({
27
+ ...d,
28
+ iri: `https://${domain}/department/${d.id}`,
29
+ }));
30
+ const teams = ast.teams.map((t) => ({
31
+ ...t,
32
+ repos: t.repos || [],
33
+ iri: `https://${domain}/team/${t.id}`,
34
+ getdx_team_id: `gdx_team_${t.id}`,
35
+ }));
36
+ const people = generatePeople(ast, rng, teams, domain);
37
+ const projects = ast.projects.map((p) => ({
38
+ ...p,
39
+ teams: p.teams || [],
40
+ phase: p.phase || null,
41
+ prose_topic: p.prose_topic || null,
42
+ prose_tone: p.prose_tone || null,
43
+ iri: `https://${domain}/project/${p.id}`,
44
+ }));
45
+
46
+ return { orgs, departments, teams, people, projects };
47
+ }
48
+
49
+ function generatePeople(ast, rng, teams, domain) {
50
+ const { count, distribution, disciplines } = ast.people;
51
+ const people = [];
52
+ const usedNames = new Set();
53
+
54
+ // Reserve manager names
55
+ const managerAssignments = new Map();
56
+ for (const team of teams) {
57
+ if (team.manager) {
58
+ const name = MANAGER_NAMES[team.manager] || team.manager;
59
+ managerAssignments.set(team.id, name);
60
+ usedNames.add(name);
61
+ }
62
+ }
63
+
64
+ const levelKeys = Object.keys(distribution);
65
+ const levelWeights = Object.values(distribution);
66
+ const discKeys = Object.keys(disciplines);
67
+ const discWeights = Object.values(disciplines);
68
+ const available = rng.shuffle(GREEK_NAMES.filter((n) => !usedNames.has(n)));
69
+
70
+ // Create managers
71
+ for (const team of teams) {
72
+ if (!team.manager) continue;
73
+ const name = managerAssignments.get(team.id);
74
+ people.push(
75
+ makePerson(
76
+ name,
77
+ rng.pick(["L3", "L4", "L5"]),
78
+ discKeys[rng.weightedPick(discWeights)] || "software_engineering",
79
+ team,
80
+ domain,
81
+ true,
82
+ null,
83
+ ),
84
+ );
85
+ }
86
+
87
+ // Fill remaining people
88
+ let idx = 0;
89
+ while (people.length < count && idx < available.length) {
90
+ const name = available[idx++];
91
+ const level = levelKeys[rng.weightedPick(levelWeights)];
92
+ const disc = discKeys[rng.weightedPick(discWeights)];
93
+ const team = rng.pick(teams);
94
+ const mgr = people.find((p) => p.is_manager && p.team_id === team.id);
95
+ people.push(
96
+ makePerson(
97
+ name,
98
+ level,
99
+ disc,
100
+ team,
101
+ domain,
102
+ false,
103
+ mgr?.email || null,
104
+ `2023-${pad2(rng.randomInt(1, 12))}-${pad2(rng.randomInt(1, 28))}`,
105
+ ),
106
+ );
107
+ }
108
+
109
+ return people;
110
+ }
111
+
112
+ function makePerson(
113
+ name,
114
+ level,
115
+ discipline,
116
+ team,
117
+ domain,
118
+ isManager,
119
+ managerEmail,
120
+ hireDate = "2023-01-15",
121
+ ) {
122
+ const id = name.toLowerCase().replace(/\s+/g, "-");
123
+ return {
124
+ id,
125
+ name,
126
+ email: toEmail(name, domain),
127
+ github: toGithubUsername(name),
128
+ github_username: toGithubUsername(name),
129
+ discipline,
130
+ level,
131
+ track: null,
132
+ team_id: team.id,
133
+ department: team.department,
134
+ is_manager: isManager,
135
+ manager_email: managerEmail,
136
+ hire_date: hireDate,
137
+ iri: `https://${domain}/person/${id}`,
138
+ };
139
+ }
140
+
141
+ /** @param {number} n */
142
+ function pad2(n) {
143
+ return String(n).padStart(2, "0");
144
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Greek mythology name pool for synthetic people generation.
3
+ * Names are deterministically assigned based on seed order.
4
+ */
5
+
6
+ export const GREEK_NAMES = [
7
+ // Olympians and major deities
8
+ "Zeus",
9
+ "Hera",
10
+ "Poseidon",
11
+ "Demeter",
12
+ "Athena",
13
+ "Apollo",
14
+ "Artemis",
15
+ "Ares",
16
+ "Aphrodite",
17
+ "Hephaestus",
18
+ "Hermes",
19
+ "Dionysus",
20
+ "Hades",
21
+ "Persephone",
22
+ "Hestia",
23
+ // Titans
24
+ "Prometheus",
25
+ "Epimetheus",
26
+ "Atlas",
27
+ "Cronus",
28
+ "Rhea",
29
+ "Hyperion",
30
+ "Theia",
31
+ "Coeus",
32
+ "Phoebe",
33
+ "Oceanus",
34
+ "Tethys",
35
+ "Mnemosyne",
36
+ "Themis",
37
+ "Crius",
38
+ "Iapetus",
39
+ // Heroes and mortals
40
+ "Achilles",
41
+ "Odysseus",
42
+ "Perseus",
43
+ "Heracles",
44
+ "Theseus",
45
+ "Jason",
46
+ "Orpheus",
47
+ "Atalanta",
48
+ "Bellerophon",
49
+ "Cadmus",
50
+ "Daedalus",
51
+ "Icarus",
52
+ "Minos",
53
+ "Ariadne",
54
+ "Andromeda",
55
+ "Antigone",
56
+ "Electra",
57
+ "Medea",
58
+ "Penelope",
59
+ "Telemachus",
60
+ "Nestor",
61
+ "Ajax",
62
+ "Agamemnon",
63
+ "Menelaus",
64
+ "Helen",
65
+ "Paris",
66
+ "Hector",
67
+ "Priam",
68
+ "Cassandra",
69
+ "Hecuba",
70
+ // Lesser deities and spirits
71
+ "Nike",
72
+ "Iris",
73
+ "Eros",
74
+ "Psyche",
75
+ "Tyche",
76
+ "Nemesis",
77
+ "Hecate",
78
+ "Selene",
79
+ "Eos",
80
+ "Helios",
81
+ "Pan",
82
+ "Triton",
83
+ "Proteus",
84
+ "Nereus",
85
+ "Amphitrite",
86
+ "Galatea",
87
+ "Thetis",
88
+ "Calliope",
89
+ "Clio",
90
+ "Erato",
91
+ "Euterpe",
92
+ "Melpomene",
93
+ "Polyhymnia",
94
+ "Terpsichore",
95
+ "Thalia",
96
+ "Urania",
97
+ // Physicians and wisdom
98
+ "Asclepius",
99
+ "Hygieia",
100
+ "Panacea",
101
+ "Chiron",
102
+ "Metis",
103
+ // Egyptian-Greek crossover (Thoth)
104
+ "Thoth",
105
+ "Chronos",
106
+ "Astraea",
107
+ "Plutus",
108
+ // More heroes and mythological
109
+ "Patroclus",
110
+ "Diomedes",
111
+ "Philoctetes",
112
+ "Pyrrhus",
113
+ "Aeneas",
114
+ "Deucalion",
115
+ "Pyrrha",
116
+ "Ganymede",
117
+ "Endymion",
118
+ "Narcissus",
119
+ "Echo",
120
+ "Adonis",
121
+ "Actaeon",
122
+ "Orion",
123
+ "Callisto",
124
+ "Io",
125
+ "Europa",
126
+ "Leda",
127
+ "Danae",
128
+ "Semele",
129
+ "Alcmene",
130
+ "Amphion",
131
+ "Zethus",
132
+ "Castor",
133
+ "Pollux",
134
+ "Clytemnestra",
135
+ "Iphigenia",
136
+ "Aegisthus",
137
+ "Pylades",
138
+ "Hermione",
139
+ "Neoptolemus",
140
+ "Astyanax",
141
+ "Polyxena",
142
+ "Briseis",
143
+ "Chryseis",
144
+ // Argonauts and companions
145
+ "Peleus",
146
+ "Teucer",
147
+ "Idomeneus",
148
+ "Meriones",
149
+ "Machaon",
150
+ "Podalirius",
151
+ "Antilochus",
152
+ "Eurypylus",
153
+ "Thrasymedes",
154
+ "Peisistratus",
155
+ // Mythological creatures (personified)
156
+ "Sphinx",
157
+ "Phoenix",
158
+ "Griffin",
159
+ "Pegasus",
160
+ "Cerberus",
161
+ // More names for 211 people
162
+ "Laertes",
163
+ "Autolycus",
164
+ "Sisyphus",
165
+ "Tantalus",
166
+ "Pelops",
167
+ "Atreus",
168
+ "Thyestes",
169
+ "Aegeus",
170
+ "Medus",
171
+ "Circe",
172
+ "Calypso",
173
+ "Nausicaa",
174
+ "Alcinous",
175
+ "Arete",
176
+ "Eumaeus",
177
+ "Philoetius",
178
+ "Melanthius",
179
+ "Eurycleia",
180
+ "Mentor",
181
+ "Halitherses",
182
+ "Tiresias",
183
+ "Polyphemus",
184
+ "Aeolus",
185
+ "Scylla",
186
+ "Charybdis",
187
+ "Leucothea",
188
+ "Phaeacian",
189
+ "Antiope",
190
+ "Hippolyta",
191
+ "Phaedra",
192
+ "Procris",
193
+ "Cephalus",
194
+ "Meleager",
195
+ "Althaea",
196
+ "Oeneus",
197
+ "Tydeus",
198
+ "Capaneus",
199
+ "Hippomedon",
200
+ "Parthenopaeus",
201
+ "Adrastus",
202
+ "Polynices",
203
+ "Eteocles",
204
+ "Ismene",
205
+ "Haemon",
206
+ "Eurydice",
207
+ "Creon",
208
+ "Jocasta",
209
+ "Laius",
210
+ "Oedipus",
211
+ "Chrysippus",
212
+ "Niobe",
213
+ "Amphion2",
214
+ "Leto",
215
+ "Asteria",
216
+ "Hecate2",
217
+ "Perses",
218
+ "Pallas",
219
+ "Styx",
220
+ "Zelus",
221
+ "Kratos",
222
+ "Bia",
223
+ "Boreas",
224
+ "Zephyrus",
225
+ "Notus",
226
+ "Eurus",
227
+ "Aether",
228
+ "Hemera",
229
+ "Nyx",
230
+ "Erebus",
231
+ "Gaia",
232
+ "Uranus",
233
+ "Pontus",
234
+ "Tartarus",
235
+ "Ananke",
236
+ "Chronos2",
237
+ "Phanes",
238
+ "Thalassa",
239
+ "Ourea",
240
+ "Nesoi",
241
+ "Achelous",
242
+ ];
243
+
244
+ /**
245
+ * Stable manager name lookup (used for @references in DSL).
246
+ * Maps DSL @name to a canonical full name.
247
+ */
248
+ export const MANAGER_NAMES = {
249
+ thoth: "Thoth",
250
+ chronos: "Chronos",
251
+ apollo: "Apollo",
252
+ hygieia: "Hygieia",
253
+ themis: "Themis",
254
+ athena: "Athena",
255
+ prometheus: "Prometheus",
256
+ hephaestus: "Hephaestus",
257
+ ares: "Ares",
258
+ hermes: "Hermes",
259
+ iris: "Iris",
260
+ demeter: "Demeter",
261
+ astraea: "Astraea",
262
+ tyche: "Tyche",
263
+ daedalus: "Daedalus",
264
+ asclepius: "Asclepius",
265
+ plutus: "Plutus",
266
+ aphrodite: "Aphrodite",
267
+ artemis: "Artemis",
268
+ hestia: "Hestia",
269
+ metis: "Metis",
270
+ };
271
+
272
+ /**
273
+ * Get a GitHub username from a person name.
274
+ * @param {string} name
275
+ * @param {string} domain - organization domain
276
+ * @returns {string}
277
+ */
278
+ export function toGithubUsername(name) {
279
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "") + "-bio";
280
+ }
281
+
282
+ /**
283
+ * Get an email from a person name.
284
+ * @param {string} name
285
+ * @param {string} domain
286
+ * @returns {string}
287
+ */
288
+ export function toEmail(name, domain) {
289
+ return `${name.toLowerCase().replace(/[^a-z0-9]/g, "")}@${domain}`;
290
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Prose Keys — collects all keys that need LLM-generated prose.
3
+ *
4
+ * Each key maps to a context object that guides the LLM prompt.
5
+ */
6
+
7
+ /**
8
+ * Collect all prose keys from the entity graph.
9
+ * @param {object} entities - Generated entity graph from tier0
10
+ * @returns {Map<string, object>} key → context for prose generation
11
+ */
12
+ export function collectProseKeys(entities) {
13
+ const keys = new Map();
14
+
15
+ // Organization README prose
16
+ keys.set("org_readme", {
17
+ topic: `${entities.orgs[0]?.name || "BioNova"} company overview`,
18
+ tone: "corporate, informative",
19
+ length: "3-4 paragraphs",
20
+ domain: entities.domain,
21
+ });
22
+
23
+ // Project descriptions
24
+ for (const proj of entities.projects) {
25
+ if (proj.prose_topic) {
26
+ keys.set(`project_${proj.id}`, {
27
+ topic: proj.prose_topic,
28
+ tone: proj.prose_tone || "technical",
29
+ length: "2-3 paragraphs",
30
+ domain: entities.domain,
31
+ });
32
+ }
33
+ }
34
+
35
+ // HTML content — articles
36
+ const guideContent = entities.content.find((c) => c.id === "guide_html");
37
+ if (guideContent) {
38
+ for (const topic of guideContent.article_topics || []) {
39
+ keys.set(`article_${topic}`, {
40
+ topic: `${topic.replace(/_/g, " ")} in pharmaceutical industry`,
41
+ tone: "technical, informative",
42
+ length: "6-8 paragraphs",
43
+ domain: entities.domain,
44
+ });
45
+ }
46
+
47
+ // Blog posts
48
+ for (let i = 0; i < (guideContent.blogs || 0); i++) {
49
+ keys.set(`blog_${i}`, {
50
+ topic: "pharmaceutical engineering blog post",
51
+ tone: "conversational, technical",
52
+ length: "4-5 paragraphs",
53
+ domain: entities.domain,
54
+ });
55
+ }
56
+
57
+ // FAQs
58
+ for (let i = 0; i < (guideContent.faqs || 0); i++) {
59
+ keys.set(`faq_${i}`, {
60
+ topic: "pharmaceutical engineering FAQ",
61
+ tone: "helpful, concise",
62
+ length: "1 paragraph",
63
+ domain: entities.domain,
64
+ });
65
+ }
66
+
67
+ // HowTos
68
+ for (const topic of guideContent.howto_topics || []) {
69
+ keys.set(`howto_${topic}`, {
70
+ topic: `how-to guide for ${topic.replace(/_/g, " ")}`,
71
+ tone: "instructional",
72
+ length: "5-6 paragraphs",
73
+ domain: entities.domain,
74
+ });
75
+ }
76
+
77
+ // Reviews
78
+ for (let i = 0; i < (guideContent.reviews || 0); i++) {
79
+ keys.set(`review_${i}`, {
80
+ topic: "peer review comment on engineering work",
81
+ tone: "professional, constructive",
82
+ length: "1-2 sentences",
83
+ maxTokens: 100,
84
+ domain: entities.domain,
85
+ });
86
+ }
87
+
88
+ // Comments
89
+ for (let i = 0; i < (guideContent.comments || 0); i++) {
90
+ keys.set(`comment_${i}`, {
91
+ topic: "discussion comment on engineering topic",
92
+ tone: "casual, technical",
93
+ length: "1-2 sentences",
94
+ maxTokens: 80,
95
+ domain: entities.domain,
96
+ });
97
+ }
98
+ }
99
+
100
+ // Basecamp personas
101
+ const basecampContent = entities.content.find(
102
+ (c) => c.id === "basecamp_markdown",
103
+ );
104
+ if (basecampContent) {
105
+ const personas = selectPersonaNames(entities, basecampContent);
106
+ for (const persona of personas) {
107
+ // Briefings
108
+ for (let i = 0; i < (basecampContent.briefings_per_persona || 0); i++) {
109
+ keys.set(`briefing_${persona.name}_${i}`, {
110
+ topic: `daily briefing for ${persona.name}, a ${persona.level} ${persona.discipline}`,
111
+ tone: "professional, concise",
112
+ length: "2-3 paragraphs",
113
+ domain: entities.domain,
114
+ role: `${persona.level} ${persona.discipline}`,
115
+ });
116
+ }
117
+
118
+ // Notes
119
+ for (let i = 0; i < (basecampContent.notes_per_persona || 0); i++) {
120
+ keys.set(`note_${persona.name}_${i}`, {
121
+ topic: `engineering knowledge note by ${persona.name}`,
122
+ tone: "personal, technical",
123
+ length: "1-2 paragraphs",
124
+ domain: entities.domain,
125
+ role: `${persona.level} ${persona.discipline}`,
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ // Snapshot comments (GetDX engineer voice)
132
+ if (entities.activity?.commentKeys) {
133
+ for (const ck of entities.activity.commentKeys) {
134
+ const direction =
135
+ ck.trajectory === "declining" ? "declining" : "improving";
136
+ keys.set(
137
+ `snapshot_comment_${ck.snapshot_id}_${ck.email.replace(/[@.]/g, "_")}`,
138
+ {
139
+ topic: `GetDX snapshot survey comment about ${ck.driver_name.toLowerCase()}`,
140
+ tone: "authentic, first-person developer voice",
141
+ length: "1-2 sentences",
142
+ maxTokens: 80,
143
+ domain: entities.domain,
144
+ role: `${ck.person_level} ${ck.person_discipline.replace(/_/g, " ")} on the ${ck.team_name}`,
145
+ scenario: ck.scenario_name,
146
+ driver: ck.driver_name,
147
+ direction,
148
+ magnitude: ck.magnitude,
149
+ },
150
+ );
151
+ }
152
+ }
153
+
154
+ return keys;
155
+ }
156
+
157
+ /**
158
+ * Select persona representatives from people.
159
+ */
160
+ function selectPersonaNames(entities, basecampContent) {
161
+ const levels = basecampContent.persona_levels || [
162
+ "L1",
163
+ "L2",
164
+ "L3",
165
+ "L4",
166
+ "L5",
167
+ ];
168
+ const personas = [];
169
+ for (const level of levels) {
170
+ const person = entities.people.find((p) => p.level === level);
171
+ if (person) {
172
+ personas.push({
173
+ name: person.name,
174
+ level: person.level,
175
+ discipline: person.discipline,
176
+ email: person.email,
177
+ team_id: person.team_id,
178
+ });
179
+ }
180
+ }
181
+ return personas;
182
+ }
package/engine/rng.js ADDED
@@ -0,0 +1,43 @@
1
+ import seedrandom from "seedrandom";
2
+
3
+ /**
4
+ * Create a seeded RNG with convenience methods.
5
+ * @param {number|string} seed
6
+ * @returns {{ random: () => number, randomInt: (min: number, max: number) => number, pick: <T>(arr: T[]) => T, shuffle: <T>(arr: T[]) => T[], weightedPick: (items: Array<{weight: number}>) => number, gaussian: (mean: number, std: number) => number }}
7
+ */
8
+ export function createSeededRNG(seed) {
9
+ const rng = seedrandom(String(seed));
10
+
11
+ const random = () => rng();
12
+ const randomInt = (min, max) => Math.floor(random() * (max - min + 1)) + min;
13
+ const pick = (arr) => arr[Math.floor(random() * arr.length)];
14
+
15
+ const shuffle = (arr) => {
16
+ const result = [...arr];
17
+ for (let i = result.length - 1; i > 0; i--) {
18
+ const j = Math.floor(random() * (i + 1));
19
+ [result[i], result[j]] = [result[j], result[i]];
20
+ }
21
+ return result;
22
+ };
23
+
24
+ const weightedPick = (weights) => {
25
+ const total = weights.reduce((s, w) => s + w, 0);
26
+ let r = random() * total;
27
+ for (let i = 0; i < weights.length; i++) {
28
+ r -= weights[i];
29
+ if (r <= 0) return i;
30
+ }
31
+ return weights.length - 1;
32
+ };
33
+
34
+ const gaussian = (mean, std) => {
35
+ const u1 = random();
36
+ const u2 = random();
37
+ const z =
38
+ Math.sqrt(-2 * Math.log(u1 || 1e-10)) * Math.cos(2 * Math.PI * u2);
39
+ return mean + z * std;
40
+ };
41
+
42
+ return { random, randomInt, pick, shuffle, weightedPick, gaussian };
43
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tier 0 — Deterministic Entity & Activity Generation (no LLM).
3
+ *
4
+ * @module libuniverse/engine/tier0
5
+ */
6
+
7
+ import { createSeededRNG } from "./rng.js";
8
+ import { buildEntities } from "./entities.js";
9
+ import { generateActivity } from "./activity.js";
10
+
11
+ /**
12
+ * Entity generator that wraps deterministic generation from a parsed AST.
13
+ */
14
+ export class EntityGenerator {
15
+ /**
16
+ * @param {Function} rngFactory - Factory function that creates a seeded RNG
17
+ * @param {object} logger - Logger instance
18
+ */
19
+ constructor(rngFactory, logger) {
20
+ if (!rngFactory) throw new Error("rngFactory is required");
21
+ if (!logger) throw new Error("logger is required");
22
+ this.rngFactory = rngFactory;
23
+ this.logger = logger;
24
+ }
25
+
26
+ /**
27
+ * Generate all entities and activity from a parsed AST.
28
+ * @param {import('../dsl/parser.js').UniverseAST} ast
29
+ * @returns {object} Entity graph with activity data
30
+ */
31
+ generate(ast) {
32
+ const rng = this.rngFactory(ast.seed);
33
+ const { orgs, departments, teams, people, projects } = buildEntities(
34
+ ast,
35
+ rng,
36
+ );
37
+ const activity = generateActivity(ast, rng, people, teams);
38
+
39
+ return {
40
+ orgs,
41
+ departments,
42
+ teams,
43
+ people,
44
+ projects,
45
+ scenarios: ast.scenarios,
46
+ snapshots: ast.snapshots,
47
+ framework: { ...ast.framework, seed: ast.seed },
48
+ content: ast.content,
49
+ activity,
50
+ domain: ast.domain,
51
+ industry: ast.industry,
52
+ };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Creates an EntityGenerator with the built-in seeded RNG factory.
58
+ * @param {object} logger - Logger instance
59
+ * @returns {EntityGenerator}
60
+ */
61
+ export function createEntityGenerator(logger) {
62
+ return new EntityGenerator(createSeededRNG, logger);
63
+ }