@ak--47/dungeon-master 1.7.0 → 1.8.1
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 +30 -11
- package/.claude/skills/create-dungeon/SKILL.md +84 -44
- package/.claude/skills/create-project/SKILL.md +28 -3
- package/.claude/skills/create-project/context.mjs +89 -0
- package/.claude/skills/create-project/provision.mjs +1 -60
- package/.claude/skills/headless-build/SKILL.md +39 -12
- package/.claude/skills/powertools/SKILL.md +26 -3
- package/.claude/skills/release-check/SKILL.md +124 -0
- package/.claude/skills/verify-dungeon/SKILL.md +103 -29
- package/.claude/skills/verify-dungeon/references/alignment-contract.md +84 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +41 -16
- package/.claude/skills/verify-dungeon/references/report-format.md +41 -10
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +171 -226
- package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
- package/.claude/skills/warehouse-metrics/SKILL.md +111 -0
- package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
- package/.claude/skills/write-hooks/SKILL.md +94 -51
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +165 -18
- package/README.md +265 -1
- package/docs/guides/1.8.0-upgrade-guide.md +151 -0
- package/docs/guides/1.8.1-upgrade-guide.md +153 -0
- package/dungeons/technical/warehouse.js +187 -0
- package/index.js +116 -2
- package/lib/core/config-validator.js +21 -0
- package/lib/core/dungeon-loader.js +1 -1
- package/lib/core/storage.js +51 -3
- package/lib/generators/events.js +6 -0
- package/lib/generators/funnels.js +15 -0
- package/lib/generators/standalone.js +248 -0
- package/lib/generators/warehouse.js +828 -0
- package/lib/hook-helpers/shape.js +73 -17
- package/lib/orchestrators/mixpanel-sender.js +27 -2
- package/lib/orchestrators/user-loop.js +83 -15
- package/lib/templates/story-spec.schema.json +41 -16
- package/lib/utils/utils.js +37 -12
- package/lib/verify/funnel-engine.js +66 -26
- package/lib/verify/index.js +1 -0
- package/lib/verify/story-runner.js +71 -8
- package/lib/verify/warehouse.js +683 -0
- package/package.json +4 -2
- package/scripts/verify-stories.mjs +150 -44
- package/types.d.ts +312 -9
|
@@ -2,26 +2,18 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Apply the [1.8.1 verification contract](alignment-contract.md) to every query.
|
|
6
|
+
The SQL examples below are diagnostics for their named measures. They cannot
|
|
7
|
+
replace a different report's acceptance check. Use explicit report options,
|
|
8
|
+
paired baselines, neutral controls, and actual eligible population counts.
|
|
8
9
|
|
|
9
|
-
|
|
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
|
-
```
|
|
10
|
+
## Schema validation queries
|
|
23
11
|
|
|
24
|
-
|
|
12
|
+
For each event type, compare raw record keys against config-declared properties
|
|
13
|
+
and enabled engine/SDK fields. SQL unioned columns lose per-record key presence;
|
|
14
|
+
null coverage alone cannot distinguish an absent key from a declared null.
|
|
15
|
+
Use the programmatic API (`lib/verify/schema-validator.js`) to derive expected
|
|
16
|
+
keys, then explicitly fail any undeclared key in the raw records:
|
|
25
17
|
|
|
26
18
|
```javascript
|
|
27
19
|
import { deriveExpectedSchema, validateSchema } from './lib/verify/index.js';
|
|
@@ -53,17 +45,57 @@ The expected set of columns per event type is derived from config:
|
|
|
53
45
|
|
|
54
46
|
For each event type, classify any column present in output but NOT in expected schema:
|
|
55
47
|
|
|
56
|
-
- **SCHEMA-PASS**
|
|
57
|
-
- **SCHEMA-FAIL**
|
|
48
|
+
- **SCHEMA-PASS** - every observed key is declared or a recognized enabled engine/SDK field.
|
|
49
|
+
- **SCHEMA-FAIL** - any undeclared key, even at 100% coverage. Uniform enrichment does not bypass schema-first authorship.
|
|
50
|
+
|
|
51
|
+
The runtime summary may permit uniform enrichment. Retain its output, but apply
|
|
52
|
+
the stricter authorship gate separately. Declared nullable fields remain valid.
|
|
58
53
|
|
|
59
54
|
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
55
|
|
|
61
56
|
## Standard identity-model invariants
|
|
62
57
|
|
|
63
|
-
|
|
58
|
+
### Metric artifacts use separate schemas
|
|
59
|
+
|
|
60
|
+
The expected-schema table above applies only to user EVENTS. For `standaloneEvents`,
|
|
61
|
+
expect `event`, `time`, `insert_id`, `distinct_id`, plus the matching spec's
|
|
62
|
+
dimension and property keys. User superProps, session ids, and SDK flags do not
|
|
63
|
+
apply. Check undeclared columns explicitly; the story CLI's user schema pass
|
|
64
|
+
does not cover standalone shards.
|
|
65
|
+
|
|
66
|
+
Use a `duckdb` story assertion with this source, filtering the declared event:
|
|
64
67
|
|
|
65
68
|
```sql
|
|
66
|
-
|
|
69
|
+
SELECT event, count(*) AS records, min(time::TIMESTAMP) AS first_tick,
|
|
70
|
+
max(time::TIMESTAMP) AS last_tick
|
|
71
|
+
FROM read_json_auto('{{PREFIX}}-STANDALONE*.json',
|
|
72
|
+
union_by_name=true, sample_size=-1)
|
|
73
|
+
GROUP BY event;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Compare the count with cadence ticks times the dimension cross-product size.
|
|
77
|
+
Check duplicate `(event, time, <dimension keys>)` tuples and undeclared keys,
|
|
78
|
+
and assert that `user_id` and `device_id` are absent. Synthetic `distinct_id`
|
|
79
|
+
values identify series, never people. Do not union these shards into EVENTS
|
|
80
|
+
for funnels, retention, stitching, or user counts. Substitute `{{PREFIX}}`
|
|
81
|
+
with the exact artifact prefix when running SQL outside the story CLI.
|
|
82
|
+
|
|
83
|
+
For `warehouseMetrics`, use the matching `-WAREHOUSE-MANIFEST.json` to resolve
|
|
84
|
+
each table file and schema. Read JSONL with `read_json_auto` or CSV with
|
|
85
|
+
`read_csv_auto` according to that manifest. Check the declared `timeColumn`,
|
|
86
|
+
group keys, `valueColumn`, and extra `columns`; no identity fields are required.
|
|
87
|
+
Use `warehouse` or `warehouse-stats` assertions for stories. The
|
|
88
|
+
automatic warehouse audit runs even without stories. Account for `history`
|
|
89
|
+
backfill and `sparse` point-in-time rows before judging counts or time coverage.
|
|
90
|
+
|
|
91
|
+
### User-event identity checks
|
|
92
|
+
|
|
93
|
+
Run identity checks whenever the dungeon uses device identity, before per-pattern
|
|
94
|
+
checks. Count configured auth rows as a diagnostic, not as a universal one-stitch
|
|
95
|
+
invariant; ordinary both-ID events can also establish a link.
|
|
96
|
+
|
|
97
|
+
```sql
|
|
98
|
+
-- Diagnostic counts for a configured auth event, not a mapping proof.
|
|
67
99
|
WITH e AS (SELECT * FROM read_json_auto('./data/<file>-EVENTS.json')),
|
|
68
100
|
auth_event AS (SELECT 'Sign Up' AS name) -- name of your isAuthEvent
|
|
69
101
|
SELECT
|
|
@@ -71,17 +103,14 @@ SELECT
|
|
|
71
103
|
SUM(CASE WHEN user_id IS NOT NULL AND device_id IS NOT NULL THEN 1 ELSE 0 END) AS stitches,
|
|
72
104
|
COUNT(DISTINCT CASE WHEN user_id IS NOT NULL THEN user_id END) AS converted_users
|
|
73
105
|
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
106
|
```
|
|
83
107
|
|
|
84
|
-
|
|
108
|
+
Build the proof map from valid emitted both-ID events, including later ordinary
|
|
109
|
+
Login events, and resolve earlier device-only rows retrospectively. Inspect
|
|
110
|
+
conflicting links. Profile pools are not mapping evidence. For pre-existing
|
|
111
|
+
stamping, use generator ownership evidence and resolved dataset bounds; joining
|
|
112
|
+
on `e.user_id` and then testing it for NULL can never detect missing IDs. If row
|
|
113
|
+
ownership is unavailable in retained artifacts, report that check as unproved.
|
|
85
114
|
|
|
86
115
|
## Experiment invariants
|
|
87
116
|
|
|
@@ -232,7 +261,10 @@ FROM events
|
|
|
232
261
|
GROUP BY period;
|
|
233
262
|
```
|
|
234
263
|
|
|
235
|
-
###
|
|
264
|
+
### Activity-span diagnostic (not a retention report)
|
|
265
|
+
|
|
266
|
+
This measures first-to-last activity span. Use `retention` with the report's
|
|
267
|
+
cohort, return event, buckets, and mature horizon for a retention claim.
|
|
236
268
|
```sql
|
|
237
269
|
WITH user_first_event AS (
|
|
238
270
|
SELECT user_id, MIN(time::TIMESTAMP) as first_seen
|
|
@@ -284,29 +316,13 @@ JOIN read_json_auto('./data/verify-dungeon-EVENTS.json') e ON b.user_id = e.user
|
|
|
284
316
|
GROUP BY b.is_target_buyer;
|
|
285
317
|
```
|
|
286
318
|
|
|
287
|
-
### Funnel
|
|
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
|
-
```
|
|
319
|
+
### Funnel conversion by segment
|
|
308
320
|
|
|
309
|
-
|
|
321
|
+
Use `emulateBreakdown({type: 'funnelFrequency'})` with explicit report options.
|
|
322
|
+
A join between users who did A and users who did B does not check ordered
|
|
323
|
+
completion, restart, grace, exclusions, or the conversion window. If the emulator
|
|
324
|
+
cannot express the requested report, record the semantic gap instead of substituting
|
|
325
|
+
an unordered SQL intersection. See [counting-semantics.md](counting-semantics.md).
|
|
310
326
|
|
|
311
327
|
### Property Distribution Shift
|
|
312
328
|
```sql
|
|
@@ -331,34 +347,13 @@ WHERE event = 'find treasure' AND treasure_type = 'Shadowmourne Legendary'
|
|
|
331
347
|
GROUP BY period;
|
|
332
348
|
```
|
|
333
349
|
|
|
334
|
-
### Value
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
```
|
|
350
|
+
### Value magnitude by session-derived cohort
|
|
351
|
+
|
|
352
|
+
Derive sessions with the verifier from the full resolved user stream before
|
|
353
|
+
filtering events or partitioning by hold-property value. Use all three split
|
|
354
|
+
rules (idle timeout, maximum duration, UTC day change). A timeout-only SQL `LAG`
|
|
355
|
+
query and generator-stamped `session_id` do not establish this contract. Export
|
|
356
|
+
the resulting user/cohort mapping for a SQL value diagnostic if needed.
|
|
362
357
|
|
|
363
358
|
### Temporal Value Scaling (e.g., 3x amounts on 1st/15th)
|
|
364
359
|
```sql
|
|
@@ -435,7 +430,9 @@ SELECT 'bots' as metric, count(*) FROM read_json_auto('./data/verify-dungeon-USE
|
|
|
435
430
|
UNION ALL SELECT 'null_props', count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE category IS NULL;
|
|
436
431
|
```
|
|
437
432
|
|
|
438
|
-
|
|
433
|
+
Check whether the relevant population and time window are present before asserting
|
|
434
|
+
an advanced-feature effect. Seeded determinism does not guarantee that a finite
|
|
435
|
+
sample contains every configured segment or outcome.
|
|
439
436
|
|
|
440
437
|
**Deprecated config blocks (silently stripped by validator since 1.4):**
|
|
441
438
|
`subscription`, `attribution`, `geo`, `features`, `anomalies`. If a
|
|
@@ -465,7 +462,8 @@ For pre-existing-only dungeons (`percentUsersBornInDataset: 0`) expect
|
|
|
465
462
|
`dropped = 0`.
|
|
466
463
|
|
|
467
464
|
### 1. SuperProp Consistency
|
|
468
|
-
|
|
465
|
+
For properties declared in `stickyEventProps` or explicitly promised as stable,
|
|
466
|
+
check per-user consistency. Other `superProps` may legitimately vary by event:
|
|
469
467
|
|
|
470
468
|
```sql
|
|
471
469
|
SELECT
|
|
@@ -480,10 +478,13 @@ FROM (
|
|
|
480
478
|
GROUP BY user_id
|
|
481
479
|
);
|
|
482
480
|
```
|
|
483
|
-
|
|
481
|
+
For a strict profile projection contract, investigate every mismatch. Do not
|
|
482
|
+
replace the declared contract with a generic percentage tolerance.
|
|
484
483
|
|
|
485
484
|
### 2. SuperProp-UserProp Mirror Check
|
|
486
|
-
|
|
485
|
+
Only keys promised as profile projections must mirror `userProps`. Matching
|
|
486
|
+
enumerations alone do not guarantee equality; use `stickyEventProps`. Event-only
|
|
487
|
+
context such as an app version does not require a profile mirror.
|
|
487
488
|
|
|
488
489
|
### 3. Mixpanel Default Property Casing Check
|
|
489
490
|
The system generates device properties with Mixpanel's standard casing
|
|
@@ -501,9 +502,9 @@ intentional opt-in — not a casing conflict.
|
|
|
501
502
|
|
|
502
503
|
### 4. funnel-pre Dilution Check
|
|
503
504
|
For any dungeon with `funnel-pre` conversionRate modifications, verify the actual visible effect:
|
|
504
|
-
-
|
|
505
|
-
-
|
|
506
|
-
-
|
|
505
|
+
- Compare the declared funnel report on paired baseline and treatment streams.
|
|
506
|
+
- Inspect organic competitors, repeated opportunities, saturation, and eligible populations.
|
|
507
|
+
- Keep the target and report fixed; neither funnel-pre scaling nor everything filtering guarantees a universal ratio.
|
|
507
508
|
|
|
508
509
|
## Population threshold validation
|
|
509
510
|
|
|
@@ -520,14 +521,15 @@ GROUP BY segment_column
|
|
|
520
521
|
ORDER BY users DESC;
|
|
521
522
|
```
|
|
522
523
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
- Segment >50 users: should show clear signal if hook effect ≥1.3x
|
|
524
|
+
Set population floors before measuring, based on the intended effect and report.
|
|
525
|
+
Report actual independent eligible users and converters on both sides. No fixed
|
|
526
|
+
user count guarantees a clear signal for every effect or distribution.
|
|
527
527
|
|
|
528
528
|
## Statistical caveats
|
|
529
529
|
|
|
530
|
-
This skill
|
|
530
|
+
This skill uses the dungeon's configured scale for acceptance. Full fidelity does
|
|
531
|
+
not guarantee enough eligible users, converters, or mature cohorts. Distinguish
|
|
532
|
+
`INSUFFICIENT_EVIDENCE` from measured failure and investigate each accordingly.
|
|
531
533
|
|
|
532
534
|
`--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.
|
|
533
535
|
|
|
@@ -579,35 +581,15 @@ GROUP BY b.bucket;
|
|
|
579
581
|
|
|
580
582
|
When the dungeon doesn't have a natural "per-X" denominator, compute one from the cohort-binning event: `target_events / cohort_event_count`.
|
|
581
583
|
|
|
582
|
-
**Time-to-convert
|
|
584
|
+
**Time-to-convert verification:** use the steps-based `timeToConvert` emulator
|
|
585
|
+
with explicit report options and completed histories. Independent MIN(A)/MIN(B)
|
|
586
|
+
timestamps can mix attempts. Preserve the requested statistic and derive its
|
|
587
|
+
target from the hook, with a measured factor-one or hook-disabled control. Two
|
|
588
|
+
differently modified segments do not constitute a neutral baseline.
|
|
583
589
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_A>') AS a_time,
|
|
588
|
-
MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_B>') AS b_time
|
|
589
|
-
FROM read_json_auto('./data/<run>-EVENTS.json')
|
|
590
|
-
GROUP BY user_id
|
|
591
|
-
)
|
|
592
|
-
SELECT u.<SEGMENT_KEY>,
|
|
593
|
-
COUNT(*) AS users,
|
|
594
|
-
ROUND(MEDIAN(EXTRACT(EPOCH FROM (b_time - a_time)) / 60), 2) AS median_min_a_to_b
|
|
595
|
-
FROM funnel f
|
|
596
|
-
JOIN read_json_auto('./data/<run>-USERS.json') u ON f.user_id = u.distinct_id
|
|
597
|
-
WHERE a_time IS NOT NULL AND b_time IS NOT NULL
|
|
598
|
-
GROUP BY u.<SEGMENT_KEY>
|
|
599
|
-
ORDER BY median_min_a_to_b;
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
**Verdict for T2C**:
|
|
603
|
-
- Fast segment ≤0.85x baseline → STRONG
|
|
604
|
-
- Slow segment ≥1.2x baseline → STRONG
|
|
605
|
-
- Both directions visible → STRONG
|
|
606
|
-
- One/both missing → check that funnel exists in `funnels:` config and segment property is on `meta.profile`
|
|
607
|
-
|
|
608
|
-
**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.
|
|
609
|
-
|
|
610
|
-
**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.
|
|
590
|
+
**No-flag verification rule:** derive hidden cohorts behaviorally or with the
|
|
591
|
+
declared hash. A flag with a schema-declared default is allowed; an undeclared
|
|
592
|
+
flag fails even when present on every record.
|
|
611
593
|
|
|
612
594
|
## Drop-event funnel dilution diagnosis
|
|
613
595
|
|
|
@@ -615,22 +597,27 @@ Many dungeons have hooks of pattern `record.filter(e => e.event === 'X' && chanc
|
|
|
615
597
|
|
|
616
598
|
**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.
|
|
617
599
|
|
|
618
|
-
**
|
|
600
|
+
**Supplementary diagnostic:** per-user volume of step-3 events by tier. This
|
|
601
|
+
does not replace the declared funnel completion check:
|
|
619
602
|
|
|
620
603
|
```sql
|
|
621
604
|
SELECT u.subscription_tier,
|
|
622
605
|
COUNT(DISTINCT user_id) AS users,
|
|
623
606
|
COUNT(*) AS total_step3,
|
|
624
607
|
ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT user_id), 2) AS per_user
|
|
625
|
-
FROM read_json_auto('./data/<run>-EVENTS.json')
|
|
626
|
-
|
|
608
|
+
FROM read_json_auto('./data/<run>-EVENTS.json') e
|
|
609
|
+
JOIN read_json_auto('./data/<run>-USERS.json') u
|
|
610
|
+
ON e.user_id::VARCHAR = u.distinct_id::VARCHAR
|
|
611
|
+
WHERE e.event = '<STEP_3_EVENT>'
|
|
627
612
|
GROUP BY u.subscription_tier
|
|
628
613
|
ORDER BY per_user DESC;
|
|
629
614
|
```
|
|
630
615
|
|
|
631
616
|
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).
|
|
632
617
|
|
|
633
|
-
|
|
618
|
+
A volume gap can show the mutation fired while the declared conversion story
|
|
619
|
+
still fails. Preserve that failure. Change the report only through an explicit
|
|
620
|
+
story revision, then verify the revised claim with its own controls.
|
|
634
621
|
|
|
635
622
|
## Subscription tier cohort sizing check
|
|
636
623
|
|
|
@@ -641,12 +628,10 @@ SELECT subscription_plan, COUNT(*) FROM read_json_auto('./data/<run>-USERS.json'
|
|
|
641
628
|
GROUP BY subscription_plan;
|
|
642
629
|
```
|
|
643
630
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
- Bump `numUsers` up to 5x to enlarge cohorts
|
|
649
|
-
- Or recommend dungeon author tighten subscription lifecycle config
|
|
631
|
+
There is no active subscription lifecycle config block. Read the declared
|
|
632
|
+
`userProps` distribution and hook logic, then measure eligible populations.
|
|
633
|
+
If evidence is insufficient, request a larger run or an authorized schema change;
|
|
634
|
+
do not invent lifecycle defaults or silently relax the acceptance threshold.
|
|
650
635
|
|
|
651
636
|
## Per-day normalization for time-window hooks
|
|
652
637
|
|
|
@@ -666,41 +651,40 @@ For any spike/burst hook with a tight day window, ALWAYS normalize by window len
|
|
|
666
651
|
|
|
667
652
|
## Determinism check (optional confidence test)
|
|
668
653
|
|
|
669
|
-
|
|
654
|
+
For seeded generation, pin `datasetStart`/`datasetEnd` and `concurrency: 1`.
|
|
655
|
+
Use isolated sequential runs and strip only `insert_id` before comparing events:
|
|
670
656
|
|
|
671
657
|
1. Run a previously-passing dungeon a second time.
|
|
672
|
-
2.
|
|
673
|
-
3.
|
|
658
|
+
2. Require identical event counts, timestamps, ordering, and seeded property values.
|
|
659
|
+
3. Require identical report output under the same explicit options.
|
|
674
660
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
661
|
+
Investigate differences, including wall-clock calls, unseeded RNG, and stale
|
|
662
|
+
module state. Document known unseeded property functions separately; do not
|
|
663
|
+
accept a generic percentage drift as determinism proof.
|
|
678
664
|
|
|
679
665
|
## Critical time-window verification pattern
|
|
680
666
|
|
|
681
|
-
|
|
667
|
+
Use the resolved dataset bounds, also exposed as `meta.datasetStart` and
|
|
668
|
+
`meta.datasetEnd` in unix seconds. Generation occurs inside this window without
|
|
669
|
+
a post-generation shift. Observed event extrema do not reconstruct configured
|
|
670
|
+
bounds, especially for sparse or partially observed windows:
|
|
682
671
|
|
|
683
672
|
```sql
|
|
684
|
-
-- WRONG: uses MIN(time) which is up to 30 days BEFORE dataset start (pre-existing user spread)
|
|
685
|
-
SELECT *, EXTRACT(EPOCH FROM (time::TIMESTAMP - (SELECT MIN(time::TIMESTAMP) FROM events))) / 86400 as day_in
|
|
686
|
-
FROM events;
|
|
687
|
-
|
|
688
|
-
-- RIGHT: anchor to MAX(time) - num_days, which is the post-shift dataset start
|
|
689
673
|
WITH bounds AS (
|
|
690
|
-
SELECT
|
|
691
|
-
FROM events
|
|
674
|
+
SELECT TIMESTAMP '<RESOLVED_DATASET_START_UTC>' as datasetStart
|
|
692
675
|
)
|
|
693
676
|
SELECT *, EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.datasetStart)) / 86400 as day_in
|
|
694
677
|
FROM events e, bounds b;
|
|
695
678
|
```
|
|
696
679
|
|
|
697
|
-
Pre-existing
|
|
680
|
+
Pre-existing profile creation can precede the window; generated user events still
|
|
681
|
+
stay inside the resolved bounds. Never substitute `MAX(time) - numDays` for them.
|
|
698
682
|
|
|
699
683
|
## TTC hook verification — two approaches
|
|
700
684
|
|
|
701
685
|
TTC hooks come in two forms. Use the matching verification approach:
|
|
702
686
|
|
|
703
|
-
### Approach 1:
|
|
687
|
+
### Approach 1: Numeric timing-property report
|
|
704
688
|
|
|
705
689
|
The hook scales a timing PROPERTY (e.g., `response_time_mins *= 0.67`) by segment. Verification is trivial:
|
|
706
690
|
|
|
@@ -713,49 +697,33 @@ WHERE event IN ('alert acknowledged', 'alert resolved')
|
|
|
713
697
|
GROUP BY segment ORDER BY avg_response;
|
|
714
698
|
```
|
|
715
699
|
|
|
716
|
-
This
|
|
717
|
-
|
|
718
|
-
### Approach 2: Timestamp-Shifting TTC (use when no timing property exists)
|
|
700
|
+
This proves a property aggregate only. Measure paired baseline/treatment and a
|
|
701
|
+
factor-one control; raw segment ratios can reflect different starting distributions.
|
|
719
702
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
```sql
|
|
723
|
-
-- WRONG: lazy MIN→MIN proxy (mixes events from different funnel passes)
|
|
724
|
-
SELECT user_id, MIN(a.time) AS start, MIN(b.time) AS end ...
|
|
725
|
-
|
|
726
|
-
-- RIGHT: bound-sequence (first A, then first B AFTER that A)
|
|
727
|
-
WITH steps AS (
|
|
728
|
-
SELECT user_id, event, time::TIMESTAMP AS t
|
|
729
|
-
FROM events WHERE event IN ('step_a', 'step_b', 'step_c')
|
|
730
|
-
),
|
|
731
|
-
funnel AS (
|
|
732
|
-
SELECT DISTINCT ON (a.user_id) a.user_id, a.t AS start_t,
|
|
733
|
-
(SELECT MIN(t) FROM steps c
|
|
734
|
-
WHERE c.user_id = a.user_id AND c.event = 'step_c' AND c.t > a.t) AS end_t
|
|
735
|
-
FROM steps a WHERE a.event = 'step_a'
|
|
736
|
-
ORDER BY a.user_id, a.t
|
|
737
|
-
)
|
|
738
|
-
SELECT segment,
|
|
739
|
-
COUNT(*) AS users,
|
|
740
|
-
ROUND(MEDIAN(EXTRACT(EPOCH FROM (end_t - start_t)) / 60), 1) AS median_min
|
|
741
|
-
FROM funnel JOIN users USING (user_id)
|
|
742
|
-
WHERE end_t IS NOT NULL
|
|
743
|
-
GROUP BY segment ORDER BY median_min;
|
|
744
|
-
```
|
|
703
|
+
### Approach 2: Funnel timestamp TTC report
|
|
745
704
|
|
|
746
|
-
|
|
705
|
+
Use steps-based `timeToConvert` on completed histories with the report's window,
|
|
706
|
+
order, filters, identity, and reentry options. A first-A/next-C query can skip
|
|
707
|
+
required B, miss restarts, or mishandle grace. Compare the same report on baseline,
|
|
708
|
+
treatment, and neutral-control streams. Keep mean and median claims separate.
|
|
747
709
|
|
|
748
710
|
### Which approach to recommend when writing hooks
|
|
749
711
|
|
|
750
|
-
|
|
712
|
+
Choose the hook based on the intended report. Numeric property scaling answers
|
|
713
|
+
a property report; timestamp changes target funnel elapsed time. Ease of
|
|
714
|
+
verification does not authorize replacing one with the other.
|
|
751
715
|
|
|
752
716
|
### Legacy funnel-post TTC hooks
|
|
753
717
|
|
|
754
|
-
|
|
718
|
+
Measure legacy `funnel-post` effects with the same report contract and controls.
|
|
719
|
+
Never assign STRONG by code inspection. If instance-level mutations fail to move
|
|
720
|
+
completed report histories, retain the miss and investigate competing instances.
|
|
755
721
|
|
|
756
722
|
## Magic-number cohort sizing — inspect distribution first
|
|
757
723
|
|
|
758
|
-
Before checking inverted-U signal magnitude,
|
|
724
|
+
Before checking inverted-U signal magnitude, count independent eligible users in
|
|
725
|
+
each bucket against a predeclared population floor. A floor such as 200 is a
|
|
726
|
+
design choice, not universal statistical proof:
|
|
759
727
|
|
|
760
728
|
```sql
|
|
761
729
|
SELECT pn, COUNT(*) FROM (
|
|
@@ -764,19 +732,17 @@ SELECT pn, COUNT(*) FROM (
|
|
|
764
732
|
) GROUP BY pn ORDER BY pn LIMIT 20;
|
|
765
733
|
```
|
|
766
734
|
|
|
767
|
-
If
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
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).
|
|
735
|
+
If too few users reach the declared buckets, report insufficient evidence.
|
|
736
|
+
Request a larger run while preserving the story. Changing bucket ranges changes
|
|
737
|
+
the report specification and requires an explicit story revision and new proof.
|
|
772
738
|
|
|
773
739
|
## Re-run required after hook edits
|
|
774
740
|
|
|
775
741
|
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:
|
|
776
742
|
|
|
777
743
|
```bash
|
|
778
|
-
|
|
779
|
-
node scripts/verify-runner.mjs dungeons/vertical/<NAME>.js verify-<NAME
|
|
744
|
+
# Keep this run's files for verification and deployment; cleanup needs explicit consent.
|
|
745
|
+
node scripts/verify-runner.mjs dungeons/vertical/<NAME>/<NAME>.js verify-<NAME>-r2
|
|
780
746
|
# Wait for the {"mode":"full","eventCount":...} JSON to print before querying
|
|
781
747
|
```
|
|
782
748
|
|
|
@@ -804,49 +770,26 @@ The `event` hook receives `meta.datasetStart` as a unix timestamp, but temporal
|
|
|
804
770
|
|
|
805
771
|
## Property baseline dilution
|
|
806
772
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
773
|
+
If an independent hook forces a value on fraction `q` of eligible events with
|
|
774
|
+
baseline prevalence `p`, expected prevalence is `q + (1 - q) * p`. For `p=0.20`
|
|
775
|
+
and `q=0.40`, that is 0.52. Other targeting and time-window rules need their own
|
|
776
|
+
derivation. Measure the neutral baseline; request an authorized schema change if
|
|
777
|
+
the baseline distribution must change. Do not tune it silently after a miss.
|
|
812
778
|
|
|
813
779
|
## Computing the dataset window
|
|
814
780
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
| `numDays` only (no explicit start/end) | `datasetEnd = NOW`, `datasetStart = NOW - numDays` |
|
|
821
|
-
| `datasetStart` + `numDays` | `datasetEnd = datasetStart + numDays` |
|
|
822
|
-
|
|
823
|
-
The engine always resolves to a `[datasetStart, datasetEnd]` pair internally (see `config-validator.js`). To find the actual window from the OUTPUT data:
|
|
824
|
-
|
|
825
|
-
```sql
|
|
826
|
-
SELECT
|
|
827
|
-
MAX(time::TIMESTAMP) as datasetEnd,
|
|
828
|
-
MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as datasetStart
|
|
829
|
-
FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1);
|
|
830
|
-
```
|
|
831
|
-
|
|
832
|
-
Use `datasetStart` (derived above) as the DuckDB anchor for day-in-dataset:
|
|
833
|
-
|
|
834
|
-
```sql
|
|
835
|
-
WITH bounds AS (
|
|
836
|
-
SELECT MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as ds_start
|
|
837
|
-
FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1)
|
|
838
|
-
)
|
|
839
|
-
SELECT EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.ds_start)) / 86400 as day_in
|
|
840
|
-
FROM events e, bounds b;
|
|
841
|
-
```
|
|
842
|
-
|
|
843
|
-
Do NOT use `MIN(time)` as the anchor — pre-existing users have events up to 30 days before `datasetStart` (from `preExistingSpread: 'uniform'`).
|
|
844
|
-
|
|
845
|
-
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.
|
|
781
|
+
Record the engine's resolved `datasetStart`/`datasetEnd` from the run, including
|
|
782
|
+
derived windows. Use those values in the query shown under "Critical time-window
|
|
783
|
+
verification pattern". If the artifacts do not retain bounds, report the missing
|
|
784
|
+
metadata or regenerate a separately named pinned run. Neither observed MIN/MAX
|
|
785
|
+
nor the current wall clock can recover the original resolved window reliably.
|
|
846
786
|
|
|
847
787
|
## No flag stamping audit
|
|
848
788
|
|
|
849
|
-
Hooks must
|
|
789
|
+
Hooks must never add undeclared flags. A schema-declared flag with a default is
|
|
790
|
+
valid; hidden cohorts can use behavioral or hash definitions. Flag undeclared
|
|
791
|
+
assignments as SCHEMA-FAIL and request an authorized schema declaration or a hook
|
|
792
|
+
rewrite using existing fields. Uniform coverage does not make them acceptable.
|
|
850
793
|
|
|
851
794
|
## Clone dilution of temporal effects
|
|
852
795
|
|
|
@@ -865,7 +808,7 @@ If Hook A classifies users by event presence (`events.some(e => e.event === X)`)
|
|
|
865
808
|
**Fixes:**
|
|
866
809
|
1. Require 3+ marker events instead of 1+ (surviving events still identify)
|
|
867
810
|
2. Accept the verification limitation and note it in the report
|
|
868
|
-
3.
|
|
811
|
+
3. Propose a separate distribution diagnostic while retaining the original cohort report's unresolved status; changing the report requires explicit revision.
|
|
869
812
|
|
|
870
813
|
## Deprecated feature property gaps
|
|
871
814
|
|
|
@@ -873,4 +816,6 @@ Dungeons using deprecated config blocks (`subscription`, `attribution`, `feature
|
|
|
873
816
|
|
|
874
817
|
**Diagnosis:** Hook logic references a property that's always NULL/undefined in the output. Check if the property was produced by a deprecated feature.
|
|
875
818
|
|
|
876
|
-
**Fix:**
|
|
819
|
+
**Fix:** Request a schema declaration with a default through `/create-dungeon`
|
|
820
|
+
before hooks assign values. Report the missing field and preserve the runner's
|
|
821
|
+
verdict; adding undeclared property generation inside a hook is not a valid fix.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Warehouse Metric CRUD Gap Report
|
|
2
|
+
|
|
3
|
+
Dungeon: {{DUNGEON_PATH}}
|
|
4
|
+
Warehouse dir: {{WAREHOUSE_DIR}}
|
|
5
|
+
Dataset: {{DATASET}}
|
|
6
|
+
Source id: {{SOURCE_ID}}
|
|
7
|
+
|
|
8
|
+
Note: {{NOTE}}
|
|
9
|
+
|
|
10
|
+
## Desired Powertools Contract
|
|
11
|
+
|
|
12
|
+
All under `/crud`, POST to execute, GET for docs, standard `client_id` / `region` body convention.
|
|
13
|
+
|
|
14
|
+
| Endpoint | Required | Notes |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `createWarehouseMetric` | `project_id`, `source_id`, `name`, `sql`, `value_column` | Optional: `metric_type`, `time_column`, `aggregation`, `refresh`, `description` |
|
|
17
|
+
| `getWarehouseMetrics` | `project_id` | |
|
|
18
|
+
| `getWarehouseMetric` | `project_id`, `metric_id` | |
|
|
19
|
+
| `updateWarehouseMetric` | `project_id`, `metric_id`, `payload` | `source_id` is immutable; delete and recreate to rebind |
|
|
20
|
+
| `deleteWarehouseMetric` | `project_id`, `metric_id` | |
|
|
21
|
+
| `refreshWarehouseMetric` | `project_id`, `metric_id` | Cache invalidation only; does not execute the query |
|
|
22
|
+
| `previewWarehouseMetric` | `project_id`, `source_id`, `sql` | Returns rows; use before save because create does not validate SQL |
|
|
23
|
+
|
|
24
|
+
## Manual Notes
|
|
25
|
+
|
|
26
|
+
- `value_column` is required for every warehouse metric, including numeric ones.
|
|
27
|
+
- `source_id` must come from the actual `/macro/setup-bq-warehouse` response.
|
|
28
|
+
- `previewWarehouseMetric` rejects raw SQL containing `DROP`, `DELETE`, `TRUNCATE`, `ALTER`, `CREATE`, `INSERT`, or `UPDATE` as substrings.
|
|
29
|
+
- `created_at` and `updated_at` therefore fail preview unless the blocked text is removed from the query entirely.
|
|
30
|
+
- `refreshWarehouseMetric` invalidates cache only; it does not execute the query.
|
|
31
|
+
|
|
32
|
+
## Per-table Checklist
|
|
33
|
+
|
|
34
|
+
{{TABLE_CHECKLIST}}
|