@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,956 @@
1
+ /**
2
+ * Activity generation — roster, teams, snapshots, scores, webhooks, evidence.
3
+ *
4
+ * @module libuniverse/engine/activity
5
+ */
6
+
7
+ import { generateHash } from "@forwardimpact/libutil";
8
+
9
+ const COMMIT_MULT = {
10
+ baseline: 1.0,
11
+ moderate: 1.5,
12
+ elevated: 2.5,
13
+ spike: 4.0,
14
+ sustained_spike: 3.5,
15
+ very_high: 5.0,
16
+ };
17
+ const PR_MULT = { baseline: 1.0, moderate: 1.3, elevated: 2.0, very_high: 3.5 };
18
+
19
+ const ALL_DRIVERS = [
20
+ "clear_direction",
21
+ "say_on_priorities",
22
+ "requirements_quality",
23
+ "ease_of_release",
24
+ "test_efficiency",
25
+ "managing_tech_debt",
26
+ "code_review",
27
+ "documentation",
28
+ "codebase_experience",
29
+ "incident_response",
30
+ "learning_culture",
31
+ "experimentation",
32
+ "connectedness",
33
+ "efficient_processes",
34
+ "deep_work",
35
+ "leveraging_user_feedback",
36
+ ];
37
+
38
+ const DRIVER_NAMES = Object.fromEntries(
39
+ ALL_DRIVERS.map((d) => [
40
+ d,
41
+ d
42
+ .split("_")
43
+ .map((w) => w[0].toUpperCase() + w.slice(1))
44
+ .join(" "),
45
+ ]),
46
+ );
47
+
48
+ const FEATURES = [
49
+ "authentication",
50
+ "pipeline",
51
+ "scoring",
52
+ "analytics",
53
+ "export",
54
+ "batch-processing",
55
+ "data-validation",
56
+ "api-gateway",
57
+ "monitoring",
58
+ "caching",
59
+ "search",
60
+ "notification",
61
+ "scheduling",
62
+ "reporting",
63
+ ];
64
+
65
+ const COMMIT_MSGS = [
66
+ "Add {f} endpoint",
67
+ "Fix {f} validation",
68
+ "Update {f} tests",
69
+ "Refactor {f} module",
70
+ "Optimize {f} performance",
71
+ "Add error handling for {f}",
72
+ "Update {f} documentation",
73
+ "Implement {f} caching",
74
+ "Add {f} monitoring",
75
+ "Fix race condition in {f}",
76
+ "Migrate {f} to new API",
77
+ "Add integration tests for {f}",
78
+ "Clean up {f} imports",
79
+ ];
80
+
81
+ const PR_TITLES = [
82
+ "Add {f} support",
83
+ "Implement {f} workflow",
84
+ "Fix {f} edge cases",
85
+ "Upgrade {f} dependencies",
86
+ "Refactor {f} architecture",
87
+ "Add {f} tests",
88
+ ];
89
+
90
+ const PR_BODIES = [
91
+ "LGTM",
92
+ "Looks good to me!",
93
+ "Nice work.",
94
+ "A few minor comments.",
95
+ "Please address the feedback.",
96
+ "Approved with minor suggestions.",
97
+ ];
98
+
99
+ const PROFICIENCY_ORDER = [
100
+ "awareness",
101
+ "foundational",
102
+ "working",
103
+ "practitioner",
104
+ "expert",
105
+ ];
106
+
107
+ /**
108
+ * Generate all activity data from AST and entities.
109
+ * @param {import('../dsl/parser.js').UniverseAST} ast
110
+ * @param {import('./rng.js').SeededRNG} rng
111
+ * @param {object[]} people
112
+ * @param {object[]} teams
113
+ * @returns {object}
114
+ */
115
+ export function generateActivity(ast, rng, people, teams) {
116
+ const roster = people.map((p) => ({
117
+ email: p.email,
118
+ name: p.name,
119
+ github_username: p.github_username,
120
+ discipline: p.discipline,
121
+ level: p.level,
122
+ track: p.track,
123
+ manager_email: p.manager_email,
124
+ team_id: p.team_id,
125
+ }));
126
+
127
+ const activityTeams = buildActivityTeams(ast, teams);
128
+ const snapshots = generateSnapshots(ast);
129
+ const scores = generateScores(ast, rng, snapshots, activityTeams);
130
+ const webhooks = generateWebhooks(ast, rng, people, teams);
131
+ const evidence = generateEvidence(ast, rng, people, teams);
132
+ const { scorecards, initiatives } = deriveInitiatives(
133
+ ast,
134
+ rng,
135
+ people,
136
+ teams,
137
+ snapshots,
138
+ );
139
+ const commentKeys = generateCommentKeys(ast, rng, people, teams, snapshots);
140
+ const rosterSnapshots = generateRosterSnapshots(
141
+ ast,
142
+ rng,
143
+ people,
144
+ teams,
145
+ snapshots,
146
+ );
147
+ const projectTeams = deriveProjectTeams(ast, rng, people, teams);
148
+
149
+ return {
150
+ roster,
151
+ activityTeams,
152
+ snapshots,
153
+ scores,
154
+ webhooks,
155
+ evidence,
156
+ initiatives,
157
+ scorecards,
158
+ commentKeys,
159
+ rosterSnapshots,
160
+ projectTeams,
161
+ };
162
+ }
163
+
164
+ function buildActivityTeams(ast, teams) {
165
+ const result = [];
166
+
167
+ for (const org of ast.orgs) {
168
+ result.push({
169
+ getdx_team_id: `gdx_org_${org.id}`,
170
+ name: org.name,
171
+ is_parent: true,
172
+ parent_id: null,
173
+ manager_id: null,
174
+ contributors: 0,
175
+ reference_id: null,
176
+ ancestors: [],
177
+ last_changed_at: new Date("2025-01-01").toISOString(),
178
+ });
179
+ }
180
+
181
+ const orgMap = new Map(ast.orgs.map((o) => [o.id, o]));
182
+ for (const dept of ast.departments) {
183
+ const parentOrg = orgMap.get(dept.parent);
184
+ result.push({
185
+ getdx_team_id: `gdx_dept_${dept.id}`,
186
+ name: dept.name,
187
+ is_parent: true,
188
+ parent_id: parentOrg ? `gdx_org_${parentOrg.id}` : null,
189
+ manager_id: null,
190
+ contributors: dept.headcount,
191
+ reference_id: null,
192
+ ancestors: parentOrg ? [`gdx_org_${parentOrg.id}`] : [],
193
+ last_changed_at: new Date("2025-01-01").toISOString(),
194
+ });
195
+ }
196
+
197
+ const deptMap = new Map(ast.departments.map((d) => [d.id, d]));
198
+ for (const team of teams) {
199
+ const dept = deptMap.get(team.department);
200
+ const parentDeptId = dept ? `gdx_dept_${dept.id}` : null;
201
+ const parentOrg = dept ? orgMap.get(dept.parent) : null;
202
+ const ancestors = [];
203
+ if (parentOrg) ancestors.push(`gdx_org_${parentOrg.id}`);
204
+ if (parentDeptId) ancestors.push(parentDeptId);
205
+
206
+ result.push({
207
+ getdx_team_id: team.getdx_team_id,
208
+ name: team.name,
209
+ is_parent: false,
210
+ parent_id: parentDeptId,
211
+ manager_id: team.manager ? `gdx_mgr_${team.manager}` : null,
212
+ contributors: team.size,
213
+ reference_id: null,
214
+ ancestors,
215
+ last_changed_at: new Date("2025-01-01").toISOString(),
216
+ });
217
+ }
218
+
219
+ return result;
220
+ }
221
+
222
+ function generateSnapshots(ast) {
223
+ if (!ast.snapshots) return [];
224
+ const [fromY, fromM] = ast.snapshots.quarterly_from.split("-").map(Number);
225
+ const [toY, toM] = ast.snapshots.quarterly_to.split("-").map(Number);
226
+ const snaps = [];
227
+ let y = fromY,
228
+ m = fromM;
229
+
230
+ while (y < toY || (y === toY && m <= toM)) {
231
+ const q = Math.ceil(m / 3);
232
+ const id = `snap_${y}_Q${q}`;
233
+ const done = new Date(y, m, 1).toISOString();
234
+ snaps.push({
235
+ snapshot_id: id,
236
+ account_id: ast.snapshots.account_id,
237
+ last_result_change_at: done,
238
+ scheduled_for: `${y}-${String(m).padStart(2, "0")}-15`,
239
+ completed_at: done,
240
+ completed_count: 180,
241
+ deleted_at: null,
242
+ total_count: ast.people?.count || 50,
243
+ });
244
+ m += 3;
245
+ if (m > 12) {
246
+ m -= 12;
247
+ y++;
248
+ }
249
+ }
250
+
251
+ return snaps;
252
+ }
253
+
254
+ function generateScores(ast, rng, snapshots, activityTeams) {
255
+ const scores = [];
256
+ const leafTeams = activityTeams.filter((t) => !t.is_parent);
257
+
258
+ for (const snap of snapshots) {
259
+ const snapDate = new Date(snap.completed_at);
260
+ for (const team of leafTeams) {
261
+ for (const driverId of ALL_DRIVERS) {
262
+ let base = 65 + rng.gaussian(0, 8);
263
+
264
+ for (const scenario of ast.scenarios) {
265
+ const start = new Date(scenario.timerange_start + "-01");
266
+ const end = new Date(scenario.timerange_end + "-28");
267
+ if (snapDate >= start && snapDate <= end) {
268
+ for (const affect of scenario.affects) {
269
+ if (team.getdx_team_id === `gdx_team_${affect.team_id}`) {
270
+ const dx = (affect.dx_drivers || []).find(
271
+ (d) => d.driver_id === driverId,
272
+ );
273
+ if (dx)
274
+ base += dx.magnitude * ((snapDate - start) / (end - start));
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ const score = Math.max(0, Math.min(100, Math.round(base * 10) / 10));
281
+ scores.push({
282
+ snapshot_id: snap.snapshot_id,
283
+ snapshot_team_id: `st_${snap.snapshot_id}_${team.getdx_team_id}`,
284
+ team_name: team.name,
285
+ getdx_team_id: team.getdx_team_id,
286
+ is_parent: team.is_parent,
287
+ parent_id: team.parent_id,
288
+ ancestors: team.ancestors,
289
+ item_id: driverId,
290
+ item_type: "driver",
291
+ item_name: DRIVER_NAMES[driverId] || driverId,
292
+ response_count: rng.randomInt(5, team.contributors || 10),
293
+ score,
294
+ contributor_count: team.contributors || 0,
295
+ vs_prev: round1(rng.gaussian(0, 3)),
296
+ vs_org: round1(rng.gaussian(0, 5)),
297
+ vs_50th: round1(rng.gaussian(2, 5)),
298
+ vs_75th: round1(rng.gaussian(-3, 5)),
299
+ vs_90th: round1(rng.gaussian(-8, 5)),
300
+ });
301
+ }
302
+ }
303
+ }
304
+
305
+ return scores;
306
+ }
307
+
308
+ function generateWebhooks(ast, rng, people, teams) {
309
+ const webhooks = [];
310
+ const starts = ast.scenarios.map((s) => new Date(s.timerange_start + "-01"));
311
+ const ends = ast.scenarios.map((s) => new Date(s.timerange_end + "-28"));
312
+ const globalStart = new Date(Math.min(...starts, new Date("2024-07-01")));
313
+ const globalEnd = new Date(Math.max(...ends, new Date("2026-01-28")));
314
+
315
+ const membersByTeam = new Map();
316
+ for (const team of teams)
317
+ membersByTeam.set(
318
+ team.id,
319
+ people.filter((p) => p.team_id === team.id),
320
+ );
321
+
322
+ const oneWeek = 7 * 24 * 60 * 60 * 1000;
323
+ let week = new Date(globalStart);
324
+ let counter = 0;
325
+
326
+ while (week < globalEnd) {
327
+ const weekEnd = new Date(week.getTime() + oneWeek);
328
+
329
+ for (const team of teams) {
330
+ const members = membersByTeam.get(team.id) || [];
331
+ if (members.length === 0) continue;
332
+
333
+ let cm = 1,
334
+ pm = 1;
335
+ for (const s of ast.scenarios) {
336
+ const sStart = new Date(s.timerange_start + "-01");
337
+ const sEnd = new Date(s.timerange_end + "-28");
338
+ if (week >= sStart && week <= sEnd) {
339
+ for (const a of s.affects) {
340
+ if (a.team_id === team.id) {
341
+ cm = Math.max(cm, COMMIT_MULT[a.github_commits] || 1);
342
+ pm = Math.max(pm, PR_MULT[a.github_prs] || 1);
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ const orgName = ast.orgs[0]?.id || "org";
349
+ const pushCount = Math.round(members.length * cm * 0.3);
350
+ for (let i = 0; i < pushCount; i++) {
351
+ const author = rng.pick(members);
352
+ const repo = rng.pick(
353
+ team.repos.length > 0 ? team.repos : ["default-repo"],
354
+ );
355
+ const feat = rng.pick(FEATURES);
356
+ const ts = randDate(rng, week, weekEnd);
357
+ const cid = generateHash(
358
+ String(counter),
359
+ author.name,
360
+ ts.toISOString(),
361
+ );
362
+
363
+ webhooks.push({
364
+ delivery_id: `evt-${String(++counter).padStart(8, "0")}`,
365
+ event_type: "push",
366
+ occurred_at: ts.toISOString(),
367
+ payload: {
368
+ ref: "refs/heads/main",
369
+ commits: [
370
+ {
371
+ id: cid + cid,
372
+ message: rng.pick(COMMIT_MSGS).replace("{f}", feat),
373
+ timestamp: ts.toISOString(),
374
+ added: [`src/${feat}.js`],
375
+ removed: [],
376
+ modified: ["src/index.js"],
377
+ },
378
+ ],
379
+ repository: { full_name: `${orgName}/${repo}` },
380
+ sender: { login: author.github_username },
381
+ },
382
+ });
383
+ }
384
+
385
+ const prCount = Math.round(members.length * pm * 0.15);
386
+ for (let i = 0; i < prCount; i++) {
387
+ const author = rng.pick(members);
388
+ const repo = rng.pick(
389
+ team.repos.length > 0 ? team.repos : ["default-repo"],
390
+ );
391
+ const feat = rng.pick(FEATURES);
392
+ const ts = randDate(rng, week, weekEnd);
393
+ const prNum = rng.randomInt(1, 999);
394
+ const branch = `feature/${feat}`;
395
+
396
+ webhooks.push({
397
+ delivery_id: `evt-${String(++counter).padStart(8, "0")}`,
398
+ event_type: "pull_request",
399
+ occurred_at: ts.toISOString(),
400
+ payload: {
401
+ action: rng.pick(["opened", "closed"]),
402
+ number: prNum,
403
+ pull_request: {
404
+ number: prNum,
405
+ title: rng.pick(PR_TITLES).replace("{f}", feat),
406
+ state: "open",
407
+ user: { login: author.github_username },
408
+ created_at: ts.toISOString(),
409
+ updated_at: ts.toISOString(),
410
+ additions: rng.randomInt(10, 500),
411
+ deletions: rng.randomInt(0, 100),
412
+ changed_files: rng.randomInt(1, 20),
413
+ merged: false,
414
+ base: { ref: "main" },
415
+ head: { ref: branch },
416
+ },
417
+ repository: { full_name: `${orgName}/${repo}` },
418
+ sender: { login: author.github_username },
419
+ },
420
+ });
421
+
422
+ if (rng.random() > 0.4) {
423
+ const reviewer = rng.pick(
424
+ members.filter((m) => m.name !== author.name) || [author],
425
+ );
426
+ const rts = new Date(ts.getTime() + rng.randomInt(1, 48) * 3600000);
427
+ webhooks.push({
428
+ delivery_id: `evt-${String(++counter).padStart(8, "0")}`,
429
+ event_type: "pull_request_review",
430
+ occurred_at: rts.toISOString(),
431
+ payload: {
432
+ action: "submitted",
433
+ review: {
434
+ id: rng.randomInt(10000, 99999),
435
+ user: { login: reviewer.github_username },
436
+ state: rng.pick(["approved", "changes_requested", "commented"]),
437
+ body: rng.pick(PR_BODIES),
438
+ submitted_at: rts.toISOString(),
439
+ },
440
+ pull_request: { number: prNum },
441
+ repository: { full_name: `${orgName}/${repo}` },
442
+ sender: { login: reviewer.github_username },
443
+ },
444
+ });
445
+ }
446
+ }
447
+ }
448
+
449
+ week = weekEnd;
450
+ }
451
+
452
+ return webhooks;
453
+ }
454
+
455
+ function generateEvidence(ast, rng, people, teams) {
456
+ const evidence = [];
457
+
458
+ for (const scenario of ast.scenarios) {
459
+ const sStart = new Date(scenario.timerange_start + "-01");
460
+ const sEnd = new Date(scenario.timerange_end + "-28");
461
+
462
+ for (const affect of scenario.affects) {
463
+ const team = teams.find((t) => t.id === affect.team_id);
464
+ if (!team) continue;
465
+ const teamPeople = people.filter((p) => p.team_id === team.id);
466
+ const floorIdx = PROFICIENCY_ORDER.indexOf(affect.evidence_floor);
467
+
468
+ for (const person of teamPeople) {
469
+ for (const skillId of affect.evidence_skills || []) {
470
+ const profIdx = Math.min(
471
+ PROFICIENCY_ORDER.length - 1,
472
+ Math.max(floorIdx, floorIdx + rng.randomInt(0, 1)),
473
+ );
474
+ evidence.push({
475
+ person_email: person.email,
476
+ person_name: person.name,
477
+ skill_id: skillId,
478
+ proficiency: PROFICIENCY_ORDER[profIdx],
479
+ scenario_id: scenario.id,
480
+ team_id: team.id,
481
+ observed_at: randDate(rng, sStart, sEnd).toISOString(),
482
+ source: "synthetic",
483
+ });
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ return evidence;
490
+ }
491
+
492
+ /**
493
+ * Derive initiatives and scorecards from projects and scenarios.
494
+ * Declining drivers produce remediation initiatives; rising drivers produce
495
+ * improvement-tracking initiatives.
496
+ * @param {import('../dsl/parser.js').UniverseAST} ast
497
+ * @param {import('./rng.js').SeededRNG} rng
498
+ * @param {object[]} people
499
+ * @param {object[]} teams
500
+ * @param {object[]} snapshots
501
+ * @returns {{ scorecards: object[], initiatives: object[] }}
502
+ */
503
+ function deriveInitiatives(ast, rng, people, teams, _snapshots) {
504
+ const scorecards = [];
505
+ const initiatives = [];
506
+ const driverMap = new Map(
507
+ (ast.framework?.drivers || []).map((d) => [d.id, d]),
508
+ );
509
+ let counter = 0;
510
+
511
+ for (const scenario of ast.scenarios) {
512
+ const project = ast.projects.find((p) =>
513
+ scenario.affects.some((a) => (p.teams || []).includes(a.team_id)),
514
+ );
515
+
516
+ for (const affect of scenario.affects) {
517
+ const team = teams.find((t) => t.id === affect.team_id);
518
+ if (!team) continue;
519
+
520
+ for (const dx of affect.dx_drivers || []) {
521
+ const driver = driverMap.get(dx.driver_id);
522
+ if (!driver) continue;
523
+
524
+ counter++;
525
+ const isDeclining = dx.magnitude < 0;
526
+ const scorecardId = `sc_${scenario.id}_${affect.team_id}_${dx.driver_id}`;
527
+ const scorecardName = isDeclining
528
+ ? `${driver.name} Remediation`
529
+ : `${driver.name} Improvement`;
530
+
531
+ // Build scorecard checks from driver's contributing skills
532
+ const checks = (driver.skills || []).map((skillId, i) => ({
533
+ id: `chk_${scorecardId}_${i}`,
534
+ name: skillId.replace(/_/g, " "),
535
+ ordering: i,
536
+ published: true,
537
+ level: {
538
+ id: `lvl_${i % 3}`,
539
+ name: ["Red", "Yellow", "Green"][i % 3],
540
+ },
541
+ }));
542
+
543
+ const levels = [
544
+ { id: "lvl_0", name: "Red", rank: 1, color: "#dc2626" },
545
+ { id: "lvl_1", name: "Yellow", rank: 2, color: "#eab308" },
546
+ { id: "lvl_2", name: "Green", rank: 3, color: "#16a34a" },
547
+ ];
548
+
549
+ scorecards.push({
550
+ id: scorecardId,
551
+ name: scorecardName,
552
+ description: `Scorecard tracking ${driver.name.toLowerCase()} for ${team.name}`,
553
+ type: "LEVEL",
554
+ published: true,
555
+ checks,
556
+ levels,
557
+ tags: [
558
+ {
559
+ value: isDeclining ? "remediation" : "improvement",
560
+ color: isDeclining ? "#dc2626" : "#16a34a",
561
+ },
562
+ ],
563
+ });
564
+
565
+ // Compute initiative completion from scenario timeline
566
+ const endDate = new Date(scenario.timerange_end + "-28");
567
+ const startDate = new Date(scenario.timerange_start + "-01");
568
+ const totalDays =
569
+ (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
570
+ const elapsed = Math.min(
571
+ totalDays,
572
+ (Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
573
+ );
574
+ const rawPct = Math.max(0, Math.min(100, (elapsed / totalDays) * 100));
575
+ const pctComplete = isDeclining
576
+ ? Math.round(rawPct * 0.7)
577
+ : Math.round(Math.min(100, rawPct * 1.1));
578
+
579
+ const passedChecks = Math.round((pctComplete / 100) * checks.length);
580
+
581
+ // Determine priority: more severe declining → higher priority (lower number)
582
+ let priority;
583
+ if (isDeclining) {
584
+ if (dx.magnitude <= -6) priority = 0;
585
+ else if (dx.magnitude <= -4) priority = 1;
586
+ else priority = 2;
587
+ } else {
588
+ priority = dx.magnitude >= 5 ? 3 : 4;
589
+ }
590
+
591
+ // Set complete_by to scenario end + 1 quarter
592
+ const completeBy = new Date(endDate);
593
+ completeBy.setMonth(completeBy.getMonth() + 3);
594
+
595
+ // Resolve owner to team manager
596
+ const manager = people.find(
597
+ (p) => p.team_id === team.id && p.is_manager,
598
+ );
599
+ const ownerPerson =
600
+ manager || people.find((p) => p.team_id === team.id);
601
+
602
+ const remainingDevDays = Math.round(
603
+ ((100 - pctComplete) / 100) * totalDays * 0.3,
604
+ );
605
+
606
+ const tags = [
607
+ { value: project?.type || "program", color: "#6366f1" },
608
+ { value: dx.driver_id, color: "#8b5cf6" },
609
+ ];
610
+ if (isDeclining) tags.push({ value: "urgent", color: "#ef4444" });
611
+
612
+ initiatives.push({
613
+ id: `init_${String(counter).padStart(3, "0")}`,
614
+ name: isDeclining
615
+ ? `Address ${driver.name} in ${team.name}`
616
+ : `Sustain ${driver.name} in ${team.name}`,
617
+ description: isDeclining
618
+ ? `Initiative to address declining ${driver.name.toLowerCase()} in ${team.name} during ${scenario.name}.`
619
+ : `Track improvements in ${driver.name.toLowerCase()} for ${team.name} during ${scenario.name}.`,
620
+ scorecard_id: scorecardId,
621
+ scorecard_name: scorecardName,
622
+ priority,
623
+ published: true,
624
+ complete_by: completeBy.toISOString().split("T")[0],
625
+ percentage_complete: pctComplete,
626
+ passed_checks: passedChecks,
627
+ total_checks: checks.length,
628
+ remaining_dev_days: remainingDevDays,
629
+ owner: ownerPerson
630
+ ? {
631
+ id: `usr_${ownerPerson.id}`,
632
+ name: ownerPerson.name,
633
+ email: ownerPerson.email,
634
+ }
635
+ : {
636
+ id: "usr_unknown",
637
+ name: "Unknown",
638
+ email: "unknown@example.com",
639
+ },
640
+ tags,
641
+ // Internal fields for rendering/joining
642
+ _scenario_id: scenario.id,
643
+ _team_id: team.id,
644
+ _driver_id: dx.driver_id,
645
+ _trajectory: dx.trajectory,
646
+ });
647
+ }
648
+ }
649
+ }
650
+
651
+ return { scorecards, initiatives };
652
+ }
653
+
654
+ /**
655
+ * Generate comment metadata for LLM prose generation.
656
+ * Each comment key contains scenario context for the LLM prompt.
657
+ * @param {import('../dsl/parser.js').UniverseAST} ast
658
+ * @param {import('./rng.js').SeededRNG} rng
659
+ * @param {object[]} people
660
+ * @param {object[]} teams
661
+ * @param {object[]} snapshots
662
+ * @returns {object[]}
663
+ */
664
+ function generateCommentKeys(ast, rng, people, teams, snapshots) {
665
+ const commentsPerSnapshot = ast.snapshots?.comments_per_snapshot || 0;
666
+ if (commentsPerSnapshot === 0) return [];
667
+
668
+ const commentKeys = [];
669
+ const driverMap = new Map(
670
+ (ast.framework?.drivers || []).map((d) => [d.id, d]),
671
+ );
672
+
673
+ for (const snap of snapshots) {
674
+ const snapDate = new Date(snap.completed_at);
675
+
676
+ // Find active scenarios during this snapshot
677
+ const activeScenarios = [];
678
+ for (const scenario of ast.scenarios) {
679
+ const start = new Date(scenario.timerange_start + "-01");
680
+ const end = new Date(scenario.timerange_end + "-28");
681
+ if (snapDate >= start && snapDate <= end) {
682
+ activeScenarios.push(scenario);
683
+ }
684
+ }
685
+
686
+ if (activeScenarios.length === 0) continue;
687
+
688
+ // Collect affected team members with their scenario context
689
+ const candidates = [];
690
+ for (const scenario of activeScenarios) {
691
+ for (const affect of scenario.affects) {
692
+ const team = teams.find((t) => t.id === affect.team_id);
693
+ if (!team) continue;
694
+ const teamPeople = people.filter((p) => p.team_id === team.id);
695
+
696
+ for (const person of teamPeople) {
697
+ // Pick the most impactful driver for this person's comment
698
+ const drivers = (affect.dx_drivers || []).sort(
699
+ (a, b) => Math.abs(b.magnitude) - Math.abs(a.magnitude),
700
+ );
701
+ const topDriver = drivers[0];
702
+ if (!topDriver) continue;
703
+
704
+ const driverDef = driverMap.get(topDriver.driver_id);
705
+ candidates.push({
706
+ person,
707
+ team,
708
+ scenario,
709
+ driver_id: topDriver.driver_id,
710
+ driver_name: driverDef?.name || topDriver.driver_id,
711
+ trajectory: topDriver.trajectory,
712
+ magnitude: topDriver.magnitude,
713
+ });
714
+ }
715
+ }
716
+ }
717
+
718
+ // Select comments_per_snapshot respondents, weighted toward declining drivers
719
+ const selected = [];
720
+ const shuffled = rng.shuffle([...candidates]);
721
+ // Prioritize declining drivers
722
+ const declining = shuffled.filter((c) => c.trajectory === "declining");
723
+ const rising = shuffled.filter((c) => c.trajectory === "rising");
724
+ const ordered = [...declining, ...rising];
725
+
726
+ for (let i = 0; i < Math.min(commentsPerSnapshot, ordered.length); i++) {
727
+ const c = ordered[i];
728
+ selected.push({
729
+ snapshot_id: snap.snapshot_id,
730
+ email: c.person.email,
731
+ team_id: c.team.id,
732
+ timestamp: randDate(
733
+ rng,
734
+ new Date(snap.scheduled_for),
735
+ snapDate,
736
+ ).toISOString(),
737
+ driver_id: c.driver_id,
738
+ driver_name: c.driver_name,
739
+ trajectory: c.trajectory,
740
+ magnitude: c.magnitude,
741
+ scenario_name: c.scenario.name,
742
+ team_name: c.team.name,
743
+ person_level: c.person.level,
744
+ person_discipline: c.person.discipline,
745
+ });
746
+ }
747
+
748
+ commentKeys.push(...selected);
749
+ }
750
+
751
+ return commentKeys;
752
+ }
753
+
754
+ /**
755
+ * Generate quarterly roster snapshots for Summit trajectory.
756
+ * Simulates roster changes (hires, departures, promotions, transfers)
757
+ * between quarters.
758
+ * @param {import('../dsl/parser.js').UniverseAST} ast
759
+ * @param {import('./rng.js').SeededRNG} rng
760
+ * @param {object[]} people
761
+ * @param {object[]} teams
762
+ * @param {object[]} snapshots
763
+ * @returns {object[]}
764
+ */
765
+ function generateRosterSnapshots(ast, rng, people, teams, snapshots) {
766
+ if (snapshots.length === 0) return [];
767
+
768
+ const rosterSnapshots = [];
769
+ // Start with current roster as baseline and work through quarters
770
+ let currentRoster = people.map((p) => ({
771
+ email: p.email,
772
+ name: p.name,
773
+ discipline: p.discipline,
774
+ level: p.level,
775
+ track: p.track || null,
776
+ team_id: p.team_id,
777
+ manager_email: p.manager_email,
778
+ }));
779
+
780
+ const levelOrder = ["L1", "L2", "L3", "L4", "L5"];
781
+ let hireCounter = 0;
782
+
783
+ for (let i = 0; i < snapshots.length; i++) {
784
+ const snap = snapshots[i];
785
+ const quarter = snap.snapshot_id.replace("snap_", "");
786
+ const changes = [];
787
+
788
+ if (i > 0) {
789
+ // Simulate departures (0-2 per quarter)
790
+ const departureCount = rng.randomInt(0, 2);
791
+ for (let d = 0; d < departureCount && currentRoster.length > 10; d++) {
792
+ const idx = rng.randomInt(0, currentRoster.length - 1);
793
+ const departed = currentRoster[idx];
794
+ changes.push({
795
+ type: "depart",
796
+ name: departed.name,
797
+ email: departed.email,
798
+ team_id: departed.team_id,
799
+ });
800
+ currentRoster.splice(idx, 1);
801
+ }
802
+
803
+ // Simulate hires (1-3 per quarter)
804
+ const hireCount = rng.randomInt(1, 3);
805
+ for (let h = 0; h < hireCount; h++) {
806
+ hireCounter++;
807
+ const team = rng.pick(teams);
808
+ const level = rng.pick(["L1", "L1", "L2", "L2", "L3"]);
809
+ const discipline = rng.pick([
810
+ "software_engineering",
811
+ "software_engineering",
812
+ "data_engineering",
813
+ ]);
814
+ const email = `hire_${hireCounter}@${ast.domain || "example.com"}`;
815
+ const name = `NewHire_${hireCounter}`;
816
+ const manager = currentRoster.find(
817
+ (p) =>
818
+ p.team_id === team.id &&
819
+ people.find((op) => op.email === p.email)?.is_manager,
820
+ );
821
+
822
+ const hire = {
823
+ email,
824
+ name,
825
+ discipline,
826
+ level,
827
+ track: null,
828
+ team_id: team.id,
829
+ manager_email: manager?.email || null,
830
+ };
831
+ currentRoster.push(hire);
832
+ changes.push({ type: "join", name, email, team_id: team.id });
833
+ }
834
+
835
+ // Simulate promotions (0-2 per quarter)
836
+ const promotionCount = rng.randomInt(0, 2);
837
+ for (let p = 0; p < promotionCount; p++) {
838
+ const promotable = currentRoster.filter((r) => {
839
+ const idx = levelOrder.indexOf(r.level);
840
+ return idx >= 0 && idx < levelOrder.length - 1;
841
+ });
842
+ if (promotable.length === 0) continue;
843
+ const person = rng.pick(promotable);
844
+ const oldLevel = person.level;
845
+ const newLevel = levelOrder[levelOrder.indexOf(oldLevel) + 1];
846
+ person.level = newLevel;
847
+ changes.push({
848
+ type: "promote",
849
+ name: person.name,
850
+ email: person.email,
851
+ from: oldLevel,
852
+ to: newLevel,
853
+ });
854
+ }
855
+
856
+ // Simulate transfers (0-1 per quarter)
857
+ if (rng.random() > 0.6) {
858
+ const transferable = currentRoster.filter(
859
+ (r) => !people.find((op) => op.email === r.email)?.is_manager,
860
+ );
861
+ if (transferable.length > 0) {
862
+ const person = rng.pick(transferable);
863
+ const otherTeams = teams.filter((t) => t.id !== person.team_id);
864
+ if (otherTeams.length > 0) {
865
+ const newTeam = rng.pick(otherTeams);
866
+ const oldTeamId = person.team_id;
867
+ person.team_id = newTeam.id;
868
+ const newManager = currentRoster.find(
869
+ (p) =>
870
+ p.team_id === newTeam.id &&
871
+ people.find((op) => op.email === p.email)?.is_manager,
872
+ );
873
+ person.manager_email = newManager?.email || person.manager_email;
874
+ changes.push({
875
+ type: "transfer",
876
+ name: person.name,
877
+ email: person.email,
878
+ from_team: oldTeamId,
879
+ to_team: newTeam.id,
880
+ });
881
+ }
882
+ }
883
+ }
884
+ }
885
+
886
+ rosterSnapshots.push({
887
+ quarter,
888
+ snapshot_id: snap.snapshot_id,
889
+ members: currentRoster.length,
890
+ roster: currentRoster.map((r) => ({ ...r })),
891
+ changes,
892
+ });
893
+ }
894
+
895
+ return rosterSnapshots;
896
+ }
897
+
898
+ /**
899
+ * Derive project teams with allocation for Summit what-if scenarios.
900
+ * @param {import('../dsl/parser.js').UniverseAST} ast
901
+ * @param {import('./rng.js').SeededRNG} rng
902
+ * @param {object[]} people
903
+ * @param {object[]} teams
904
+ * @returns {object[]}
905
+ */
906
+ function deriveProjectTeams(ast, rng, people, _teams) {
907
+ const projectTeams = [];
908
+
909
+ for (const project of ast.projects) {
910
+ const projectTeamIds = project.teams || [];
911
+ const members = [];
912
+
913
+ for (const teamId of projectTeamIds) {
914
+ const teamPeople = people.filter((p) => p.team_id === teamId);
915
+ // Select a subset of team members for the project
916
+ const count = Math.max(2, Math.round(teamPeople.length * 0.6));
917
+ const selected = rng.shuffle([...teamPeople]).slice(0, count);
918
+
919
+ for (const person of selected) {
920
+ // Assign allocation: 0.2-1.0, weighted toward full-time
921
+ const allocation =
922
+ projectTeamIds.length > 1 && rng.random() > 0.5
923
+ ? Math.round(rng.random() * 0.6 * 10 + 4) / 10 // 0.4-1.0
924
+ : 1.0;
925
+ members.push({
926
+ email: person.email,
927
+ name: person.name,
928
+ job: {
929
+ discipline: person.discipline,
930
+ level: person.level,
931
+ track: person.track || undefined,
932
+ },
933
+ allocation,
934
+ });
935
+ }
936
+ }
937
+
938
+ projectTeams.push({
939
+ id: project.id,
940
+ name: project.name,
941
+ members,
942
+ });
943
+ }
944
+
945
+ return projectTeams;
946
+ }
947
+
948
+ function randDate(rng, start, end) {
949
+ return new Date(
950
+ start.getTime() + rng.random() * (end.getTime() - start.getTime()),
951
+ );
952
+ }
953
+
954
+ function round1(v) {
955
+ return Math.round(v * 10) / 10;
956
+ }