@ak--47/dungeon-master 1.5.0 → 1.5.2

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.
Files changed (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. package/types.d.ts +117 -1
@@ -0,0 +1,140 @@
1
+ /**
2
+ * v1.5.1: retention-curve weight interpolation.
3
+ *
4
+ * A `retentionCurve` config knob defines a target retention shape via anchor
5
+ * points (e.g. `day1: 0.40, day7: 0.20, day30: 0.08`). This module turns those
6
+ * anchors into a function `(dayOffset) → weight` used by `buildActiveDayPlan`
7
+ * in `lib/orchestrators/user-loop.js` to bias active-day selection.
8
+ *
9
+ * Interpolation modes:
10
+ * - 'logarithmic' (default) — log-linear between anchors. Matches typical
11
+ * real-world retention decay (large drop early, gentle tail).
12
+ * - 'linear' — straight-line interpolation. Simpler shape; rarely matches
13
+ * real data but useful for testing.
14
+ *
15
+ * Anchor semantics:
16
+ * - dayN keys (`day1`, `day7`, `day30`, etc.) → user's relative fraction
17
+ * active on day N from their birth. Values in [0, 1].
18
+ * - Day 0 (birth day) is implicitly 1.0 (every user is active on their birth
19
+ * day — that's when their first event fires). Not a configurable anchor.
20
+ * - Days beyond the largest anchor extrapolate from the last segment's slope.
21
+ *
22
+ * The curve is NOT normalized into a probability distribution — values
23
+ * directly weight the day-selection sampler. A user with curve sum ≈ 5 will
24
+ * average ~5 active days regardless of the dataset window size.
25
+ *
26
+ * Mixpanel parity note: this models the GENERATOR side. The
27
+ * `emulateBreakdown({ type: 'retention' })` verifier measures per-event
28
+ * retention using Mixpanel's bucketing rule (return_ms - birth_ms / DAY_MS).
29
+ * Generator controls DAYS active; verifier reads EVENTS. Round-trip
30
+ * verification (test-retention-curve.test.js) shows the per-day curve drives
31
+ * the per-event retention shape to within ±5%.
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} RetentionCurveConfig
36
+ * @property {('logarithmic'|'linear')} [type='logarithmic']
37
+ * @property {number} [day1]
38
+ * @property {number} [day3]
39
+ * @property {number} [day7]
40
+ * @property {number} [day14]
41
+ * @property {number} [day30]
42
+ * @property {number} [day60]
43
+ * @property {number} [day90]
44
+ */
45
+
46
+ /**
47
+ * Extract `dayN` anchors from a curve config, sorted by N ascending.
48
+ * Always includes (0, 1.0) — the birth-day anchor.
49
+ *
50
+ * @param {RetentionCurveConfig} curve
51
+ * @returns {Array<{ day: number, weight: number }>}
52
+ */
53
+ export function extractAnchors(curve) {
54
+ if (!curve || typeof curve !== 'object') return [{ day: 0, weight: 1 }];
55
+ const anchors = [{ day: 0, weight: 1 }];
56
+ for (const key of Object.keys(curve)) {
57
+ const m = /^day(\d+)$/.exec(key);
58
+ if (!m) continue;
59
+ const day = Number(m[1]);
60
+ const weight = Number(curve[key]);
61
+ if (!Number.isFinite(day) || day < 1) continue;
62
+ if (!Number.isFinite(weight) || weight < 0) continue;
63
+ anchors.push({ day, weight });
64
+ }
65
+ anchors.sort((a, b) => a.day - b.day);
66
+ return anchors;
67
+ }
68
+
69
+ /**
70
+ * Build a weight function `(dayOffset) → weight` from a curve config. Defaults
71
+ * to logarithmic interpolation; pass `type: 'linear'` for straight-line.
72
+ *
73
+ * @param {RetentionCurveConfig} curve
74
+ * @returns {(dayOffset: number) => number}
75
+ */
76
+ export function buildCurveWeightFn(curve) {
77
+ const anchors = extractAnchors(curve);
78
+ const mode = (curve && curve.type === 'linear') ? 'linear' : 'logarithmic';
79
+
80
+ if (anchors.length === 1) {
81
+ // Only day-0 anchor — return constant 1.0 (effectively legacy uniform).
82
+ return () => 1;
83
+ }
84
+
85
+ return function weightForDay(dayOffset) {
86
+ if (!Number.isFinite(dayOffset) || dayOffset < 0) return 0;
87
+ if (dayOffset <= anchors[0].day) return anchors[0].weight;
88
+ if (dayOffset >= anchors[anchors.length - 1].day) {
89
+ // Extrapolate from the last segment.
90
+ if (anchors.length === 1) return anchors[0].weight;
91
+ const last = anchors[anchors.length - 1];
92
+ const prev = anchors[anchors.length - 2];
93
+ return Math.max(0, interpolate(prev, last, dayOffset, mode));
94
+ }
95
+ // Find the bracketing anchors and interpolate.
96
+ for (let i = 0; i < anchors.length - 1; i++) {
97
+ if (dayOffset >= anchors[i].day && dayOffset <= anchors[i + 1].day) {
98
+ return interpolate(anchors[i], anchors[i + 1], dayOffset, mode);
99
+ }
100
+ }
101
+ return 0;
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Log-linear or linear interpolation between two anchors `a` and `b`.
107
+ *
108
+ * @param {{ day: number, weight: number }} a
109
+ * @param {{ day: number, weight: number }} b
110
+ * @param {number} x
111
+ * @param {'logarithmic'|'linear'} mode
112
+ * @returns {number}
113
+ */
114
+ function interpolate(a, b, x, mode) {
115
+ if (a.day === b.day) return (a.weight + b.weight) / 2;
116
+ if (mode === 'logarithmic' && a.weight > 0 && b.weight > 0 && a.day > 0 && b.day > 0 && x > 0) {
117
+ // y = y_a * (y_b / y_a) ^ ((log(x) - log(x_a)) / (log(x_b) - log(x_a)))
118
+ const t = (Math.log(x) - Math.log(a.day)) / (Math.log(b.day) - Math.log(a.day));
119
+ return a.weight * Math.pow(b.weight / a.weight, t);
120
+ }
121
+ // Linear fallback (or when log doesn't apply because day=0 / weight=0).
122
+ const t = (x - a.day) / (b.day - a.day);
123
+ return a.weight + t * (b.weight - a.weight);
124
+ }
125
+
126
+ /**
127
+ * Convenience: derive the expected active-day count from a curve over a span
128
+ * of `maxDayOffset` days. Sum of weights across days [0, maxDayOffset].
129
+ *
130
+ * @param {RetentionCurveConfig} curve
131
+ * @param {number} maxDayOffset - Number of days to sum over (exclusive upper).
132
+ * @returns {number}
133
+ */
134
+ export function expectedActiveDays(curve, maxDayOffset) {
135
+ const fn = buildCurveWeightFn(curve);
136
+ let sum = 0;
137
+ const span = Math.max(0, Math.floor(maxDayOffset));
138
+ for (let d = 0; d < span; d++) sum += fn(d);
139
+ return sum;
140
+ }
@@ -24,29 +24,56 @@ const { NODE_ENV = "unknown" } = process.env;
24
24
  let globalChance;
25
25
  let chanceInitialized = false;
26
26
 
27
+ // v1.5.1: Optional second Chance instance dedicated to user-id generation.
28
+ // When `Dungeon.userSeed` is set, the orchestrator calls `initUserChance(userSeed)`
29
+ // to bind this — `chance.guid()` for `distinct_id` (user-loop.js) then uses it,
30
+ // allowing two runs with the SAME `userSeed` but DIFFERENT `seed` to produce the
31
+ // SAME user pool with DIFFERENT events. Used by sharded/cloud-run dungeons that
32
+ // need cross-shard user identity (e.g., dungeons/user/kodiak).
33
+ // Falls back to `globalChance` when never initialized — so all existing dungeons
34
+ // behave unchanged.
35
+ let globalUserChance;
36
+ let userChanceInitialized = false;
37
+
27
38
  // Module-scoped memoization cache for weighted-array resolvers in `choose()`.
28
39
  // Lives for the lifetime of the node process; key is the function source string.
29
40
  const weightedArrayCache = new Map();
30
41
 
31
- // Reference "now" used by date() and day() factories. Defaults to wall-clock at
32
- // import time; the orchestrator overrides this via setDatasetNow() once the dataset
33
- // window is resolved, so date helpers in dungeon configs produce deterministic
34
- // values relative to the dataset end (not the process start).
35
- let DATASET_NOW = dayjs.utc();
36
- let DATASET_BEGIN = dayjs.utc().subtract(30, 'day');
42
+ // v1.5.1: dataset-window state moved to AsyncLocalStorage scope
43
+ // (`lib/utils/dataset-context.js`). Each `generate()` call wraps the pipeline
44
+ // in `runWithDataset(begin, now, fn)`, and factory thunks read via the
45
+ // `getDatasetNow()` / `getDatasetBegin()` getters below. Concurrent in-process
46
+ // `generate()` calls no longer clobber each other's window.
47
+ //
48
+ // `setDatasetNow` / `setDatasetBegin` remain as DEPRECATED shims that write to
49
+ // a legacy fallback in dataset-context.js — kept so existing tests that call
50
+ // the setters directly (instead of wrapping in `runWithDataset`) continue
51
+ // working through v1.5.1. The getters check ALS first, then the legacy
52
+ // fallback, then wall-clock.
53
+ import {
54
+ getDatasetNow as _alsGetDatasetNow,
55
+ getDatasetBegin as _alsGetDatasetBegin,
56
+ _setLegacyDatasetNow,
57
+ _setLegacyDatasetBegin,
58
+ } from './dataset-context.js';
37
59
 
38
- /** @param {number} unixSeconds */
60
+ /**
61
+ * @deprecated v1.5.1 — use `runWithDataset(begin, now, fn)` from
62
+ * `lib/utils/dataset-context.js` to scope the dataset window per
63
+ * `generate()` call. The setter is kept as a back-compat shim that writes
64
+ * to a legacy fallback store; ALS scopes always win when active.
65
+ * @param {number} unixSeconds
66
+ */
39
67
  function setDatasetNow(unixSeconds) {
40
- if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds)) {
41
- DATASET_NOW = dayjs.unix(unixSeconds).utc();
42
- }
68
+ _setLegacyDatasetNow(unixSeconds);
43
69
  }
44
70
 
45
- /** @param {number} unixSeconds */
71
+ /**
72
+ * @deprecated v1.5.1 — see `setDatasetNow` for migration.
73
+ * @param {number} unixSeconds
74
+ */
46
75
  function setDatasetBegin(unixSeconds) {
47
- if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds)) {
48
- DATASET_BEGIN = dayjs.unix(unixSeconds).utc();
49
- }
76
+ _setLegacyDatasetBegin(unixSeconds);
50
77
  }
