@ak--47/dungeon-master 1.2.1 → 1.2.3

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.
@@ -8,7 +8,7 @@
8
8
  /** @typedef {import('../../types.js').Storage} Storage */
9
9
  /** @typedef {import('../../types.js').hookArrayOptions<any>} hookArrayOptions */
10
10
 
11
- import { existsSync } from "fs";
11
+ import { existsSync, mkdirSync } from "fs";
12
12
  import pLimit from 'p-limit';
13
13
  import os from "os";
14
14
  import path from "path";
@@ -60,8 +60,13 @@ export async function createHookArray(arr = [], opts) {
60
60
  writeDir = path.resolve(os.tmpdir());
61
61
  }
62
62
 
63
- if (typeof config.writeToDisk === "string" && config.writeToDisk.startsWith('gs://')) {
64
- writeDir = config.writeToDisk;
63
+ if (typeof config.writeToDisk === "string") {
64
+ if (config.writeToDisk.startsWith('gs://')) {
65
+ writeDir = config.writeToDisk;
66
+ } else {
67
+ writeDir = path.resolve(config.writeToDisk);
68
+ if (!existsSync(writeDir)) mkdirSync(writeDir, { recursive: true });
69
+ }
65
70
  }
66
71
 
67
72
  function getWritePath() {
@@ -213,7 +218,7 @@ export async function createHookArray(arr = [], opts) {
213
218
  while (isWriting) {
214
219
  await new Promise(resolve => setTimeout(resolve, 10));
215
220
  }
216
-
221
+
217
222
  isWriting = true;
218
223
  try {
219
224
  batch++;
@@ -221,6 +226,11 @@ export async function createHookArray(arr = [], opts) {
221
226
  const dataToWrite = [...arr];
222
227
  arr.length = 0; // Clear array after copying data
223
228
  await FILE_CONN(() => writeToDisk(dataToWrite, { writePath }));
229
+ // Data now lives on disk, not in arr — mirror what transformThenPush
230
+ // does when crossing BATCH_SIZE so the Mixpanel sender knows to read
231
+ // from disk instead of from the (now empty) in-memory array.
232
+ isBatchMode = true;
233
+ runtime.isBatchMode = true;
224
234
  } finally {
225
235
  isWriting = false;
226
236
  }
@@ -48,9 +48,10 @@ export async function sendToMixpanel(context) {
48
48
  const commonOpts = {
49
49
  region,
50
50
  fixData: true,
51
+ v2_compat: true,
51
52
  verbose: false,
52
53
  forceStream: true,
53
- strict: true,
54
+ strict: false,
54
55
  epochEnd: dayjs().unix(),
55
56
  dryRun: false,
56
57
  abridged: false,
@@ -159,7 +160,6 @@ export async function sendToMixpanel(context) {
159
160
  const imported = await mp(creds, groupEventDataToImport, {
160
161
  recordType: "event",
161
162
  ...commonOpts,
162
- strict: false
163
163
  });
164
164
  log(` -> ${comma(imported.success)} group events sent\n`);
165
165
  importResults.groupEvents = imported;
@@ -198,7 +198,6 @@ export async function sendToMixpanel(context) {
198
198
  scdKey,
199
199
  scdType,
200
200
  scdLabel: `${scdKey}`,
201
- fixData: true,
202
201
  ...commonOpts,
203
202
  };
204
203
 
@@ -227,6 +226,9 @@ export async function sendToMixpanel(context) {
227
226
  }
228
227
  }
229
228
 
229
+ importResults.problems = collectProblems(importResults);
230
+ logProblems(importResults.problems);
231
+
230
232
  log(`${'─'.repeat(50)}\n`);
231
233
 
232
234
  // Clean up batch files if needed
@@ -263,4 +265,63 @@ export async function sendToMixpanel(context) {
263
265
  let _verbose = true;
264
266
  function log(message) {
265
267
  if (_verbose) console.log(message);
268
+ }
269
+
270
+ /**
271
+ * Walk importResults and collect any failure/error indicators per import.
272
+ * Returns an array of problem objects (empty when all imports succeeded).
273
+ * @param {Object} importResults
274
+ * @returns {Array<{label: string, failed: number, unparsable: number, serverErrors: number, clientErrors: number, errorCount: number, error?: string, errors?: any[]}>}
275
+ */
276
+ function collectProblems(importResults) {
277
+ const entries = [];
278
+ for (const [key, value] of Object.entries(importResults)) {
279
+ if (!value || key === 'problems') continue;
280
+ if (Array.isArray(value)) {
281
+ value.forEach((v, i) => entries.push([`${key}[${i}]`, v]));
282
+ } else {
283
+ entries.push([key, value]);
284
+ }
285
+ }
286
+
287
+ const problems = [];
288
+ for (const [label, result] of entries) {
289
+ if (!result || typeof result !== 'object') continue;
290
+ const failed = result.failed || 0;
291
+ const unparsable = result.unparsable || 0;
292
+ const serverErrors = result.serverErrors || 0;
293
+ const clientErrors = result.clientErrors || 0;
294
+ const errorCount = Array.isArray(result.errors) ? result.errors.length : 0;
295
+ const error = result.error;
296
+ if (failed || unparsable || serverErrors || clientErrors || errorCount || error) {
297
+ problems.push({ label, failed, unparsable, serverErrors, clientErrors, errorCount, error, errors: result.errors });
298
+ }
299
+ }
300
+ return problems;
301
+ }
302
+
303
+ /**
304
+ * Print the compact problem report. Silent when there are no problems.
305
+ * @param {ReturnType<typeof collectProblems>} problems
306
+ */
307
+ function logProblems(problems) {
308
+ if (!problems || problems.length === 0) return;
309
+
310
+ log(` Problems`);
311
+ for (const p of problems) {
312
+ const parts = [];
313
+ if (p.failed) parts.push(`${comma(p.failed)} failed`);
314
+ if (p.unparsable) parts.push(`${comma(p.unparsable)} unparsable`);
315
+ if (p.serverErrors) parts.push(`${comma(p.serverErrors)} 5xx (retried)`);
316
+ if (p.clientErrors) parts.push(`${comma(p.clientErrors)} client errors (retried)`);
317
+ if (p.errorCount) parts.push(`${comma(p.errorCount)} error records`);
318
+ if (p.error) parts.push(`error: ${p.error}`);
319
+ log(` -> ${p.label}: ${parts.join(', ')}`);
320
+ if (Array.isArray(p.errors) && p.errors.length > 0) {
321
+ const sample = p.errors[0];
322
+ const summary = typeof sample === 'string' ? sample : JSON.stringify(sample).slice(0, 200);
323
+ log(` sample: ${summary}`);
324
+ }
325
+ }
326
+ log('');
266
327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -66,7 +66,7 @@
66
66
  "dotenv": "^16.4.5",
67
67
  "hyparquet-writer": "^0.6.1",
68
68
  "mixpanel": "^0.18.0",
69
- "mixpanel-import": "^3.2.8",
69
+ "mixpanel-import": "^3.2.9",
70
70
  "p-limit": "^3.1.0",
71
71
  "pino": "^9.0.0",
72
72
  "pino-pretty": "^11.0.0",
@@ -1,847 +0,0 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "dm4-dungeon";
3
- const num_days = 90;
4
- const num_users = 2_000;
5
- const avg_events_per_user = 120;
6
- let token = "your-mixpanel-token";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
11
- import dayjs from "dayjs";
12
- import utc from "dayjs/plugin/utc.js";
13
- import * as u from "@ak--47/dungeon-master/utils";
14
- import * as v from "ak-tools";
15
-
16
- dayjs.extend(utc);
17
- const chance = u.initChance(SEED);
18
-
19
- /** @typedef {import("@ak--47/dungeon-master").Dungeon} Config */
20
-
21
- const lastSearchQueryType = new Map();
22
- const workspaceAiUserCount = new Map();
23
- const workspaceTotalUsers = new Map();
24
-
25
- /**
26
- * ═══════════════════════════════════════════════════════════════════════════
27
- * DATASET OVERVIEW — SLACK AI PRODUCTIVITY & COLLABORATION PLATFORM
28
- * ═══════════════════════════════════════════════════════════════════════════
29
- *
30
- * Slack AI is a modern team collaboration platform with AI-powered features:
31
- * channel/thread summarization, intelligent natural-language search, and
32
- * AI-assisted writing. This dungeon is designed for a CPO-level demo
33
- * (Shalini Agarwal) focused on behavioral correlations between AI feature
34
- * adoption and core platform metrics.
35
- *
36
- * Scale: 2,000 users · 500 workspaces · 90 days · 19 event types
37
- * Groups: 500 workspace_id groups associated with messaging + workspace events
38
- *
39
- * Key Segments:
40
- * - Power User: high session count, AI feature heavy, message volume leader
41
- * - Traditionalist: message-first, low AI adoption, consistent but not viral
42
- * - Lurker: reads and summarizes, rarely sends; high AI summary usage
43
- *
44
- * Subscription tiers: Pro / Business+ / Enterprise Grid
45
- *
46
- * Core loop:
47
- * workspace_created → member_invited → session_start →
48
- * message_sent / message_read / thread_replied →
49
- * ai_prompt_viewed → ai_summary_generated / ai_search_performed →
50
- * huddle_started → canvas_edited → reaction_added
51
- *
52
- * Monetization signals: integration_added, subscription tier (superProp),
53
- * canvas adoption, AI feature depth (tokens_saved).
54
- */
55
-
56
- /**
57
- * ═══════════════════════════════════════════════════════════════════════════
58
- * ANALYTICS HOOKS (7 architected patterns)
59
- * ═══════════════════════════════════════════════════════════════════════════
60
- *
61
- * ───────────────────────────────────────────────────────────────────────────
62
- * 1. THE AI MULTIPLIER (everything)
63
- * ───────────────────────────────────────────────────────────────────────────
64
- *
65
- * PATTERN: Users who generate 5+ AI summaries in any 7-day rolling window
66
- * become "AI-activated." For each such window, clone message_sent and
67
- * thread_replied events into the FOLLOWING 7 days (3x volume boost with
68
- * randomized time offsets so it looks organic). Events tagged ai_multiplier.
69
- *
70
- * HOW TO FIND IT IN MIXPANEL:
71
- *
72
- * Report 1: AI Multiplier — Message Volume
73
- * • Report type: Insights
74
- * • Event: "message_sent"
75
- * • Measure: Total (per user)
76
- * • Breakdown: "ai_multiplier"
77
- * • Expected: ai_multiplier=true users show ~3x higher message volume
78
- * than ai_multiplier=false users
79
- *
80
- * Report 2: AI Multiplier — Thread Engagement
81
- * • Report type: Insights
82
- * • Event: "thread_replied"
83
- * • Measure: Total (per user)
84
- * • Breakdown: "ai_multiplier"
85
- * • Expected: ~3x higher thread reply count for AI-activated users
86
- *
87
- * REAL-WORLD ANALOGUE: Teams that discover AI summarization unlock faster
88
- * context-switching and communicate more frequently as a result.
89
- *
90
- * ───────────────────────────────────────────────────────────────────────────
91
- * 2. SEARCH EFFICIENCY (event + closure Map)
92
- * ───────────────────────────────────────────────────────────────────────────
93
- *
94
- * PATTERN: Natural-language AI searches lead to 40% faster result clicks
95
- * vs keyword searches. The event hook tracks the last ai_search_performed
96
- * query_type per user in a module-level Map, then reads it on the next
97
- * search_result_clicked to multiply time_to_click_ms by 0.6 for
98
- * natural_language queries.
99
- *
100
- * HOW TO FIND IT IN MIXPANEL:
101
- *
102
- * Report 1: Search Click Speed by Query Type
103
- * • Report type: Insights
104
- * • Event: "search_result_clicked"
105
- * • Measure: Average of "time_to_click_ms"
106
- * • Breakdown: "query_type"
107
- * • Expected: "natural_language" ≈ 3,000ms vs "keyword" ≈ 5,000ms (~40% faster)
108
- *
109
- * Report 2: AI Search Adoption Funnel
110
- * • Report type: Funnels
111
- * • Steps: "ai_search_performed" → "search_result_clicked"
112
- * • Breakdown: "is_ai_powered"
113
- * • Expected: is_ai_powered=true converts at ~80%, false at ~60%
114
- *
115
- * REAL-WORLD ANALOGUE: Natural language search reduces cognitive load —
116
- * users find what they need faster because they can express intent in plain
117
- * English rather than crafting keyword strings.
118
- *
119
- * ───────────────────────────────────────────────────────────────────────────
120
- * 3. WORKSPACE STICKINESS (everything)
121
- * ───────────────────────────────────────────────────────────────────────────
122
- *
123
- * PATTERN: Workspaces where fewer than 20% of members use AI features
124
- * (ai_summary_generated or ai_search_performed) are "low-adoption." For
125
- * users in low-adoption workspaces, 30% of events after day 30 are removed,
126
- * simulating the retention gap between high- and low-AI workspaces.
127
- *
128
- * HOW TO FIND IT IN MIXPANEL:
129
- *
130
- * Report 1: Retention by Workspace AI Adoption
131
- * • Report type: Retention
132
- * • Event: Any Event (first event = session_start)
133
- * • Breakdown: Group Property "workspace_id" → "ai_adoption_tier"
134
- * • Expected: High-adoption workspaces show ~95% MoM retention;
135
- * low-adoption workspaces show ~70% (≈25pp gap)
136
- *
137
- * Report 2: Event Volume Over Time
138
- * • Report type: Insights (line chart)
139
- * • Event: Any Event
140
- * • Measure: Total
141
- * • Breakdown: "workspace_id"
142
- * • Filter: events after day 30
143
- * • Expected: Low-adoption workspaces show visible event drop-off
144
- *
145
- * REAL-WORLD ANALOGUE: AI adoption is a network effect — one power user
146
- * sharing summaries pulls colleagues in. Below a critical mass threshold,
147
- * the workspace stagnates and churns.
148
- *
149
- * ───────────────────────────────────────────────────────────────────────────
150
- * 4. HUDDLE STICKINESS (everything)
151
- * ───────────────────────────────────────────────────────────────────────────
152
- *
153
- * PATTERN: Users who start a huddle with ai_notes_enabled=true are 2x more
154
- * likely to edit a Canvas within 1 hour afterward. If no canvas_edited
155
- * event exists within 60 minutes of the huddle, one is cloned from the
156
- * user's history and injected 20 minutes after the huddle_started event.
157
- *
158
- * HOW TO FIND IT IN MIXPANEL:
159
- *
160
- * Report 1: Huddle → Canvas Funnel by AI Notes
161
- * • Report type: Funnels
162
- * • Steps: "huddle_started" → "canvas_edited"
163
- * • Conversion window: 1 hour
164
- * • Breakdown: "ai_notes_enabled"
165
- * • Expected: ai_notes_enabled=true converts at ~40% vs ~20% for false
166
- *
167
- * Report 2: Canvas Edits After Huddles
168
- * • Report type: Insights
169
- * • Event: "canvas_edited"
170
- * • Measure: Total (per user)
171
- * • Filter: Preceded by "huddle_started" within 1 hour
172
- * • Breakdown: "ai_notes_enabled"
173
- * • Expected: 2x more canvas edits for AI-notes users
174
- *
175
- * REAL-WORLD ANALOGUE: AI meeting notes lower the activation energy for
176
- * documenting outcomes — users don't have to reconstruct what was said,
177
- * so they're more likely to immediately capture action items in a Canvas.
178
- *
179
- * ───────────────────────────────────────────────────────────────────────────
180
- * 5. LURKER SUMMARIZATION PATTERN (everything)
181
- * ───────────────────────────────────────────────────────────────────────────
182
- *
183
- * PATTERN: Users with persona="Lurker" have 80% of their message_sent
184
- * events removed (they read, don't write) but get their ai_summary_generated
185
- * events quadrupled (they consume AI summaries voraciously). This creates a
186
- * clear behavioral segment: high summaries, low messages.
187
- *
188
- * HOW TO FIND IT IN MIXPANEL:
189
- *
190
- * Report 1: AI Summary Usage by Persona
191
- * • Report type: Insights
192
- * • Event: "ai_summary_generated"
193
- * • Measure: Total (per user)
194
- * • Breakdown: User Property "persona"
195
- * • Expected: "Lurker" shows ~4x higher summaries than "Power User"
196
- * or "Traditionalist"
197
- *
198
- * Report 2: Message Volume by Persona
199
- * • Report type: Insights
200
- * • Event: "message_sent"
201
- * • Measure: Total (per user)
202
- * • Breakdown: User Property "persona"
203
- * • Expected: "Lurker" shows ~0.2x messages vs "Power User"
204
- *
205
- * Report 3: Persona Behavioral Matrix
206
- * • Report type: Insights
207
- * • Events: "message_sent" + "ai_summary_generated" side by side
208
- * • Measure: Total (per user)
209
- * • Breakdown: User Property "persona"
210
- * • Expected: Inverse relationship — Lurkers summarize more, send less
211
- *
212
- * REAL-WORLD ANALOGUE: In every Slack workspace, there are silent readers
213
- * who follow dozens of channels but rarely post. AI summaries are their
214
- * killer feature — they can stay informed without the noise.
215
- *
216
- * ───────────────────────────────────────────────────────────────────────────
217
- * 6. MONDAY PRODUCTIVITY SPIKE (event)
218
- * ───────────────────────────────────────────────────────────────────────────
219
- *
220
- * PATTERN: AI summary generation is 50% higher on Mondays as users catch up
221
- * from the weekend. When an ai_summary_generated event fires on a Monday,
222
- * the tokens_saved value is boosted by 1.5x and the event is cloned once
223
- * with a short time offset to double Monday volume.
224
- *
225
- * HOW TO FIND IT IN MIXPANEL:
226
- *
227
- * Report 1: AI Summaries by Day of Week
228
- * • Report type: Insights (bar chart)
229
- * • Event: "ai_summary_generated"
230
- * • Measure: Total
231
- * • Time: Break down by Day of Week
232
- * • Expected: Monday bar is visibly ~1.5x taller than Tue–Fri average
233
- *
234
- * Report 2: Tokens Saved on Mondays
235
- * • Report type: Insights
236
- * • Event: "ai_summary_generated"
237
- * • Measure: Average of "tokens_saved"
238
- * • Breakdown: "monday_spike"
239
- * • Expected: monday_spike=true shows ~1.5x higher avg tokens_saved
240
- *
241
- * REAL-WORLD ANALOGUE: Monday morning is "catch-up time" — users open Slack
242
- * and immediately want to know what happened over the weekend. AI summaries
243
- * are the fastest path from inbox-zero anxiety to being in the loop.
244
- *
245
- * ───────────────────────────────────────────────────────────────────────────
246
- * 7. AI WRITING ASSISTANCE VALUE (event)
247
- * ───────────────────────────────────────────────────────────────────────────
248
- *
249
- * PATTERN: Messages marked as is_ai_assisted=true have 2x higher
250
- * content_length. In the event hook, when message_sent fires with
251
- * is_ai_assisted=true, content_length is multiplied by 2.0.
252
- *
253
- * HOW TO FIND IT IN MIXPANEL:
254
- *
255
- * Report 1: Message Length by AI Assistance
256
- * • Report type: Insights
257
- * • Event: "message_sent"
258
- * • Measure: Average of "content_length"
259
- * • Breakdown: "is_ai_assisted"
260
- * • Expected: is_ai_assisted=true ≈ 300 chars vs false ≈ 150 chars (2x)
261
- *
262
- * Report 2: AI Writing Adoption Over Time
263
- * • Report type: Insights (line chart)
264
- * • Event: "message_sent"
265
- * • Measure: Total
266
- * • Filter: "is_ai_assisted" = true
267
- * • Time: Weekly
268
- * • Expected: Growing trend as users discover the feature (growth soup)
269
- *
270
- * REAL-WORLD ANALOGUE: AI writing assistance helps users write more complete,
271
- * thoughtful messages — reducing back-and-forth and improving async
272
- * communication quality. Longer messages carry more context.
273
- *
274
- * ═══════════════════════════════════════════════════════════════════════════
275
- * EXPECTED METRICS SUMMARY
276
- * ═══════════════════════════════════════════════════════════════════════════
277
- *
278
- * Hook | Metric | Baseline | Effect | Ratio
279
- * ──────────────────────|───────────────────────|───────────|───────────|──────
280
- * AI Multiplier | message_sent/user/wk | ~40 | ~120 | 3x
281
- * Search Efficiency | time_to_click_ms | ~5,000ms | ~3,000ms | 0.6x
282
- * Workspace Stickiness | MoM retention | ~70% | ~95% | 1.35x
283
- * Huddle Stickiness | huddle→canvas conv | ~20% | ~40% | 2x
284
- * Lurker Summarization | summaries/user | ~12 | ~48 | 4x
285
- * Monday Spike | summaries on Monday | baseline | +50% | 1.5x
286
- * AI Writing Value | content_length (avg) | ~150 | ~300 | 2x
287
- */
288
-
289
- /** @type {Config} */
290
- const config = {
291
- token,
292
- seed: SEED,
293
- numDays: num_days,
294
- numEvents: num_users * avg_events_per_user,
295
- numUsers: num_users,
296
- hasAnonIds: false,
297
- hasSessionIds: true,
298
- format: "json",
299
- gzip: true,
300
- alsoInferFunnels: false,
301
- hasLocation: true,
302
- hasAndroidDevices: true,
303
- hasIOSDevices: true,
304
- hasDesktopDevices: true,
305
- hasBrowser: false,
306
- hasCampaigns: false,
307
- isAnonymous: false,
308
- hasAdSpend: false,
309
- percentUsersBornInDataset: 35,
310
- hasAvatar: true,
311
- batchSize: 2_500_000,
312
- concurrency: 1,
313
- writeToDisk: false,
314
- scdProps: {},
315
- mirrorProps: {},
316
- lookupTables: [],
317
-
318
- soup: "growth",
319
-
320
- groupKeys: [
321
- ["workspace_id", 500, [
322
- "message_sent",
323
- "message_read",
324
- "thread_replied",
325
- "ai_summary_generated",
326
- "ai_search_performed",
327
- "huddle_started",
328
- "huddle_joined",
329
- "canvas_edited",
330
- "channel_created",
331
- "member_invited",
332
- "workspace_created",
333
- ]],
334
- ],
335
-
336
- groupProps: {
337
- workspace_id: {
338
- "workspace_name": () => `${chance.pickone(["Acme", "Globex", "Initech", "Umbrella", "Hooli", "Pied Piper", "Dunder Mifflin", "Vandelay", "Bluth", "Sterling"])} ${chance.pickone(["Corp", "Inc", "Labs", "HQ", "Team", "Group", "Co", "Ltd"])}`,
339
- "workspace_plan": ["Pro", "Business+", "Business+", "Enterprise Grid"],
340
- "member_count": u.weighNumRange(5, 500, 0.5, 30),
341
- "ai_adoption_tier": ["low", "low", "medium", "high"],
342
- "industry": ["Technology", "Finance", "Healthcare", "Retail", "Media", "Education", "Consulting"],
343
- }
344
- },
345
-
346
- funnels: [
347
- {
348
- name: "Workspace Growth",
349
- sequence: ["member_invited", "session_start", "ai_summary_generated"],
350
- isFirstFunnel: true,
351
- conversionRate: 60,
352
- timeToConvert: 2,
353
- },
354
- {
355
- name: "AI Adoption",
356
- sequence: ["message_sent", "ai_prompt_viewed", "ai_summary_generated"],
357
- conversionRate: 25,
358
- timeToConvert: 0.1,
359
- weight: 10,
360
- },
361
- {
362
- name: "Collaboration Loop",
363
- sequence: ["huddle_joined", "canvas_edited"],
364
- conversionRate: 40,
365
- timeToConvert: 0.04,
366
- },
367
- {
368
- name: "Intelligent Search",
369
- sequence: ["ai_search_performed", "search_result_clicked"],
370
- conversionRate: 80,
371
- timeToConvert: 0.01,
372
- },
373
- {
374
- name: "Onboarding",
375
- sequence: ["workspace_created", "channel_created", "member_invited"],
376
- conversionRate: 45,
377
- timeToConvert: 1,
378
- },
379
- ],
380
-
381
- events: [
382
- {
383
- event: "workspace_created",
384
- weight: 1,
385
- isFirstEvent: true,
386
- properties: {
387
- "template_used": ["blank", "engineering", "sales", "marketing", "hr", "product"],
388
- "import_from": ["none", "none", "none", "microsoft_teams", "google_chat"],
389
- }
390
- },
391
- {
392
- event: "member_invited",
393
- weight: 3,
394
- isFirstEvent: true,
395
- properties: {
396
- "invite_method": ["email", "link", "directory_sync"],
397
- "invitee_role": ["member", "member", "member", "admin", "guest"],
398
- }
399
- },
400
- {
401
- event: "session_start",
402
- weight: 20,
403
- isSessionStartEvent: true,
404
- properties: {
405
- "session_duration_sec": u.weighNumRange(60, 7200, 0.5, 900),
406
- "notifications_pending": u.weighNumRange(0, 50, 0.8, 5),
407
- }
408
- },
409
- {
410
- event: "message_sent",
411
- weight: 18,
412
- properties: {
413
- "channel_type": ["public", "private", "dm"],
414
- "content_length": u.weighNumRange(10, 500, 0.5, 80),
415
- "is_ai_assisted": u.pickAWinner([true, false], 0.25),
416
- "has_attachment": u.pickAWinner([true, false], 0.2),
417
- "ai_multiplier": [false],
418
- }
419
- },
420
- {
421
- event: "message_read",
422
- weight: 15,
423
- properties: {
424
- "source": ["mobile", "desktop"],
425
- "unread_count": u.weighNumRange(1, 100, 0.8, 10),
426
- "channel_type": ["public", "private", "dm"],
427
- }
428
- },
429
- {
430
- event: "thread_replied",
431
- weight: 10,
432
- properties: {
433
- "is_ai_assisted": u.pickAWinner([true, false], 0.2),
434
- "reply_length": u.weighNumRange(5, 300, 0.5, 60),
435
- "thread_depth": u.weighNumRange(1, 20, 0.8, 3),
436
- "ai_multiplier": [false],
437
- }
438
- },
439
- {
440
- event: "reaction_added",
441
- weight: 12,
442
- properties: {
443
- "emoji": ["+1", "clap", "fire", "eyes", "check"],
444
- "message_age_hours": u.weighNumRange(0, 72, 0.8, 2),
445
- }
446
- },
447
- {
448
- event: "ai_prompt_viewed",
449
- weight: 8,
450
- properties: {
451
- "feature": ["summarize", "search", "compose"],
452
- "entry_point": ["channel_header", "search_bar", "compose_box", "shortcut"],
453
- "converted": u.pickAWinner([true, false], 0.6),
454
- }
455
- },
456
- {
457
- event: "ai_summary_generated",
458
- weight: 6,
459
- properties: {
460
- "source_type": ["channel", "thread", "huddle"],
461
- "tokens_saved": u.weighNumRange(50, 2000, 0.5, 400),
462
- "messages_summarized": u.weighNumRange(5, 200, 0.8, 25),
463
- "monday_spike": [false],
464
- }
465
- },
466
- {
467
- event: "ai_search_performed",
468
- weight: 7,
469
- properties: {
470
- "query_type": ["natural_language", "keyword"],
471
- "is_ai_powered": u.pickAWinner([true, false], 0.55),
472
- "results_count": u.weighNumRange(0, 50, 0.5, 12),
473
- "query_length_chars": u.weighNumRange(5, 200, 0.5, 40),
474
- }
475
- },
476
- {
477
- event: "ai_writing_assisted",
478
- weight: 4,
479
- properties: {
480
- "tone": ["professional", "casual", "concise"],
481
- "words_suggested": u.weighNumRange(5, 100, 0.5, 25),
482
- "accepted": u.pickAWinner([true, false], 0.7),
483
- }
484
- },
485
- {
486
- event: "search_result_clicked",
487
- weight: 6,
488
- properties: {
489
- "time_to_click_ms": u.weighNumRange(1000, 15000, 1, 5000),
490
- "position": u.weighNumRange(1, 10, 0.8, 3),
491
- "result_type": ["message", "file", "channel", "person"],
492
- "query_type": ["natural_language", "keyword"],
493
- }
494
- },
495
- {
496
- event: "huddle_started",
497
- weight: 4,
498
- properties: {
499
- "ai_notes_enabled": u.pickAWinner([true, false], 0.4),
500
- "is_scheduled": u.pickAWinner([true, false], 0.35),
501
- "participant_count": u.weighNumRange(2, 15, 0.8, 4),
502
- "duration_min": u.weighNumRange(5, 90, 0.5, 20),
503
- }
504
- },
505
- {
506
- event: "huddle_joined",
507
- weight: 8,
508
- properties: {
509
- "join_method": ["link", "notification", "scheduled"],
510
- "device_type": ["desktop", "mobile", "tablet"],
511
- }
512
- },
513
- {
514
- event: "canvas_edited",
515
- weight: 6,
516
- properties: {
517
- "edit_type": ["text", "table", "checklist", "image"],
518
- "canvas_age_days": u.weighNumRange(0, 90, 0.8, 7),
519
- "collaborators_count": u.weighNumRange(1, 10, 0.8, 2),
520
- }
521
- },
522
- {
523
- event: "file_shared",
524
- weight: 5,
525
- properties: {
526
- "extension": ["pdf", "docx", "png", "csv"],
527
- "file_size_kb": u.weighNumRange(10, 50000, 0.3, 500),
528
- "channel_type": ["public", "private", "dm"],
529
- }
530
- },
531
- {
532
- event: "channel_created",
533
- weight: 2,
534
- properties: {
535
- "is_private": u.pickAWinner([true, false], 0.4),
536
- "purpose_set": u.pickAWinner([true, false], 0.6),
537
- "channel_type": ["topic", "project", "team", "social"],
538
- }
539
- },
540
- {
541
- event: "integration_added",
542
- weight: 2,
543
- properties: {
544
- "category": ["crm", "dev_tools", "hr", "productivity"],
545
- "integration_name": ["Salesforce", "GitHub", "Jira", "Zoom", "Google Drive", "Notion", "PagerDuty", "Figma"],
546
- "installed_by_role": ["admin", "admin", "member"],
547
- }
548
- },
549
- {
550
- event: "user_churned",
551
- weight: 0.5,
552
- isChurnEvent: true,
553
- isStrictEvent: true,
554
- returnLikelihood: 0.1,
555
- properties: {
556
- "churn_reason": ["inactivity", "workspace_deleted", "plan_downgrade", "competitor"],
557
- "days_active": u.weighNumRange(1, 90, 0.5, 20),
558
- }
559
- },
560
- ],
561
-
562
- superProps: {
563
- workspace_id: () => `T-${v.uid(8).toUpperCase()}`,
564
- account_tier: ["Pro", "Pro", "Business+", "Business+", "Enterprise Grid"],
565
- platform: ["macOS", "Windows", "iOS", "Android", "Web"],
566
- },
567
-
568
- userProps: {
569
- "persona": ["Power User", "Power User", "Traditionalist", "Traditionalist", "Traditionalist", "Lurker"],
570
- "job_role": ["Engineering", "Product", "Sales", "Marketing", "HR"],
571
- "ai_onboarding_complete": u.pickAWinner([true, false], 0.55),
572
- "department_size": u.weighNumRange(3, 200, 0.5, 20),
573
- "tenure_days": u.weighNumRange(1, 730, 0.3, 90),
574
- },
575
-
576
- hook: function (record, type, meta) {
577
- const NOW = dayjs();
578
- const DATASET_START = NOW.subtract(num_days, "days");
579
-
580
- // ═══════════════════════════════════════════════════════════════════
581
- // HOOKS 2, 6, 7: EVENT HOOK
582
- // Per-event property modifications and closure-based state tracking.
583
- // ═══════════════════════════════════════════════════════════════════
584
- if (type === "event") {
585
- const EVENT_TIME = dayjs(record.time);
586
- const dayOfWeek = EVENT_TIME.day();
587
- const userId = record.user_id;
588
-
589
- // ── Hook 2: SEARCH EFFICIENCY — track query type via closure Map ──
590
- // Store the query_type of ai_search_performed so the next
591
- // search_result_clicked can read it and apply the speed multiplier.
592
- if (record.event === "ai_search_performed") {
593
- lastSearchQueryType.set(userId, record.query_type);
594
- }
595
-
596
- // ── Hook 2: SEARCH EFFICIENCY — apply 40% speed boost on click ──
597
- // natural_language → time_to_click_ms * 0.6 (40% faster)
598
- if (record.event === "search_result_clicked") {
599
- const lastQueryType = lastSearchQueryType.get(userId);
600
- if (lastQueryType) {
601
- record.query_type = lastQueryType;
602
- if (lastQueryType === "natural_language") {
603
- record.time_to_click_ms = Math.round(record.time_to_click_ms * 0.6);
604
- }
605
- lastSearchQueryType.delete(userId);
606
- }
607
- }
608
-
609
- // ── Hook 6: MONDAY PRODUCTIVITY SPIKE — boost tokens_saved 1.5x ──
610
- // Tag monday_spike=true; the everything hook will clone these events
611
- // to produce the observable 1.5x volume increase on Mondays.
612
- if (record.event === "ai_summary_generated") {
613
- if (dayOfWeek === 1) {
614
- record.tokens_saved = Math.round(record.tokens_saved * 1.5);
615
- record.monday_spike = true;
616
- } else {
617
- record.monday_spike = false;
618
- }
619
- }
620
-
621
- // ── Hook 7: AI WRITING VALUE — double content_length for AI messages ──
622
- // is_ai_assisted=true messages get 2x content_length (capped at 500).
623
- if (record.event === "message_sent" && record.is_ai_assisted === true) {
624
- record.content_length = Math.min(500, Math.round(record.content_length * 2.0));
625
- }
626
-
627
- return record;
628
- }
629
-
630
- // ═══════════════════════════════════════════════════════════════════
631
- // HOOKS 1, 3, 4, 5, 6 (clone pass): EVERYTHING HOOK
632
- // Full event stream per user. meta.profile available for cross-table
633
- // correlation. Runs after all events for one user are generated.
634
- // ═══════════════════════════════════════════════════════════════════
635
- if (type === "everything") {
636
- const events = record;
637
- if (!events || events.length === 0) return record;
638
-
639
- const profile = meta && meta.profile ? meta.profile : {};
640
- const persona = profile.persona || "Traditionalist";
641
- const firstEventTime = dayjs(events[0].time);
642
-
643
- // ── First pass: collect behavioral signals ──────────────────
644
- const aiSummaryTimestamps = [];
645
- const huddlesWithAiNotes = [];
646
- const workspaceId = events[0] ? events[0].workspace_id : null;
647
-
648
- events.forEach((event, idx) => {
649
- if (event.event === "ai_summary_generated") {
650
- aiSummaryTimestamps.push({ time: dayjs(event.time), idx });
651
- }
652
- if (event.event === "huddle_started" && event.ai_notes_enabled === true) {
653
- huddlesWithAiNotes.push({ time: dayjs(event.time), idx });
654
- }
655
- });
656
-
657
- // ── Track workspace AI adoption ratio across all users ──────
658
- // Used by Hook 3 to determine low-adoption workspaces.
659
- if (workspaceId) {
660
- if (!workspaceTotalUsers.has(workspaceId)) {
661
- workspaceTotalUsers.set(workspaceId, 0);
662
- workspaceAiUserCount.set(workspaceId, 0);
663
- }
664
- workspaceTotalUsers.set(workspaceId, workspaceTotalUsers.get(workspaceId) + 1);
665
- if (aiSummaryTimestamps.length > 0) {
666
- workspaceAiUserCount.set(workspaceId, workspaceAiUserCount.get(workspaceId) + 1);
667
- }
668
- }
669
-
670
- // ═══════════════════════════════════════════════════════════
671
- // HOOK 1: THE AI MULTIPLIER
672
- // Find 7-day windows with 5+ summaries → clone message_sent
673
- // and thread_replied into the next 7 days (3x volume boost).
674
- // ═══════════════════════════════════════════════════════════
675
- const templateMsg = events.find(e => e.event === "message_sent");
676
- const templateThread = events.find(e => e.event === "thread_replied");
677
- const qualifyingWindowStarts = new Set();
678
-
679
- for (let i = 0; i < aiSummaryTimestamps.length; i++) {
680
- const windowStart = aiSummaryTimestamps[i].time;
681
- const windowEnd = windowStart.add(7, "days");
682
- let countInWindow = 0;
683
- for (let j = i; j < aiSummaryTimestamps.length; j++) {
684
- if (aiSummaryTimestamps[j].time.isBefore(windowEnd)) {
685
- countInWindow++;
686
- } else {
687
- break;
688
- }
689
- }
690
- if (countInWindow >= 5) {
691
- qualifyingWindowStarts.add(windowStart.valueOf());
692
- }
693
- }
694
-
695
- if (qualifyingWindowStarts.size > 0) {
696
- const injected = [];
697
- qualifyingWindowStarts.forEach(windowStartMs => {
698
- const boostStart = dayjs(windowStartMs).add(7, "days");
699
-
700
- // Clone 6 message_sent events with organic jitter (≈3x boost)
701
- if (templateMsg) {
702
- for (let k = 0; k < 6; k++) {
703
- injected.push({
704
- ...templateMsg,
705
- time: boostStart.add(chance.integer({ min: 10, max: 9000 }), "minutes").toISOString(),
706
- user_id: templateMsg.user_id,
707
- content_length: Math.round(templateMsg.content_length * (0.8 + Math.random() * 0.5)),
708
- is_ai_assisted: chance.bool({ likelihood: 40 }),
709
- ai_multiplier: true,
710
- });
711
- }
712
- }
713
-
714
- // Clone 3 thread_replied events
715
- if (templateThread) {
716
- for (let k = 0; k < 3; k++) {
717
- injected.push({
718
- ...templateThread,
719
- time: boostStart.add(chance.integer({ min: 15, max: 9000 }), "minutes").toISOString(),
720
- user_id: templateThread.user_id,
721
- reply_length: Math.round(templateThread.reply_length * (0.8 + Math.random() * 0.5)),
722
- ai_multiplier: true,
723
- });
724
- }
725
- }
726
- });
727
-
728
- if (injected.length > 0) {
729
- events.push(...injected);
730
- }
731
- }
732
-
733
- // ═══════════════════════════════════════════════════════════
734
- // HOOK 3: WORKSPACE STICKINESS
735
- // Low-adoption workspaces (<20% AI users) lose 30% of events
736
- // after day 30 — simulating the retention gap.
737
- // ═══════════════════════════════════════════════════════════
738
- const totalInWs = workspaceId ? (workspaceTotalUsers.get(workspaceId) || 1) : 1;
739
- const aiInWs = workspaceId ? (workspaceAiUserCount.get(workspaceId) || 0) : 0;
740
- const isLowAdoption = (aiInWs / totalInWs) < 0.20;
741
-
742
- if (isLowAdoption) {
743
- const day30 = firstEventTime.add(30, "days");
744
- for (let i = events.length - 1; i >= 0; i--) {
745
- if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 30 })) {
746
- events.splice(i, 1);
747
- }
748
- }
749
- }
750
-
751
- // ═══════════════════════════════════════════════════════════
752
- // HOOK 4: HUDDLE STICKINESS
753
- // For each huddle with ai_notes_enabled=true, inject a
754
- // canvas_edited event 20 minutes later if none exists in
755
- // the 60-minute window — producing 2x funnel conversion.
756
- // ═══════════════════════════════════════════════════════════
757
- const templateCanvas = events.find(e => e.event === "canvas_edited");
758
-
759
- huddlesWithAiNotes.forEach(huddle => {
760
- const huddleTime = huddle.time;
761
- const windowEnd = huddleTime.add(60, "minutes");
762
-
763
- const hasNearbyCanvas = events.some(e =>
764
- e.event === "canvas_edited" &&
765
- dayjs(e.time).isAfter(huddleTime) &&
766
- dayjs(e.time).isBefore(windowEnd)
767
- );
768
-
769
- // Inject if no canvas nearby, or 50% of the time even if one exists
770
- // (the 50% case pushes the ratio toward 2x)
771
- if (templateCanvas && (!hasNearbyCanvas || chance.bool({ likelihood: 50 }))) {
772
- events.push({
773
- ...templateCanvas,
774
- time: huddleTime.add(20, "minutes").toISOString(),
775
- user_id: templateCanvas.user_id,
776
- edit_type: chance.pickone(["text", "checklist"]),
777
- collaborators_count: chance.integer({ min: 1, max: 4 }),
778
- });
779
- }
780
- });
781
-
782
- // ═══════════════════════════════════════════════════════════
783
- // HOOK 5: LURKER SUMMARIZATION PATTERN
784
- // Lurkers: drop 80% of message_sent, quadruple ai_summary_generated.
785
- // Creates the inverse behavioral signature: high summaries, low messages.
786
- // ═══════════════════════════════════════════════════════════
787
- if (persona === "Lurker") {
788
- // Remove 80% of message_sent events (iterate backwards)
789
- for (let i = events.length - 1; i >= 0; i--) {
790
- if (events[i].event === "message_sent" && chance.bool({ likelihood: 80 })) {
791
- events.splice(i, 1);
792
- }
793
- }
794
-
795
- // Clone 3 extra summaries per existing one (= 4x total)
796
- const summarySnapshot = events.filter(e => e.event === "ai_summary_generated");
797
- const extraSummaries = [];
798
- summarySnapshot.forEach(s => {
799
- for (let k = 0; k < 3; k++) {
800
- extraSummaries.push({
801
- ...s,
802
- time: dayjs(s.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
803
- user_id: s.user_id,
804
- source_type: chance.pickone(["channel", "thread", "huddle"]),
805
- tokens_saved: chance.integer({ min: 100, max: 1500 }),
806
- messages_summarized: chance.integer({ min: 10, max: 80 }),
807
- monday_spike: false,
808
- });
809
- }
810
- });
811
- if (extraSummaries.length > 0) {
812
- events.push(...extraSummaries);
813
- }
814
- }
815
-
816
- // ═══════════════════════════════════════════════════════════
817
- // HOOK 6 (clone pass): MONDAY PRODUCTIVITY SPIKE
818
- // Clone each monday_spike=true summary once with a short offset
819
- // to produce the observable 1.5x volume effect on Mondays.
820
- // ═══════════════════════════════════════════════════════════
821
- const mondaySummaries = events.filter(
822
- e => e.event === "ai_summary_generated" && e.monday_spike === true
823
- );
824
- const mondayClones = [];
825
- mondaySummaries.forEach(s => {
826
- if (chance.bool({ likelihood: 50 })) {
827
- mondayClones.push({
828
- ...s,
829
- time: dayjs(s.time).add(chance.integer({ min: 15, max: 120 }), "minutes").toISOString(),
830
- user_id: s.user_id,
831
- tokens_saved: Math.round(s.tokens_saved * (0.8 + Math.random() * 0.4)),
832
- messages_summarized: chance.integer({ min: 5, max: 50 }),
833
- });
834
- }
835
- });
836
- if (mondayClones.length > 0) {
837
- events.push(...mondayClones);
838
- }
839
-
840
- return record;
841
- }
842
-
843
- return record;
844
- },
845
- };
846
-
847
- export default config;