@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
package/HOOKS.md
CHANGED
|
@@ -21,10 +21,12 @@ hook: function (record, type, meta) { ... return record; }
|
|
|
21
21
|
| `event` | `events.js:176` | Single event (flat props) | **Used** (replaces event) | `user: { distinct_id }`, `config`, `datasetStart`, `datasetEnd` |
|
|
22
22
|
| `funnel-post` | `funnels.js:153` | Array of funnel events | Ignored (mutate in-place) | `user`, `profile`, `scd`, `funnel`, `config`, `experiment` |
|
|
23
23
|
| `everything` | `user-loop.js:280` | Array of ALL user events | **Used** if array returned | `profile`, `scd`, `config`, `datasetStart`, `datasetEnd`, `userIsBornInDataset`, `authTime`, `isPreAuth`, `persona` |
|
|
24
|
-
| `ad-spend` | `storage.js` | Ad spend event |
|
|
25
|
-
| `group` | `storage.js` | Group profile |
|
|
26
|
-
| `mirror` | `storage.js` | Mirror data point |
|
|
27
|
-
| `lookup` | `storage.js` | Lookup table entry |
|
|
24
|
+
| `ad-spend` | `storage.js` | Ad spend event | Object or array used | -- |
|
|
25
|
+
| `group` | `storage.js` | Group profile | Object or array used | -- |
|
|
26
|
+
| `mirror` | `storage.js` | Mirror data point | Object or array used | -- |
|
|
27
|
+
| `lookup` | `storage.js` | Lookup table entry | Object or array used | -- |
|
|
28
|
+
| `standalone` | `storage.js`, before user loop | Identity-less cadence event | Object or array used; `undefined` drops | `spec`, `config`, `datasetStart`, `datasetEnd` |
|
|
29
|
+
| `warehouse` | `storage.js` | One materialized warehouse row | Ignored | `spec`, `config`, `metricName`, `bucketIndex`, `bucketCount`, `grain`, `seriesKey`, `isBackfill`, `raw`, `datasetStart`, `datasetEnd` |
|
|
28
30
|
|
|
29
31
|
**Per-user execution order:** `user` -> `scd-pre` -> `funnel-pre` -> `event` -> `funnel-post` -> `everything`
|
|
30
32
|
|
|
@@ -36,7 +38,105 @@ double-fire mutations.
|
|
|
36
38
|
**Return rules:**
|
|
37
39
|
- `event`: return the (possibly replaced) event object.
|
|
38
40
|
- `everything`: return the (possibly modified) array. Filtered array removes events.
|
|
39
|
-
-
|
|
41
|
+
- `ad-spend`, `group`, `mirror`, `lookup`, `standalone`: return the record or an
|
|
42
|
+
array of records. Returning `undefined` drops the record.
|
|
43
|
+
- `user`, `scd-pre`, `funnel-pre`, `funnel-post`, `warehouse`: mutate `record`
|
|
44
|
+
in place. Return value is ignored.
|
|
45
|
+
|
|
46
|
+
`standaloneEvents` runs before the user loop; `warehouseMetrics` materializes
|
|
47
|
+
after it. Neither hook receives person metadata or enters `everything`.
|
|
48
|
+
Standalone synthetic `distinct_id` values identify series, never people. Use
|
|
49
|
+
disk-backed `duckdb` assertions on `{{PREFIX}}-STANDALONE*.json` for cadence
|
|
50
|
+
stories. Warehouse stories support `warehouse` and `warehouse-stats`
|
|
51
|
+
assertions, with automatic table audits even when no stories are exported.
|
|
52
|
+
|
|
53
|
+
### 1.1 Warehouse rows (`type === 'warehouse'`)
|
|
54
|
+
|
|
55
|
+
`warehouse` fires once per materialized row, after the user loop and before the
|
|
56
|
+
rows are written to disk. the row already matches the manifest contract:
|
|
57
|
+
|
|
58
|
+
- `timeColumn`
|
|
59
|
+
- every `source.groupBy` key, in order
|
|
60
|
+
- `valueColumn`
|
|
61
|
+
- every declared key in `columns`
|
|
62
|
+
|
|
63
|
+
declare every key up front. warehouse containers are created with a fixed column
|
|
64
|
+
list, and the manifest is built from that same list. an undeclared key is not
|
|
65
|
+
part of the output contract even if it exists briefly in memory.
|
|
66
|
+
|
|
67
|
+
`meta.seriesKey` is the joined group tuple in `source.groupBy` order, separated
|
|
68
|
+
by `|`. examples:
|
|
69
|
+
|
|
70
|
+
- no `groupBy` → `''`
|
|
71
|
+
- `groupBy: ['region']` and `row.region === 'us'` → `'us'`
|
|
72
|
+
- `groupBy: ['region', 'plan_tier']` and `row.region === 'us'`, `row.plan_tier === 'enterprise'` → `'us|enterprise'`
|
|
73
|
+
|
|
74
|
+
`meta.bucketIndex` and `meta.bucketCount` are chronological and include history
|
|
75
|
+
buckets even when `sparse: true` skips repeated rows. `meta.isBackfill` is true
|
|
76
|
+
for the synthetic buckets created by `history`. `meta.raw` is the bucketed
|
|
77
|
+
source truth before `scale`, `noise`, and point-in-time carry-forward.
|
|
78
|
+
|
|
79
|
+
Treat the time axis as immutable. `row[spec.timeColumn]` drives ordering,
|
|
80
|
+
manifest SQL, and warehouse verification. mutate the value column or declared
|
|
81
|
+
extra columns instead.
|
|
82
|
+
|
|
83
|
+
Recipe: scale a point-in-time level for an in-window story slice
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
warehouseMetrics: [{
|
|
87
|
+
name: 'daily_active_subscriptions',
|
|
88
|
+
type: 'point-in-time',
|
|
89
|
+
source: { event: 'subscription_started', minus: 'subscription_cancelled', groupBy: 'region' },
|
|
90
|
+
baseline: 40,
|
|
91
|
+
timeColumn: 'date',
|
|
92
|
+
valueColumn: 'active_subscriptions',
|
|
93
|
+
columns: { lifted: false },
|
|
94
|
+
}],
|
|
95
|
+
|
|
96
|
+
hook: (row, type, meta) => {
|
|
97
|
+
if (type !== 'warehouse') return row;
|
|
98
|
+
if (meta.metricName !== 'daily_active_subscriptions') return row;
|
|
99
|
+
if (meta.isBackfill) return row;
|
|
100
|
+
|
|
101
|
+
const liveIndex = meta.bucketIndex - meta.spec.history;
|
|
102
|
+
if (meta.seriesKey === 'us' && liveIndex >= 7 && liveIndex < 14) {
|
|
103
|
+
row.active_subscriptions = Math.round(row.active_subscriptions * 1.2);
|
|
104
|
+
row.lifted = true;
|
|
105
|
+
}
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Recipe: apply an incident dip to one series only
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
warehouseMetrics: [{
|
|
114
|
+
name: 'daily_new_bookings',
|
|
115
|
+
source: {
|
|
116
|
+
event: 'new_booking',
|
|
117
|
+
measure: 'sum',
|
|
118
|
+
property: 'booking_value',
|
|
119
|
+
groupBy: ['region', 'plan_tier'],
|
|
120
|
+
},
|
|
121
|
+
timeColumn: 'date',
|
|
122
|
+
valueColumn: 'bookings',
|
|
123
|
+
columns: { incident: false },
|
|
124
|
+
}],
|
|
125
|
+
|
|
126
|
+
hook: (row, type, meta) => {
|
|
127
|
+
if (type !== 'warehouse') return row;
|
|
128
|
+
if (meta.metricName !== 'daily_new_bookings') return row;
|
|
129
|
+
if (meta.isBackfill) return row;
|
|
130
|
+
if (meta.seriesKey !== 'us|enterprise') return row;
|
|
131
|
+
|
|
132
|
+
const liveIndex = meta.bucketIndex - meta.spec.history;
|
|
133
|
+
if (liveIndex >= 14 && liveIndex <= 16) {
|
|
134
|
+
row.bookings = Math.round(row.bookings * 0.35);
|
|
135
|
+
row.incident = true;
|
|
136
|
+
}
|
|
137
|
+
return row;
|
|
138
|
+
}
|
|
139
|
+
```
|
|
40
140
|
|
|
41
141
|
**What 1.7.0 changed for hooks.** No hook signature, `meta` field, or firing
|
|
42
142
|
order changed, and the hook-helper atoms and patterns are untouched. What a hook
|
|
@@ -437,12 +537,22 @@ enter on birth, or drop next-day spill in an `everything` hook.
|
|
|
437
537
|
|
|
438
538
|
### 2.8 Funnel reentry: state machine resets after completion
|
|
439
539
|
|
|
440
|
-
Reference: `history.cpp` (`
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
540
|
+
Reference: `history.cpp` (`history_is_mutable`) and `funnel_query.cpp`
|
|
541
|
+
(shared first/last step handling). With `reentry: true`, completion absorbs
|
|
542
|
+
events through the inclusive 2-second grace period. The next event beyond
|
|
543
|
+
grace starts the next scan. Conversion-window expiry can restart earlier.
|
|
544
|
+
`graceperiod: false` disables the completion wait.
|
|
545
|
+
|
|
546
|
+
When an event records the ordered last step and also matches the ordered
|
|
547
|
+
first step, it closes one attempt and anchors the next immediately. Both
|
|
548
|
+
selectors must match. Any-order edges do not use this exception.
|
|
549
|
+
`woRepeat` still restarts only at window expiry.
|
|
550
|
+
|
|
551
|
+
`result.completions` reports repeat completions in uniques mode. In
|
|
552
|
+
`countMode: 'totals'`, the engine returns one `FunnelResult` per attempt,
|
|
553
|
+
including partial attempts. **Compatibility unchanged:** `reentry` defaults
|
|
554
|
+
to `false`, even for totals. Totals alone does not enable analytics general
|
|
555
|
+
counting's repeat-history behavior.
|
|
446
556
|
|
|
447
557
|
### 2.9 HPC (Hold Property Constant) — parallel sub-funnels
|
|
448
558
|
|
|
@@ -455,13 +565,24 @@ directly, or (v1.6) pass `holdPropertyConstant: '<prop>'` to the
|
|
|
455
565
|
`funnelFrequency` emulator — it routes through the HPC engine and reports
|
|
456
566
|
per-held-value sub-funnel counts.
|
|
457
567
|
|
|
568
|
+
Session windows derive ordinals from the full user stream before HPC
|
|
569
|
+
partitioning. Events with another held value can bridge a session but cannot
|
|
570
|
+
fill steps in the current bucket. This applies to explicit session windows
|
|
571
|
+
and `countMode: 'sessions'`. The local session defaults remain a 30-minute
|
|
572
|
+
timeout, 24-hour maximum, and UTC day boundaries.
|
|
573
|
+
|
|
458
574
|
### 2.10 Funnel segment modes (FIRST_TOUCH / LAST_TOUCH / STEP)
|
|
459
575
|
|
|
460
576
|
Reference: `options.hpp` `funnel_segment_mode`; `history.cpp`
|
|
461
577
|
`property_set_buffer`. The engine snapshots the matched event's properties
|
|
462
|
-
at every
|
|
463
|
-
|
|
464
|
-
|
|
578
|
+
at every reached position. FIRST_TOUCH and LAST_TOUCH merge those snapshots
|
|
579
|
+
in recorded path order, including partial and any-order paths. The first
|
|
580
|
+
or last defined non-null value wins, respectively. Undefined never replaces
|
|
581
|
+
a defined value; null never replaces a defined non-null value. If only null
|
|
582
|
+
and undefined are present, null wins. Snapshots remain unchanged.
|
|
583
|
+
|
|
584
|
+
STEP N selects one reached position without merging fallback values.
|
|
585
|
+
Enable with `evaluateFunnel(events, steps, { trackStepProperties: true })`, then pick with
|
|
465
586
|
`resolveFunnelSegment(result, 'first' | 'last' | { step: N })`.
|
|
466
587
|
|
|
467
588
|
### 2.11 Engine-validation guarantees (v1.5+)
|
|
@@ -1743,8 +1864,9 @@ event set rather than one value moment.
|
|
|
1743
1864
|
**Hook:** `everything`
|
|
1744
1865
|
**Mixpanel report:** Flows — top paths after the anchor event show the engineered branch (Section 2.17)
|
|
1745
1866
|
|
|
1746
|
-
**In Mixpanel:**
|
|
1747
|
-
|
|
1867
|
+
**In Mixpanel:** Bias the first branch toward `add to cart → begin checkout`.
|
|
1868
|
+
The helper selects ~30% of users for append-only injection. Existing traffic
|
|
1869
|
+
can interrupt the branch; the final branch share is not guaranteed to be 30%.
|
|
1748
1870
|
|
|
1749
1871
|
```js
|
|
1750
1872
|
import { applyPathBias } from "@ak--47/dungeon-master/hook-helpers";
|
|
@@ -1789,6 +1911,8 @@ if (type === "everything") {
|
|
|
1789
1911
|
sessionsPerWeek: 3,
|
|
1790
1912
|
eventsPerSession: 5,
|
|
1791
1913
|
sessionMinutes: 25,
|
|
1914
|
+
datasetStart: meta.datasetStart,
|
|
1915
|
+
datasetEnd: meta.datasetEnd,
|
|
1792
1916
|
});
|
|
1793
1917
|
}
|
|
1794
1918
|
```
|
|
@@ -1797,12 +1921,35 @@ if (type === "everything") {
|
|
|
1797
1921
|
event set (after the `everything` hook), so wholesale timestamp rewrites no
|
|
1798
1922
|
longer leave stale session labels. The atom keeps intra-session gaps well
|
|
1799
1923
|
under the 30-min timeout (spacing capped at 20min + bounded jitter), keeps
|
|
1800
|
-
inter-session gaps
|
|
1924
|
+
inter-session gaps over it when explicit bounds are supplied, and never crosses UTC midnight inside one
|
|
1801
1925
|
engineered session (the day-boundary split would cut it). Retiming only — no
|
|
1802
1926
|
events are added or dropped, so total counts and event mixes are untouched.
|
|
1803
1927
|
Session count follows `min(sessionsPerWeek × weeks, ceil(N /
|
|
1804
1928
|
eventsPerSession))`: scarce users get fewer sessions, not fabricated events.
|
|
1805
1929
|
|
|
1930
|
+
`datasetStart` and `datasetEnd` are additive, optional arguments. Existing
|
|
1931
|
+
calls with neither bound keep the original full-UTC-day placement between
|
|
1932
|
+
the user's first and last active days. A two-event stream at 12:00/12:20 can
|
|
1933
|
+
still request two sessions with `eventsPerSession: 1` and `sessionMinutes: 5`.
|
|
1934
|
+
Legacy overfull requests also keep their old behavior: they do not throw,
|
|
1935
|
+
but their clusters can merge under the 30-minute timeout.
|
|
1936
|
+
|
|
1937
|
+
The bounds accept ISO strings, unix seconds, or unix milliseconds. Hook
|
|
1938
|
+
metadata uses unix seconds and can be passed directly, as in the example.
|
|
1939
|
+
Either bound enables constrained placement; an omitted side uses the start
|
|
1940
|
+
of the first active UTC day or the end of the last active UTC day. The helper
|
|
1941
|
+
cannot infer `datasetEnd` from the last event. Pass known bounds when the
|
|
1942
|
+
dataset ends partway through an active day, including an inclusive midnight
|
|
1943
|
+
endpoint, to prevent later engine clipping.
|
|
1944
|
+
|
|
1945
|
+
With explicit bounds, partial days compress clusters, including zero-duration
|
|
1946
|
+
clusters at midnight. If the requested sessions cannot fit inside a week's
|
|
1947
|
+
available day slices, the helper throws `RangeError` before changing any
|
|
1948
|
+
events. Invalid bounds also throw. It never silently reduces the target or
|
|
1949
|
+
drops records. Widen the allowed window or reduce the session target. Exact
|
|
1950
|
+
session separation assumes UTC and the default 30-minute timeout, without
|
|
1951
|
+
a maximum session duration.
|
|
1952
|
+
|
|
1806
1953
|
---
|
|
1807
1954
|
|
|
1808
1955
|
## 5. Phase 3 Atom Reference
|
|
@@ -1833,7 +1980,7 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
|
|
|
1833
1980
|
| `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
|
|
1834
1981
|
| **`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 |
|
|
1835
1982
|
| **`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) |
|
|
1836
|
-
| **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes }) -> events[]` |
|
|
1983
|
+
| **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes, datasetStart?, datasetEnd? }) -> events[]` | Preserve records; default legacy full-UTC-day placement. Optional bounds constrain placement and throw atomically on insufficient capacity (recipe 4.31) |
|
|
1837
1984
|
|
|
1838
1985
|
**Inject atoms + v1.5:** the engine auto-sorts events by time after the
|
|
1839
1986
|
`everything` hook (`autoSortAfterEverything: true` default — see Principle
|
package/README.md
CHANGED
|
@@ -91,6 +91,11 @@ const result = await DUNGEON_MASTER({
|
|
|
91
91
|
console.log(result.importResults);
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
`token` imports event-shaped streams only: events, users, groups, ad spend, and
|
|
95
|
+
`standaloneEvents`. `warehouseMetrics` does **not** import through this path.
|
|
96
|
+
warehouse tables are materialized locally and need a separate warehouse deploy
|
|
97
|
+
step after the run.
|
|
98
|
+
|
|
94
99
|
## dungeons
|
|
95
100
|
|
|
96
101
|
a dungeon is a javascript file that exports a configuration object. it defines your entire data model: events, funnels, user properties, group analytics, SCDs, and a hook function that engineers discoverable patterns into the data.
|
|
@@ -537,6 +542,8 @@ dungeon-master generates multiple data types that mirror a real analytics implem
|
|
|
537
542
|
| SCDs | `scdProps` | slowly changing dimensions (subscription tier over time) |
|
|
538
543
|
| lookup tables | `lookupTables` | dimension tables (product catalog, region mapping) |
|
|
539
544
|
| ad spend | `hasAdSpend` | daily ad spend with impressions, clicks, cost metrics |
|
|
545
|
+
| standalone events | `standaloneEvents` | identity-less metric snapshots on a cadence (infrastructure, finance, ops) |
|
|
546
|
+
| warehouse metrics | `warehouseMetrics` | warehouse source tables derived from generated events, with a manifest for downstream deploy |
|
|
540
547
|
| mirror datasets | `mirrorProps` | transformed copies of event data (A/B versions) |
|
|
541
548
|
| organic text | `createTextGenerator` | reviews, support tickets, search queries, chat messages |
|
|
542
549
|
|
|
@@ -606,6 +613,222 @@ variant assignment is **sticky by default**: a deterministic hash of `user_id` +
|
|
|
606
613
|
|
|
607
614
|
**the variant lands on the user profile (1.7.0).** every exposed user carries `"Experiment: <name>": "<variant>"` (e.g. `"Experiment: Checkout Redesign": "New Checkout"`), so the funnel breaks down by variant in Mixpanel with a user-property breakdown — no cohort built from the exposure event. stamped when the user is first exposed (respects `startDaysBeforeEnd`); never-exposed users carry nothing; the `user` hook fires before exposure and does not see it, the `everything` hook does. `stampProfile: false` turns it off; `sticky: false` implies off. measured: 0 mismatches between the profile value and the `Variant name` on 13,005 exposure events.
|
|
608
615
|
|
|
616
|
+
## standalone events (identity-less metric snapshots)
|
|
617
|
+
|
|
618
|
+
`standaloneEvents` generates records that describe a **system, not a person**. they carry
|
|
619
|
+
no `user_id` and no `device_id`. use them for infrastructure, finance, and ops telemetry:
|
|
620
|
+
daily CDN egress per region, weekly billing rollups per plan tier, hourly queue depth per
|
|
621
|
+
cluster. `hasAdSpend` is the same idea hard-coded to `$ad_spend`; this is the general form
|
|
622
|
+
and it does not use a Mixpanel reserved event name.
|
|
623
|
+
|
|
624
|
+
```javascript
|
|
625
|
+
standaloneEvents: [
|
|
626
|
+
{
|
|
627
|
+
event: 'cdn_egress',
|
|
628
|
+
cadence: 'day', // 'hour' | 'day' | 'week' (default 'day')
|
|
629
|
+
dimensions: { region: ['us-east', 'us-west', 'eu'] }, // cross-producted
|
|
630
|
+
distinctIdFrom: 'region', // synthetic id, never a person
|
|
631
|
+
properties: {
|
|
632
|
+
gb_out: (ctx) => 400 + ctx.tickIndex * 3, // shape a trend across the window
|
|
633
|
+
cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
|
|
634
|
+
p95_ms: [120, 140, 160], // same ValueValid forms as event props
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
event: 'billing_rollup',
|
|
639
|
+
cadence: 'week',
|
|
640
|
+
dimensions: { tier: ['free', 'pro', 'max'] },
|
|
641
|
+
properties: { mrr_usd: (ctx) => ..., churn_usd: (ctx) => ... },
|
|
642
|
+
},
|
|
643
|
+
]
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
the engine emits **one record per cadence tick per dimension cross-product row**. the
|
|
647
|
+
example above produces 3 records per day (`cdn_egress`) plus 3 records per week
|
|
648
|
+
(`billing_rollup`). ticks start at the dataset start and step by the cadence; the last tick
|
|
649
|
+
is the final one at or before the dataset end, so nothing lands in the future.
|
|
650
|
+
|
|
651
|
+
each record carries `event`, `time`, `insert_id`, `distinct_id`, every dimension as a flat
|
|
652
|
+
property, and every resolved entry in `properties`.
|
|
653
|
+
|
|
654
|
+
| field | behavior |
|
|
655
|
+
|---|---|
|
|
656
|
+
| `event` | required, unique across `standaloneEvents` |
|
|
657
|
+
| `cadence` | `'hour'`, `'day'`, or `'week'`. default `'day'` |
|
|
658
|
+
| `dimensions` | object of non-empty arrays, cross-producted. omit for one record per tick |
|
|
659
|
+
| `distinctIdFrom` | must name a declared dimension. omitted → `distinct_id` is the event name |
|
|
660
|
+
| `properties` | keys may not collide with a dimension or with `event`/`time`/`insert_id`/`distinct_id`/`user_id`/`device_id` |
|
|
661
|
+
|
|
662
|
+
property value functions receive a `StandaloneValueContext`: `{ time, config, dimensions,
|
|
663
|
+
tickIndex, tickCount, cadence, event }`. `tickIndex / (tickCount - 1)` is window progress —
|
|
664
|
+
use it to shape growth, a dip, or a spike, guarding `tickCount <= 1` before division.
|
|
665
|
+
|
|
666
|
+
the stream lands in `result.standaloneEventData`, writes to its own `-STANDALONE` file
|
|
667
|
+
shard, and imports to Mixpanel as its own event stream. hooks fire with type
|
|
668
|
+
`"standalone"`; `meta.spec` carries the resolved config so a hook can tell streams apart.
|
|
669
|
+
the hook runs before the user loop. return the record object or an array of records;
|
|
670
|
+
returning `undefined` drops the record. it has no person metadata and never enters
|
|
671
|
+
`everything`. warehouse hooks have a different contract: mutate the row in place;
|
|
672
|
+
their return values are ignored.
|
|
673
|
+
|
|
674
|
+
```javascript
|
|
675
|
+
hook: (record, type, meta) => {
|
|
676
|
+
if (type === 'standalone' && meta.spec.event === 'cdn_egress' && record.region === 'us-east') {
|
|
677
|
+
record.p95_ms *= 40;
|
|
678
|
+
}
|
|
679
|
+
return record;
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
validation throws rather than skipping. a malformed entry would silently drop a whole data
|
|
684
|
+
stream, and you would not notice until the charts were wrong.
|
|
685
|
+
|
|
686
|
+
## warehouse metrics (local source tables)
|
|
687
|
+
|
|
688
|
+
`warehouseMetrics` materializes warehouse-ready tables from the run's own event
|
|
689
|
+
stream after user generation completes. use it when you need a bookings table, a
|
|
690
|
+
subscription level snapshot, or an ARR table that reads like a real warehouse
|
|
691
|
+
source. these rows land in `result.warehouseMetricData`, write to
|
|
692
|
+
`<name>-WAREHOUSE-<table>.csv|json`, and emit one manifest at
|
|
693
|
+
`<name>-WAREHOUSE-MANIFEST.json`.
|
|
694
|
+
|
|
695
|
+
they are **not** imported by `token`. that is deliberate. the live path is:
|
|
696
|
+
|
|
697
|
+
1. run the dungeon
|
|
698
|
+
2. review `/warehouse-metrics` in dry-run mode
|
|
699
|
+
3. obtain explicit operator consent for live execution
|
|
700
|
+
4. load the tables to bigquery and save the metrics there
|
|
701
|
+
|
|
702
|
+
live deploy uses `bq load --replace`, so it overwrites the destination warehouse
|
|
703
|
+
tables. the shipped script does not prompt on its own, so the operator or agent
|
|
704
|
+
must obtain explicit consent before running it in live mode. if the
|
|
705
|
+
warehouse CRUD docs route returns 404, the deploy still loads tables and connects
|
|
706
|
+
the source, then writes `warehouse/GAPS.md` for manual metric creation.
|
|
707
|
+
|
|
708
|
+
the manifest carries `recommendedAggregation: 'sum' | 'last value'`. the
|
|
709
|
+
Mixpanel warehouse metric API spells that second value as `last_value`; the
|
|
710
|
+
deploy flow maps it for you.
|
|
711
|
+
|
|
712
|
+
there is one real preview trap: `previewWarehouseMetric` rejects raw SQL
|
|
713
|
+
containing `DROP`, `DELETE`, `TRUNCATE`, `ALTER`, `CREATE`, `INSERT`, or
|
|
714
|
+
`UPDATE` as plain substrings. `created_at` trips `CREATE`; `updated_at` trips
|
|
715
|
+
`UPDATE`. aliasing only helps if the blocked text disappears from the query
|
|
716
|
+
entirely.
|
|
717
|
+
|
|
718
|
+
### canonical shapes
|
|
719
|
+
|
|
720
|
+
additive daily bookings:
|
|
721
|
+
|
|
722
|
+
```javascript
|
|
723
|
+
warehouseMetrics: [{
|
|
724
|
+
name: 'daily_new_bookings',
|
|
725
|
+
source: {
|
|
726
|
+
event: 'new_booking',
|
|
727
|
+
measure: 'sum',
|
|
728
|
+
property: 'booking_value',
|
|
729
|
+
},
|
|
730
|
+
timeColumn: 'date',
|
|
731
|
+
valueColumn: 'bookings',
|
|
732
|
+
}]
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
point-in-time daily active subscriptions:
|
|
736
|
+
|
|
737
|
+
```javascript
|
|
738
|
+
warehouseMetrics: [{
|
|
739
|
+
name: 'daily_active_subscriptions',
|
|
740
|
+
type: 'point-in-time',
|
|
741
|
+
source: {
|
|
742
|
+
event: 'subscription_started',
|
|
743
|
+
minus: 'subscription_cancelled',
|
|
744
|
+
measure: 'count',
|
|
745
|
+
},
|
|
746
|
+
baseline: 40,
|
|
747
|
+
timeColumn: 'date',
|
|
748
|
+
valueColumn: 'active_subscriptions',
|
|
749
|
+
}]
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
sparse monthly ARR with backfill:
|
|
753
|
+
|
|
754
|
+
```javascript
|
|
755
|
+
warehouseMetrics: [{
|
|
756
|
+
name: 'monthly_arr_snapshot',
|
|
757
|
+
type: 'point-in-time',
|
|
758
|
+
grain: 'month',
|
|
759
|
+
sparse: true,
|
|
760
|
+
history: 18,
|
|
761
|
+
source: {
|
|
762
|
+
event: 'subscription_started',
|
|
763
|
+
minus: 'subscription_cancelled',
|
|
764
|
+
measure: 'sum',
|
|
765
|
+
property: 'monthly_value',
|
|
766
|
+
},
|
|
767
|
+
baseline: 24000,
|
|
768
|
+
scale: 12,
|
|
769
|
+
timeColumn: 'month',
|
|
770
|
+
valueColumn: 'arr_usd',
|
|
771
|
+
}]
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
the shipped technical fixture uses a 60-day live window plus 18 monthly backfill
|
|
775
|
+
buckets. sample row counts are illustrative only. `grain`, `history`, `sparse`,
|
|
776
|
+
and `groupBy` all change how many rows a table emits.
|
|
777
|
+
|
|
778
|
+
### config surface
|
|
779
|
+
|
|
780
|
+
| key | default | range / contract |
|
|
781
|
+
|---|---|---|
|
|
782
|
+
| `name` | required | unique table / metric name, `/^[a-z][a-z0-9_]{0,63}$/` |
|
|
783
|
+
| `type` | `'additive'` | `'additive'` or `'point-in-time'` |
|
|
784
|
+
| `grain` | `'day'` | `'day'`, `'week'`, `'month'` |
|
|
785
|
+
| `sparse` | `false` | boolean, valid only with `type: 'point-in-time'` |
|
|
786
|
+
| `source.event` | required | string or string[] of declared source events |
|
|
787
|
+
| `source.minus` | `[]` | string or string[] of declared subtractive events |
|
|
788
|
+
| `source.measure` | `'count'` | `'count'`, `'sum'`, `'avg'`, `'dau'`, `'users'`; point-in-time forbids `'avg'` and `'dau'` |
|
|
789
|
+
| `source.property` | `null` | required for `'sum'` and `'avg'`; must be declared on every source event or in `superProps` |
|
|
790
|
+
| `source.where` | `null` | optional function over flat event rows |
|
|
791
|
+
| `source.groupBy` | `[]` | up to 2 keys, each declared on every source event or in `superProps`; observed cardinality above 50 warns |
|
|
792
|
+
| `timeColumn` | `'date'` | valid JS identifier; becomes the ordered time axis in rows and manifest |
|
|
793
|
+
| `valueColumn` | `'value'` | valid JS identifier |
|
|
794
|
+
| `baseline` | `0` | number `>= 0`; used only for point-in-time metrics, ignored on additive |
|
|
795
|
+
| `scale` | `1` | finite number `> 0`, applied after bucket aggregation |
|
|
796
|
+
| `noise` | `0` | finite number, clamped to `[0, 0.5]` with a warning |
|
|
797
|
+
| `history` | `0` | integer `>= 0`; warns above roughly 3 years at each grain (`1095` day, `156` week, `36` month) |
|
|
798
|
+
| `columns` | `{}` | extra declared output columns; keys must be valid identifiers and cannot collide with time/value/groupBy columns |
|
|
799
|
+
| `format` | dungeon `format`, else `'csv'` | `'csv'` or `'json'` |
|
|
800
|
+
|
|
801
|
+
materialized tables are deterministic at the same seed and do not perturb the
|
|
802
|
+
event stream. the warehouse pass runs after the user loop, so seeded noise and
|
|
803
|
+
derived columns never change generated events.
|
|
804
|
+
|
|
805
|
+
### result and manifest
|
|
806
|
+
|
|
807
|
+
```javascript
|
|
808
|
+
const result = await DUNGEON_MASTER(config);
|
|
809
|
+
|
|
810
|
+
result.warehouseMetricData.daily_new_bookings
|
|
811
|
+
result.warehouseManifest.tables
|
|
812
|
+
result.files
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
each manifest table includes:
|
|
816
|
+
|
|
817
|
+
| field | meaning |
|
|
818
|
+
|---|---|
|
|
819
|
+
| `table` | warehouse table name |
|
|
820
|
+
| `file` | file prefix without extension |
|
|
821
|
+
| `format` | `'csv'` or `'json'` |
|
|
822
|
+
| `grain` | bucket grain |
|
|
823
|
+
| `type` | additive vs point-in-time |
|
|
824
|
+
| `timeColumn` | date axis column |
|
|
825
|
+
| `valueColumn` | numeric value column |
|
|
826
|
+
| `dimensionColumns` | copied `groupBy` keys |
|
|
827
|
+
| `columns` | ordered BigQuery schema (`DATE`, `FLOAT64`, `BOOL`, `STRING`) |
|
|
828
|
+
| `recommendedAggregation` | `'sum'` or `'last value'` |
|
|
829
|
+
| `sql` | `SELECT * FROM \`{{DATASET}}.<table>\` ORDER BY <timeColumn>` |
|
|
830
|
+
| `refreshHint` | currently `'hourly'` |
|
|
831
|
+
|
|
609
832
|
## user generation
|
|
610
833
|
|
|
611
834
|
users are generated with configurable birth distributions, normally controlled via the `macro` preset (see "time shape" above). these three knobs can also be set directly on the dungeon config — they override the preset's values.
|
|
@@ -697,8 +920,11 @@ result.userProfilesData // user profiles
|
|
|
697
920
|
result.scdTableData // SCD mutations
|
|
698
921
|
result.groupProfilesData // group profiles
|
|
699
922
|
result.adSpendData // ad spend data
|
|
923
|
+
result.standaloneEventData // identity-less event snapshots
|
|
700
924
|
result.lookupTableData // lookup table entries
|
|
701
925
|
result.mirrorEventData // mirror dataset
|
|
926
|
+
result.warehouseMetricData // warehouse tables keyed by metric name
|
|
927
|
+
result.warehouseManifest // warehouse table manifest
|
|
702
928
|
|
|
703
929
|
result.eventCount // total event count
|
|
704
930
|
result.userCount // total user count
|
|
@@ -731,7 +957,7 @@ styles: `support`, `review`, `search`, `feedback`, `chat`, `email`, `forum`, `co
|
|
|
731
957
|
## scripts
|
|
732
958
|
|
|
733
959
|
```bash
|
|
734
|
-
npm test #
|
|
960
|
+
npm test # default unit/integration/e2e suite; prunes data/tmp
|
|
735
961
|
npm run typecheck # typescript check
|
|
736
962
|
npm run dungeon:run # run a dungeon file locally
|
|
737
963
|
npm run dungeon:to-json # convert JS dungeon to JSON (for UI import)
|
|
@@ -752,6 +978,8 @@ node scripts/verify-runner.mjs <path> [prefix] # generate at full fidelity for
|
|
|
752
978
|
|
|
753
979
|
## tests
|
|
754
980
|
|
|
981
|
+
1.8.1 compatibility and output changes: [upgrade guide](docs/guides/1.8.1-upgrade-guide.md).
|
|
982
|
+
|
|
755
983
|
vitest tests live under `tests/` in three tiers:
|
|
756
984
|
|
|
757
985
|
| dir | scope | wall time |
|
|
@@ -773,6 +1001,40 @@ npx vitest tests/unit # watch mode
|
|
|
773
1001
|
|
|
774
1002
|
`tests/e2e/engine-shape-full-sweep.test.js` skips itself unless `RUN_FULL_SWEEP=1` is set (it wraps the long-running 194-combo engine sweep).
|
|
775
1003
|
|
|
1004
|
+
### editor and offline alignment tests
|
|
1005
|
+
|
|
1006
|
+
VS Code discovers unit, integration, E2E, and alignment tests through one serial
|
|
1007
|
+
`vitest.editor.config.js`. The workspace disables Go test
|
|
1008
|
+
discovery and ignores the overlapping diagnostic Vitest configs. After changing
|
|
1009
|
+
these settings, run **Developer: Reload Window** if stale providers or test runs
|
|
1010
|
+
remain in the Testing panel. Editor runs omit the pruning setup, but they are not
|
|
1011
|
+
OS-sandboxed.
|
|
1012
|
+
|
|
1013
|
+
Use **Tasks: Run Test Task** for `test: regression (offline, macOS)`, or choose
|
|
1014
|
+
the named alignment and sweep tasks from **Tasks: Run Task**. These test tasks
|
|
1015
|
+
never invoke the prune or dungeon-run tasks. Existing dungeon-run cleanup is
|
|
1016
|
+
unchanged and remains separate from testing.
|
|
1017
|
+
|
|
1018
|
+
The `test: engine matrix`, `test: engine short sweep`, and `test: engine full sweep`
|
|
1019
|
+
tasks expose the direct-run engine checks. They are opt-in and use OS network denial.
|
|
1020
|
+
The full engine wrapper is visible under E2E but skipped until `RUN_FULL_SWEEP=1`.
|
|
1021
|
+
Direct-run `.mjs` scripts are not Vitest tests, so they do not get a separate
|
|
1022
|
+
`engine` folder in the Testing tree. The legacy engine sweeps do not use the
|
|
1023
|
+
alignment runner's ten-minute deadline. E2E tests may write files or perform
|
|
1024
|
+
network operations when run directly in the editor; editor execution is not an
|
|
1025
|
+
offline guarantee.
|
|
1026
|
+
|
|
1027
|
+
```sh
|
|
1028
|
+
node tests/alignment/run.mjs # offline alignment gate
|
|
1029
|
+
node tests/alignment/run.mjs --sweep --timeout-ms=600000 # opt-in bounded sweep
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
Alignment is a separate test family, excluded from `npm test` but visible in the editor.
|
|
1033
|
+
Its runner enforces OS network denial on macOS, fails closed elsewhere, and kills
|
|
1034
|
+
workers at the ten-minute deadline. Tests and reports live in the source checkout;
|
|
1035
|
+
they are not included in the npm package. The default `npm test` and direct root
|
|
1036
|
+
Vitest commands still prune `data` and `tmp` through their global setup.
|
|
1037
|
+
|
|
776
1038
|
### engine tests (direct-run, NOT vitest)
|
|
777
1039
|
|
|
778
1040
|
`tests/engine/` houses direct-run regression tests at scale. these are NOT vitest-compatible — invoke with `node` directly. used to catch engine regressions across a wide variety of dungeon configurations and for ad-hoc chart inspection. outputs land in `./tmp/` (gitignored).
|
|
@@ -899,6 +1161,8 @@ see [types.d.ts](types.d.ts) for the complete `Dungeon` interface. here are the
|
|
|
899
1161
|
| `hasLocation` | boolean | false | include geo properties |
|
|
900
1162
|
| `hasCampaigns` | boolean | false | include UTM properties |
|
|
901
1163
|
| `hasAdSpend` | boolean | false | generate ad spend data |
|
|
1164
|
+
| `standaloneEvents` | array | `[]` | identity-less cadence streams that import as events |
|
|
1165
|
+
| `warehouseMetrics` | array | `[]` | local warehouse source tables + manifest, derived from generated events |
|
|
902
1166
|
| `hasAnonIds` | boolean | false | generate anonymous IDs |
|
|
903
1167
|
| `hasSessionIds` | boolean | false | generate session IDs |
|
|
904
1168
|
| `alsoInferFunnels` | boolean | false | auto-generate funnels from events |
|