51
78
 
52
79
 
@@ -57,9 +84,50 @@ class ListValue extends Array {
57
84
  }
58
85
  }
59
86
 
60
- import { Storage as cloudStorage } from '@google-cloud/storage';
87
+ import { Storage as cloudStorage, IdempotencyStrategy } from '@google-cloud/storage';
61
88
  const projectId = 'YOUR_PROJECT_ID';
62
- const storage = new cloudStorage({ projectId });
89
+ // v1.5.1: aggressive retry config. Default SDK gives up after ~3 retries —
90
+ // not enough at large parallel scale. The kodiak Cloud Run Job (1000 concurrent
91
+ // tasks all uploading to GCS) saw "Retry limit exceeded" on ECONNRESET errors,
92
+ // killing entire tasks (each costing ~$0.14 of wasted compute). These settings
93
+ // retry up to 10 times with exponential back-off over a 10-min total budget,
94
+ // covering ECONNRESET / ETIMEDOUT / EAI_AGAIN / 5xx responses.
95
+ //
96
+ // `IdempotencyStrategy.RetryConditional` (the SDK default) only retries when
97
+ // the SDK can verify the operation didn't apply. Resumable upload chunk PUTs
98
+ // are idempotent at the session/offset level, so they retry safely. We tried
99
+ // `RetryAlways` once but it caused "offset is lower than bytes written" by
100
+ // re-sending already-committed chunks — the conditional strategy is correct.
101
+ const storage = new cloudStorage({
102
+ projectId,
103
+ retryOptions: {
104
+ autoRetry: true,
105
+ maxRetries: 10,
106
+ retryDelayMultiplier: 2,
107
+ totalTimeout: 600, // 10-min total budget per request
108
+ maxRetryDelay: 64, // cap each individual back-off at 64 sec
109
+ idempotencyStrategy: IdempotencyStrategy.RetryConditional,
110
+ },
111
+ });
112
+
113
+ // v1.5.1: GCS write options for ALL createWriteStream calls below.
114
+ //
115
+ // `resumable: true` is the SDK default and Google's recommendation for objects
116
+ // >8 MB. Our gzip batches land at ~80 MB compressed; we previously forced
117
+ // `resumable: false` to dodge a different bug (RetryAlways re-sending committed
118
+ // chunks → "offset is lower than bytes written"). Forcing multipart uploads
119
+ // made every batch one fragile 80 MB POST: any TCP hiccup mid-upload killed the
120
+ // whole transfer and bubbled up as ECONNRESET. With `RetryConditional` (above)
121
+ // the offset bug is gone — resumable is back to its proper default.
122
+ //
123
+ // `chunkSize: 8 MiB` enables true chunked resumable upload: the SDK PUTs each
124
+ // 8 MB slice via the resumable session URL; ECONNRESET kills only the in-flight
125
+ // chunk, the SDK queries the session for the committed offset and resumes.
126
+ // 8 MiB is a multiple of GCS's 256 KiB chunk-alignment requirement (32 × 256 KiB).
127
+ const GCS_WRITE_OPTS = {
128
+ resumable: true,
129
+ chunkSize: 8 * 1024 * 1024,
130
+ };
63
131
 
