@ak--47/dungeon-master 1.5.3 → 1.6.0
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/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +211 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +3 -2
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
package/HOOKS.md
CHANGED
|
@@ -53,20 +53,33 @@ Mixpanel's frequency distribution / cohort-by-event-count reports count
|
|
|
53
53
|
**distinct time periods** (default: days) on which the user fired the
|
|
54
54
|
event. Two purchases on the same day = frequency **1**, not 2.
|
|
55
55
|
|
|
56
|
-
Two related rules exist
|
|
56
|
+
Two related rules exist (v1.6 names them for what they are; the old
|
|
57
|
+
`'calendar'` / `'rolling'` names remain as silent aliases, unknown names
|
|
58
|
+
now throw):
|
|
57
59
|
|
|
58
|
-
-
|
|
60
|
+
- **`algorithm: 'ui-bucket'`** (default in our verifier):
|
|
59
61
|
`COUNT(DISTINCT date_trunc(unit, time))` in UTC. Matches what the Mixpanel
|
|
60
62
|
UI shows and what [`injectOnNewDays`](lib/hook-helpers/inject.js) uses
|
|
61
63
|
internally.
|
|
62
|
-
-
|
|
63
|
-
`addiction_query.cpp` rule `qtz_time >= last_counted + seconds_for_unit
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
- **`algorithm: 'mixpanel-rolling'`**: the C++
|
|
65
|
+
`addiction_query.cpp` rule `qtz_time >= last_counted + seconds_for_unit`
|
|
66
|
+
(`addiction_query_update_history`, `addiction_query.cpp:363-374`) — what
|
|
67
|
+
Mixpanel's reader actually computes. Diverges from ui-bucket at unit
|
|
68
|
+
boundaries (events at 23:59 + 00:01 next day = 1 rolling period, 2
|
|
69
|
+
calendar periods).
|
|
70
|
+
|
|
71
|
+
Use the default (`ui-bucket`) for hooks. Use `'mixpanel-rolling'` only
|
|
68
72
|
when verifying behavior that explicitly depends on the C++ implementation.
|
|
69
73
|
|
|
74
|
+
**The actual Frequency report shape** is `frequencyHistogram(events,
|
|
75
|
+
{ event, unit, intervalDays, profiles })` (v1.6): per report interval, a
|
|
76
|
+
per-user ROLLING unit counter that **resets at every interval boundary**
|
|
77
|
+
(`last_counted` is per-(user, interval), `addiction_query.cpp:363-374`),
|
|
78
|
+
bucketed into `histogram[count - 1]` with zero-count users **omitted** — no
|
|
79
|
+
zero bucket (`addiction_query.cpp:546-573`). Array length is
|
|
80
|
+
`ceil(interval / unit)` (`unit.c:108-113`). Use it when a dungeon targets
|
|
81
|
+
the Frequency report itself rather than a frequency-derived cohort.
|
|
82
|
+
|
|
70
83
|
**Implication for hooks:** `scaleEventCount(record, "Buy", 3)` clones 3x as
|
|
71
84
|
many Buy events at sub-second offsets — they all land on the same calendar
|
|
72
85
|
day, so the user moves **zero bins** in Mixpanel's frequency report. Use
|
|
@@ -93,6 +106,26 @@ Implementation: [`evaluateFunnel`](lib/verify/funnel-engine.js).
|
|
|
93
106
|
- TTC is `stepTimes[last] - stepTimes[0]` from the greedy match, not from a
|
|
94
107
|
property value. To shift TTC, shift event timestamps (Recipe 3.14).
|
|
95
108
|
|
|
109
|
+
**v1.6 funnel completion** (all in [`evaluateFunnel`](lib/verify/funnel-engine.js)
|
|
110
|
+
/ `emulateBreakdown`):
|
|
111
|
+
|
|
112
|
+
- **Session-count conversion windows** (`conversionWindowSessions`): the
|
|
113
|
+
window is bounded by N session boundaries after step 0, not wall-clock
|
|
114
|
+
(`WINDOW_TYPE_SESSIONS`, `conversion_window.cpp`). `countMode: 'sessions'`
|
|
115
|
+
is the API preset that rewrites count type + window together.
|
|
116
|
+
- **Exclusion steps** use ARB's gap-slot semantics: an exclusion event
|
|
117
|
+
kills only the funnel attempt whose gap it lands in — full audit against
|
|
118
|
+
`history.cpp` shipped in v1.6.
|
|
119
|
+
- **Any-order step blocks** run ARB's anchor/chunk greedy pass: unordered
|
|
120
|
+
steps inside a block match in any sequence between the surrounding
|
|
121
|
+
anchors.
|
|
122
|
+
- **Trends under `timeBucket`** anchor on STEP 0's timestamp — a funnel
|
|
123
|
+
converting across midnight counts in the bucket where it STARTED
|
|
124
|
+
(`funnel_query.cpp` anchors step 0 in `[start, stop)`).
|
|
125
|
+
- **TTC** aggregates use ARB's integer-second gap deltas: per-gap deltas
|
|
126
|
+
floor to whole seconds and clamp to 0 per gap; `$ttc` is integer seconds
|
|
127
|
+
(`history.cpp`). Sub-second TTC engineering is invisible to Mixpanel.
|
|
128
|
+
|
|
96
129
|
### 2.3 Aggregations are null-aware
|
|
97
130
|
|
|
98
131
|
`AVG(x)` skips null/undefined/NaN/non-numeric from BOTH numerator AND
|
|
@@ -105,13 +138,26 @@ it exists. You don't need to "fill" missing values with 0 to keep the
|
|
|
105
138
|
average sensible — Mixpanel ignores them. Conversely, if you want to dilute
|
|
106
139
|
an average, removing the property is a no-op; you have to add zeros.
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
`
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
**List-valued properties (v1.6):** Mixpanel's list branch aggregates each
|
|
142
|
+
numeric list ITEM independently — for AVG every item adds to the numerator
|
|
143
|
+
AND increments the denominator (`normal_query.cpp:1585-1617`), one level
|
|
144
|
+
deep (nested lists are skipped, not recursed). `nullAwareAvg` /
|
|
145
|
+
`nullAwareSum` mirror this behind `{ flatten: true }` — opt-in, because the
|
|
146
|
+
v1.5 default (arrays skipped whole) would otherwise silently change results
|
|
147
|
+
for 1-item-array data. If a dungeon carries numeric list properties and
|
|
148
|
+
targets an Insights SUM/AVG, verify with `flatten: true` or the numbers
|
|
149
|
+
won't match Mixpanel.
|
|
150
|
+
|
|
151
|
+
### 2.4 Attribution: first/last touch are UNCAPPED; only multi-touch caps at 10
|
|
152
|
+
|
|
153
|
+
First-touch and last-touch attribution read the globally first / most
|
|
154
|
+
recent touch in the lookback window, no matter how many touches precede
|
|
155
|
+
the conversion — ARB's FIRST/LAST paths execute hard-`LIMIT 1` statements
|
|
156
|
+
(`whoval/read.cpp:173-192`, `:643-655`). `TOUCHPOINTS_LIMIT = 10`
|
|
157
|
+
(`attributed_value_reader.cpp:16`) is consumed only by the sorted-list
|
|
158
|
+
statement (`LIMIT ?4`, `read.cpp:595`) serving multi-touch models
|
|
159
|
+
(linear / participation / time-decay). A user with 50 touches before
|
|
160
|
+
conversion still first-touch-attributes to touch #1, not touch #41.
|
|
115
161
|
|
|
116
162
|
**v1.5 generation contract:** the engine now caps UTM stamping at
|
|
117
163
|
`maxTouchpointsPerUser` (default 10) per user, sampled uniform-random across
|
|
@@ -120,6 +166,24 @@ chronologically before being applied, so attribution properties land in
|
|
|
120
166
|
time order. Hooks that bias attribution should OVERWRITE engine-stamped
|
|
121
167
|
values, not stamp from scratch (those would push the user past the cap).
|
|
122
168
|
|
|
169
|
+
**Per-conversion attribution (v1.6):** Mixpanel runs attribution once PER
|
|
170
|
+
conversion event — each conversion gets its own lookback read ending at
|
|
171
|
+
that conversion (`attributed_value_reader_read` takes one `event_time_ms`
|
|
172
|
+
per read; `backend/libquery/properties_over_time/attributed_value_reader.cpp`).
|
|
173
|
+
The verifier's `attributedBy` matches with `perConversion: 'all'`; its
|
|
174
|
+
default stays `'first'` (one conversion per user — v1.5 back-compat, NOT
|
|
175
|
+
ARB semantics).
|
|
176
|
+
|
|
177
|
+
**⚠ Touchpoint seam (documented, by design):** the generator samples WHICH
|
|
178
|
+
events get UTMs uniformly across a user's lifetime (capped at
|
|
179
|
+
`maxTouchpointsPerUser`, default 10); the verifier and Mixpanel read
|
|
180
|
+
touches BEFORE each conversion. A user with more eligible events than the
|
|
181
|
+
generator cap can carry touches that never got stamped — and stamped
|
|
182
|
+
touches can postdate every conversion. Divergence is theoretical below
|
|
183
|
+
~10 eligible events per user. Attribution-engineering hooks should
|
|
184
|
+
overwrite engine-stamped UTMs rather than relying on lifetime-uniform
|
|
185
|
+
sampling.
|
|
186
|
+
|
|
123
187
|
### 2.5 Active-day distribution is config-first
|
|
124
188
|
|
|
125
189
|
Mixpanel frequency reports count distinct days (§2.1). The v1.5 engine
|
|
@@ -166,10 +230,24 @@ query from THREE reset triggers:
|
|
|
166
230
|
Each session emits synthetic `$duration_s`, `$event_count`, `$origin_start`,
|
|
167
231
|
`$origin_end` properties.
|
|
168
232
|
|
|
169
|
-
**
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
233
|
+
**Namespace rule:** synthetic session events are `EVENT_TYPE_SESSION` — a
|
|
234
|
+
separate selector namespace from regular events (`libquery/event/filter.h`).
|
|
235
|
+
A dungeon event literally named `"$session_start"` would NOT merge with or
|
|
236
|
+
shadow Mixpanel's session events; don't name events into the `$session_*`
|
|
237
|
+
family (see `lib/verify/sessionize.js:41-42`).
|
|
238
|
+
|
|
239
|
+
**v1.6 contract:** the verifier derives sessions at query time via
|
|
240
|
+
`sessionize()` (`lib/verify/sessionize.js`) — the same three triggers, all
|
|
241
|
+
strict `>`, plus synthetic `$session_start`/`$session_end` events carrying
|
|
242
|
+
the four computed props. `sessionMetrics` defaults to `source: 'derived'`;
|
|
243
|
+
the generator's pre-stamped `session_id` (from `assignSessionIds`) is a
|
|
244
|
+
generator artifact Mixpanel never sees, kept available via
|
|
245
|
+
`source: 'stamped'` and audited by the per-row `stampedDivergence` count.
|
|
246
|
+
`eventBreakdown` and `uniques` accept `countType: 'sessions'` (count once
|
|
247
|
+
per (user, session, segment) / distinct (user, session) pairs per bucket —
|
|
248
|
+
`normal_query.cpp:1318-1352`). Sessions always derive from the FULL event
|
|
249
|
+
stream; name filters select which events count, never which events shape
|
|
250
|
+
sessions (`normal_query.cpp:2271-2280`).
|
|
173
251
|
|
|
174
252
|
**Verifier-only conveniences (not directly reproducible in Mixpanel UI):**
|
|
175
253
|
- `evaluateFunnel({ sessionScoped: true })` partitions events per session and
|
|
@@ -202,31 +280,63 @@ bucket = floor(time_to_retention_event_s / bucket_seconds)
|
|
|
202
280
|
A return 23h after birth lands in bucket 0; a return 25h after birth lands in
|
|
203
281
|
bucket 1 — even when both fall on the UTC calendar day after the birth day.
|
|
204
282
|
|
|
205
|
-
**`birth_can_retain`** (default `false`; `retention_query.cpp:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
`
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
-
|
|
224
|
-
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
283
|
+
**`birth_can_retain`** (default `false`; `retention_query.cpp:1120-1139`,
|
|
284
|
+
`retention_query_event_occurs_after_birth` — cites COR-233): returns strictly
|
|
285
|
+
after the birth ms count by default (`first_event_time <
|
|
286
|
+
retention_event_time`). `birthCanRetain: true` relaxes the gate to `<=` — but
|
|
287
|
+
ONLY when the return event ALSO matches the birth filter (`matches_first`). A
|
|
288
|
+
*distinct* return event at the exact birth ms stays excluded even with the
|
|
289
|
+
flag on. The gate reads the RAW birth time — calendar alignment applies only
|
|
290
|
+
inside the bucket delta.
|
|
291
|
+
|
|
292
|
+
**Unbounded modes** — `unbounded: 'carryForward' | 'carryBack' |
|
|
293
|
+
'consecutiveForward'` (v1.6; the v1.5 `carry_forward: true` spelling remains
|
|
294
|
+
as an alias for `carryForward`):
|
|
295
|
+
|
|
296
|
+
- `carryForward` — retained at bucket N if active in ANY bucket ≤ N; read-time
|
|
297
|
+
carry (`retention_query.cpp:1854-1868`). Curve is monotonically
|
|
298
|
+
non-decreasing.
|
|
299
|
+
- `carryBack` — retained at bucket N if active in ANY bucket ≥ N;
|
|
300
|
+
reverse-iteration carry (`retention_query.cpp:274-278`). Curve is
|
|
301
|
+
monotonically non-increasing.
|
|
302
|
+
- `consecutiveForward` — gated at WRITE time (`retention_query.cpp:1275-1287`):
|
|
303
|
+
bucket N is marked only if N−1 is already marked (except N = 0), so a user's
|
|
304
|
+
surviving marks are exactly their maximal consecutive streak `{0..k}` from
|
|
305
|
+
birth.
|
|
306
|
+
|
|
307
|
+
**`compounded: true`** (`retention_query.cpp:677-685`): `rq->second =
|
|
308
|
+
rq->first` — the return side IS the cohort side, so every cohort event is a
|
|
309
|
+
return candidate. This is Mixpanel's "DAU coming back" report family. Setting
|
|
310
|
+
a conflicting `returnEvent` alongside `compounded` throws.
|
|
311
|
+
|
|
312
|
+
**`bucketUnit: 'hour' | 'day' | 'week' | 'month'`** — month is **31 days
|
|
313
|
+
FIXED** ("maximum possible seconds in a month", `libquery/util.h:265-273`),
|
|
314
|
+
NOT calendar months. Week is 7 fixed days.
|
|
315
|
+
|
|
316
|
+
**`bucketAlignment: 'calendarStart'`** (`retention_query.cpp:312-332`): floors
|
|
317
|
+
the BIRTH time to the bucket-unit boundary before computing deltas (week
|
|
318
|
+
floors to ISO Monday, matching `partitionByTimeBucket`). Month alignment
|
|
319
|
+
floors to the calendar month start while the bucket WIDTH stays 31d fixed.
|
|
320
|
+
|
|
321
|
+
**`segmentBy` + `segmentOn: 'birth' | 'return'`**: `'birth'` (default,
|
|
322
|
+
SEGMENT_EVENT_FIRST) reads the segment value from the BIRTH event; `'return'`
|
|
323
|
+
(SEGMENT_EVENT_SECOND, `retention_query.cpp:1421-1444`) reads it from each
|
|
324
|
+
RETURN event — a user joins a segment's cohort only via a qualifying return
|
|
325
|
+
carrying that value, so births are unsegmented unless `birthCanRetain` lets
|
|
326
|
+
the birth itself qualify as a return.
|
|
327
|
+
|
|
328
|
+
**`cohortWindow: { from, to }`** restricts births to `[from, to]` inclusive —
|
|
329
|
+
Mixpanel's `[from_date, to_date]` cohort restriction. Without it the verifier
|
|
330
|
+
uses all users with the birth event anywhere in the dataset.
|
|
331
|
+
|
|
332
|
+
**Internal-event ignore list** (`retention_query.cpp:2546-2555`): when a side
|
|
333
|
+
has no explicit event selector (`'$any_event'`), `$campaign_delivery`,
|
|
334
|
+
`$campaign_bounced`, `$create_alias`, `$identify`, `$merge` are ignored for
|
|
335
|
+
that side. Explicit selectors bypass the list.
|
|
336
|
+
|
|
337
|
+
All items on the v1.5.0 "documented gaps" list closed in 1.6.0. Unrecognized
|
|
338
|
+
retention option keys now THROW — kills the silent-ignore class of bug where a
|
|
339
|
+
typo'd `compounded: true` was dropped without effect.
|
|
230
340
|
|
|
231
341
|
### 2.8 Funnel reentry: state machine resets after completion
|
|
232
342
|
|
|
@@ -244,7 +354,9 @@ HPC partitions a single funnel into one parallel sub-funnel per unique value
|
|
|
244
354
|
of the held property on the step-0 event. A user can complete the funnel in
|
|
245
355
|
one HPC bucket and drop off in another simultaneously — the buckets are
|
|
246
356
|
independent. Use `evaluateFunnelHPC(events, steps, holdProperty, options)`
|
|
247
|
-
directly (
|
|
357
|
+
directly, or (v1.6) pass `holdPropertyConstant: '<prop>'` to the
|
|
358
|
+
`funnelFrequency` emulator — it routes through the HPC engine and reports
|
|
359
|
+
per-held-value sub-funnel counts.
|
|
248
360
|
|
|
249
361
|
### 2.10 Funnel segment modes (FIRST_TOUCH / LAST_TOUCH / STEP)
|
|
250
362
|
|
|
@@ -302,6 +414,152 @@ Validator strict-clamps prevent the worst pathological combos (e.g.,
|
|
|
302
414
|
with arbitrary timestamps without polluting the dataset. Verified across
|
|
303
415
|
the 194-combo matrix; this guarantee survives every hook pattern.
|
|
304
416
|
|
|
417
|
+
### 2.12 Event breakdown counts EVENTS, not users (v1.6)
|
|
418
|
+
|
|
419
|
+
Reference: `normal_query.cpp:1718-1776` (ACTION_TYPE_FOR_EACH). Insights
|
|
420
|
+
"Total" broken down by a property counts every matching EVENT into its
|
|
421
|
+
segment — a user firing 50 times contributes 50, not 1. Emulator:
|
|
422
|
+
`emulateBreakdown(events, { type: 'eventBreakdown', event, breakdownProperty,
|
|
423
|
+
topN })`.
|
|
424
|
+
|
|
425
|
+
- **List-valued properties explode**: an event with `tags: ['a', 'b']`
|
|
426
|
+
contributes one count to segment `a` AND one to segment `b`. An EMPTY list
|
|
427
|
+
lands in the literal segment `"$empty_list"` (`normal_query.cpp:1762`).
|
|
428
|
+
- **Segments are case-sensitive and type-tagged**: number `1` and string
|
|
429
|
+
`'1'` are DIFFERENT segments. Engineer property values with exact casing
|
|
430
|
+
and types.
|
|
431
|
+
- **Two rulebooks — segment IDENTITY vs WHERE-filter matching**
|
|
432
|
+
(implementation: `lib/verify/coerce.js`):
|
|
433
|
+
|
|
434
|
+
| Operation | Case | Types | ARB source |
|
|
435
|
+
|---|---|---|---|
|
|
436
|
+
| Segment bucketing (breakdown key) | **sensitive** | tagged (`1` ≠ `'1'`) | `hash_value.c:114-115` (raw XXH3), `:92-97` (type tags); ordering `cmp.c:24-32` (`arb_strcmp`) |
|
|
437
|
+
| Filter `==` / `!=` | **INsensitive** | string-coerced | `value.c:285` (`arb_strcasecmp`) |
|
|
438
|
+
| Filter `contains` | **INsensitive** | string-coerced | `eval_node.c:2914` (`arb_strcaseinstr`) |
|
|
439
|
+
| Filter `<` `>` `<=` `>=` on strings | **INsensitive** | string-coerced | `eval_node.c:2931` (`arb_strcasecmp`) |
|
|
440
|
+
|
|
441
|
+
So `plan == "PRO"` in a WHERE filter matches `"pro"` events, but those
|
|
442
|
+
events still land in a `"pro"` segment distinct from `"PRO"` when broken
|
|
443
|
+
down. A hook that stamps mixed-case variants passes its own filter check
|
|
444
|
+
and STILL splits the breakdown table.
|
|
445
|
+
- **Coercion to breakdown key**: `null`/`undefined` → `"undefined"`
|
|
446
|
+
(`arb_selector.py:889-916`), booleans → `"true"`/`"false"`, `-0` → `0`
|
|
447
|
+
(`hash_value.c:111`), objects JSON-stringify, lists fan out per item
|
|
448
|
+
BEFORE coercion.
|
|
449
|
+
- **`topN` defaults to 250** (`normal_query.cpp:1195-1197` — "If no
|
|
450
|
+
meaningful limit is supplied, set it to 250"), sorted count-desc, truncated
|
|
451
|
+
with NO "other" bucket. Segments below the cut disappear from the table
|
|
452
|
+
entirely — keep engineered breakdowns well under 250 distinct values.
|
|
453
|
+
- `countType: 'unique'` switches to per-segment distinct users;
|
|
454
|
+
`countType: 'sessions'` counts distinct derived sessions per segment.
|
|
455
|
+
`firstTimeOnly: true` composes (see §2.15).
|
|
456
|
+
|
|
457
|
+
### 2.13 Uniques, XAU rolling windows, and cumulative (v1.6)
|
|
458
|
+
|
|
459
|
+
Reference: `normal_query.cpp:1300-1316` (per-interval dedup), `:1797-1830`
|
|
460
|
+
(rolling), `:1834-1863` (cumulative). Emulator: `{ type: 'uniques', event,
|
|
461
|
+
unit, rollingWindow, cumulative }`.
|
|
462
|
+
|
|
463
|
+
- **Per-interval dedup is independent**: a user active on day 3 and day 5
|
|
464
|
+
counts once in EACH bucket. DAU across a week can sum to 7× the true user
|
|
465
|
+
count.
|
|
466
|
+
- **XAU (`rollingWindow: W`) is a look-back window, NOT a calendar period**:
|
|
467
|
+
an event on day E contributes the user to buckets `[E, E+W−1]` — forward
|
|
468
|
+
tiling of the look-back. WAU on a Wednesday covers the 7 days ENDING that
|
|
469
|
+
Wednesday, not the ISO calendar week. Do not verify weekly-active stories
|
|
470
|
+
against calendar-week buckets.
|
|
471
|
+
- **`cumulative: true`**: bucket N reports distinct users seen in buckets
|
|
472
|
+
0..N — each user counts once, at first appearance, and the curve is
|
|
473
|
+
monotonically non-decreasing.
|
|
474
|
+
- **Empty/missing distinct_ids are skipped** (`normal_query.cpp:2200-2208`) —
|
|
475
|
+
events with `''` ids never count toward uniques.
|
|
476
|
+
- `countType: 'sessions'` composes with `rollingWindow` but NOT with
|
|
477
|
+
`cumulative` (`normal_query.cpp:1318-1352` — no cumulative sessions path).
|
|
478
|
+
|
|
479
|
+
### 2.14 Formulas evaluate in the API layer, zero-filled (v1.6)
|
|
480
|
+
|
|
481
|
+
Reference: `analytics/api/.../formula/util.py` `operate()` (:25-47),
|
|
482
|
+
div-by-zero (:81-86), `grammar.lark` (PEMDAS). Insights formulas (e.g.
|
|
483
|
+
`A/B*100`) are NOT an ARB query — the API layer fetches each letter's series
|
|
484
|
+
independently and combines them in Python:
|
|
485
|
+
|
|
486
|
+
- **Union-of-keys broadcast**: series are joined on the union of their date
|
|
487
|
+
keys; a date missing from one series contributes **0** (not null, not
|
|
488
|
+
skipped).
|
|
489
|
+
- **Division by zero yields 0** — not NaN, not infinity, not a gap. A
|
|
490
|
+
conversion-rate formula over a day with zero denominators shows 0%.
|
|
491
|
+
- **A missing/empty series is all zeros.**
|
|
492
|
+
|
|
493
|
+
Helper: `evaluateFormula(expr, { A: series, B: series })` implements the same
|
|
494
|
+
grammar and zero-fill rules. Engineer stories so denominators are non-zero on
|
|
495
|
+
days the chart must look alive.
|
|
496
|
+
|
|
497
|
+
### 2.15 First-time-ever is a two-query rewrite (v1.6)
|
|
498
|
+
|
|
499
|
+
Reference: `analytics/.../event_selector.py:59-149`,
|
|
500
|
+
`arb_selector.py:1874-1936`. "First time ever doing X" runs TWO queries:
|
|
501
|
+
pass 1 computes each user's `first_event_time` over (event + **pre-filters**);
|
|
502
|
+
pass 2 selects events matching event name + `$time == first_event_time` +
|
|
503
|
+
**post-filters**. Consequences:
|
|
504
|
+
|
|
505
|
+
- **Pre-filters define the universe** — they decide WHICH event is "the
|
|
506
|
+
first". Post-filters only test the already-picked event; a first event
|
|
507
|
+
failing a post-filter is dropped, NOT replaced by the next candidate.
|
|
508
|
+
- **Filter position decides pre vs post** — in Mixpanel's UI, filters above
|
|
509
|
+
the first-time operator are pre, below are post.
|
|
510
|
+
- Helper: `filterFirstTimeEver(events, { event, preWhere, postWhere })`;
|
|
511
|
+
`firstTimeOnly: true` on the `eventBreakdown`/`uniques` emulators applies
|
|
512
|
+
the no-filter form.
|
|
513
|
+
|
|
514
|
+
### 2.16 Lifecycle is a board template, not an engine query (v1.6)
|
|
515
|
+
|
|
516
|
+
Mixpanel has NO lifecycle query type (`query_type.cpp:8-30` enumerates every
|
|
517
|
+
ARB query — nothing lifecycle-shaped). "Lifecycle" is the Lifecycle Cohort
|
|
518
|
+
Analysis BOARD TEMPLATE: Insights uniques filtered by four behavioral cohorts
|
|
519
|
+
on a **Value Moment** event (`iron/common/report/dashboards/types.ts:367-390`),
|
|
520
|
+
in 7- and 30-day period variants. The canonical cohort definition is
|
|
521
|
+
`weeklyResurrectedUserBookmark`
|
|
522
|
+
(`iron/common/widgets/profile-summary/bookmark_templates.ts:179-320`).
|
|
523
|
+
|
|
524
|
+
Classification per period T: **new** = first-ever value moment in T;
|
|
525
|
+
**retained** = active in T and T−1; **resurrected** = active in T, inactive
|
|
526
|
+
in T−1, active in some period before; **dormant** = INACTIVE in T, active in
|
|
527
|
+
T−1. Emulator: `{ type: 'lifecycle', valueMomentEvent, periodDays: 7|30 }`.
|
|
528
|
+
|
|
529
|
+
- **Declared divergence (tiled vs rolling)**: the real template's cohort
|
|
530
|
+
windows are rolling, re-anchored as-of each charting interval; the emulator
|
|
531
|
+
tiles fixed periods back from the dataset's last event day — identical
|
|
532
|
+
classification rules, deterministic period edges.
|
|
533
|
+
- **Dormancy is an EqualTo-0 filter**: ONE stray value-moment event inside a
|
|
534
|
+
would-be dormancy window reclassifies the user (resurrected → retained).
|
|
535
|
+
Resurrection stories need disciplined gaps — use the lifecycle-wave atom
|
|
536
|
+
rather than hand-rolled probabilistic gaps.
|
|
537
|
+
|
|
538
|
+
### 2.17 Flows (Top Paths) are next-anchor-only with per-level pruning (v1.6)
|
|
539
|
+
|
|
540
|
+
Reference: `flows_query.cpp:988-994` (next-anchor-only), `flows.cpp:680-717`
|
|
541
|
+
(buffers), `bookmark.py:96/:110` (pruning thresholds). Emulator: `{ type:
|
|
542
|
+
'topPaths', anchors, forward, reverse, countType, output }`; helpers
|
|
543
|
+
`extractFlows` / `aggregateFlows`.
|
|
544
|
+
|
|
545
|
+
- **Next-anchor-only matching**: an event only advances the flow if it
|
|
546
|
+
matches anchor `reached + 1`. Matching a LATER anchor (or an earlier one
|
|
547
|
+
again) makes it a plain step. Out-of-order anchor engineering does nothing.
|
|
548
|
+
- **Capacity rings**: each anchor keeps `forward` steps after it (linear —
|
|
549
|
+
first N) and `reverse` steps before it (circular — LAST N). Defaults
|
|
550
|
+
forward=4, reverse=0 mirror the Flows UI view, not ARB constants.
|
|
551
|
+
- **`countType: 'unique'`** = one flow per user across the whole stream;
|
|
552
|
+
`'general'` coincides for pure flows; `'sessions'` restarts the flow
|
|
553
|
+
universe at every session boundary (sessions derive from the user's FULL
|
|
554
|
+
stream, not the filtered steps).
|
|
555
|
+
- **Per-level top-N pruning**: nodes below the per-level top
|
|
556
|
+
`cardinalityThreshold` coalesce into `$mp_uncommon_flows_events`. Defaults:
|
|
557
|
+
**50** for list output, **3** for sankey. ANCHOR nodes are exempt from
|
|
558
|
+
coalescing. A path must hold roughly ≥20-25% share at each level to survive
|
|
559
|
+
the sankey view — engineer dominant paths, not long tails.
|
|
560
|
+
- **Step spacing**: give engineered path steps ≥1s spacing — same-ms steps
|
|
561
|
+
sort nondeterministically and can reorder the path.
|
|
562
|
+
|
|
305
563
|
---
|
|
306
564
|
|
|
307
565
|
## 3. Core Principles
|
|
@@ -451,13 +709,16 @@ Validator strict-clamps prevent the worst pathological combos (e.g.,
|
|
|
451
709
|
`injectOnNewDays` for cohort-conditional cases ("premium users get 7+
|
|
452
710
|
active days, rest stay default"). See §2.5.
|
|
453
711
|
|
|
454
|
-
29. **Touchpoint cap awareness for attribution hooks.** The engine
|
|
712
|
+
29. **Touchpoint cap awareness for attribution hooks.** The engine caps
|
|
455
713
|
UTM stamping at `maxTouchpointsPerUser` (default 10) per user, sampled
|
|
456
714
|
across the user's lifetime. Attribution-biasing hooks should OVERWRITE
|
|
457
715
|
engine-stamped values (e.g., set `event.utm_source = "google"` on
|
|
458
|
-
already-stamped touches), NOT stamp fresh touches from scratch.
|
|
459
|
-
|
|
460
|
-
|
|
716
|
+
already-stamped touches), NOT stamp fresh touches from scratch.
|
|
717
|
+
First/last-touch attribution is UNCAPPED (§2.4) — a fresh stamp
|
|
718
|
+
EARLIER than the engine's first stamp silently becomes the first-touch
|
|
719
|
+
winner, changing results out from under your derivation. Overwriting
|
|
720
|
+
the engine's stamps keeps the touch set fixed so your bias lands
|
|
721
|
+
exactly where attribution reads.
|
|
461
722
|
|
|
462
723
|
---
|
|
463
724
|
|
|
@@ -888,6 +1149,13 @@ max gap. If your sequence has multiple `step_a` events, only the first is
|
|
|
888
1149
|
used. The greedy engine in Mixpanel picks the same first one, so behavior
|
|
889
1150
|
matches.
|
|
890
1151
|
|
|
1152
|
+
**As a pattern (v1.6):** `applyTTCBySegmentV2` from
|
|
1153
|
+
`@ak--47/dungeon-master/hook-patterns` packages this recipe — segment lookup,
|
|
1154
|
+
`findFirstSequence`, `scaleFunnelTTC` — in one call. The older funnel-post
|
|
1155
|
+
`applyTTCBySegment` is deprecated: it scales one run's internal gaps, which
|
|
1156
|
+
only reaches the TTC report when that run is the user's first occurrence of
|
|
1157
|
+
the steps.
|
|
1158
|
+
|
|
891
1159
|
**Conversion-window strict `<`:** If `step_c` lands at exactly `step_a + window`,
|
|
892
1160
|
it is **excluded**. When shifting timestamps, leave at least 1ms of slack
|
|
893
1161
|
under the conversion-window cap.
|
|
@@ -1041,10 +1309,14 @@ if (type === "user") {
|
|
|
1041
1309
|
**Mixpanel report:** Retention / Insights — surviving event count broken down by hash-derived cohort
|
|
1042
1310
|
|
|
1043
1311
|
```js
|
|
1312
|
+
import { hashCohort } from "@ak--47/dungeon-master/hook-helpers";
|
|
1313
|
+
|
|
1044
1314
|
if (type === "everything") {
|
|
1045
1315
|
const uid = record[0]?.user_id || record[0]?.device_id || "";
|
|
1046
|
-
|
|
1047
|
-
|
|
1316
|
+
// hashCohort = FNV-1a over the FULL id (v1.6). Don't hand-roll char-code
|
|
1317
|
+
// arithmetic — id alphabets don't cover charcode space uniformly, so
|
|
1318
|
+
// `% N` idioms silently miss their target rate (see §5, hashFloat).
|
|
1319
|
+
if (!hashCohort(uid, 20)) return record; // ~20% of users churn-silenced
|
|
1048
1320
|
|
|
1049
1321
|
const cutoff = dayjs.unix(meta.datasetStart).add(30, "days");
|
|
1050
1322
|
dropEventsWhere(record, e => dayjs(e.time).isAfter(cutoff));
|
|
@@ -1109,9 +1381,11 @@ for distinct days, not total events.
|
|
|
1109
1381
|
**Mixpanel report:** Varies by use — same report the deprecated feature targeted (typically Insights breakdown by `subscription_tier`)
|
|
1110
1382
|
|
|
1111
1383
|
```js
|
|
1384
|
+
import { hashFloat } from "@ak--47/dungeon-master/hook-helpers";
|
|
1385
|
+
|
|
1112
1386
|
if (type === "user") {
|
|
1113
|
-
const
|
|
1114
|
-
record.subscription_tier =
|
|
1387
|
+
const h = hashFloat(record.distinct_id); // FNV-1a full-string → [0,1)
|
|
1388
|
+
record.subscription_tier = h < 0.6 ? "free" : h < 0.8 ? "monthly" : "annual";
|
|
1115
1389
|
}
|
|
1116
1390
|
|
|
1117
1391
|
if (type === "everything") {
|
|
@@ -1147,10 +1421,10 @@ return record;
|
|
|
1147
1421
|
|
|
1148
1422
|
### Attribution
|
|
1149
1423
|
|
|
1150
|
-
#### 4.25 First-Touch Attribution Bias
|
|
1424
|
+
#### 4.25 First-Touch Attribution Bias
|
|
1151
1425
|
|
|
1152
|
-
**Hook:** `everything` | **Counting:**
|
|
1153
|
-
**Mixpanel report:** Attribution — Conversions by Source (first-touch model
|
|
1426
|
+
**Hook:** `everything` | **Counting:** first-touch is uncapped (Section 2.4)
|
|
1427
|
+
**Mixpanel report:** Attribution — Conversions by Source (first-touch model)
|
|
1154
1428
|
|
|
1155
1429
|
**In Mixpanel:** `Convert` events broken down by first-touch `Touch.source`
|
|
1156
1430
|
show Google >> Facebook >> Twitter (10:5:1 weights).
|
|
@@ -1162,33 +1436,30 @@ if (type === "everything") {
|
|
|
1162
1436
|
const conversion = record.find(e => e.event === "Convert");
|
|
1163
1437
|
if (!conversion) return record;
|
|
1164
1438
|
const convTime = dayjs(conversion.time).valueOf();
|
|
1165
|
-
// Mixpanel only considers the LAST 10 touches before conversion. Stamp at
|
|
1166
|
-
// most ~6-8 touches per user so the first one is clearly the bias target.
|
|
1167
1439
|
const sources = weighArray(["google", "facebook", "twitter"], [10, 5, 1]);
|
|
1168
1440
|
const touches = record.filter(e => e.event === "Touch" && dayjs(e.time).valueOf() <= convTime);
|
|
1169
1441
|
if (touches.length === 0) return record;
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
if (inCap.length > 0) {
|
|
1175
|
-
inCap[0].source = chance.pickone(sources);
|
|
1176
|
-
}
|
|
1442
|
+
// First-touch reads the CHRONOLOGICALLY FIRST touch before conversion —
|
|
1443
|
+
// no cap (whoval/read.cpp LIMIT 1). Bias exactly that one.
|
|
1444
|
+
const sorted = touches.slice().sort((a, b) => dayjs(a.time).valueOf() - dayjs(b.time).valueOf());
|
|
1445
|
+
sorted[0].source = chance.pickone(sources);
|
|
1177
1446
|
return record;
|
|
1178
1447
|
}
|
|
1179
1448
|
```
|
|
1180
1449
|
|
|
1181
|
-
**
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1450
|
+
**Where the 10-cap DOES apply:** only multi-touch models (linear /
|
|
1451
|
+
participation / time-decay) consider just the last `TOUCHPOINTS_LIMIT = 10`
|
|
1452
|
+
touches. First/last-touch read the true first/most-recent touch however
|
|
1453
|
+
many exist. Sparse touches are still good practice — they keep the data
|
|
1454
|
+
legible — but they're realism, not a correctness requirement.
|
|
1185
1455
|
|
|
1186
1456
|
**v1.5 with `hasCampaigns: true`:** when the engine has already stamped UTMs
|
|
1187
1457
|
on up to `maxTouchpointsPerUser` events per user (default 10), DO NOT stamp
|
|
1188
|
-
fresh touches in your hook —
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
instead. See
|
|
1458
|
+
fresh touches in your hook — a fresh stamp earlier than the engine's first
|
|
1459
|
+
would silently become the first-touch winner. Use
|
|
1460
|
+
[Recipe 4.26](#426-bias-engine-stamped-touches-v15) to OVERWRITE the
|
|
1461
|
+
engine's `utm_source` on the existing stamped events instead. See
|
|
1462
|
+
[§2.4](#24-attribution-firstlast-touch-are-uncapped-only-multi-touch-caps-at-10).
|
|
1192
1463
|
|
|
1193
1464
|
---
|
|
1194
1465
|
|
|
@@ -1223,9 +1494,19 @@ if (type === "everything") {
|
|
|
1223
1494
|
```
|
|
1224
1495
|
|
|
1225
1496
|
**Why this works under v1.5:** the engine has already capped + sampled the
|
|
1226
|
-
touches
|
|
1227
|
-
|
|
1228
|
-
|
|
1497
|
+
touches, so the stamped set IS the touch set attribution reads. Stamping
|
|
1498
|
+
fresh touches from scratch would change that set — an earlier fresh stamp
|
|
1499
|
+
becomes the new first-touch winner (first/last-touch are uncapped, §2.4) —
|
|
1500
|
+
invisibly to whatever derivation your bands came from. Overwriting is
|
|
1501
|
+
correct.
|
|
1502
|
+
|
|
1503
|
+
**As a pattern (v1.6):** `applyAttributedBySource` from
|
|
1504
|
+
`@ak--47/dungeon-master/hook-patterns` packages this recipe:
|
|
1505
|
+
`applyAttributedBySource(record, null, { weights: { google: 10, facebook: 5,
|
|
1506
|
+
twitter: 1 }, model: 'firstTouch' })` overwrites the first engine-stamped
|
|
1507
|
+
touch with a seeded weighted pick (models: `firstTouch`, `lastTouch`,
|
|
1508
|
+
`both`). It never stamps unstamped events, so total touch count is
|
|
1509
|
+
unchanged.
|
|
1229
1510
|
|
|
1230
1511
|
---
|
|
1231
1512
|
|
|
@@ -1242,7 +1523,7 @@ a cohort-conditional `everything` hook that uses `injectOnNewDays` to push
|
|
|
1242
1523
|
specific users above the baseline.
|
|
1243
1524
|
|
|
1244
1525
|
```js
|
|
1245
|
-
import { injectOnNewDays } from "@ak--47/dungeon-master/hook-helpers";
|
|
1526
|
+
import { injectOnNewDays, hashCohort } from "@ak--47/dungeon-master/hook-helpers";
|
|
1246
1527
|
import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
|
|
1247
1528
|
|
|
1248
1529
|
// Config:
|
|
@@ -1250,10 +1531,9 @@ import { countDistinctPeriods } from "@ak--47/dungeon-master/verify";
|
|
|
1250
1531
|
// events: [{ event: "open app", weight: 5 }, ...]
|
|
1251
1532
|
|
|
1252
1533
|
if (type === "everything") {
|
|
1253
|
-
// Hash-based cohort (deterministic, ~10% of users).
|
|
1534
|
+
// Hash-based cohort (deterministic, ~10% of users — FNV-1a full-string).
|
|
1254
1535
|
const uid = record[0]?.user_id || "";
|
|
1255
|
-
|
|
1256
|
-
if (!isPowerUser) return record;
|
|
1536
|
+
if (!hashCohort(uid, 10)) return record;
|
|
1257
1537
|
|
|
1258
1538
|
const days = countDistinctPeriods(record, "open app", "day");
|
|
1259
1539
|
if (days >= 10) return record;
|
|
@@ -1308,6 +1588,111 @@ your factor.
|
|
|
1308
1588
|
|
|
1309
1589
|
---
|
|
1310
1590
|
|
|
1591
|
+
#### 4.29 Lifecycle Wave — Dormancy + Resurrection (v1.6)
|
|
1592
|
+
|
|
1593
|
+
**Hook:** `everything`
|
|
1594
|
+
**Mixpanel report:** Lifecycle — Resurrected users spike after the dormancy window (Section 2.16)
|
|
1595
|
+
|
|
1596
|
+
**In Mixpanel:** ~15% of users go dormant for two full weeks starting a week
|
|
1597
|
+
after signup, then resurrect with a burst of value-moment activity.
|
|
1598
|
+
|
|
1599
|
+
```js
|
|
1600
|
+
import { applyLifecycleWave, hashCohort } from "@ak--47/dungeon-master/hook-helpers";
|
|
1601
|
+
|
|
1602
|
+
if (type === "everything") {
|
|
1603
|
+
const uid = record[0]?.user_id || "";
|
|
1604
|
+
if (!hashCohort(uid, 15)) return record;
|
|
1605
|
+
// Sweep [birth+7d, birth+21d] clean of value moments, then clone a
|
|
1606
|
+
// 4-event resurrection burst 1-3h after the window.
|
|
1607
|
+
return applyLifecycleWave(record, uid, {
|
|
1608
|
+
dormantFromDay: 7,
|
|
1609
|
+
dormantDays: 14,
|
|
1610
|
+
resurrectBurst: 4,
|
|
1611
|
+
valueMomentEvent: "complete workout",
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
```
|
|
1615
|
+
|
|
1616
|
+
**Why the atom sweeps the whole window:** lifecycle "dormant" is an
|
|
1617
|
+
`EqualTo 0` filter per period — ONE stray value moment inside the window
|
|
1618
|
+
reclassifies the user and the Resurrected spike vanishes. The atom filters
|
|
1619
|
+
by timestamp over the array as passed, so events injected by EARLIER hook
|
|
1620
|
+
logic in the same pass get swept too. Size `dormantDays` to ≥ 2 lifecycle
|
|
1621
|
+
periods (2 weeks for weekly charts) so period tiling can't clip the gap, and
|
|
1622
|
+
keep the window inside the user's lifespan (clones past the dataset end are
|
|
1623
|
+
dropped by the future-time guard). `dropAll: true` silences the user
|
|
1624
|
+
completely for the window — use it when the lifecycle chart counts a broad
|
|
1625
|
+
event set rather than one value moment.
|
|
1626
|
+
|
|
1627
|
+
---
|
|
1628
|
+
|
|
1629
|
+
#### 4.30 Flows Path Share — Biased Branch After an Anchor (v1.6)
|
|
1630
|
+
|
|
1631
|
+
**Hook:** `everything`
|
|
1632
|
+
**Mixpanel report:** Flows — top paths after the anchor event show the engineered branch (Section 2.17)
|
|
1633
|
+
|
|
1634
|
+
**In Mixpanel:** ~30% of users who view an item proceed straight down
|
|
1635
|
+
`add to cart → begin checkout`, making it the dominant Sankey branch.
|
|
1636
|
+
|
|
1637
|
+
```js
|
|
1638
|
+
import { applyPathBias } from "@ak--47/dungeon-master/hook-helpers";
|
|
1639
|
+
|
|
1640
|
+
if (type === "everything") {
|
|
1641
|
+
const uid = record[0]?.user_id || "";
|
|
1642
|
+
return applyPathBias(record, uid, {
|
|
1643
|
+
anchor: "view item",
|
|
1644
|
+
path: ["add to cart", "begin checkout"],
|
|
1645
|
+
share: 0.30, // FRACTION [0,1] — the atom hashes uid itself
|
|
1646
|
+
gapSeconds: [2, 30],
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
```
|
|
1650
|
+
|
|
1651
|
+
**Why the constraints exist:** Flows' unique mode reads only the FIRST flow
|
|
1652
|
+
per user, so the atom anchors on the first `anchor` occurrence; gaps are
|
|
1653
|
+
clamped ≥1s because sub-second jitter scrambles Sankey step order; and the
|
|
1654
|
+
branch needs roughly ≥20-25% share to survive top-3-per-level pruning —
|
|
1655
|
+
don't engineer a 5% path and expect to see it. Users missing a source event
|
|
1656
|
+
for ANY step are skipped entirely (a partial path would pollute the share);
|
|
1657
|
+
verify the effective share with `extractFlows`/`aggregateFlows` rather than
|
|
1658
|
+
assuming `share` landed.
|
|
1659
|
+
|
|
1660
|
+
---
|
|
1661
|
+
|
|
1662
|
+
#### 4.31 Session Shape — Deterministic Cadence (v1.6)
|
|
1663
|
+
|
|
1664
|
+
**Hook:** `everything`
|
|
1665
|
+
**Mixpanel report:** Insights — sessions per user per week / events per session (Section 2.13)
|
|
1666
|
+
|
|
1667
|
+
**In Mixpanel:** A "focused" cohort shows exactly ~3 tight sessions per week
|
|
1668
|
+
of ~5 events each, against a diffuse baseline.
|
|
1669
|
+
|
|
1670
|
+
```js
|
|
1671
|
+
import { applySessionShape, hashCohort } from "@ak--47/dungeon-master/hook-helpers";
|
|
1672
|
+
|
|
1673
|
+
if (type === "everything") {
|
|
1674
|
+
const uid = record[0]?.user_id || "";
|
|
1675
|
+
if (!hashCohort(uid, 20)) return record; // cohort gating is the caller's job
|
|
1676
|
+
return applySessionShape(record, uid, {
|
|
1677
|
+
sessionsPerWeek: 3,
|
|
1678
|
+
eventsPerSession: 5,
|
|
1679
|
+
sessionMinutes: 25,
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
```
|
|
1683
|
+
|
|
1684
|
+
**Why it's safe in v1.6:** the engine re-derives `session_id` on the FINAL
|
|
1685
|
+
event set (after the `everything` hook), so wholesale timestamp rewrites no
|
|
1686
|
+
longer leave stale session labels. The atom keeps intra-session gaps well
|
|
1687
|
+
under the 30-min timeout (spacing capped at 20min + bounded jitter), keeps
|
|
1688
|
+
inter-session gaps well over it, and never crosses UTC midnight inside one
|
|
1689
|
+
engineered session (the day-boundary split would cut it). Retiming only — no
|
|
1690
|
+
events are added or dropped, so total counts and event mixes are untouched.
|
|
1691
|
+
Session count follows `min(sessionsPerWeek × weeks, ceil(N /
|
|
1692
|
+
eventsPerSession))`: scarce users get fewer sessions, not fabricated events.
|
|
1693
|
+
|
|
1694
|
+
---
|
|
1695
|
+
|
|
1311
1696
|
## 5. Phase 3 Atom Reference
|
|
1312
1697
|
|
|
1313
1698
|
Import from `@ak--47/dungeon-master/hook-helpers`:
|
|
@@ -1318,6 +1703,8 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
|
|
|
1318
1703
|
| `binUsersByEventInRange` | cohort | `(events, eventName, start, end, bins) -> string\|null` | Same, restricted to a time range |
|
|
1319
1704
|
| `countEventsBetween` | cohort | `(events, eventA, eventB) -> number` | Count events between first A and first B |
|
|
1320
1705
|
| `userInProfileSegment` | cohort | `(profile, key, values) -> boolean` | Profile property match |
|
|
1706
|
+
| **`hashFloat`** | cohort | `(id) -> number` | FNV-1a over the FULL id string → [0,1). Deterministic bucketing primitive (v1.6) — replaces `charCodeAt(0) % N` idioms, which bias cohort rates on hex-ish id alphabets |
|
|
1707
|
+
| **`hashCohort`** | cohort | `(id, pct) -> boolean` | True for ~`pct`% of ids (pct on a 0–100 scale). Membership nests: `pct=5` ⊂ `pct=20` |
|
|
1321
1708
|
| `cloneEvent` | mutate | `(template, overrides?) -> event` | Shallow clone with overrides |
|
|
1322
1709
|
| `dropEventsWhere` | mutate | `(events, predicate) -> number` | Remove matching events in-place |
|
|
1323
1710
|
| `scaleEventCount` | mutate | `(events, eventName, factor) -> number` | Scale total count via clones at sub-second offsets (does NOT move frequency-distribution bins — see Section 2.1) |
|
|
@@ -1332,6 +1719,9 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
|
|
|
1332
1719
|
| **`injectOnNewDays`** | inject | `(events, eventName, targetDays, options?) -> events[]` | Inject clones on previously empty days within active window — **the right tool for moving frequency-distribution bins** |
|
|
1333
1720
|
| `isPreAuthEvent` | identity | `(event, authTime) -> boolean` | Check if before user's stitch |
|
|
1334
1721
|
| `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
|
|
1722
|
+
| **`applyLifecycleWave`** | shape | `(events, uid, { dormantFromDay, dormantDays, resurrectBurst?, valueMomentEvent, dropAll? }) -> events[]` | Clean dormancy gap + resurrection burst; sweeps the ENTIRE window by timestamp (v1.6, recipe 4.29). Returns a NEW array |
|
|
1723
|
+
| **`applyPathBias`** | shape | `(events, uid, { anchor, path, share, gapSeconds? }) -> events[]` | Inject a Flows path after the user's first anchor for ~`share` (fraction) of users; skips users missing any step template (v1.6, recipe 4.30) |
|
|
1724
|
+
| **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes }) -> events[]` | Retime the stream into deterministic session clusters — intra-gaps ≪ 30min, inter-gaps ≫ 30min, never crosses UTC midnight (v1.6, recipe 4.31) |
|
|
1335
1725
|
|
|
1336
1726
|
**Inject atoms + v1.5:** the engine auto-sorts events by time after the
|
|
1337
1727
|
`everything` hook (`autoSortAfterEverything: true` default — see Principle
|
|
@@ -1349,15 +1739,25 @@ Import from `@ak--47/dungeon-master/verify`:
|
|
|
1349
1739
|
|
|
1350
1740
|
| Function | Purpose | Mixpanel Reference |
|
|
1351
1741
|
|---|---|---|
|
|
1352
|
-
| `emulateBreakdown(events, config)` | Run the table-shape emulator
|
|
1742
|
+
| `emulateBreakdown(events, config)` | Run the table-shape emulator; `config.type` selects one of 12 analyses (`frequencyByFrequency`, `funnelFrequency`, `aggregatePerUser`, `timeToConvert`, `attributedBy`, `sessionMetrics`, `retention`, `distinctCount`, `eventBreakdown`, `uniques`, `lifecycle`, `topPaths` — `topPaths` returns an object, the rest return row arrays) | Insights / Funnels / Retention / Flows |
|
|
1353
1743
|
| `verifyDungeon(dungeonConfig, assertions)` | High-level wrapper: run dungeon + run assertions | n/a |
|
|
1354
1744
|
| `evaluateFunnel(events, steps, options?)` | Greedy single-pass funnel state machine | `history.cpp` |
|
|
1745
|
+
| `evaluateFunnelHPC(events, steps, holdProperty, options?)` | Hold-property-constant parallel sub-funnels (also routed via `funnelFrequency` + `holdPropertyConstant`) | `funnel_query.cpp` |
|
|
1355
1746
|
| `timestampComesAfter(t1, t2, grace?)` | 2-second grace window check | `history.cpp` |
|
|
1356
1747
|
| `withinConversionWindow(eventTime, step0Time, windowMs)` | Strict `<` window check | `conversion_window.cpp` |
|
|
1357
|
-
| `countDistinctPeriods(events, eventName, unit?, options?)` | Distinct-period count (default
|
|
1358
|
-
| `
|
|
1748
|
+
| `countDistinctPeriods(events, eventName, unit?, options?)` | Distinct-period count (default `'ui-bucket'` calendar math; `{algorithm: 'mixpanel-rolling'}` for the C++ rolling counter; unknown names throw) | `addiction_query.cpp` |
|
|
1749
|
+
| `frequencyHistogram(events, { event, unit, intervalDays, profiles? })` | Full Frequency-report shape: per-interval histogram of users by rolling-counter value | `addiction_query.cpp` |
|
|
1750
|
+
| `countEvents(events, eventName?, where?)` | Total event count with optional name/property filter | `normal_query.cpp` |
|
|
1751
|
+
| `countDistinctValues(events, property, options?)` | Distinct property-value count | `normal_query.cpp` |
|
|
1752
|
+
| `nullAwareAvg(values, options?)` / `nullAwareSum(values, options?)` | AVG/SUM that skip non-numeric; `{ flatten: true }` explodes list values one level | `normal_query.cpp` |
|
|
1359
1753
|
| `nullAwareExtreme(values, mode)` | MIN/MAX that skip non-numeric | `normal_query.cpp` |
|
|
1360
1754
|
| `binByDistinctPeriods(events, eventName, bins, unit?)` | Distinct-period cohort assignment | `addiction_query.cpp` |
|
|
1755
|
+
| `partitionByTimeBucket(events, unit, range?)` | Split a stream into day/week/month buckets (week = ISO Monday) | `normal_query.cpp` |
|
|
1756
|
+
| `evaluateFormula(expr, series)` | API-layer formula math: PEMDAS, zero-fill broadcast, div-by-zero → 0 | `formula/util.py` |
|
|
1757
|
+
| `filterFirstTimeEver(events, { event, preWhere?, postWhere? })` | Two-query first-time-ever rewrite (§2.15) | `event_selector.py` |
|
|
1758
|
+
| `sessionize(events, options?)` / `sessionOrdinals(events, options?)` | Query-time session derivation (30-min gap, 24h max) | `session_query.cpp` |
|
|
1759
|
+
| `extractFlows(events, options)` / `aggregateFlows(flows, options?)` | Per-user flow extraction + Top Paths tree (`UNCOMMON_FLOWS_EVENT` = coalesced-node label) | `flows_query.cpp` |
|
|
1760
|
+
| `buildIdentityMap(profiles)` / `resolveUserId(event, identityMap?)` | device_id → canonical-id identity merge | n/a |
|
|
1361
1761
|
| `deriveExpectedSchema(config)` / `validateSchema(events, schema)` | Schema integrity checks | n/a |
|
|
1362
1762
|
|
|
1363
1763
|
Full JSDoc in [`lib/verify/*.js`](lib/verify/).
|
|
@@ -1370,19 +1770,21 @@ Import from `@ak--47/dungeon-master/hook-patterns`:
|
|
|
1370
1770
|
|
|
1371
1771
|
| Pattern | Hook Type | Signature | Mixpanel Report |
|
|
1372
1772
|
|---|---|---|---|
|
|
1373
|
-
| `applyFrequencyByFrequency` | everything | `(events, profile, { cohortEvent, bins, targetEvent, multipliers })` | Frequency of A by per-user
|
|
1374
|
-
| `applyFunnelFrequencyBreakdown` | funnel-post | `(allEvents, profile, funnelEvents, { cohortEvent, bins, dropMultipliers })` | Funnel conversion by per-user activity bucket |
|
|
1375
|
-
| `applyAggregateByBin` | everything | `(events, profile, { cohortEvent, bins, event, propertyName, deltas })` | Avg property value by per-user activity bucket |
|
|
1376
|
-
| `
|
|
1377
|
-
| `
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
>
|
|
1381
|
-
>
|
|
1382
|
-
>
|
|
1383
|
-
>
|
|
1384
|
-
>
|
|
1385
|
-
>
|
|
1773
|
+
| `applyFrequencyByFrequency` | everything | `(events, profile, { cohortEvent, bins, targetEvent, multipliers, binBy? })` | Frequency of A by per-user frequency of B |
|
|
1774
|
+
| `applyFunnelFrequencyBreakdown` | funnel-post | `(allEvents, profile, funnelEvents, { cohortEvent, bins, dropMultipliers, binBy? })` | Funnel conversion by per-user activity bucket |
|
|
1775
|
+
| `applyAggregateByBin` | everything | `(events, profile, { cohortEvent, bins, event, propertyName, deltas, binBy? })` | Avg property value by per-user activity bucket |
|
|
1776
|
+
| `applyTTCBySegmentV2` | everything | `(events, profile, { segmentKey, factors, steps, maxGapMinutes? })` | Funnel TTC by profile segment (greedy first sequence — recipe 4.14 as code, v1.6) |
|
|
1777
|
+
| `applyTTCBySegment` | funnel-post | `(funnelEvents, profile, { segmentKey, factors })` | **Deprecated (v1.6)** — scales one run's gaps; only reaches Mixpanel TTC for `isFirstFunnel` runs. Use V2. |
|
|
1778
|
+
| `applyAttributedBySource` | everything | `(events, profile, { weights, property?, model? })` | Conversions by source — overwrites engine-stamped touches (recipe 4.26 as code, rewritten v1.6) |
|
|
1779
|
+
|
|
1780
|
+
> **Bin axis (v1.6).** The three `*ByBin` / `*Frequency*` patterns bin by
|
|
1781
|
+
> **distinct calendar days** of `cohortEvent` by default
|
|
1782
|
+
> (`binBy: 'distinctDays'`, via `binByDistinctPeriods`) — the same axis the
|
|
1783
|
+
> verification emulator and Mixpanel's frequency reports use, so pattern
|
|
1784
|
+
> cohorts and report buckets align. Pass `binBy: 'events'` to restore the
|
|
1785
|
+
> pre-1.6 total-event-count axis (needed for
|
|
1786
|
+
> `applyFunnelFrequencyBreakdown`'s funnelEvents fallback, where a single
|
|
1787
|
+
> funnel run rarely spans more than one day).
|
|
1386
1788
|
|
|
1387
1789
|
Full JSDoc in [`lib/hook-patterns/*.js`](lib/hook-patterns/). Pair with
|
|
1388
1790
|
`emulateBreakdown` from `@ak--47/dungeon-master/verify` to assert patterns
|
|
@@ -1408,7 +1810,7 @@ const rows = emulateBreakdown(events, {
|
|
|
1408
1810
|
returnEvent: 'Login',
|
|
1409
1811
|
dayBuckets: [0, 1, 7, 14, 30], // bucket 0 = within 24h of birth
|
|
1410
1812
|
segmentBy: 'plan', // optional — segment by birth event prop
|
|
1411
|
-
|
|
1813
|
+
unbounded: 'none', // optional — 'carryForward' = monotonically non-decreasing
|
|
1412
1814
|
birthCanRetain: false, // optional — count returns AT birth ms (default false)
|
|
1413
1815
|
profiles, // optional — auto-builds identity map
|
|
1414
1816
|
});
|
|
@@ -1424,15 +1826,56 @@ const rows = emulateBreakdown(events, {
|
|
|
1424
1826
|
```js
|
|
1425
1827
|
const rows = emulateBreakdown(events, {
|
|
1426
1828
|
type: 'sessionMetrics',
|
|
1427
|
-
event: 'Page View',
|
|
1829
|
+
event: 'Page View', // optional — only sessions containing this event
|
|
1428
1830
|
metrics: ['count', 'duration', 'eventsPerSession'],
|
|
1831
|
+
source: 'derived', // default — sessions re-derived from timestamps
|
|
1832
|
+
sessionTimeoutMs: 30 * 60_000, // optional — gap trigger (strict >)
|
|
1833
|
+
});
|
|
1834
|
+
// → [{ metric, avg, median, p90, total_sessions, source, stampedDivergence }, ...]
|
|
1835
|
+
```
|
|
1836
|
+
|
|
1837
|
+
`source: 'derived'` (default since v1.6.0) re-derives sessions from raw
|
|
1838
|
+
timestamps the way `session_query.cpp` does — pre-stamped `session_id`s are
|
|
1839
|
+
ignored. `source: 'stamped'` keeps the v1.5 group-by-`(user, session_id)`
|
|
1840
|
+
behavior. Either way, when events carry stamps, every row reports
|
|
1841
|
+
`stampedDivergence`: the number of consecutive stamped-event pairs whose
|
|
1842
|
+
stamped session boundary disagrees with the derived one (0 means the
|
|
1843
|
+
generator's stamping matches what Mixpanel will compute).
|
|
1844
|
+
|
|
1845
|
+
**Session duration / depth metrics need no special type.** `sessionize()`
|
|
1846
|
+
returns `syntheticEvents` — `$session_start`/`$session_end` pairs carrying
|
|
1847
|
+
`$duration_s` and `$event_count` as plain numeric properties (plus
|
|
1848
|
+
`$origin_start`/`$origin_end` and the copy props). Aggregate them with the
|
|
1849
|
+
existing types, exactly like Mixpanel aggregates session properties:
|
|
1850
|
+
|
|
1851
|
+
```js
|
|
1852
|
+
import { sessionize, emulateBreakdown } from '@ak--47/dungeon-master/verify';
|
|
1853
|
+
|
|
1854
|
+
const { syntheticEvents } = sessionize(events);
|
|
1855
|
+
// Avg session duration per user, cohorted by # active session days
|
|
1856
|
+
// (Insights: AGGREGATE $duration_s on $session_end):
|
|
1857
|
+
emulateBreakdown(syntheticEvents, {
|
|
1858
|
+
type: 'aggregatePerUser', event: '$session_end', property: '$duration_s',
|
|
1859
|
+
agg: 'avg', breakdownByFrequencyOf: '$session_start',
|
|
1860
|
+
});
|
|
1861
|
+
// Session-depth distribution (Insights: $session_end segmented by $event_count):
|
|
1862
|
+
emulateBreakdown(syntheticEvents, {
|
|
1863
|
+
type: 'eventBreakdown', event: '$session_end', breakdownProperty: '$event_count',
|
|
1429
1864
|
});
|
|
1430
|
-
// → [{ metric, avg, median, p90, total_sessions }, ...]
|
|
1431
1865
|
```
|
|
1432
1866
|
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1867
|
+
For plain duration averages without a cohort axis, `sessionMetrics`'s
|
|
1868
|
+
`duration` row (avg/median/p90) is the direct path.
|
|
1869
|
+
|
|
1870
|
+
Synthetic session events live OUTSIDE the regular event-name namespace
|
|
1871
|
+
(`libquery/event/filter.h`) — name filters and "all events" over raw events
|
|
1872
|
+
never match them, which is why `sessionize` returns them as a separate array
|
|
1873
|
+
you feed in explicitly.
|
|
1874
|
+
|
|
1875
|
+
If you need to verify session-scoped funnels (steps must land in same
|
|
1876
|
+
session), pass `sessionScoped: true` to `evaluateFunnel`, or use the
|
|
1877
|
+
Mixpanel-faithful session count window
|
|
1878
|
+
(`conversionWindow: { unit: 'sessions', n: 1 }`).
|
|
1436
1879
|
|
|
1437
1880
|
### 8.3 Funnel reentry (counting repeat completions)
|
|
1438
1881
|
|
|
@@ -1493,10 +1936,10 @@ console.log(map.get('pro').completed); // pro user completed
|
|
|
1493
1936
|
console.log(map.get('free').completed); // free user did not (independent)
|
|
1494
1937
|
```
|
|
1495
1938
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
`assert` callback
|
|
1939
|
+
Since v1.6, passing `holdPropertyConstant: 'plan'` to the `funnelFrequency`
|
|
1940
|
+
emulator routes through the HPC engine and reports per-held-value sub-funnel
|
|
1941
|
+
rows. `evaluateFunnelHPC` remains available directly for bespoke assertions
|
|
1942
|
+
inside a `verifyDungeon` check's `assert` callback.
|
|
1500
1943
|
|
|
1501
1944
|
### 8.6 Step-level filter patterns
|
|
1502
1945
|
|
|
@@ -1587,10 +2030,11 @@ emulateBreakdown(events, { type: 'retention', cohortEvent: 'Sign Up',
|
|
|
1587
2030
|
|
|
1588
2031
|
20-dungeon eval surfaced patterns where naive verification inverts. Apply
|
|
1589
2032
|
these recipes when writing per-dungeon verify scripts under
|
|
1590
|
-
`
|
|
2033
|
+
`dungeons/vertical/<name>/`.
|
|
1591
2034
|
|
|
1592
|
-
**Proof in repo:** `
|
|
1593
|
-
|
|
2035
|
+
**Proof in repo:** `dungeons/vertical/<dungeon>/<dungeon>.{verify.mjs,sql}` plus a
|
|
2036
|
+
`stories` export per dungeon — 22 dungeons, 212 machine-checkable stories,
|
|
2037
|
+
evaluated mechanically by `scripts/verify-stories.mjs`. Each
|
|
1594
2038
|
`<dungeon>.verify.mjs` is a CI-runnable assertion that the dungeon's
|
|
1595
2039
|
engineered story patterns appear in full-fidelity generated data.
|
|
1596
2040
|
|
|
@@ -1695,22 +2139,32 @@ Cleanest pattern for hidden cohorts. Deterministic, no schema mutation, no
|
|
|
1695
2139
|
flag stamping. Produces textbook long-tail signals:
|
|
1696
2140
|
|
|
1697
2141
|
```js
|
|
2142
|
+
import { hashCohort } from "@ak--47/dungeon-master/hook-helpers";
|
|
2143
|
+
|
|
1698
2144
|
// In hook (everything):
|
|
1699
2145
|
for (const e of events) {
|
|
1700
|
-
const isWhale = e.user_id && e.user_id
|
|
2146
|
+
const isWhale = e.user_id && hashCohort(e.user_id, 2); // ~2% of users
|
|
1701
2147
|
if (isWhale && e.event === 'swap') e.trade_amount_usd *= 50;
|
|
1702
2148
|
}
|
|
1703
2149
|
|
|
1704
|
-
// In verify:
|
|
2150
|
+
// In verify (same primitive — hook and verifier CANNOT disagree on membership):
|
|
1705
2151
|
const whaleAmts = [], rest = [];
|
|
1706
2152
|
for (const [uid, evs] of byUser) {
|
|
1707
|
-
const isWhale = uid
|
|
2153
|
+
const isWhale = hashCohort(uid, 2);
|
|
1708
2154
|
const amts = evs.filter(e => e.event === 'swap').map(e => e.trade_amount_usd);
|
|
1709
2155
|
(isWhale ? whaleAmts : rest).push(...amts);
|
|
1710
2156
|
}
|
|
1711
2157
|
check('whale 5x+ trade', avg(whaleAmts) / avg(rest) >= 5);
|
|
1712
2158
|
```
|
|
1713
2159
|
|
|
2160
|
+
**Why not `uid.charCodeAt(0) % 50 === 0`** (the pre-1.6 idiom): first-char
|
|
2161
|
+
arithmetic depends on the id alphabet covering charcode space uniformly — it
|
|
2162
|
+
doesn't. GUID first chars are hex (0-9, a-f), which reach only ~2 of 50
|
|
2163
|
+
residues under `% 50`, so the "2% cohort" actually lands anywhere from 0% to
|
|
2164
|
+
~12% depending on id format. `hashCohort` runs FNV-1a over the FULL string;
|
|
2165
|
+
on engine-stamped GUIDs the share tracks the target within a few tenths of a
|
|
2166
|
+
point.
|
|
2167
|
+
|
|
1714
2168
|
### 9.7 Hook ordering inside `everything`
|
|
1715
2169
|
|
|
1716
2170
|
When one hook injects events that another hook then mutates, ordering
|