@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.
- package/.claude/skills/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +24 -11
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- 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
|
+
}
|
package/lib/utils/utils.js
CHANGED
|
@@ -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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
41
|
-
DATASET_NOW = dayjs.unix(unixSeconds).utc();
|
|
42
|
-
}
|
|
68
|
+
_setLegacyDatasetNow(unixSeconds);
|
|
43
69
|
}
|
|
44
70
|
|
|
45
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* @deprecated v1.5.1 — see `setDatasetNow` for migration.
|
|
73
|
+
* @param {number} unixSeconds
|
|
74
|
+
*/
|
|
46
75
|
function setDatasetBegin(unixSeconds) {
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
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 =
|
|
308
|
+
const day = sResolved.add(delta, 'day');
|
|
201
309
|
return {
|
|
202
|
-
start:
|
|
203
|
-
end:
|
|
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() :
|
|
220
|
-
const e = end != null ? dayjs(typeof end === 'number' ? end * 1000 : end).utc() :
|
|
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:
|
|
1066
|
-
//
|
|
1067
|
-
if (!earliestTime) earliestTime =
|
|
1068
|
-
if (!latestTime) latestTime =
|
|
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:
|
|
1291
|
-
//
|
|
1292
|
-
if (!earliestTime) earliestTime =
|
|
1293
|
-
if (!latestTime) latestTime =
|
|
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,
|
package/lib/verify/counting.js
CHANGED
|
@@ -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();
|
package/lib/verify/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
package/scripts/run-dungeon.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|