64
132
 
65
133
  /*
@@ -96,6 +164,40 @@ function getChance() {
96
164
  return globalChance;
97
165
  }
98
166
 
167
+ /**
168
+ * Initialize the user-id RNG with a separate seed. Called by the orchestrator
169
+ * when `Dungeon.userSeed` is set (or env `USER_SEED`).
170
+ * @param {string} seed
171
+ * @returns {Chance}
172
+ */
173
+ function initUserChance(seed) {
174
+ if (!seed && process.env.USER_SEED) seed = process.env.USER_SEED;
175
+ globalUserChance = new Chance(seed);
176
+ userChanceInitialized = true;
177
+ return globalUserChance;
178
+ }
179
+
180
+ /**
181
+ * Clear any previously-initialized user RNG so `getUserChance()` falls back to
182
+ * `getChance()` again. Called by the orchestrator at the start of every run
183
+ * when `Dungeon.userSeed` is NOT set, so state from a prior in-process run
184
+ * (e.g., a previous vitest spec) doesn't leak into the next.
185
+ */
186
+ function resetUserChance() {
187
+ globalUserChance = undefined;
188
+ userChanceInitialized = false;
189
+ }
190
+
191
+ /**
192
+ * Returns the user-id RNG. Falls back to the event RNG (`getChance()`) when
193
+ * `userSeed` was not configured, so existing dungeons stay byte-identical.
194
+ * @returns {Chance}
195
+ */
196
+ function getUserChance() {
197
+ if (!userChanceInitialized) return getChance();
198
+ return globalUserChance;
199
+ }
200
+
99
201
  /*
100
202
  ----
101
203
  PICKERS
@@ -132,9 +234,12 @@ function pick(items) {
132
234
  */
