@ak--47/dungeon-master 1.6.5 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/analyze-soup/SKILL.md +21 -11
- package/.claude/skills/create-dungeon/SKILL.md +49 -10
- package/.claude/skills/create-project/SKILL.md +22 -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 +18 -1
- package/.claude/skills/powertools/SKILL.md +20 -1
- package/.claude/skills/release-check/SKILL.md +99 -0
- package/.claude/skills/verify-dungeon/SKILL.md +71 -16
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
- package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
- package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
- package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
- package/.claude/skills/write-hooks/SKILL.md +33 -3
- package/CHANGELOG.md +331 -0
- package/HOOKS.md +154 -5
- package/README.md +357 -8
- package/docs/guides/1.7.0-upgrade-guide.md +154 -0
- package/docs/guides/1.8.0-upgrade-guide.md +151 -0
- package/dungeons/technical/warehouse.js +187 -0
- package/index.js +131 -2
- package/lib/core/config-validator.js +264 -13
- package/lib/core/context.js +39 -0
- package/lib/core/dungeon-loader.js +5 -2
- package/lib/core/storage.js +51 -3
- package/lib/generators/events.js +53 -7
- package/lib/generators/funnels.js +85 -11
- package/lib/generators/profiles.js +9 -4
- package/lib/generators/standalone.js +248 -0
- package/lib/generators/warehouse.js +828 -0
- package/lib/orchestrators/mixpanel-sender.js +39 -3
- package/lib/orchestrators/user-loop.js +240 -9
- package/lib/templates/story-spec.schema.json +41 -16
- package/lib/utils/conditions.js +62 -0
- package/lib/utils/json-evaluator.js +12 -2
- package/lib/utils/utils.js +115 -19
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +8 -0
- package/lib/verify/story-runner.js +71 -8
- package/lib/verify/warehouse.js +683 -0
- package/package.json +5 -11
- package/scripts/verify-stories.mjs +150 -44
- package/types.d.ts +606 -38
|
@@ -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,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, 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';
|
|
@@ -150,6 +154,12 @@ async function runDungeon(config) {
|
|
|
150
154
|
// Step 1: Validate and enrich configuration (resolves dataset window)
|
|
151
155
|
validatedConfig = validateDungeonConfig(config);
|
|
152
156
|
|
|
157
|
+
// v1.7.0 (P2-1): `autoPowerLaw: false` turns off the implicit 45/25/15 draw on
|
|
158
|
+
// 3–19-item string arrays for this run. Module-level flag, like the seeded
|
|
159
|
+
// chance instance — `choose()` has no config access. resetValueCaches()
|
|
160
|
+
// above already restored the default (true) for this run.
|
|
161
|
+
setAutoPowerLaw(validatedConfig.autoPowerLaw !== false);
|
|
162
|
+
|
|
153
163
|
// validateDungeonConfig always resolves these to unix seconds, but the
|
|
154
164
|
// public Dungeon type accepts string | number on input. Narrow here.
|
|
155
165
|
const fixedNow = /** @type {number} */ (validatedConfig.datasetEnd);
|
|
@@ -178,6 +188,13 @@ async function runDungeon(config) {
|
|
|
178
188
|
storage = await storageManager.initializeContainers();
|
|
179
189
|
updateContextWithStorage(context, storage);
|
|
180
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
|
+
|
|
181
198
|
// ! DATA GENERATION STARTS HERE
|
|
182
199
|
|
|
183
200
|
// Step 4: Generate ad spend data (if enabled)
|
|
@@ -188,6 +205,14 @@ async function runDungeon(config) {
|
|
|
188
205
|
context.reportProgress({ phase: "step", step: "adspend", status: "complete", duration: Date.now() - _t4 });
|
|
189
206
|
}
|
|
190
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
|
+
|
|
191
216
|
if (context.config.verbose) logger.info('Starting user and event generation...');
|
|
192
217
|
// Step 5: Main user and event generation
|
|
193
218
|
context.reportProgress({ phase: "step", step: "users", status: "start" });
|
|
@@ -227,6 +252,13 @@ async function runDungeon(config) {
|
|
|
227
252
|
context.reportProgress({ phase: "step", step: "mirrors", status: "complete", duration: Date.now() - _t9 });
|
|
228
253
|
}
|
|
229
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
|
+
|
|
230
262
|
if (context.config.verbose) logger.info('Data generation completed successfully');
|
|
231
263
|
|
|
232
264
|
// ! DATA GENERATION ENDS HERE
|
|
@@ -282,9 +314,24 @@ async function runDungeon(config) {
|
|
|
282
314
|
// population for downstream tools.
|
|
283
315
|
const profilesPushed = countProfilesPushed(storage.userProfilesData);
|
|
284
316
|
|
|
317
|
+
// v1.7.0 (P2-2): every value the engine changed or flagged, validator
|
|
318
|
+
// clamps first, then aggregated runtime warnings (conversionRate saturation,
|
|
319
|
+
// users matching no funnel, …). Always present, even when empty.
|
|
320
|
+
const warnings = [
|
|
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
|
+
})) : []),
|
|
327
|
+
...context.getWarnings(),
|
|
328
|
+
];
|
|
329
|
+
|
|
285
330
|
return {
|
|
286
331
|
...extractedData,
|
|
332
|
+
warehouseManifest: context.warehouseManifest,
|
|
287
333
|
importResults,
|
|
334
|
+
warnings,
|
|
288
335
|
files: extractFileInfo(storage),
|
|
289
336
|
time: { start, end, delta, human },
|
|
290
337
|
operations: context.getOperations(),
|
|
@@ -341,6 +388,79 @@ async function generateAdSpendData(context) {
|
|
|
341
388
|
}
|
|
342
389
|
}
|
|
343
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
|
+
|
|
344
464
|
/**
|
|
345
465
|
* Generate group profiles for all configured group keys
|
|
346
466
|
* @param {Context} context - Context object
|
|
@@ -552,11 +672,12 @@ async function flushStorageToDisk(storage, config) {
|
|
|
552
672
|
if (storage.eventData?.flush) flushPromises.push(storage.eventData.flush());
|
|
553
673
|
if (storage.userProfilesData?.flush) flushPromises.push(storage.userProfilesData.flush());
|
|
554
674
|
if (storage.adSpendData?.flush) flushPromises.push(storage.adSpendData.flush());
|
|
675
|
+
if (storage.standaloneEventData?.flush) flushPromises.push(storage.standaloneEventData.flush());
|
|
555
676
|
if (storage.mirrorEventData?.flush) flushPromises.push(storage.mirrorEventData.flush());
|
|
556
677
|
if (storage.groupEventData?.flush) flushPromises.push(storage.groupEventData.flush());
|
|
557
678
|
|
|
558
679
|
// Flush arrays of HookedArrays (excluding lookup tables which are handled separately)
|
|
559
|
-
[storage.scdTableData, storage.groupProfilesData].forEach(arrayOfContainers => {
|
|
680
|
+
[storage.scdTableData, storage.groupProfilesData, storage.warehouseMetricData].forEach(arrayOfContainers => {
|
|
560
681
|
if (Array.isArray(arrayOfContainers)) {
|
|
561
682
|
arrayOfContainers.forEach(container => {
|
|
562
683
|
if (container?.flush) flushPromises.push(container.flush());
|
|
@@ -620,11 +741,19 @@ function countProfilesPushed(profilesContainer) {
|
|
|
620
741
|
* @returns {object} Extracted data in Result format
|
|
621
742
|
*/
|
|
622
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
|
+
|
|
623
750
|
return {
|
|
624
751
|
eventData: storage.eventData || [],
|
|
625
752
|
mirrorEventData: storage.mirrorEventData || [],
|
|
626
753
|
userProfilesData: storage.userProfilesData || [],
|
|
627
754
|
adSpendData: storage.adSpendData || [],
|
|
755
|
+
standaloneEventData: storage.standaloneEventData || [],
|
|
756
|
+
warehouseMetricData,
|
|
628
757
|
// Keep arrays of HookedArrays as separate arrays (don't flatten)
|
|
629
758
|
scdTableData: storage.scdTableData || [],
|
|
630
759
|
groupProfilesData: storage.groupProfilesData || [],
|