@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# 1.8.0 Upgrade Guide
|
|
2
|
+
|
|
3
|
+
**TL;DR: 1.8.0 adds two new metric-table surfaces and one CSV serialization fix.**
|
|
4
|
+
`standaloneEvents` is the identity-less event stream shipped earlier on this
|
|
5
|
+
branch. `warehouseMetrics` is new in this release: warehouse source tables plus a
|
|
6
|
+
manifest, derived from the run's own events. The one behavior change is that CSV
|
|
7
|
+
output now preserves falsy cells, so `0` and `false` stop turning into empty
|
|
8
|
+
strings.
|
|
9
|
+
|
|
10
|
+
## What changed
|
|
11
|
+
|
|
12
|
+
### 1. `standaloneEvents` is the general identity-less stream
|
|
13
|
+
|
|
14
|
+
If you already adopted the branch version of `standaloneEvents`, 1.8.0 is the
|
|
15
|
+
release that ships it. These rows describe a system, not a person. They carry no
|
|
16
|
+
`user_id` and no `device_id`, but they do import to Mixpanel as normal events.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
standaloneEvents: [{
|
|
20
|
+
event: 'cdn_egress',
|
|
21
|
+
cadence: 'day',
|
|
22
|
+
dimensions: { region: ['us-east', 'us-west', 'eu'] },
|
|
23
|
+
distinctIdFrom: 'region',
|
|
24
|
+
properties: {
|
|
25
|
+
gb_out: (ctx) => 400 + ctx.tickIndex * 3,
|
|
26
|
+
cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
|
|
27
|
+
},
|
|
28
|
+
}]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use it for infra, finance, and ops telemetry. If the thing should show up in
|
|
32
|
+
Mixpanel as an event stream, use `standaloneEvents`.
|
|
33
|
+
|
|
34
|
+
### 2. `warehouseMetrics` materializes local warehouse tables
|
|
35
|
+
|
|
36
|
+
If the thing should become a warehouse metric source table, use
|
|
37
|
+
`warehouseMetrics` instead. This pass runs after event generation, reads the
|
|
38
|
+
run's final event stream, and emits local CSV/JSON tables plus a manifest.
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
warehouseMetrics: [
|
|
42
|
+
{
|
|
43
|
+
name: 'daily_new_bookings',
|
|
44
|
+
source: { event: 'new_booking', measure: 'sum', property: 'booking_value' },
|
|
45
|
+
timeColumn: 'date',
|
|
46
|
+
valueColumn: 'bookings',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'daily_active_subscriptions',
|
|
50
|
+
type: 'point-in-time',
|
|
51
|
+
source: { event: 'subscription_started', minus: 'subscription_cancelled', measure: 'count' },
|
|
52
|
+
baseline: 40,
|
|
53
|
+
timeColumn: 'date',
|
|
54
|
+
valueColumn: 'active_subscriptions',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'monthly_arr_snapshot',
|
|
58
|
+
type: 'point-in-time',
|
|
59
|
+
grain: 'month',
|
|
60
|
+
sparse: true,
|
|
61
|
+
history: 18,
|
|
62
|
+
source: {
|
|
63
|
+
event: 'subscription_started',
|
|
64
|
+
minus: 'subscription_cancelled',
|
|
65
|
+
measure: 'sum',
|
|
66
|
+
property: 'monthly_value',
|
|
67
|
+
},
|
|
68
|
+
baseline: 24000,
|
|
69
|
+
scale: 12,
|
|
70
|
+
timeColumn: 'month',
|
|
71
|
+
valueColumn: 'arr_usd',
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Key rules:
|
|
77
|
+
|
|
78
|
+
- `type`: `'additive'` or `'point-in-time'`.
|
|
79
|
+
- `grain`: `'day'`, `'week'`, `'month'`.
|
|
80
|
+
- `source.measure`: `'count'`, `'sum'`, `'avg'`, `'dau'`, `'users'`.
|
|
81
|
+
- `sum` and `avg` require `source.property`.
|
|
82
|
+
- `point-in-time` forbids `avg` and `dau`.
|
|
83
|
+
- `source.groupBy` allows up to two declared keys.
|
|
84
|
+
- `history` prepends synthetic buckets before the live window.
|
|
85
|
+
- `sparse: true` is only valid on point-in-time metrics and emits the first row
|
|
86
|
+
plus changed values.
|
|
87
|
+
|
|
88
|
+
Artifacts:
|
|
89
|
+
|
|
90
|
+
- `result.warehouseMetricData`
|
|
91
|
+
- `result.warehouseManifest`
|
|
92
|
+
- `<name>-WAREHOUSE-<table>.csv|json`
|
|
93
|
+
- `<name>-WAREHOUSE-MANIFEST.json`
|
|
94
|
+
|
|
95
|
+
`token` does not import these tables. They stay local until you deploy them.
|
|
96
|
+
|
|
97
|
+
### 3. Warehouse deploy is a separate, confirm-before-live step
|
|
98
|
+
|
|
99
|
+
The shipped path is `/warehouse-metrics` or:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
node .claude/skills/warehouse-metrics/deploy.mjs <dungeon-path> --data-prefix <verified-prefix> --dry-run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Review the SQL files and `warehouse/GAPS.md` first. Live mode uses
|
|
106
|
+
`bq load --replace`, so it overwrites destination tables. The direct live script
|
|
107
|
+
does not prompt, so the operator or agent must obtain explicit consent before
|
|
108
|
+
running the live command.
|
|
109
|
+
|
|
110
|
+
If the warehouse metric CRUD docs route is unavailable (`GET
|
|
111
|
+
/crud/getWarehouseMetrics` returns 404), deploy still loads the tables and
|
|
112
|
+
connects the source, then writes `warehouse/GAPS.md` for the manual create flow.
|
|
113
|
+
|
|
114
|
+
Two real gotchas are now documented in the shipped flow:
|
|
115
|
+
|
|
116
|
+
- Manifest `recommendedAggregation: 'last value'` becomes API
|
|
117
|
+
`aggregation: 'last_value'`.
|
|
118
|
+
- `previewWarehouseMetric` blocks raw substrings like `CREATE` and `UPDATE`, so
|
|
119
|
+
identifiers like `created_at` and `updated_at` fail preview. A fake alias does
|
|
120
|
+
not help if the blocked text still appears anywhere in the SQL.
|
|
121
|
+
|
|
122
|
+
### 4. CSV falsy cells stop collapsing to empty strings
|
|
123
|
+
|
|
124
|
+
Before 1.8.0, CSV serialization wrote `0` and `false` as blank cells. That made
|
|
125
|
+
some downstream previews and ad hoc SQL look fine until a real warehouse load or
|
|
126
|
+
verification run compared them to JSON or in-memory output.
|
|
127
|
+
|
|
128
|
+
Now:
|
|
129
|
+
|
|
130
|
+
- `0` stays `0`
|
|
131
|
+
- `false` stays `false`
|
|
132
|
+
- only missing values stay empty
|
|
133
|
+
|
|
134
|
+
If you have downstream logic that treated `''` as a stand-in for zero or false,
|
|
135
|
+
fix that logic.
|
|
136
|
+
|
|
137
|
+
## Migration checklist
|
|
138
|
+
|
|
139
|
+
1. Keep `standaloneEvents` when you want Mixpanel events without a person.
|
|
140
|
+
2. Add `warehouseMetrics` when you want local warehouse tables and a manifest.
|
|
141
|
+
3. Run the dungeon once before deploy so the warehouse artifacts exist.
|
|
142
|
+
4. Use `/warehouse-metrics` in dry-run mode, then obtain explicit operator
|
|
143
|
+
consent before the live `bq load --replace` step.
|
|
144
|
+
5. Recheck any CSV consumers that depended on blank falsy cells.
|
|
145
|
+
|
|
146
|
+
## Notes on row counts
|
|
147
|
+
|
|
148
|
+
The shipped warehouse fixture is a good shape reference, not a row-count
|
|
149
|
+
contract. It uses a 60-day live window and `history: 18` on the monthly ARR
|
|
150
|
+
table. `grain`, `history`, `sparse`, and `groupBy` all change row counts, so
|
|
151
|
+
sample numbers in review output are illustrative.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# 1.8.1 Upgrade Guide
|
|
2
|
+
|
|
3
|
+
1.8.1 repairs funnel counting, lifecycle timing, and emitted identity. Existing
|
|
4
|
+
configurations and call forms remain supported. No property renames or required
|
|
5
|
+
configuration changes are introduced. `applySessionShape` adds optional dataset
|
|
6
|
+
bounds; its existing unbounded calls retain their behavior.
|
|
7
|
+
|
|
8
|
+
Generated timestamps, identities, and report counts can change from 1.8.0 because
|
|
9
|
+
the previous output contained defects. Recheck saved story expectations rather
|
|
10
|
+
than expecting byte-identical output across versions.
|
|
11
|
+
|
|
12
|
+
## Update the dependency
|
|
13
|
+
|
|
14
|
+
After 1.8.1 is published:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install @ak--47/dungeon-master@1.8.1
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No additional runtime dependency is required. The package still requires Node.js
|
|
21
|
+
20.20.0 or newer. This guide does not indicate that npm publication has occurred.
|
|
22
|
+
|
|
23
|
+
## Funnel counts can change
|
|
24
|
+
|
|
25
|
+
- An ordered funnel whose first and last steps match now finalizes its conversion
|
|
26
|
+
correctly. With reentry enabled, the closing event can also start the next
|
|
27
|
+
attempt. Without reentry, it still completes and does not create another attempt.
|
|
28
|
+
- Ordinary completed histories consume events through the inclusive two-second
|
|
29
|
+
completion grace period. Those events do not become a second attempt. The
|
|
30
|
+
existing `graceperiod: false` option disables that wait.
|
|
31
|
+
- Hold-property-constant session windows use session boundaries from the full
|
|
32
|
+
stream before property filtering. Unrelated activity can keep a session open.
|
|
33
|
+
- First/last-touch segmentation merges properties across reached steps. A missing
|
|
34
|
+
property on the preferred step can fall back to another reached step. Explicit
|
|
35
|
+
step selection remains separate.
|
|
36
|
+
|
|
37
|
+
`countMode: 'totals'` still defaults to `reentry: false`. Specify `reentry: true`
|
|
38
|
+
when repeated histories are intended. This compatibility default differs from
|
|
39
|
+
Mixpanel general totals. Do not change existing report options implicitly when
|
|
40
|
+
comparing old and new results.
|
|
41
|
+
|
|
42
|
+
## Lifecycle and identity output is corrected
|
|
43
|
+
|
|
44
|
+
Retention entry follows adjusted creation, retries precede the final onboarding
|
|
45
|
+
attempt, and usage follows onboarding completion. The engine reconciles its own
|
|
46
|
+
identity fields against surviving auth events after filtering. Engine-created
|
|
47
|
+
data-quality duplicates and world-event clones preserve identity provenance.
|
|
48
|
+
|
|
49
|
+
Explicit hook identity overrides and the synthetic experiment identity exception
|
|
50
|
+
remain supported. A later valid event carrying both IDs can provide identity
|
|
51
|
+
mapping evidence; this is not restricted to the first funnel.
|
|
52
|
+
|
|
53
|
+
Short windows retain partial output instead of introducing a required exception.
|
|
54
|
+
Inspect the existing `result.warnings` collection for:
|
|
55
|
+
|
|
56
|
+
| warning key | meaning |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `lifecycle.firstFunnelClipped` | Configured timing cannot fit before dataset end; future steps are omitted without compressing TTC. |
|
|
59
|
+
| `lifecycle.emptyPreAuthAttempt` | Auth is the first step, so a failed pre-auth attempt emits no rows. |
|
|
60
|
+
| `lifecycle.strictAttemptBudget` | The strict event budget cannot retain every surviving attempt entry. |
|
|
61
|
+
|
|
62
|
+
Configured suppression and hook filtering remain authoritative. The engine does
|
|
63
|
+
not recreate intentionally removed events to satisfy an attempt count.
|
|
64
|
+
|
|
65
|
+
## Bound session reshaping when the dataset end matters
|
|
66
|
+
|
|
67
|
+
Calls without bounds retain legacy full-UTC-day placement. They cannot infer the
|
|
68
|
+
dataset end from the last observed event and may still move events beyond it.
|
|
69
|
+
Pass known bounds to prevent that clipping:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { applySessionShape } from '@ak--47/dungeon-master/hook-helpers';
|
|
73
|
+
|
|
74
|
+
const hook = (records, type, meta) => {
|
|
75
|
+
if (type === 'everything') {
|
|
76
|
+
applySessionShape(records, meta.profile.distinct_id, {
|
|
77
|
+
sessionsPerWeek: 3,
|
|
78
|
+
eventsPerSession: 5,
|
|
79
|
+
sessionMinutes: 10,
|
|
80
|
+
datasetStart: meta.datasetStart,
|
|
81
|
+
datasetEnd: meta.datasetEnd,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return records;
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Bounds accept ISO strings, Unix seconds, or Unix milliseconds. Either explicit
|
|
89
|
+
bound enables constrained placement; an omitted side uses the original UTC day
|
|
90
|
+
edge. Invalid bounds or insufficient per-week session capacity throw `RangeError`
|
|
91
|
+
before records are mutated. Two same-day sessions separated by more than thirty
|
|
92
|
+
minutes cannot fit in a twenty-minute interval. Adjust the requested cadence or
|
|
93
|
+
available window rather than swallowing that error.
|
|
94
|
+
|
|
95
|
+
The helper retimes the same records. It does not add or drop events. Unbounded
|
|
96
|
+
legacy requests remain nonthrowing, including overfull layouts where derived
|
|
97
|
+
sessions can merge.
|
|
98
|
+
|
|
99
|
+
## Path injection remains append-only
|
|
100
|
+
|
|
101
|
+
`applyPathBias` selects the earliest chronological anchor and leaves original
|
|
102
|
+
traffic intact. `share: 1` selects all eligible users for injection. Competing
|
|
103
|
+
events can still interrupt the immediate branch, so it does not promise a 100%
|
|
104
|
+
Flows branch share. Recheck the observed branch, not only the injected count.
|
|
105
|
+
|
|
106
|
+
## Revalidate the story, not just the schema
|
|
107
|
+
|
|
108
|
+
Run a pinned, seeded representative dungeon with ordinary standalone traffic and
|
|
109
|
+
competing funnels. Compare counts using explicit report settings. For deterministic
|
|
110
|
+
same-version comparisons, use `concurrency: 1` and strip only `insert_id`.
|
|
111
|
+
|
|
112
|
+
The new alignment checks establish selected source-derived Mixpanel contracts.
|
|
113
|
+
They do not execute Mixpanel's engine. Timezone/DST variants, list-valued HPC,
|
|
114
|
+
project-specific session exclusions, all parameter combinations, and arbitrary
|
|
115
|
+
hooks are not covered by a universal parity claim.
|
|
116
|
+
|
|
117
|
+
The recorded sweep produced 17.08 million events across 594 dungeons within ten
|
|
118
|
+
minutes. Its largest single dungeon had 281,751 events. Of 297 cells, 125 met their
|
|
119
|
+
evidence criteria and 172 had insufficient eligible populations. Increasing total
|
|
120
|
+
events is not a substitute for enough eligible users or converters.
|
|
121
|
+
|
|
122
|
+
## Repository test workflows
|
|
123
|
+
|
|
124
|
+
These commands require a repository checkout with development dependencies.
|
|
125
|
+
The npm package ships this guide, but does not ship the test suites.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
node tests/alignment/run.mjs
|
|
129
|
+
node tests/alignment/run.mjs --sweep --timeout-ms=600000
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Both commands use macOS OS-level network denial and a hard maximum of ten minutes.
|
|
133
|
+
They fail closed on unsupported platforms. The editor's Testing panel is a separate
|
|
134
|
+
workflow: one serial editor config discovers unit, integration, E2E, and alignment
|
|
135
|
+
tests without global pruning. Direct-run engine scripts have opt-in tasks; their
|
|
136
|
+
full-sweep E2E wrapper is visible but skipped by default. Editor runs do not provide
|
|
137
|
+
an OS network sandbox. Use the named offline test tasks when network denial is
|
|
138
|
+
required, especially for E2E tests that can perform external operations.
|
|
139
|
+
|
|
140
|
+
`npm test` retains the default unit/integration/E2E suite and excludes alignment.
|
|
141
|
+
Its global setup prunes local `data` and `tmp`; preserve artifacts before using it.
|
|
142
|
+
The explicit `prune` task and default dungeon-run task retain their existing cleanup
|
|
143
|
+
behavior. None of the new offline test tasks depend on them.
|
|
144
|
+
|
|
145
|
+
## Before publishing
|
|
146
|
+
|
|
147
|
+
Confirm package version and changelog are 1.8.1. Inspect `npm pack --dry-run --json`
|
|
148
|
+
and rerun the relevant release checks. Publishing to npm is a separate operator
|
|
149
|
+
action; neither test execution nor a Git commit publishes the package.
|
|
150
|
+
|
|
151
|
+
See the [changelog](../../CHANGELOG.md), [hook reference](../../HOOKS.md), and
|
|
152
|
+
[1.8.0 guide](1.8.0-upgrade-guide.md) for the unchanged standalone and warehouse
|
|
153
|
+
metric APIs.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
/** @typedef {import('../../types').Dungeon} Config */
|
|
3
|
+
|
|
4
|
+
// ── OVERVIEW ──
|
|
5
|
+
/*
|
|
6
|
+
* NAME: warehouse
|
|
7
|
+
* PURPOSE: Minimal warehouse-metrics fixture covering the three canonical table shapes.
|
|
8
|
+
* SCALE: 200 users, 120 events, 60 days
|
|
9
|
+
* EVENTS (4): page_view (14) > subscription_started (2) > new_booking (1) > subscription_cancelled (1)
|
|
10
|
+
* FUNNELS: none
|
|
11
|
+
* USER PROPS: none
|
|
12
|
+
* SUPER PROPS: none
|
|
13
|
+
* GROUPS: none
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── SCALE ──
|
|
17
|
+
const SEED = 'warehouse-fixture';
|
|
18
|
+
const DATASET_START = '2025-01-01T00:00:00Z';
|
|
19
|
+
const DATASET_END = '2025-03-01T23:59:59Z';
|
|
20
|
+
|
|
21
|
+
// ── CONFIG ──
|
|
22
|
+
/** @type {Config} */
|
|
23
|
+
const config = {
|
|
24
|
+
name: 'warehouse',
|
|
25
|
+
seed: SEED,
|
|
26
|
+
datasetStart: DATASET_START,
|
|
27
|
+
datasetEnd: DATASET_END,
|
|
28
|
+
numUsers: 200,
|
|
29
|
+
numEvents: 120,
|
|
30
|
+
format: 'csv',
|
|
31
|
+
writeToDisk: false,
|
|
32
|
+
verbose: false,
|
|
33
|
+
concurrency: 1,
|
|
34
|
+
credentials: {
|
|
35
|
+
token: '',
|
|
36
|
+
region: 'US',
|
|
37
|
+
},
|
|
38
|
+
switches: {
|
|
39
|
+
hasSessionIds: false,
|
|
40
|
+
hasAdSpend: false,
|
|
41
|
+
hasLocation: false,
|
|
42
|
+
hasAndroidDevices: false,
|
|
43
|
+
hasIOSDevices: false,
|
|
44
|
+
hasDesktopDevices: false,
|
|
45
|
+
hasBrowser: false,
|
|
46
|
+
hasCampaigns: false,
|
|
47
|
+
isAnonymous: false,
|
|
48
|
+
alsoInferFunnels: false,
|
|
49
|
+
},
|
|
50
|
+
events: [
|
|
51
|
+
{
|
|
52
|
+
event: 'page_view',
|
|
53
|
+
weight: 14,
|
|
54
|
+
isStrictEvent: false,
|
|
55
|
+
properties: {
|
|
56
|
+
page: ['/', '/pricing', '/reports', '/billing'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
event: 'new_booking',
|
|
61
|
+
weight: 1,
|
|
62
|
+
isStrictEvent: false,
|
|
63
|
+
properties: {
|
|
64
|
+
booking_value: [1200, 1800, 2400, 3600],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
event: 'subscription_started',
|
|
69
|
+
weight: 2,
|
|
70
|
+
isStrictEvent: false,
|
|
71
|
+
properties: {
|
|
72
|
+
monthly_value: [100, 250, 500],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
event: 'subscription_cancelled',
|
|
77
|
+
weight: 1,
|
|
78
|
+
isStrictEvent: false,
|
|
79
|
+
properties: {
|
|
80
|
+
monthly_value: [100, 250, 500],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
warehouseMetrics: [
|
|
85
|
+
{
|
|
86
|
+
name: 'daily_new_bookings',
|
|
87
|
+
type: 'additive',
|
|
88
|
+
grain: 'day',
|
|
89
|
+
source: {
|
|
90
|
+
event: 'new_booking',
|
|
91
|
+
measure: 'sum',
|
|
92
|
+
property: 'booking_value',
|
|
93
|
+
},
|
|
94
|
+
timeColumn: 'date',
|
|
95
|
+
valueColumn: 'bookings',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'daily_active_subscriptions',
|
|
99
|
+
type: 'point-in-time',
|
|
100
|
+
grain: 'day',
|
|
101
|
+
source: {
|
|
102
|
+
event: 'subscription_started',
|
|
103
|
+
minus: 'subscription_cancelled',
|
|
104
|
+
measure: 'count',
|
|
105
|
+
},
|
|
106
|
+
baseline: 40,
|
|
107
|
+
timeColumn: 'date',
|
|
108
|
+
valueColumn: 'active_subscriptions',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'monthly_arr_snapshot',
|
|
112
|
+
type: 'point-in-time',
|
|
113
|
+
grain: 'month',
|
|
114
|
+
sparse: true,
|
|
115
|
+
history: 18,
|
|
116
|
+
source: {
|
|
117
|
+
event: 'subscription_started',
|
|
118
|
+
minus: 'subscription_cancelled',
|
|
119
|
+
measure: 'sum',
|
|
120
|
+
property: 'monthly_value',
|
|
121
|
+
},
|
|
122
|
+
baseline: 24000,
|
|
123
|
+
scale: 12,
|
|
124
|
+
timeColumn: 'month',
|
|
125
|
+
valueColumn: 'arr_usd',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export default config;
|
|
131
|
+
|
|
132
|
+
export const stories = [
|
|
133
|
+
{
|
|
134
|
+
id: 'H1-bookings-corr',
|
|
135
|
+
hook: 'H1',
|
|
136
|
+
archetype: 'temporal-inflection',
|
|
137
|
+
narrative: 'The additive warehouse bookings table should track the generated booking revenue closely enough for a warehouse metric demo.',
|
|
138
|
+
assertions: [
|
|
139
|
+
{
|
|
140
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_new_bookings' },
|
|
141
|
+
select: { s: { where: {} } },
|
|
142
|
+
expect: { metric: 's.corr', op: '>=', target: 0.9, floor: 0.7 },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'H2-active-subs-shape',
|
|
148
|
+
hook: 'H2',
|
|
149
|
+
archetype: 'session-shape',
|
|
150
|
+
narrative: 'The dense active subscription snapshot should stay fully ordered, gap-free, and numerically populated across the full dataset window.',
|
|
151
|
+
assertions: [
|
|
152
|
+
{
|
|
153
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
|
|
154
|
+
select: { s: { where: {} } },
|
|
155
|
+
expect: { metric: 's.gaps', op: '<=', target: 0 },
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
|
|
159
|
+
select: { s: { where: {} } },
|
|
160
|
+
expect: { metric: 's.emptyNumericCells', op: '<=', target: 0 },
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
|
|
164
|
+
select: { s: { where: {} } },
|
|
165
|
+
expect: { metric: 's.nonMonotonicTime', op: '<=', target: 0 },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'H3-arr-history',
|
|
171
|
+
hook: 'H3',
|
|
172
|
+
archetype: 'composition-drift',
|
|
173
|
+
narrative: 'The sparse ARR snapshot should carry meaningful monthly history before the event window without a large seam jump into the live months.',
|
|
174
|
+
assertions: [
|
|
175
|
+
{
|
|
176
|
+
breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
|
|
177
|
+
select: { s: { where: {} } },
|
|
178
|
+
expect: { metric: 's.buckets', op: '>=', target: 18 },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
|
|
182
|
+
select: { s: { where: {} } },
|
|
183
|
+
expect: { metric: 's.seamJumpPct', op: '<=', target: 30 },
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
];
|
package/index.js
CHANGED
|
@@ -23,14 +23,18 @@ import { userLoop } from './lib/orchestrators/user-loop.js';
|
|
|
23
23
|
import { sendToMixpanel, collectWrittenFiles, releaseConnections } from './lib/orchestrators/mixpanel-sender.js';
|
|
24
24
|
// Generators
|
|
25
25
|
import { makeAdSpend } from './lib/generators/adspend.js';
|
|
26
|
+
import { makeStandaloneEvents } from './lib/generators/standalone.js';
|
|
26
27
|
import { makeMirror } from './lib/generators/mirror.js';
|
|
27
28
|
import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
|
|
29
|
+
import { WarehouseAccumulator, materializeWarehouseMetrics, buildManifest } from './lib/generators/warehouse.js';
|
|
28
30
|
|
|
29
31
|
// Utilities
|
|
30
|
-
import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
32
|
+
import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile, getChance } from './lib/utils/utils.js';
|
|
31
33
|
import { runWithDataset } from './lib/utils/dataset-context.js';
|
|
32
34
|
|
|
33
35
|
// External dependencies
|
|
36
|
+
import { writeFile } from 'node:fs/promises';
|
|
37
|
+
import path from 'node:path';
|
|
34
38
|
import dayjs from "dayjs";
|
|
35
39
|
import utc from "dayjs/plugin/utc.js";
|
|
36
40
|
import { timer } from 'ak-tools';
|
|
@@ -184,6 +188,13 @@ async function runDungeon(config) {
|
|
|
184
188
|
storage = await storageManager.initializeContainers();
|
|
185
189
|
updateContextWithStorage(context, storage);
|
|
186
190
|
|
|
191
|
+
if (validatedConfig.warehouseMetrics?.length > 0) {
|
|
192
|
+
context.warehouseAccumulator = new WarehouseAccumulator(validatedConfig.warehouseMetrics, {
|
|
193
|
+
FIXED_BEGIN: context.FIXED_BEGIN,
|
|
194
|
+
FIXED_NOW: context.FIXED_NOW,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
187
198
|
// ! DATA GENERATION STARTS HERE
|
|
188
199
|
|
|
189
200
|
// Step 4: Generate ad spend data (if enabled)
|
|
@@ -194,6 +205,14 @@ async function runDungeon(config) {
|
|
|
194
205
|
context.reportProgress({ phase: "step", step: "adspend", status: "complete", duration: Date.now() - _t4 });
|
|
195
206
|
}
|
|
196
207
|
|
|
208
|
+
// Step 4b: Generate standalone identity-less metric snapshots (if configured) — v1.8.0
|
|
209
|
+
if (validatedConfig.standaloneEvents?.length > 0) {
|
|
210
|
+
context.reportProgress({ phase: "step", step: "standalone", status: "start" });
|
|
211
|
+
const _t4b = Date.now();
|
|
212
|
+
await generateStandaloneData(context);
|
|
213
|
+
context.reportProgress({ phase: "step", step: "standalone", status: "complete", duration: Date.now() - _t4b });
|
|
214
|
+
}
|
|
215
|
+
|
|
197
216
|
if (context.config.verbose) logger.info('Starting user and event generation...');
|
|
198
217
|
// Step 5: Main user and event generation
|
|
199
218
|
context.reportProgress({ phase: "step", step: "users", status: "start" });
|
|
@@ -233,6 +252,13 @@ async function runDungeon(config) {
|
|
|
233
252
|
context.reportProgress({ phase: "step", step: "mirrors", status: "complete", duration: Date.now() - _t9 });
|
|
234
253
|
}
|
|
235
254
|
|
|
255
|
+
if (validatedConfig.warehouseMetrics?.length > 0) {
|
|
256
|
+
context.reportProgress({ phase: "step", step: "warehouse", status: "start" });
|
|
257
|
+
const _t9b = Date.now();
|
|
258
|
+
await generateWarehouseData(context);
|
|
259
|
+
context.reportProgress({ phase: "step", step: "warehouse", status: "complete", duration: Date.now() - _t9b });
|
|
260
|
+
}
|
|
261
|
+
|
|
236
262
|
if (context.config.verbose) logger.info('Data generation completed successfully');
|
|
237
263
|
|
|
238
264
|
// ! DATA GENERATION ENDS HERE
|
|
@@ -293,11 +319,17 @@ async function runDungeon(config) {
|
|
|
293
319
|
// users matching no funnel, …). Always present, even when empty.
|
|
294
320
|
const warnings = [
|
|
295
321
|
...(Array.isArray(validatedConfig._warnings) ? validatedConfig._warnings : []),
|
|
322
|
+
...(Array.isArray(context.warehouseAccumulator?.warnings) ? context.warehouseAccumulator.warnings.map((reason) => ({
|
|
323
|
+
key: 'warehouseMetrics',
|
|
324
|
+
reason,
|
|
325
|
+
severity: 'warn',
|
|
326
|
+
})) : []),
|
|
296
327
|
...context.getWarnings(),
|
|
297
328
|
];
|
|
298
329
|
|
|
299
330
|
return {
|
|
300
331
|
...extractedData,
|
|
332
|
+
warehouseManifest: context.warehouseManifest,
|
|
301
333
|
importResults,
|
|
302
334
|
warnings,
|
|
303
335
|
files: extractFileInfo(storage),
|
|
@@ -356,6 +388,79 @@ async function generateAdSpendData(context) {
|
|
|
356
388
|
}
|
|
357
389
|
}
|
|
358
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Generate standalone identity-less metric snapshots — v1.8.0.
|
|
393
|
+
*
|
|
394
|
+
* One record per cadence tick per dimension cross-product row. Records carry no
|
|
395
|
+
* `user_id` and no `device_id`; they describe a system, not a person.
|
|
396
|
+
*
|
|
397
|
+
* @param {Context} context - Context object
|
|
398
|
+
*/
|
|
399
|
+
async function generateStandaloneData(context) {
|
|
400
|
+
const { config, storage } = context;
|
|
401
|
+
const specs = /** @type {import('./types').ResolvedStandaloneEventConfig[]} */ (config.standaloneEvents);
|
|
402
|
+
|
|
403
|
+
for (const spec of specs) {
|
|
404
|
+
const records = makeStandaloneEvents(context, spec);
|
|
405
|
+
for (const record of records) {
|
|
406
|
+
// The `standalone` hook fires on push, like ad-spend. Meta carries the
|
|
407
|
+
// stream's resolved spec so a hook can tell the streams apart.
|
|
408
|
+
// `datasetStart`/`datasetEnd` are added by hookPush itself.
|
|
409
|
+
await storage.standaloneEventData.hookPush(
|
|
410
|
+
/** @type {import('./types').EventSchema} */ (record),
|
|
411
|
+
{ spec, config }
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Materialize configured warehouse metric tables after the user loop completes.
|
|
419
|
+
*
|
|
420
|
+
* The accumulator taps the final per-user event stream during Step 5. This step
|
|
421
|
+
* runs afterward so seeded noise and column callbacks cannot perturb event generation.
|
|
422
|
+
*
|
|
423
|
+
* @param {Context} context - Context object
|
|
424
|
+
*/
|
|
425
|
+
async function generateWarehouseData(context) {
|
|
426
|
+
const { config, storage } = context;
|
|
427
|
+
const specs = /** @type {import('./types').ResolvedWarehouseMetricConfig[]} */ (config.warehouseMetrics);
|
|
428
|
+
const accumulator = context.warehouseAccumulator;
|
|
429
|
+
if (!Array.isArray(specs) || specs.length === 0 || !accumulator) return;
|
|
430
|
+
|
|
431
|
+
const materialized = materializeWarehouseMetrics({
|
|
432
|
+
specs,
|
|
433
|
+
accumulator,
|
|
434
|
+
chance: getChance(),
|
|
435
|
+
FIXED_BEGIN: context.FIXED_BEGIN,
|
|
436
|
+
FIXED_NOW: context.FIXED_NOW,
|
|
437
|
+
configName: config.name,
|
|
438
|
+
config,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
for (let index = 0; index < materialized.length; index += 1) {
|
|
442
|
+
const entry = materialized[index];
|
|
443
|
+
const container = storage.warehouseMetricData?.[index];
|
|
444
|
+
if (!container) continue;
|
|
445
|
+
|
|
446
|
+
for (let rowIndex = 0; rowIndex < entry.rows.length; rowIndex += 1) {
|
|
447
|
+
await container.hookPush(entry.rows[rowIndex], entry.metas[rowIndex]);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const postHookMaterialized = specs.map((spec, index) => ({
|
|
452
|
+
spec,
|
|
453
|
+
rows: Array.from(storage.warehouseMetricData?.[index] || []),
|
|
454
|
+
}));
|
|
455
|
+
context.warehouseManifest = buildManifest(specs, postHookMaterialized, config.name);
|
|
456
|
+
|
|
457
|
+
if (config.writeToDisk && storage.warehouseMetricData?.[0]?.getWriteDir) {
|
|
458
|
+
const manifestPath = path.join(storage.warehouseMetricData[0].getWriteDir(), `${config.name}-WAREHOUSE-MANIFEST.json`);
|
|
459
|
+
await writeFile(manifestPath, JSON.stringify(context.warehouseManifest, null, 2));
|
|
460
|
+
storage.warehouseManifestFile = manifestPath;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
359
464
|
/**
|
|
360
465
|
* Generate group profiles for all configured group keys
|
|
361
466
|
* @param {Context} context - Context object
|
|
@@ -567,11 +672,12 @@ async function flushStorageToDisk(storage, config) {
|
|
|
567
672
|
if (storage.eventData?.flush) flushPromises.push(storage.eventData.flush());
|
|
568
673
|
if (storage.userProfilesData?.flush) flushPromises.push(storage.userProfilesData.flush());
|
|
569
674
|
if (storage.adSpendData?.flush) flushPromises.push(storage.adSpendData.flush());
|
|
675
|
+
if (storage.standaloneEventData?.flush) flushPromises.push(storage.standaloneEventData.flush());
|
|
570
676
|
if (storage.mirrorEventData?.flush) flushPromises.push(storage.mirrorEventData.flush());
|
|
571
677
|
if (storage.groupEventData?.flush) flushPromises.push(storage.groupEventData.flush());
|
|
572
678
|
|
|
573
679
|
// Flush arrays of HookedArrays (excluding lookup tables which are handled separately)
|
|
574
|
-
[storage.scdTableData, storage.groupProfilesData].forEach(arrayOfContainers => {
|
|
680
|
+
[storage.scdTableData, storage.groupProfilesData, storage.warehouseMetricData].forEach(arrayOfContainers => {
|
|
575
681
|
if (Array.isArray(arrayOfContainers)) {
|
|
576
682
|
arrayOfContainers.forEach(container => {
|
|
577
683
|
if (container?.flush) flushPromises.push(container.flush());
|
|
@@ -635,11 +741,19 @@ function countProfilesPushed(profilesContainer) {
|
|
|
635
741
|
* @returns {object} Extracted data in Result format
|
|
636
742
|
*/
|
|
637
743
|
function extractStorageData(storage) {
|
|
744
|
+
const warehouseMetricData = {};
|
|
745
|
+
for (const container of storage.warehouseMetricData || []) {
|
|
746
|
+
if (!container?.metricName) continue;
|
|
747
|
+
warehouseMetricData[container.metricName] = Array.from(container);
|
|
748
|
+
}
|
|
749
|
+
|
|
638
750
|
return {
|
|
639
751
|
eventData: storage.eventData || [],
|
|
640
752
|
mirrorEventData: storage.mirrorEventData || [],
|
|
641
753
|
userProfilesData: storage.userProfilesData || [],
|
|
642
754
|
adSpendData: storage.adSpendData || [],
|
|
755
|
+
standaloneEventData: storage.standaloneEventData || [],
|
|
756
|
+
warehouseMetricData,
|
|
643
757
|
// Keep arrays of HookedArrays as separate arrays (don't flatten)
|
|
644
758
|
scdTableData: storage.scdTableData || [],
|
|
645
759
|
groupProfilesData: storage.groupProfilesData || [],
|