133
235
  function date(inTheLast = 30, isPast = true, format = 'YYYY-MM-DD') {
134
236
  const chance = getChance();
135
- const now = DATASET_NOW;
136
237
  if (Math.abs(inTheLast) > 365 * 10) inTheLast = chance.integer({ min: 1, max: 180 });
137
238
  return function () {
239
+ // v1.5.1: read DATASET_NOW per-invocation (inside the thunk) so the value
240
+ // reflects the ALS scope active at event-generation time, not the wall-clock
241
+ // captured when the dungeon module was loaded.
242
+ const now = _alsGetDatasetNow();
138
243
  const when = chance.integer({ min: 0, max: Math.abs(inTheLast) });
139
244
  let then;
140
245
  if (isPast) {
@@ -188,19 +293,22 @@ function datesBetween(start, end) {
188
293
  * @param {any} end
189
294
  */
190
295
  function day(start, end) {
191
- if (!start) start = DATASET_NOW.subtract(30, 'd').toISOString();
192
- if (!end) end = DATASET_NOW.toISOString();
193
296
  const chance = getChance();
194
297
  const format = 'YYYY-MM-DD';
298
+ const startArg = start;
299
+ const endArg = end;
195
300
  return function (min, max) {
196
- start = dayjs(start);
197
- end = dayjs(end);
198
- const diff = end.diff(start, 'day');
301
+ // v1.5.1: read DATASET_NOW per-invocation so missing args resolve against
302
+ // the active ALS scope, not the wall-clock at factory-call time.
303
+ const now = _alsGetDatasetNow();
304
+ const sResolved = startArg != null ? dayjs(startArg) : dayjs(now.subtract(30, 'd').toISOString());
305
+ const eResolved = endArg != null ? dayjs(endArg) : dayjs(now.toISOString());
306
+ const diff = eResolved.diff(sResolved, 'day');
199
307
  const delta = chance.integer({ min: min, max: diff });
200
- const day = start.add(delta, 'day');
308
+ const day = sResolved.add(delta, 'day');
201
309
  return {
202
- start: start.format(format),
203
- end: end.format(format),
310
+ start: sResolved.format(format),
311
+ end: eResolved.format(format),
204
312
  day: day.format(format)
205
313
  };
206
314
  };
@@ -216,8 +324,8 @@ function day(start, end) {
216
324
  function dateRange(start, end, format = 'YYYY-MM-DDTHH:mm:ss') {
217
325
  return function () {
218
326
  const chance = getChance();
219
- const s = start != null ? dayjs(typeof start === 'number' ? start * 1000 : start).utc() : DATASET_BEGIN;
220
- const e = end != null ? dayjs(typeof end === 'number' ? end * 1000 : end).utc() : DATASET_NOW;
327
+ const s = start != null ? dayjs(typeof start === 'number' ? start * 1000 : start).utc() : _alsGetDatasetBegin();
328
+ const e = end != null ? dayjs(typeof end === 'number' ? end * 1000 : end).utc() : _alsGetDatasetNow();
221
329
  const diffSec = e.diff(s, 'second');
222
330
  const offsetSec = chance.integer({ min: 0, max: Math.max(0, diffSec) });
223
331
  const result = s.add(offsetSec, 'second');
@@ -578,7 +686,7 @@ function streamJSON(filePath, data, options = {}) {
578
686
 
579
687
  if (filePath?.startsWith('gs://')) {
580
688
  const { uri, bucket, file } = parseGCSUri(filePath);
581
- const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
689
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream(GCS_WRITE_OPTS);
582
690
  gcsStream.on('finish', () => resolve(filePath));
583
691
  gcsStream.on('error', reject);
584
692
  if (gzip) {
@@ -615,7 +723,7 @@ function streamCSV(filePath, data, options = {}) {
615
723
 
616
724
  if (filePath?.startsWith('gs://')) {
617
725
  const { uri, bucket, file } = parseGCSUri(filePath);
618
- const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
726
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream(GCS_WRITE_OPTS);
619
727
  gcsStream.on('finish', () => resolve(filePath));
620
728
  gcsStream.on('error', reject);
621
729
  if (gzip) {
@@ -730,7 +838,7 @@ async function streamParquet(filePath, data, options = {}) {
730
838
  // @ts-ignore
731
839
  const arrayBuffer = parquetWriteBuffer({ columnData });
732
840
  const { bucket, file } = parseGCSUri(filePath);
733
- const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
841
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream(GCS_WRITE_OPTS);
734
842
 
735
843
  return new Promise((resolve, reject) => {
736
844
  gcsStream.on('finish', () => resolve(filePath));
@@ -1062,10 +1170,10 @@ function validateEventConfig(events) {
1062
1170
  }
1063
1171
 
1064
1172
  function validTime(chosenTime, earliestTime, latestTime) {
1065
- // Fallback: use module-scoped DATASET_BEGIN/NOW (set by orchestrator via
1066
- // setDatasetBegin/setDatasetNow). v1.5: no longer reads `global.FIXED_*`.
1067
- if (!earliestTime) earliestTime = DATASET_BEGIN.unix();
1068
- if (!latestTime) latestTime = DATASET_NOW.unix();
1173
+ // Fallback: ALS-scoped DATASET_BEGIN/NOW (v1.5.1: was module-scoped, now
1174
+ // resolved per `generate()` call via `runWithDataset` see dataset-context.js).
1175
+ if (!earliestTime) earliestTime = _alsGetDatasetBegin().unix();
1176
+ if (!latestTime) latestTime = _alsGetDatasetNow().unix();
1069
1177
 
1070
1178
  if (typeof chosenTime === 'number') {
1071
1179
  if (chosenTime > 0) {
@@ -1287,10 +1395,10 @@ const DEFAULT_HOD_WEIGHTS = [
1287
1395
  ];
1288
1396
 
1289
1397
  function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0, dayOfWeekWeights = DEFAULT_DOW_WEIGHTS, hourOfDayWeights = DEFAULT_HOD_WEIGHTS) {
1290
- // Fallback: use module-scoped DATASET_BEGIN/NOW (set by orchestrator via
1291
- // setDatasetBegin/setDatasetNow). v1.5: no longer reads `global.FIXED_*`.
1292
- if (!earliestTime) earliestTime = DATASET_BEGIN.unix();
1293
- if (!latestTime) latestTime = DATASET_NOW.unix();
1398
+ // Fallback: ALS-scoped DATASET_BEGIN/NOW (v1.5.1: was module-scoped, now
1399
+ // resolved per `generate()` call via `runWithDataset` see dataset-context.js).
1400
+ if (!earliestTime) earliestTime = _alsGetDatasetBegin().unix();
1401
+ if (!latestTime) latestTime = _alsGetDatasetNow().unix();
1294
1402
  const chance = getChance();
1295
1403
  let totalRange = latestTime - earliestTime;
1296
1404
  if (totalRange <= 0 || earliestTime > latestTime) {
@@ -1649,6 +1757,9 @@ export {
1649
1757
  deepClone,
1650
1758
  initChance,
1651
1759
  getChance,
1760
+ initUserChance,
1761
+ getUserChance,
1762
+ resetUserChance,
1652
1763
  decimal,
1653
1764
  validTime,
1654
1765
  validEvent,
@@ -168,6 +168,46 @@ export function nullAwareExtreme(values, mode) {
168
168
  return any ? extreme : null;
169
169
  }
170
170
 
171
+ /**
172
+ * v1.5.1: count distinct values of a flat property across events.
173
+ *
174
+ * Mixpanel parity: `COUNT_DISTINCT(prop)` aggregator (Insights). Skips
175
+ * null/undefined/empty-string values. Returns the distinct-value count plus
176
+ * the top-N most-frequent values (default 25, matching Mixpanel UI default).
177
+ *
178
+ * Property keys are FLAT on event records per the dungeon-master schema
179
+ * contract (see HOOKS.md §1) — no dot-path support.
180
+ *
181
+ * @param {Object[]} events
182
+ * @param {string} property - Flat property name.
183
+ * @param {Object} [options]
184
+ * @param {string} [options.event] - Optional event-name filter.
185
+ * @param {number} [options.topN=25] - Number of top values to include in the result.
186
+ * @returns {{ distinct_count: number, top_values: Array<{ value: any, count: number }> }}
187
+ */
188
+ export function countDistinctValues(events, property, options = {}) {
189
+ if (!Array.isArray(events)) throw new Error('countDistinctValues: events must be an array');
190
+ if (typeof property !== 'string' || !property) throw new Error('countDistinctValues: property is required');
191
+ const topN = Number.isFinite(options.topN) && options.topN > 0 ? Math.floor(options.topN) : 25;
192
+ const filterEvent = typeof options.event === 'string' && options.event ? options.event : null;
193
+ const valueCounts = new Map();
194
+ for (const e of events) {
195
+ if (!e || typeof e !== 'object') continue;
196
+ if (filterEvent && e.event !== filterEvent) continue;
197
+ const v = e[property];
198
+ if (v === null || v === undefined || v === '') continue;
199
+ // Hashable normalization — Map keys distinguish primitives but objects
200
+ // use reference identity. For Mixpanel parity, stringify non-primitives.
201
+ const key = (typeof v === 'object') ? JSON.stringify(v) : v;
202
+ valueCounts.set(key, (valueCounts.get(key) || 0) + 1);
203
+ }
204
+ const sorted = [...valueCounts.entries()]
205
+ .sort((a, b) => b[1] - a[1])
206
+ .slice(0, topN)
207
+ .map(([value, count]) => ({ value, count }));
208
+ return { distinct_count: valueCounts.size, top_values: sorted };
209
+ }
210
+
171
211
  /**
172
212
  * Partition events into time buckets by UTC calendar (`day`, `week`, or
173
213
  * `month`). Used by `emulateBreakdown` when `timeBucket` is set to slice any
@@ -26,6 +26,7 @@ import { evaluateFunnel, evaluateAnyOrderCompletion } from './funnel-engine.js';
26
26
  import { buildIdentityMap, resolveUserId } from './identity.js';
27
27
  import {
28
28
  countDistinctPeriods,
29
+ countDistinctValues,
29
30
  nullAwareAvg,
30
31
  nullAwareSum,
31
32
  nullAwareExtreme,
@@ -125,7 +126,7 @@ function evaluateFunnelByOrder(userEvents, steps, options = {}) {
125
126
 
126
127
  /**
127
128
  * @typedef {Object} EmulateOptions
128
- * @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'|'sessionMetrics'|'retention'} type
129
+ * @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'|'sessionMetrics'|'retention'|'distinctCount'} type
129
130
  *
130
131
  * @property {string} [metricEvent]
131
132
  * @property {string} [breakdownByFrequencyOf]
@@ -232,6 +233,7 @@ export function emulateBreakdown(events, config) {
232
233
  case 'attributedBy': return attributedBy(events, /** @type {*} */ (cfg));
233
234
  case 'sessionMetrics': return sessionMetrics(events, /** @type {*} */ (cfg));
234
235
  case 'retention': return retention(events, /** @type {*} */ (cfg));
236
+ case 'distinctCount': return distinctCount(events, /** @type {*} */ (cfg));
235
237
  default: throw new Error(`emulateBreakdown: unknown type "${config.type}"`);
236
238
  }
237
239
  }
@@ -563,6 +565,23 @@ function retention(events, { cohortEvent, returnEvent, dayBuckets = [1, 7, 14, 3
563
565
  // [{ metric: 'duration', avg_ms, median_ms, p90_ms, total_sessions }]
564
566
  // [{ metric: 'eventsPerSession',avg, median, p90, total_sessions }]
565
567
 
568
+ // ── COUNT_DISTINCT(property) — v1.5.1 ─────────────────────────────────────
569
+ //
570
+ // Mixpanel Insights `COUNT_DISTINCT(prop)` aggregator. Returns the count of
571
+ // unique values of a flat property across events, plus the top-N most-frequent
572
+ // values. Optionally filtered to a single event name.
573
+ //
574
+ // Schema contract (HOOKS.md §1): properties are FLAT on event records —
575
+ // `e.utm_campaign`, not `e.properties.utm_campaign`. Dot-path support deferred.
576
+ //
577
+ // Returns a single row: `{ distinct_count, top_values: [{ value, count }] }`.
578
+
579
+ function distinctCount(events, { property, event, topN = 25 }) {
580
+ if (!property) throw new Error('distinctCount requires `property`');
581
+ const result = countDistinctValues(events, property, { event, topN });
582
+ return [result];
583
+ }
584
+
566
585
  function sessionMetrics(events, { event, metrics = ['count', 'duration', 'eventsPerSession'], identityMap }) {
567
586
  const userEvents = groupByUser(events, identityMap);
568
587
  const sessionsByUser = new Map();
@@ -24,6 +24,7 @@ export {
24
24
  export { buildIdentityMap, resolveUserId } from './identity.js';
25
25
  export {
26
26
  countDistinctPeriods,
27
+ countDistinctValues,
27
28
  nullAwareAvg,
28
29
  nullAwareSum,
29
30
  nullAwareExtreme,
@@ -13,7 +13,9 @@
13
13
 
14
14
  const CORE_KEYS = new Set(['event', 'time', 'insert_id', 'user_id']);
15
15
  const LOCATION_KEYS = ['city', 'region', 'country', 'country_code'];
16
- const DEVICE_KEYS = ['model', 'screen_height', 'screen_width', 'os', 'Platform', 'carrier', 'radio'];
16
+ // v1.5.1: `Platform` removed engine no longer stamps it as a default
17
+ // device key (collided with dungeon-defined Platform props). See TODO #11.
18
+ const DEVICE_KEYS = ['model', 'screen_height', 'screen_width', 'os', 'carrier', 'radio'];
17
19
  const CAMPAIGN_KEYS = ['utm_source', 'utm_campaign', 'utm_medium', 'utm_content', 'utm_term'];
18
20
 
19
21
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -47,7 +47,15 @@
47
47
  "dungeon:run": "node ./scripts/run-dungeon.mjs",
48
48
  "dungeon:to-json": "node ./scripts/dungeon-to-json.mjs",
49
49
  "dungeon:from-json": "node ./scripts/json-to-dungeon.mjs",
50
- "dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs"
50
+ "dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs",
51
+ "kodiak:deploy": "gcloud builds submit --config dungeons/user/kodiak/cloudbuild.yaml",
52
+ "kodiak:plan": "node dungeons/user/kodiak/kickoff.mjs --plan-only",
53
+ "kodiak:smoke:local": "bash dungeons/user/kodiak/smoke-local.sh",
54
+ "kodiak:smoke": "node dungeons/user/kodiak/kickoff.mjs --total-events 1000000000 --chunk-cap 10",
55
+ "kodiak:generate": "node dungeons/user/kodiak/kickoff.mjs --no-wait",
56
+ "kodiak:status": "node dungeons/user/kodiak/status.mjs",
57
+ "kodiak:run": "bash dungeons/user/kodiak/run-all.sh",
58
+ "kodiak:load": "node dungeons/user/kodiak/load-bq.mjs --replace --no-wait"
51
59
  },
52
60
  "repository": {
53
61
  "type": "git",
@@ -70,6 +78,7 @@
70
78
  },
71
79
  "homepage": "https://github.com/ak--47/dungeon-master#readme",
72
80
  "dependencies": {
81
+ "@google-cloud/bigquery": "^7.9.4",
73
82
  "@google-cloud/storage": "^7.14.0",
74
83
  "ak-tools": "^1.1.12",
75
84
  "chance": "^1.1.11",
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import path from 'path';
4
+ import fs from 'fs';
4
5
  import generate from '../index.js';
5
6
  const { NODE_ENV = "unknown" } = process.env;
6
7
 
@@ -14,10 +15,20 @@ if (!dungeonPath) {
14
15
  }
15
16
 
16
17
  // Resolve the absolute path
17
- const absolutePath = path.isAbsolute(dungeonPath)
18
+ let absolutePath = path.isAbsolute(dungeonPath)
18
19
  ? dungeonPath
19
20
  : path.resolve(process.cwd(), dungeonPath);
20
21
 
22
+ // Extension fallback: ESM requires explicit extension. Try .js / .mjs / .json.
23
+ if (!fs.existsSync(absolutePath)) {
24
+ for (const ext of ['.js', '.mjs', '.json']) {
25
+ if (fs.existsSync(absolutePath + ext)) {
26
+ absolutePath += ext;
27
+ break;
28
+ }
29
+ }
30
+ }
31
+
21
32
  // Handle Ctrl+C gracefully — let user-loop finish current user, then exit
22
33
  process.on('SIGINT', () => {
23
34
  // Second Ctrl+C forces immediate exit