@ak--47/dungeon-master 1.4.4 → 1.5.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 +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +147 -0
- package/HOOKS.md +1243 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +1 -1
- package/dungeons/technical/anonymous-users.js +1 -1
- package/dungeons/technical/array-of-object-lookup.js +1 -1
- package/dungeons/technical/datagen-v15-verify.js +74 -0
- package/dungeons/technical/experiments.js +1 -1
- package/dungeons/technical/foobar.js +1 -1
- package/dungeons/technical/group-analytics.js +1 -1
- package/dungeons/technical/mirror-strategies.js +1 -1
- package/dungeons/technical/nested-objects.js +1 -1
- package/dungeons/technical/retention-cadence.js +1 -1
- package/dungeons/technical/sanity.js +1 -1
- package/dungeons/technical/scale-test.js +1 -1
- package/dungeons/technical/scd.js +1 -1
- package/dungeons/technical/simple.js +1 -1
- package/dungeons/technical/simplest.js +74 -20
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +4 -0
- package/dungeons/vertical/community.js +9 -3
- package/dungeons/vertical/crypto.js +5 -0
- package/dungeons/vertical/dating.js +23 -10
- package/dungeons/vertical/devtools.js +10 -0
- package/dungeons/vertical/ecommerce.js +6 -0
- package/dungeons/vertical/education.js +11 -0
- package/dungeons/vertical/fintech.js +13 -0
- package/dungeons/vertical/fitness.js +10 -0
- package/dungeons/vertical/food-delivery.js +9 -0
- package/dungeons/vertical/gaming.js +10 -0
- package/dungeons/vertical/healthcare.js +5 -0
- package/dungeons/vertical/insurance-application.js +10 -0
- package/dungeons/vertical/logistics.js +8 -1
- package/dungeons/vertical/marketplace.js +7 -0
- package/dungeons/vertical/media.js +8 -0
- package/dungeons/vertical/real-estate.js +7 -1
- package/dungeons/vertical/sass.js +12 -0
- package/dungeons/vertical/social.js +9 -0
- package/dungeons/vertical/travel.js +5 -0
- package/index.js +45 -7
- package/lib/core/config-validator.js +270 -7
- package/lib/core/context.js +58 -0
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +72 -1
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +27 -1
- package/lib/orchestrators/user-loop.js +488 -29
- package/lib/templates/macro-presets.js +39 -9
- package/lib/utils/utils.js +16 -79
- package/lib/verify/counting.js +320 -0
- package/lib/verify/emulate-breakdown.js +512 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +19 -0
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +4 -2
- package/types.d.ts +314 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
# DuckDB SQL Recipes for Verification
|
|
2
|
+
|
|
3
|
+
Use DuckDB only for schema integrity, identity-model invariants, experiment invariants, and bespoke patterns the emulator can't express. For funnel / frequency / aggregate / TTC / attribution patterns, use `emulateBreakdown` instead — see [counting-semantics.md](counting-semantics.md).
|
|
4
|
+
|
|
5
|
+
## Schema validation queries
|
|
6
|
+
|
|
7
|
+
For each unique event type in the output, compare actual columns against the config-declared properties:
|
|
8
|
+
|
|
9
|
+
```sql
|
|
10
|
+
WITH event_data AS (
|
|
11
|
+
SELECT * FROM read_json_auto('./data/<run-name>-EVENTS.json', sample_size=-1)
|
|
12
|
+
WHERE event = '<EVENT_TYPE>'
|
|
13
|
+
)
|
|
14
|
+
SELECT
|
|
15
|
+
unnest(map_keys(columns(*))) as col_name,
|
|
16
|
+
COUNT(*) as total_events,
|
|
17
|
+
COUNT(col_name) FILTER (WHERE col_name IS NOT NULL) as non_null_count,
|
|
18
|
+
ROUND(COUNT(col_name) FILTER (WHERE col_name IS NOT NULL) * 100.0 / COUNT(*), 1) as coverage_pct
|
|
19
|
+
FROM event_data
|
|
20
|
+
GROUP BY col_name
|
|
21
|
+
ORDER BY coverage_pct DESC;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or use the programmatic API (`lib/verify/schema-validator.js`):
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
import { deriveExpectedSchema, validateSchema } from './lib/verify/index.js';
|
|
28
|
+
// deriveExpectedSchema(config) → Map<eventName, Set<propKey>>
|
|
29
|
+
// validateSchema(events, config) → { pass, eventTypes, summary, flagStamping }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Expected schema sources
|
|
33
|
+
|
|
34
|
+
The expected set of columns per event type is derived from config:
|
|
35
|
+
|
|
36
|
+
| Source | Keys | Condition |
|
|
37
|
+
|--------|------|-----------|
|
|
38
|
+
| Core | `event`, `time`, `insert_id`, `user_id` | Always |
|
|
39
|
+
| Identity | `device_id` | `avgDevicePerUser > 0` |
|
|
40
|
+
| Identity | `session_id` | `hasSessionIds` |
|
|
41
|
+
| Event config | `events[i].properties` keys | Per event type |
|
|
42
|
+
| Super props | `superProps` keys | All event types |
|
|
43
|
+
| Location | `city`, `region`, `country`, `country_code` | `hasLocation` |
|
|
44
|
+
| Browser | `browser` | `hasBrowser` |
|
|
45
|
+
| Device | `model`, `screen_height`, `screen_width`, `os`, `Platform`, `carrier`, `radio` | `hasAndroidDevices`/`hasIOSDevices`/`hasDesktopDevices` |
|
|
46
|
+
| Campaigns | `utm_source`, `utm_campaign`, `utm_medium`, `utm_content`, `utm_term` | `hasCampaigns` |
|
|
47
|
+
| Group keys | group key name | Per event type from `groupKeys[i][2]`, or all if empty |
|
|
48
|
+
| Funnel props | `funnel.props` keys | Events in funnel sequence |
|
|
49
|
+
| Experiment | `Experiment name`, `Variant name` | `$experiment_started` event |
|
|
50
|
+
| World events | `worldEvent.injectProps` keys | Events matching `affectsEvents` |
|
|
51
|
+
|
|
52
|
+
### Schema verdicts
|
|
53
|
+
|
|
54
|
+
For each event type, classify any column present in output but NOT in expected schema:
|
|
55
|
+
|
|
56
|
+
- **SCHEMA-PASS** — Column appears on 100% of events of this type. Uniform enrichment is acceptable.
|
|
57
|
+
- **SCHEMA-FAIL** — Column appears on <100% of events of this type. This is flag stamping — hook conditionally adds a property, creating an inconsistent schema.
|
|
58
|
+
|
|
59
|
+
If any event type has SCHEMA-FAIL, flag it prominently and include specific remediation: which hook line adds the property and how to remove it while preserving the intended pattern.
|
|
60
|
+
|
|
61
|
+
## Standard identity-model invariants
|
|
62
|
+
|
|
63
|
+
Run these for every dungeon that uses the identity model (`isAuthEvent` + `attempts` + `avgDevicePerUser`), BEFORE per-pattern checks:
|
|
64
|
+
|
|
65
|
+
```sql
|
|
66
|
+
-- Stitch event count must match converted-born count, exactly one per user.
|
|
67
|
+
WITH e AS (SELECT * FROM read_json_auto('./data/<file>-EVENTS.json')),
|
|
68
|
+
auth_event AS (SELECT 'Sign Up' AS name) -- name of your isAuthEvent
|
|
69
|
+
SELECT
|
|
70
|
+
COUNT(*) AS auth_events_total,
|
|
71
|
+
SUM(CASE WHEN user_id IS NOT NULL AND device_id IS NOT NULL THEN 1 ELSE 0 END) AS stitches,
|
|
72
|
+
COUNT(DISTINCT CASE WHEN user_id IS NOT NULL THEN user_id END) AS converted_users
|
|
73
|
+
FROM e WHERE event = (SELECT name FROM auth_event);
|
|
74
|
+
|
|
75
|
+
-- Pre-existing users must have user_id on every event (no anon-only records).
|
|
76
|
+
WITH e AS (SELECT * FROM read_json_auto('./data/<file>-EVENTS.json')),
|
|
77
|
+
u AS (SELECT * FROM read_json_auto('./data/<file>-USERS.json'))
|
|
78
|
+
SELECT COUNT(*) AS preexisting_anon_only_records
|
|
79
|
+
FROM e JOIN u ON u.distinct_id::VARCHAR = e.user_id::VARCHAR
|
|
80
|
+
WHERE u.created < (SELECT MIN(time::TIMESTAMP) FROM e)
|
|
81
|
+
AND e.user_id IS NULL;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Failures usually indicate incomplete identity-model migration. Flag in report.
|
|
85
|
+
|
|
86
|
+
## Experiment invariants
|
|
87
|
+
|
|
88
|
+
Run when dungeon uses `experiment:` on any funnel:
|
|
89
|
+
|
|
90
|
+
```sql
|
|
91
|
+
-- Variant distribution should be roughly even (within ±10% of expected share)
|
|
92
|
+
SELECT "Variant name", COUNT(*) AS exposure_count,
|
|
93
|
+
COUNT(DISTINCT user_id) AS unique_users
|
|
94
|
+
FROM read_json_auto('./data/<file>-EVENTS.json')
|
|
95
|
+
WHERE event = '$experiment_started'
|
|
96
|
+
GROUP BY "Variant name"
|
|
97
|
+
ORDER BY exposure_count DESC;
|
|
98
|
+
|
|
99
|
+
-- $experiment_started should only appear after experiment start date
|
|
100
|
+
SELECT MIN(time) AS earliest_exposure, MAX(time) AS latest_exposure
|
|
101
|
+
FROM read_json_auto('./data/<file>-EVENTS.json')
|
|
102
|
+
WHERE event = '$experiment_started';
|
|
103
|
+
|
|
104
|
+
-- Same user should always be in the same variant (deterministic assignment)
|
|
105
|
+
SELECT user_id, COUNT(DISTINCT "Variant name") AS variant_count
|
|
106
|
+
FROM read_json_auto('./data/<file>-EVENTS.json')
|
|
107
|
+
WHERE event = '$experiment_started' AND user_id IS NOT NULL
|
|
108
|
+
GROUP BY user_id
|
|
109
|
+
HAVING variant_count > 1;
|
|
110
|
+
-- Expected: 0 rows (no user in multiple variants)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## DuckDB notes
|
|
114
|
+
|
|
115
|
+
- Output is **JSONL** (newline-delimited JSON) — `read_json_auto()` handles this natively
|
|
116
|
+
- **Properties are FLAT on event records** — use `event.amount`, NOT `event.properties.amount`
|
|
117
|
+
- **Time field** is an ISO string — use `CAST(time AS TIMESTAMP)` or `time::TIMESTAMP`
|
|
118
|
+
- Use `COALESCE(column, default)` for properties that only exist on some events (spliced events may lack some fields)
|
|
119
|
+
- Use `TRY_CAST()` instead of `CAST()` for columns with mixed types
|
|
120
|
+
- For large queries, use `LIMIT` to keep output manageable
|
|
121
|
+
- Escape single quotes in bash: use `$'...'` syntax or double-quote the SQL and escape internal quotes
|
|
122
|
+
|
|
123
|
+
## DuckDB pitfalls
|
|
124
|
+
|
|
125
|
+
### Bot/Anomaly user_id breaks UUID type inference
|
|
126
|
+
|
|
127
|
+
When a dungeon uses `dataQuality.botUsers > 0` or `anomalies` features, some events have `user_id` like `"bot_db9a7a37"` or `"anomaly_f148a044"` instead of UUIDs. DuckDB auto-inference reads first chunk as UUID, then fails on string IDs:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
Conversion Error: Could not convert string 'bot_db9a7a37' to INT128
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Fix:** every query against EVENTS must use `sample_size=-1` to scan all rows for typing AND filter out synthetic IDs:
|
|
134
|
+
|
|
135
|
+
```sql
|
|
136
|
+
SELECT ... FROM read_json_auto('./data/verify-X-EVENTS.json', sample_size=-1)
|
|
137
|
+
WHERE user_id NOT LIKE 'bot_%' AND user_id NOT LIKE 'anomaly_%'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For joins on USERS where the join key is UUID, cast both sides to VARCHAR:
|
|
141
|
+
```sql
|
|
142
|
+
JOIN read_json_auto('./data/verify-X-USERS.json') u
|
|
143
|
+
ON u.distinct_id::VARCHAR = e.user_id::VARCHAR
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Multi-part EVENTS files (batch mode)
|
|
147
|
+
|
|
148
|
+
Dungeons that produce >2M total events auto-enable batch mode. Output is split into part files:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
data/verify-X-EVENTS-part-1.json
|
|
152
|
+
data/verify-X-EVENTS-part-2.json
|
|
153
|
+
data/verify-X-EVENTS-part-3.json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Use a glob plus `union_by_name=true` (schemas may differ slightly across parts):
|
|
157
|
+
|
|
158
|
+
```sql
|
|
159
|
+
SELECT ... FROM read_json_auto('./data/verify-X-EVENTS-part-*.json',
|
|
160
|
+
sample_size=-1, union_by_name=true)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### DuckDB reserved words
|
|
164
|
+
|
|
165
|
+
DuckDB reserves common identifiers including `on`, `at`, `from`, `to`, `order`, `group`. If you name a CTE column `on` (e.g. "order count"), the parser fails:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
Parser Error: syntax error at or near "on"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Use suffixed names: `order_n`, `txn_n`, `swap_n`. Same applies to `at` / `to` etc.
|
|
172
|
+
|
|
173
|
+
### Schema mismatch between JSDoc and actual data
|
|
174
|
+
|
|
175
|
+
Stale JSDocs sometimes reference field names that don't exist in the actual data. When a query returns 0 rows or NULL where you expected data, run `DESCRIBE SELECT * FROM read_json_auto(...)` to inspect actual columns and adjust the query. If the doc is wrong (not the hook), note this in results.md as a doc nit.
|
|
176
|
+
|
|
177
|
+
### Nested properties
|
|
178
|
+
|
|
179
|
+
Some events store data in struct/array columns (e.g. ecommerce checkout has `cart STRUCT(...)[]`). The flat columns `amount`/`total_value` will be NULL — actual data is inside the array. Use `UNNEST(cart)` or `cart[1].total_value` to access.
|
|
180
|
+
|
|
181
|
+
## How hooks work (critical for query design)
|
|
182
|
+
|
|
183
|
+
Hooks do NOT add new properties to the schema. They modify existing property values, filter/remove events, and inject events cloned from existing ones. This means you often CANNOT verify a hook by checking for a boolean flag's existence. Instead, verify by:
|
|
184
|
+
|
|
185
|
+
1. **Comparing value magnitudes** across segments — e.g., power users should have ~3x higher avg purchase amount
|
|
186
|
+
2. **Comparing value distributions in time windows** — e.g., avg amount on 1st/15th of month vs other days
|
|
187
|
+
3. **Deriving behavioral segments from the data itself** — e.g., sessionize the event stream, count sessions, compare users with >20 sessions vs fewer
|
|
188
|
+
4. **Checking event density patterns** — e.g., cloned/injected events create unusually dense clusters within short time windows
|
|
189
|
+
5. **Cross-table joins** — e.g., join user profiles with events to see if user-level properties correlate with event-level value differences
|
|
190
|
+
|
|
191
|
+
Some hooks DO define boolean properties in the config with defaults (e.g., `payday: [false]`) that the hook sets to `true`. For those, you CAN query `WHERE payday = true`. But always check the dungeon's event config to see what properties are defined — don't assume a hook-created flag exists just because the documentation mentions a pattern.
|
|
192
|
+
|
|
193
|
+
## Query design approach
|
|
194
|
+
|
|
195
|
+
For each hook, design a query that compares:
|
|
196
|
+
- **Affected group** (users/events where the hook should have had an effect)
|
|
197
|
+
- **Control group** (users/events where the hook should NOT have had an effect)
|
|
198
|
+
- **Metric** (the specific measure that should differ between groups)
|
|
199
|
+
|
|
200
|
+
Then compute a **ratio** or **difference** and compare it to the expected effect size.
|
|
201
|
+
|
|
202
|
+
## Query templates by hook archetype
|
|
203
|
+
|
|
204
|
+
### Segment Comparison (e.g., "premium users have higher engagement")
|
|
205
|
+
```sql
|
|
206
|
+
SELECT
|
|
207
|
+
segment_property,
|
|
208
|
+
COUNT(*) as event_count,
|
|
209
|
+
AVG(metric) as avg_metric,
|
|
210
|
+
COUNT(DISTINCT user_id) as unique_users
|
|
211
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
212
|
+
WHERE event = 'relevant_event'
|
|
213
|
+
GROUP BY segment_property
|
|
214
|
+
ORDER BY segment_property;
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Time-Based Anomaly (e.g., "cursed week has higher death rate")
|
|
218
|
+
```sql
|
|
219
|
+
WITH events AS (
|
|
220
|
+
SELECT *, time::TIMESTAMP as ts
|
|
221
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
222
|
+
)
|
|
223
|
+
SELECT
|
|
224
|
+
CASE
|
|
225
|
+
WHEN ts BETWEEN 'start_date' AND 'end_date' THEN 'anomaly_window'
|
|
226
|
+
ELSE 'normal'
|
|
227
|
+
END as period,
|
|
228
|
+
COUNT(*) FILTER (WHERE event = 'target_event') as target_count,
|
|
229
|
+
COUNT(*) as total_events,
|
|
230
|
+
ROUND(COUNT(*) FILTER (WHERE event = 'target_event') * 100.0 / COUNT(*), 2) as target_pct
|
|
231
|
+
FROM events
|
|
232
|
+
GROUP BY period;
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Retention / Churn (e.g., "early guild joiners retain better")
|
|
236
|
+
```sql
|
|
237
|
+
WITH user_first_event AS (
|
|
238
|
+
SELECT user_id, MIN(time::TIMESTAMP) as first_seen
|
|
239
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
240
|
+
GROUP BY user_id
|
|
241
|
+
),
|
|
242
|
+
user_segments AS (
|
|
243
|
+
SELECT
|
|
244
|
+
e.user_id,
|
|
245
|
+
BOOL_OR(e.event = 'guild joined'
|
|
246
|
+
AND (e.time::TIMESTAMP - f.first_seen) < INTERVAL '3 days') as early_joiner
|
|
247
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json') e
|
|
248
|
+
JOIN user_first_event f ON e.user_id = f.user_id
|
|
249
|
+
GROUP BY e.user_id
|
|
250
|
+
),
|
|
251
|
+
user_activity AS (
|
|
252
|
+
SELECT
|
|
253
|
+
e.user_id,
|
|
254
|
+
MAX(e.time::TIMESTAMP) - MIN(e.time::TIMESTAMP) as active_span
|
|
255
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json') e
|
|
256
|
+
GROUP BY e.user_id
|
|
257
|
+
)
|
|
258
|
+
SELECT
|
|
259
|
+
s.early_joiner,
|
|
260
|
+
COUNT(*) as users,
|
|
261
|
+
AVG(EXTRACT(DAY FROM a.active_span)) as avg_active_days
|
|
262
|
+
FROM user_segments s
|
|
263
|
+
JOIN user_activity a ON s.user_id = a.user_id
|
|
264
|
+
GROUP BY s.early_joiner;
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Revenue / LTV (e.g., "lucky charm buyers spend 5x more")
|
|
268
|
+
```sql
|
|
269
|
+
WITH buyer_segments AS (
|
|
270
|
+
SELECT
|
|
271
|
+
user_id,
|
|
272
|
+
BOOL_OR(event = 'real money purchase' AND product = 'Lucky Charm Pack') as is_target_buyer
|
|
273
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
274
|
+
GROUP BY user_id
|
|
275
|
+
)
|
|
276
|
+
SELECT
|
|
277
|
+
b.is_target_buyer,
|
|
278
|
+
COUNT(*) FILTER (WHERE e.event = 'real money purchase') as purchase_count,
|
|
279
|
+
ROUND(AVG(TRY_CAST(e.price_usd AS DOUBLE)), 2) as avg_purchase,
|
|
280
|
+
ROUND(SUM(TRY_CAST(e.price_usd AS DOUBLE)), 2) as total_revenue,
|
|
281
|
+
COUNT(DISTINCT b.user_id) as users
|
|
282
|
+
FROM buyer_segments b
|
|
283
|
+
JOIN read_json_auto('./data/verify-dungeon-EVENTS.json') e ON b.user_id = e.user_id
|
|
284
|
+
GROUP BY b.is_target_buyer;
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Funnel Conversion by Segment (when emulator can't do it)
|
|
288
|
+
```sql
|
|
289
|
+
WITH step1 AS (
|
|
290
|
+
SELECT DISTINCT user_id, segment_prop
|
|
291
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
292
|
+
WHERE event = 'funnel_step_1'
|
|
293
|
+
),
|
|
294
|
+
step2 AS (
|
|
295
|
+
SELECT DISTINCT user_id
|
|
296
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
297
|
+
WHERE event = 'funnel_step_2'
|
|
298
|
+
)
|
|
299
|
+
SELECT
|
|
300
|
+
s1.segment_prop,
|
|
301
|
+
COUNT(DISTINCT s1.user_id) as started,
|
|
302
|
+
COUNT(DISTINCT s2.user_id) as completed,
|
|
303
|
+
ROUND(COUNT(DISTINCT s2.user_id) * 100.0 / COUNT(DISTINCT s1.user_id), 2) as conversion_pct
|
|
304
|
+
FROM step1 s1
|
|
305
|
+
LEFT JOIN step2 s2 ON s1.user_id = s2.user_id
|
|
306
|
+
GROUP BY s1.segment_prop;
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
For Mixpanel-accurate funnel verification, prefer `emulateBreakdown({type: 'funnelFrequency'})` — see [counting-semantics.md](counting-semantics.md).
|
|
310
|
+
|
|
311
|
+
### Property Distribution Shift
|
|
312
|
+
```sql
|
|
313
|
+
SELECT
|
|
314
|
+
segment_column,
|
|
315
|
+
property_column,
|
|
316
|
+
COUNT(*) as cnt,
|
|
317
|
+
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY segment_column), 2) as pct
|
|
318
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
319
|
+
WHERE event = 'relevant_event'
|
|
320
|
+
GROUP BY segment_column, property_column
|
|
321
|
+
ORDER BY segment_column, cnt DESC;
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Event Existence After Date
|
|
325
|
+
```sql
|
|
326
|
+
SELECT
|
|
327
|
+
CASE WHEN time::TIMESTAMP < 'release_date' THEN 'before' ELSE 'after' END as period,
|
|
328
|
+
COUNT(*) as occurrences
|
|
329
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
330
|
+
WHERE event = 'find treasure' AND treasure_type = 'Shadowmourne Legendary'
|
|
331
|
+
GROUP BY period;
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Value Magnitude by Behavioral Segment (sessionize derived cohorts)
|
|
335
|
+
```sql
|
|
336
|
+
WITH ordered AS (
|
|
337
|
+
SELECT *, time::TIMESTAMP as ts,
|
|
338
|
+
LAG(time::TIMESTAMP) OVER (PARTITION BY user_id ORDER BY time) as prev_ts
|
|
339
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
340
|
+
),
|
|
341
|
+
sessions AS (
|
|
342
|
+
SELECT user_id,
|
|
343
|
+
SUM(CASE WHEN prev_ts IS NULL OR ts - prev_ts > INTERVAL '30 minutes' THEN 1 ELSE 0 END) as session_count
|
|
344
|
+
FROM ordered
|
|
345
|
+
GROUP BY user_id
|
|
346
|
+
),
|
|
347
|
+
segments AS (
|
|
348
|
+
SELECT user_id,
|
|
349
|
+
CASE WHEN session_count > 20 THEN 'power_user' ELSE 'regular' END as segment
|
|
350
|
+
FROM sessions
|
|
351
|
+
)
|
|
352
|
+
SELECT
|
|
353
|
+
seg.segment,
|
|
354
|
+
COUNT(*) as purchase_count,
|
|
355
|
+
ROUND(AVG(TRY_CAST(e.amount AS DOUBLE)), 2) as avg_amount,
|
|
356
|
+
COUNT(DISTINCT seg.user_id) as users
|
|
357
|
+
FROM segments seg
|
|
358
|
+
JOIN read_json_auto('./data/verify-dungeon-EVENTS.json') e ON seg.user_id = e.user_id
|
|
359
|
+
WHERE e.event = 'purchase'
|
|
360
|
+
GROUP BY seg.segment;
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Temporal Value Scaling (e.g., 3x amounts on 1st/15th)
|
|
364
|
+
```sql
|
|
365
|
+
SELECT
|
|
366
|
+
CASE WHEN EXTRACT(DAY FROM time::TIMESTAMP) IN (1, 15) THEN 'payday' ELSE 'normal_day' END as period,
|
|
367
|
+
COUNT(*) as event_count,
|
|
368
|
+
ROUND(AVG(TRY_CAST(amount AS DOUBLE)), 2) as avg_amount,
|
|
369
|
+
ROUND(MEDIAN(TRY_CAST(amount AS DOUBLE)), 2) as median_amount
|
|
370
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
371
|
+
WHERE event = 'transaction completed'
|
|
372
|
+
GROUP BY period;
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Injected Event Detection (cloned events create density anomalies)
|
|
376
|
+
```sql
|
|
377
|
+
WITH events AS (
|
|
378
|
+
SELECT *, time::TIMESTAMP as ts,
|
|
379
|
+
LAG(time::TIMESTAMP) OVER (PARTITION BY user_id, event ORDER BY time) as prev_same_event
|
|
380
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
381
|
+
WHERE event = 'purchase'
|
|
382
|
+
)
|
|
383
|
+
SELECT
|
|
384
|
+
CASE WHEN prev_same_event IS NOT NULL AND ts - prev_same_event < INTERVAL '10 minutes'
|
|
385
|
+
THEN 'rapid_cluster' ELSE 'normal_spacing' END as pattern,
|
|
386
|
+
COUNT(*) as count,
|
|
387
|
+
ROUND(AVG(TRY_CAST(amount AS DOUBLE)), 2) as avg_amount
|
|
388
|
+
FROM events
|
|
389
|
+
GROUP BY pattern;
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Cross-Table Correlation (everything hook reads meta.profile)
|
|
393
|
+
```sql
|
|
394
|
+
WITH users AS (
|
|
395
|
+
SELECT * FROM read_json_auto('./data/verify-dungeon-USERS.json')
|
|
396
|
+
),
|
|
397
|
+
events AS (
|
|
398
|
+
SELECT * FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
399
|
+
)
|
|
400
|
+
SELECT
|
|
401
|
+
u.tier,
|
|
402
|
+
COUNT(*) as event_count,
|
|
403
|
+
COUNT(DISTINCT e.user_id) as user_count,
|
|
404
|
+
ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT e.user_id), 2) as events_per_user,
|
|
405
|
+
AVG(TRY_CAST(e.metric AS DOUBLE)) as avg_metric
|
|
406
|
+
FROM events e
|
|
407
|
+
JOIN users u ON e.user_id = u.distinct_id
|
|
408
|
+
GROUP BY u.tier
|
|
409
|
+
ORDER BY u.tier;
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
When verifying `everything` hooks, you often MUST join events with user profiles. Join key is **`events.user_id = users.distinct_id`**.
|
|
413
|
+
|
|
414
|
+
## Output files by data type
|
|
415
|
+
|
|
416
|
+
- `verify-dungeon-EVENTS.json` — events (most hooks produce effects here)
|
|
417
|
+
- `verify-dungeon-USERS.json` — user profiles (check for `user` hook enrichment)
|
|
418
|
+
- `verify-dungeon-*-GROUPS.json` — group profiles (if groups configured)
|
|
419
|
+
- `verify-dungeon-*-SCD.json` — SCD data (if SCDs configured)
|
|
420
|
+
|
|
421
|
+
## Advanced feature verification
|
|
422
|
+
|
|
423
|
+
Advanced features (personas, worldEvents, engagementDecay, dataQuality, subscription, attribution, geo, features, anomalies) produce data patterns alongside hooks. When verifying:
|
|
424
|
+
|
|
425
|
+
```sql
|
|
426
|
+
-- Personas: check distribution matches configured weights
|
|
427
|
+
SELECT _persona, count(*) as users FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE _persona IS NOT NULL GROUP BY 1;
|
|
428
|
+
|
|
429
|
+
-- World Events: check injected properties exist during event windows
|
|
430
|
+
SELECT promo, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE promo IS NOT NULL GROUP BY 1;
|
|
431
|
+
|
|
432
|
+
-- Data Quality: verify bots, nulls, empty events
|
|
433
|
+
SELECT 'bots' as metric, count(*) FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE is_bot = true
|
|
434
|
+
UNION ALL SELECT 'null_props', count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE category IS NULL;
|
|
435
|
+
|
|
436
|
+
-- Subscription: lifecycle events generated
|
|
437
|
+
SELECT event, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
438
|
+
WHERE event IN ('trial started','subscription started','plan upgraded','subscription cancelled') GROUP BY 1;
|
|
439
|
+
|
|
440
|
+
-- Attribution: campaign sources on profiles
|
|
441
|
+
SELECT utm_source, count(*) FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE utm_source IS NOT NULL GROUP BY 1;
|
|
442
|
+
|
|
443
|
+
-- Geo: region distribution
|
|
444
|
+
SELECT _region, count(*) FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE _region IS NOT NULL GROUP BY 1;
|
|
445
|
+
|
|
446
|
+
-- Features: progressive adoption properties
|
|
447
|
+
SELECT theme, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE theme IS NOT NULL GROUP BY 1;
|
|
448
|
+
|
|
449
|
+
-- Anomalies: burst/extreme events
|
|
450
|
+
SELECT _anomaly, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE _anomaly IS NOT NULL GROUP BY 1;
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Advanced feature patterns should ALWAYS be present (deterministic from config), unlike hooks which may have statistical variance.
|
|
454
|
+
|
|
455
|
+
## Standard verification checks (run for every dungeon)
|
|
456
|
+
|
|
457
|
+
### 1. SuperProp Consistency
|
|
458
|
+
Verify each user has exactly 1 value per superProp:
|
|
459
|
+
|
|
460
|
+
```sql
|
|
461
|
+
SELECT
|
|
462
|
+
'PROP_NAME' as prop,
|
|
463
|
+
COUNT(*) as total_users,
|
|
464
|
+
COUNT(*) FILTER (WHERE n = 1) as consistent,
|
|
465
|
+
COUNT(*) FILTER (WHERE n > 1) as inconsistent,
|
|
466
|
+
ROUND(COUNT(*) FILTER (WHERE n = 1) * 100.0 / COUNT(*), 1) as consistency_pct
|
|
467
|
+
FROM (
|
|
468
|
+
SELECT user_id, COUNT(DISTINCT PROP_NAME) as n
|
|
469
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
470
|
+
GROUP BY user_id
|
|
471
|
+
);
|
|
472
|
+
```
|
|
473
|
+
Verdict: **STRONG** ≥99% consistent, **WEAK** 90-99%, **FAIL** <90%.
|
|
474
|
+
|
|
475
|
+
### 2. SuperProp-UserProp Mirror Check
|
|
476
|
+
Every superProp key should also appear on user profiles. Compare the dungeon's `superProps` keys against columns in the USERS file. Any superProp not mirrored in `userProps` means the stamping fix is incomplete.
|
|
477
|
+
|
|
478
|
+
### 3. Mixpanel Default Property Casing Check
|
|
479
|
+
The system generates device properties with Mixpanel's standard casing (`Platform` capital P, `os`, `model`, etc.) and location properties (`city`, `region`, `country`). If a dungeon defines a superProp with conflicting casing (e.g., lowercase `platform`), both properties appear on events — confusing in Mixpanel. Check for:
|
|
480
|
+
- `platform` (lowercase) vs system `Platform` — verdict **FAIL** if dungeon uses lowercase
|
|
481
|
+
- `City`, `Region`, `Country` vs system `city`, `region`, `country` — check casing matches
|
|
482
|
+
|
|
483
|
+
### 4. funnel-pre Dilution Check
|
|
484
|
+
For any dungeon with `funnel-pre` conversionRate modifications, verify the actual visible effect:
|
|
485
|
+
- A `conversionRate *= 1.5` in funnel-pre typically shows as ~1.02-1.08x in the data (diluted by organic events)
|
|
486
|
+
- If observed ratio is <1.1x for a funnel-pre conversionRate hook, verdict is **FAIL** with note: "funnel-pre conversionRate diluted by organic events — migrate to `everything` hook event filtering"
|
|
487
|
+
- When the dungeon uses `everything` hook filtering instead, expect the full intended ratio (1.3-1.5x)
|
|
488
|
+
|
|
489
|
+
## Population threshold validation
|
|
490
|
+
|
|
491
|
+
When a hook targets a specific segment, verify the affected population is large enough to produce a visible signal:
|
|
492
|
+
|
|
493
|
+
```sql
|
|
494
|
+
SELECT
|
|
495
|
+
segment_column,
|
|
496
|
+
COUNT(DISTINCT user_id) as users,
|
|
497
|
+
ROUND(COUNT(DISTINCT user_id) * 100.0 / (SELECT COUNT(DISTINCT user_id) FROM read_json_auto('./data/verify-dungeon-EVENTS.json')), 1) as pct_of_users
|
|
498
|
+
FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
|
|
499
|
+
WHERE event = 'relevant_event'
|
|
500
|
+
GROUP BY segment_column
|
|
501
|
+
ORDER BY users DESC;
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**Thresholds (at 1K users):**
|
|
505
|
+
- Segment <20 users (<2%): hook signal will be WEAK or invisible — flag as "insufficient population"
|
|
506
|
+
- Segment 20-50 users: may show signal but with high variance — note in report
|
|
507
|
+
- Segment >50 users: should show clear signal if hook effect ≥1.3x
|
|
508
|
+
|
|
509
|
+
## Statistical caveats
|
|
510
|
+
|
|
511
|
+
This skill always runs at full fidelity (the dungeon's own scale). At full fidelity, cohorts of all sizes should produce clear signal because the absolute population is large. WEAK or FAIL results at full fidelity indicate a real problem — investigate, do not retry at smaller scale.
|
|
512
|
+
|
|
513
|
+
`--small` mode is a developer-troubleshooting escape hatch on the runner script; verdicts from `--small` runs are unreliable and not permitted in this skill's output.
|
|
514
|
+
|
|
515
|
+
## Verifying no-flag cohort patterns
|
|
516
|
+
|
|
517
|
+
Modern dungeons hide cohort effects behind raw event mutations rather than stamping flags like `is_whale=true`. Verification must DERIVE the cohort behaviorally, then measure the downstream metric.
|
|
518
|
+
|
|
519
|
+
**Magic-number BEHAVIORAL pattern** — count an event per user, bin into low/sweet/over, compare downstream metric per bucket:
|
|
520
|
+
|
|
521
|
+
```sql
|
|
522
|
+
WITH x_counts AS (
|
|
523
|
+
SELECT user_id, COUNT(*) FILTER (WHERE event = '<X_EVENT>') AS x_n
|
|
524
|
+
FROM read_json_auto('./data/<run>-EVENTS.json')
|
|
525
|
+
GROUP BY user_id
|
|
526
|
+
),
|
|
527
|
+
buckets AS (
|
|
528
|
+
SELECT user_id,
|
|
529
|
+
CASE WHEN x_n < <SWEET_LOW> THEN 'low'
|
|
530
|
+
WHEN x_n <= <SWEET_HIGH> THEN 'sweet'
|
|
531
|
+
ELSE 'over' END AS bucket
|
|
532
|
+
FROM x_counts
|
|
533
|
+
)
|
|
534
|
+
SELECT b.bucket,
|
|
535
|
+
COUNT(DISTINCT b.user_id) AS users,
|
|
536
|
+
AVG(TRY_CAST(e.<TARGET_PROP> AS DOUBLE)) AS avg_target_prop,
|
|
537
|
+
COUNT(*) FILTER (WHERE e.event = '<TARGET_EVENT>') AS total_target_events,
|
|
538
|
+
ROUND(COUNT(*) FILTER (WHERE e.event = '<TARGET_EVENT>') * 1.0 /
|
|
539
|
+
COUNT(DISTINCT b.user_id), 2) AS target_per_user
|
|
540
|
+
FROM buckets b
|
|
541
|
+
JOIN read_json_auto('./data/<run>-EVENTS.json') e ON b.user_id = e.user_id
|
|
542
|
+
GROUP BY b.bucket;
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
**Verdict criteria for inverted-U**:
|
|
546
|
+
- `sweet` bucket avg target ≥1.2x `low` bucket → boost present (STRONG)
|
|
547
|
+
- `over` bucket target_per_user ≤0.7x `sweet` bucket → drop present (STRONG)
|
|
548
|
+
- Both visible → inverted-U STRONG
|
|
549
|
+
- Either missing → flag as WEAK or FAIL with note about cohort sizes
|
|
550
|
+
|
|
551
|
+
**Inverted-U cohort confound — use NORMALIZED metric for the drop side.** The "over" bucket users have higher activity by definition (they crossed the threshold). So per-user metrics for downstream events naturally INCREASE with bucket — masking any drop hook. Example:
|
|
552
|
+
|
|
553
|
+
| bucket | builds | deploys | deploys_per_user | deploys_per_build (NORMALIZED) |
|
|
554
|
+
|--------|--------|---------|------------------|--------------------------------|
|
|
555
|
+
| low | 10457 | 6623 | 5.02 | 0.633 |
|
|
556
|
+
| sweet | 43351 | 36770 | 18.28 (looks BIG)| 0.848 (boost visible) |
|
|
557
|
+
| over | 86765 | 52024 | 31.43 (looks BIGGER)| 0.600 (drop visible) |
|
|
558
|
+
|
|
559
|
+
`deploys_per_user` shows over > sweet > low (cohort effect dominates). `deploys_per_build` correctly shows boost (sweet > low) AND drop (over < sweet). Always include the normalized variant in the drop-side query.
|
|
560
|
+
|
|
561
|
+
When the dungeon doesn't have a natural "per-X" denominator, compute one from the cohort-binning event: `target_events / cohort_event_count`.
|
|
562
|
+
|
|
563
|
+
**Time-to-convert (funnel-post) verification** — compute median A→B time per profile segment:
|
|
564
|
+
|
|
565
|
+
```sql
|
|
566
|
+
WITH funnel AS (
|
|
567
|
+
SELECT user_id,
|
|
568
|
+
MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_A>') AS a_time,
|
|
569
|
+
MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_B>') AS b_time
|
|
570
|
+
FROM read_json_auto('./data/<run>-EVENTS.json')
|
|
571
|
+
GROUP BY user_id
|
|
572
|
+
)
|
|
573
|
+
SELECT u.<SEGMENT_KEY>,
|
|
574
|
+
COUNT(*) AS users,
|
|
575
|
+
ROUND(MEDIAN(EXTRACT(EPOCH FROM (b_time - a_time)) / 60), 2) AS median_min_a_to_b
|
|
576
|
+
FROM funnel f
|
|
577
|
+
JOIN read_json_auto('./data/<run>-USERS.json') u ON f.user_id = u.distinct_id
|
|
578
|
+
WHERE a_time IS NOT NULL AND b_time IS NOT NULL
|
|
579
|
+
GROUP BY u.<SEGMENT_KEY>
|
|
580
|
+
ORDER BY median_min_a_to_b;
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
**Verdict for T2C**:
|
|
584
|
+
- Fast segment ≤0.85x baseline → STRONG
|
|
585
|
+
- Slow segment ≥1.2x baseline → STRONG
|
|
586
|
+
- Both directions visible → STRONG
|
|
587
|
+
- One/both missing → check that funnel exists in `funnels:` config and segment property is on `meta.profile`
|
|
588
|
+
|
|
589
|
+
**Two-tier T2C interpretation**: When a dungeon has only 2 tiers (e.g. Free vs Paid) the funnel-post hook factor `1.0` branch never fires — both tiers fall into either fast or slow. Pick the slower of the two as the implicit baseline, then verify the faster shows ≤0.85x of it.
|
|
590
|
+
|
|
591
|
+
**No-flag verification rule**: NEVER attempt to verify a hook by querying for a flag like `WHERE sweet_spot = true`. If a dungeon has such flags, treat them as a doc bug — the hook should be reworked to hide the cohort behaviorally. The validator's job is to derive cohorts behaviorally.
|
|
592
|
+
|
|
593
|
+
## Drop-event funnel dilution diagnosis
|
|
594
|
+
|
|
595
|
+
Many dungeons have hooks of pattern `record.filter(e => e.event === 'X' && chance.bool({likelihood: 30}))` to drop ~30% of step-3 events for non-paid tier. The doc claims the hook produces a 30% conversion drop in the funnel — but funnel completion rates often barely move (e.g. 95% vs 97%).
|
|
596
|
+
|
|
597
|
+
**Why:** the hook drops EVENTS not users. A user with 5 step-3 events still appears in the funnel after losing 1-2 events. Funnel completion = `users with ≥1 step-3 event` — only zero-step-3 users disappear from the conversion count, which is rare.
|
|
598
|
+
|
|
599
|
+
**Correct verification metric:** per-user volume of step-3 events by tier:
|
|
600
|
+
|
|
601
|
+
```sql
|
|
602
|
+
SELECT u.subscription_tier,
|
|
603
|
+
COUNT(DISTINCT user_id) AS users,
|
|
604
|
+
COUNT(*) AS total_step3,
|
|
605
|
+
ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT user_id), 2) AS per_user
|
|
606
|
+
FROM read_json_auto('./data/<run>-EVENTS.json')
|
|
607
|
+
WHERE event = '<STEP_3_EVENT>'
|
|
608
|
+
GROUP BY u.subscription_tier
|
|
609
|
+
ORDER BY per_user DESC;
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Expected: paid tier ~1.5x non-paid per_user (matches 30% drop on non-paid → paid keeps 100%, non-paid keeps 70%, ratio 1/0.7 = 1.43x).
|
|
613
|
+
|
|
614
|
+
If funnel completion gap <5pt but per_user gap ≥30%, the hook IS firing — the doc just points to the wrong metric. Mark STRONG, recommend doc redirect to per-user query.
|
|
615
|
+
|
|
616
|
+
## Subscription tier cohort sizing check
|
|
617
|
+
|
|
618
|
+
Before testing any hook gated on `subscription_tier === "annual"` or `"family"`, check cohort sizes:
|
|
619
|
+
|
|
620
|
+
```sql
|
|
621
|
+
SELECT subscription_plan, COUNT(*) FROM read_json_auto('./data/<run>-USERS.json')
|
|
622
|
+
GROUP BY subscription_plan;
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
The default subscription lifecycle (`trialToPayRate=0.30`, `upgradeRate=0.06-0.08`) produces ~85% NULL/Free, ~10-15% Monthly, <2% Annual, ~0% Family at 5K users. Cohorts <50 users will not produce statistically clean signal at any effect size.
|
|
626
|
+
|
|
627
|
+
**If annual cohort <50 users:**
|
|
628
|
+
- Don't trust per-tier ratios — note "cohort too small" in results.md
|
|
629
|
+
- Bump `numUsers` up to 5x to enlarge cohorts
|
|
630
|
+
- Or recommend dungeon author tighten subscription lifecycle config
|
|
631
|
+
|
|
632
|
+
## Per-day normalization for time-window hooks
|
|
633
|
+
|
|
634
|
+
Time-window hooks (e.g. "5x deaths in cursed week d40-47") often produce similar per-USER counts across windows because users active in the window are different from users active overall. Compare per-DAY rates instead:
|
|
635
|
+
|
|
636
|
+
```
|
|
637
|
+
cursed period (7 days): 20711 deaths / 2948 users = 7.03 deaths/user
|
|
638
|
+
other period (~93 days): 34857 deaths / 4984 users = 6.99 deaths/user
|
|
639
|
+
|
|
640
|
+
Per-day rate:
|
|
641
|
+
cursed: 7.03 / 7 = 1.00 deaths/user/day
|
|
642
|
+
other: 6.99 / 93 = 0.075 deaths/user/day
|
|
643
|
+
ratio: 13x ← signal lives here
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
For any spike/burst hook with a tight day window, ALWAYS normalize by window length before comparing.
|
|
647
|
+
|
|
648
|
+
## Determinism check (optional confidence test)
|
|
649
|
+
|
|
650
|
+
The pinned `datasetStart`/`datasetEnd` window plus seeded RNG produces near-bit-exact output across runs. To confirm no NEW non-determinism crept in (e.g. wall-clock leak in a hook):
|
|
651
|
+
|
|
652
|
+
1. Run a previously-passing dungeon a second time.
|
|
653
|
+
2. Compare `eventCount` in the runner's JSON output — should match within ~0.5%.
|
|
654
|
+
3. Re-run the hook's headline query and verify ratios match to 2 decimals.
|
|
655
|
+
|
|
656
|
+
**Tolerance note**: most vertical dungeons produce bit-exact event counts across runs, but a few show <0.5% variance from RNG-state interactions. Variance at this scale does NOT affect hook signal direction or magnitude — all signals remain stable across runs. Treat <1% event-count drift as acceptable; investigate only if drift exceeds 1% OR a hook ratio swings meaningfully (>10% relative change between runs).
|
|
657
|
+
|
|
658
|
+
If event count differs by >1% OR a hook ratio swings sharply, the hook has a fresh non-determinism source (typically `dayjs()`, `Date.now()`, `Math.random()`, or stale module-level state). Fix before continuing.
|
|
659
|
+
|
|
660
|
+
## Critical time-window verification pattern
|
|
661
|
+
|
|
662
|
+
Many dungeons use relative time windows (e.g., "spike on days 75-85"). The post-shift dataset start is exposed to hooks as `meta.datasetStart` (unix seconds). For DuckDB verification, use the same anchor:
|
|
663
|
+
|
|
664
|
+
```sql
|
|
665
|
+
-- WRONG: uses MIN(time) which is up to 30 days BEFORE dataset start (pre-existing user spread)
|
|
666
|
+
SELECT *, EXTRACT(EPOCH FROM (time::TIMESTAMP - (SELECT MIN(time::TIMESTAMP) FROM events))) / 86400 as day_in
|
|
667
|
+
FROM events;
|
|
668
|
+
|
|
669
|
+
-- RIGHT: anchor to MAX(time) - num_days, which is the post-shift dataset start
|
|
670
|
+
WITH bounds AS (
|
|
671
|
+
SELECT MAX(time::TIMESTAMP) - INTERVAL 'NUM_DAYS' day as datasetStart
|
|
672
|
+
FROM events
|
|
673
|
+
)
|
|
674
|
+
SELECT *, EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.datasetStart)) / 86400 as day_in
|
|
675
|
+
FROM events e, bounds b;
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
Pre-existing users have events for up to 30 days BEFORE the dataset start (`preExistingSpread: 'uniform'` default in macro). MIN(time) reflects those pre-existing events, not the dataset window. Always anchor to MAX(time) - num_days for "day in dataset" calculations.
|
|
679
|
+
|
|
680
|
+
## TTC hook verification — two approaches
|
|
681
|
+
|
|
682
|
+
TTC hooks come in two forms. Use the matching verification approach:
|
|
683
|
+
|
|
684
|
+
### Approach 1: Property-Scaling TTC (preferred — produces NAILED verdicts)
|
|
685
|
+
|
|
686
|
+
The hook scales a timing PROPERTY (e.g., `response_time_mins *= 0.67`) by segment. Verification is trivial:
|
|
687
|
+
|
|
688
|
+
```sql
|
|
689
|
+
SELECT segment,
|
|
690
|
+
ROUND(AVG(response_time_mins), 1) AS avg_response,
|
|
691
|
+
ROUND(AVG(resolution_time_mins), 1) AS avg_resolution
|
|
692
|
+
FROM events
|
|
693
|
+
WHERE event IN ('alert acknowledged', 'alert resolved')
|
|
694
|
+
GROUP BY segment ORDER BY avg_response;
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
This consistently produces exact matches to the hook factors (e.g., 0.67x target → 0.665x measured).
|
|
698
|
+
|
|
699
|
+
### Approach 2: Timestamp-Shifting TTC (use when no timing property exists)
|
|
700
|
+
|
|
701
|
+
The hook shifts event timestamps in the everything hook using `scaleFunnelTTC()` or manual gap scaling. Verification requires a **bound-sequence query** — never use the lazy MIN→MIN proxy:
|
|
702
|
+
|
|
703
|
+
```sql
|
|
704
|
+
-- WRONG: lazy MIN→MIN proxy (mixes events from different funnel passes)
|
|
705
|
+
SELECT user_id, MIN(a.time) AS start, MIN(b.time) AS end ...
|
|
706
|
+
|
|
707
|
+
-- RIGHT: bound-sequence (first A, then first B AFTER that A)
|
|
708
|
+
WITH steps AS (
|
|
709
|
+
SELECT user_id, event, time::TIMESTAMP AS t
|
|
710
|
+
FROM events WHERE event IN ('step_a', 'step_b', 'step_c')
|
|
711
|
+
),
|
|
712
|
+
funnel AS (
|
|
713
|
+
SELECT DISTINCT ON (a.user_id) a.user_id, a.t AS start_t,
|
|
714
|
+
(SELECT MIN(t) FROM steps c
|
|
715
|
+
WHERE c.user_id = a.user_id AND c.event = 'step_c' AND c.t > a.t) AS end_t
|
|
716
|
+
FROM steps a WHERE a.event = 'step_a'
|
|
717
|
+
ORDER BY a.user_id, a.t
|
|
718
|
+
)
|
|
719
|
+
SELECT segment,
|
|
720
|
+
COUNT(*) AS users,
|
|
721
|
+
ROUND(MEDIAN(EXTRACT(EPOCH FROM (end_t - start_t)) / 60), 1) AS median_min
|
|
722
|
+
FROM funnel JOIN users USING (user_id)
|
|
723
|
+
WHERE end_t IS NOT NULL
|
|
724
|
+
GROUP BY segment ORDER BY median_min;
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
The bound-sequence pattern finds the first A per user, then the first C strictly after that A. This matches how the everything hook operates and typically produces STRONG verdicts. The lazy MIN→MIN proxy produces flat or inverted results because it grabs unrelated events from different funnel passes.
|
|
728
|
+
|
|
729
|
+
### Which approach to recommend when writing hooks
|
|
730
|
+
|
|
731
|
+
Property scaling is strictly better for verification. When creating new TTC hooks, always prefer scaling timing properties (see HOOKS.md principle #15). Reserve timestamp shifting for cases where no numeric timing property exists on the relevant events.
|
|
732
|
+
|
|
733
|
+
### Legacy funnel-post TTC hooks
|
|
734
|
+
|
|
735
|
+
If a dungeon still uses `funnel-post` for TTC (not yet migrated to `everything`), the effect is only visible in Mixpanel's funnel median TTC report, not in any SQL query. Mark as STRONG by code inspection and recommend migration to property scaling or everything-hook timestamp shifting.
|
|
736
|
+
|
|
737
|
+
## Magic-number cohort sizing — inspect distribution first
|
|
738
|
+
|
|
739
|
+
Before checking inverted-U signal magnitude, confirm the cohort sizes are statistically meaningful (≥200 in sweet bucket). If cohort is too small, signal magnitude is irrelevant:
|
|
740
|
+
|
|
741
|
+
```sql
|
|
742
|
+
SELECT pn, COUNT(*) FROM (
|
|
743
|
+
SELECT user_id, COUNT(*) FILTER (WHERE event = '<X_EVENT>') AS pn
|
|
744
|
+
FROM events GROUP BY user_id
|
|
745
|
+
) GROUP BY pn ORDER BY pn LIMIT 20;
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
If 90%+ of users have 0-1 events of X, the dungeon's `sweet=4-7 / over=8+` ranges produce <50 users in sweet → no signal possible. Two fixes:
|
|
749
|
+
1. Bump `numUsers` 5x (cohort grows linearly with users; preserves story)
|
|
750
|
+
2. Recommend the dungeon author redefine ranges to match actual distribution (e.g. `sweet=2-5 / over=6+`)
|
|
751
|
+
|
|
752
|
+
Choice depends on whether the JSDoc's stated ranges are load-bearing for the dungeon's narrative ("you need 8+ photos to seem fake" — preserve range, scale up users) or arbitrary ("sweet 4-7" can shift to "sweet 2-5" without losing the story).
|
|
753
|
+
|
|
754
|
+
## Re-run required after hook edits
|
|
755
|
+
|
|
756
|
+
If you edit a hook then query the existing data files, you'll get STALE results. The verifier must re-run the dungeon AND wait for full completion before re-querying:
|
|
757
|
+
|
|
758
|
+
```bash
|
|
759
|
+
rm -f ./data/verify-<NAME>-*
|
|
760
|
+
node scripts/verify-runner.mjs dungeons/vertical/<NAME>.js verify-<NAME>
|
|
761
|
+
# Wait for the {"mode":"full","eventCount":...} JSON to print before querying
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
For batched output (multi-million events), the runner writes `verify-<NAME>-EVENTS-part-*.json` instead of a single `verify-<NAME>-EVENTS.json`. Use glob in queries:
|
|
765
|
+
|
|
766
|
+
```sql
|
|
767
|
+
read_json_auto('./data/verify-<NAME>-EVENTS-part-*.json', sample_size=-1, union_by_name=true)
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Without the glob, queries against `verify-<NAME>-EVENTS.json` fail with "No files found".
|
|
771
|
+
|
|
772
|
+
## Event hook meta.datasetStart pitfall
|
|
773
|
+
|
|
774
|
+
The `event` hook receives `meta.datasetStart` as a unix timestamp, but temporal hooks checking `dayInDataset >= N` often produce NONE verdicts because the anchor doesn't match expectations. Proven fix: move temporal windowing to the `everything` hook where `meta.datasetStart` is verified reliable. The `everything` hook also allows push() for event cloning instead of return (which replaces the event in the `event` hook).
|
|
775
|
+
|
|
776
|
+
**When to move temporal hooks to `everything`:**
|
|
777
|
+
- Any hook that checks `dayInDataset` ranges and scores NONE at verification
|
|
778
|
+
- Any hook that needs to CLONE events (push to array) rather than REPLACE
|
|
779
|
+
- Any hook that needs access to the user's full event history for context
|
|
780
|
+
|
|
781
|
+
**When to keep hooks in `event` type:**
|
|
782
|
+
- Closure-based state patterns (module-level Maps) that track across users
|
|
783
|
+
- Event REPLACEMENT (returning a different event, e.g., alert → incident)
|
|
784
|
+
- Simple property mutations that don't need temporal context
|
|
785
|
+
|
|
786
|
+
## Property baseline dilution
|
|
787
|
+
|
|
788
|
+
When a hook overrides a property value (e.g., `event_type = "plan_upgraded"`), the effect is invisible if the baseline distribution already has a high rate of that value. Example: if `plan_upgraded` is 1 of 5 values (20% baseline), a 40% hook override produces ~28% observed — nearly invisible.
|
|
789
|
+
|
|
790
|
+
**Fix:** skew the baseline distribution AWAY from the hook's target value. Make `plan_upgraded` 1 of 8+ values (12.5% baseline), then the 40% hook produces ~48% in the window — a clear 4x spike.
|
|
791
|
+
|
|
792
|
+
Similarly, if a hook forces `scale_direction = "down"` but the baseline is already 86% "down" (6:1 ratio in config), the hook is invisible. Change the baseline to favor "up" (e.g., 3:1 up:down) so the hook's forced "down" creates a measurable shift.
|
|
793
|
+
|
|
794
|
+
## Computing the dataset window
|
|
795
|
+
|
|
796
|
+
Dungeons declare their time window in one of three ways — the verifier must derive the actual start/end before writing DuckDB queries:
|
|
797
|
+
|
|
798
|
+
| Config shape | How to derive window |
|
|
799
|
+
|---|---|
|
|
800
|
+
| `datasetStart` + `datasetEnd` | Use directly |
|
|
801
|
+
| `numDays` only (no explicit start/end) | `datasetEnd = NOW`, `datasetStart = NOW - numDays` |
|
|
802
|
+
| `datasetStart` + `numDays` | `datasetEnd = datasetStart + numDays` |
|
|
803
|
+
|
|
804
|
+
The engine always resolves to a `[datasetStart, datasetEnd]` pair internally (see `config-validator.js`). To find the actual window from the OUTPUT data:
|
|
805
|
+
|
|
806
|
+
```sql
|
|
807
|
+
SELECT
|
|
808
|
+
MAX(time::TIMESTAMP) as datasetEnd,
|
|
809
|
+
MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as datasetStart
|
|
810
|
+
FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1);
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Use `datasetStart` (derived above) as the DuckDB anchor for day-in-dataset:
|
|
814
|
+
|
|
815
|
+
```sql
|
|
816
|
+
WITH bounds AS (
|
|
817
|
+
SELECT MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as ds_start
|
|
818
|
+
FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1)
|
|
819
|
+
)
|
|
820
|
+
SELECT EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.ds_start)) / 86400 as day_in
|
|
821
|
+
FROM events e, bounds b;
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
Do NOT use `MIN(time)` as the anchor — pre-existing users have events up to 30 days before `datasetStart` (from `preExistingSpread: 'uniform'`).
|
|
825
|
+
|
|
826
|
+
When the dungeon has explicit `datasetStart` (e.g., `"2026-01-01T00:00:00Z"`), use it directly: `TIMESTAMP '2026-01-01'`. When `numDays` is used without explicit start, derive from MAX(time) as shown above.
|
|
827
|
+
|
|
828
|
+
## No flag stamping audit
|
|
829
|
+
|
|
830
|
+
Hooks must NEVER add cohort flags like `is_whale`, `power_user`, `sweet_spot`, `is_churned`, etc. All cohorts must be derived behaviorally from raw event data. When auditing a dungeon, check the hook for any property assignments that create boolean/categorical flags not defined in the original schema. If found, remove them and rewrite the hook to achieve the same effect through property value mutations, event filtering, or event injection.
|
|
831
|
+
|
|
832
|
+
## Clone dilution of temporal effects
|
|
833
|
+
|
|
834
|
+
When a dungeon has BOTH temporal value mutations (e.g., "days 30-60 offer_price 2.5x") AND event cloning hooks (e.g., "pre-approved users get 5 extra offers"), cloned events with time offsets can land inside the temporal window without receiving the mutation — because the temporal hook ran BEFORE the cloning.
|
|
835
|
+
|
|
836
|
+
**Diagnosis:** The temporal effect shows a lower ratio than expected (e.g., 1.2x instead of 2.5x). Check if other hooks clone events that could land in the temporal window.
|
|
837
|
+
|
|
838
|
+
**Fix:** Move the temporal value mutation to the END of the everything hook, after all cloning/injection hooks. Re-run and verify.
|
|
839
|
+
|
|
840
|
+
## Cohort detection survives filtering
|
|
841
|
+
|
|
842
|
+
If Hook A classifies users by event presence (`events.some(e => e.event === X)`) and Hook B later removes events (churn, retention filter), the verification query may misclassify users whose marker events were filtered. The "non-cohort" group gets contaminated with cohort members, diluting the measured ratio.
|
|
843
|
+
|
|
844
|
+
**Diagnosis:** Expected ratio is 8x but observed is <2x. Check if the cohort detection event is also affected by a downstream filter.
|
|
845
|
+
|
|
846
|
+
**Fixes:**
|
|
847
|
+
1. Require 3+ marker events instead of 1+ (surviving events still identify)
|
|
848
|
+
2. Accept the verification limitation and note it in the report
|
|
849
|
+
3. Use a metric that doesn't depend on cohort reconstruction (e.g., overall distribution shift instead of cohort comparison)
|
|
850
|
+
|
|
851
|
+
## Deprecated feature property gaps
|
|
852
|
+
|
|
853
|
+
Dungeons using deprecated config blocks (`subscription`, `attribution`, `features`, `geo`, `anomalies`) may have hooks that depend on properties those blocks used to generate. The engine silently strips deprecated configs, so properties like `coaching_mode`, `subscription_plan`, or `feature_tier` never appear in the data.
|
|
854
|
+
|
|
855
|
+
**Diagnosis:** Hook logic references a property that's always NULL/undefined in the output. Check if the property was produced by a deprecated feature.
|
|
856
|
+
|
|
857
|
+
**Fix:** The dungeon author must add equivalent property generation in the hook itself (via `user` or `everything` hook) or add the property to `superProps`/`userProps` with appropriate values. This is a schema-level fix, not a verification fix — flag it in the report as "NONE: deprecated feature property missing" with the recommended fix.
|