@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,683 @@
|
|
|
1
|
+
const DAY_SECONDS = 86_400;
|
|
2
|
+
|
|
3
|
+
export function pearson(left, right) {
|
|
4
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length || left.length < 2) {
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
const a = left.map(Number);
|
|
8
|
+
const b = right.map(Number);
|
|
9
|
+
if (a.some((value) => !Number.isFinite(value)) || b.some((value) => !Number.isFinite(value))) {
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
const meanA = a.reduce((sum, value) => sum + value, 0) / a.length;
|
|
13
|
+
const meanB = b.reduce((sum, value) => sum + value, 0) / b.length;
|
|
14
|
+
let numerator = 0;
|
|
15
|
+
let denomA = 0;
|
|
16
|
+
let denomB = 0;
|
|
17
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
18
|
+
const centeredA = a[index] - meanA;
|
|
19
|
+
const centeredB = b[index] - meanB;
|
|
20
|
+
numerator += centeredA * centeredB;
|
|
21
|
+
denomA += centeredA * centeredA;
|
|
22
|
+
denomB += centeredB * centeredB;
|
|
23
|
+
}
|
|
24
|
+
if (denomA === 0 || denomB === 0) {
|
|
25
|
+
return arraysEqual(a, b) ? 1 : 0;
|
|
26
|
+
}
|
|
27
|
+
return numerator / Math.sqrt(denomA * denomB);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function computeWarehouseStats(rows, spec, eventDailyCounts) {
|
|
31
|
+
const timeColumn = spec?.timeColumn || 'date';
|
|
32
|
+
const valueColumn = spec?.valueColumn || 'value';
|
|
33
|
+
const opts = arguments[3] || {};
|
|
34
|
+
const normalizedRows = Array.isArray(rows) ? rows : [];
|
|
35
|
+
const emittedSeries = buildTableSeries(normalizedRows, timeColumn, valueColumn);
|
|
36
|
+
const sourceActualSeries = buildSourceSeries(eventDailyCounts, spec);
|
|
37
|
+
const displayRange = resolveStatsDisplayRange(emittedSeries, sourceActualSeries, spec, opts);
|
|
38
|
+
const windowRange = resolveStatsWindowRange(emittedSeries, sourceActualSeries, spec, opts);
|
|
39
|
+
const reconstruction = spec?.type === 'point-in-time'
|
|
40
|
+
? reconstructPointInTimeSeries(normalizedRows, spec, sourceActualSeries, eventDailyCounts, displayRange)
|
|
41
|
+
: null;
|
|
42
|
+
const tableSeries = reconstruction?.aggregateSeries || emittedSeries;
|
|
43
|
+
const windowedTableSeries = selectTableSeriesWindow(tableSeries, windowRange, spec);
|
|
44
|
+
const sourceSeries = buildSourceSeries(eventDailyCounts, spec, windowRange);
|
|
45
|
+
const comparable = buildComparableSeries(windowedTableSeries, sourceSeries, spec, {
|
|
46
|
+
seriesCount: inferComparableSeriesCount(normalizedRows, spec, eventDailyCounts),
|
|
47
|
+
});
|
|
48
|
+
const windowStart = windowRange?.start ?? null;
|
|
49
|
+
const backfillBuckets = windowStart === null
|
|
50
|
+
? 0
|
|
51
|
+
: tableSeries.filter((entry) => entry.bucketSec < windowStart).length;
|
|
52
|
+
const numericValues = tableSeries.map((entry) => entry.value);
|
|
53
|
+
const numericColumns = resolveNumericColumns(normalizedRows, spec, opts.manifestColumns || []);
|
|
54
|
+
const bySeries = partitionRowsBySeries(normalizedRows, spec);
|
|
55
|
+
const lastBackfillValue = backfillBuckets > 0 ? tableSeries[backfillBuckets - 1]?.value : null;
|
|
56
|
+
const firstWindowValue = tableSeries[backfillBuckets]?.value ?? 0;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
corr: pearson(comparable.table, comparable.source),
|
|
60
|
+
buckets: tableSeries.length,
|
|
61
|
+
backfillBuckets,
|
|
62
|
+
gaps: countGaps(tableSeries, spec?.grain || 'day'),
|
|
63
|
+
emptyNumericCells: countEmptyNumericCells(normalizedRows, numericColumns),
|
|
64
|
+
nonMonotonicTime: [...bySeries.values()].reduce((sum, seriesRows) => sum + countSeriesNonMonotonic(seriesRows), 0),
|
|
65
|
+
seamJumpPct: computeSeamJumpPct(lastBackfillValue, firstWindowValue),
|
|
66
|
+
tailRatio: computeTailRatio(windowedTableSeries),
|
|
67
|
+
mean: roundTo(mean(numericValues), 6),
|
|
68
|
+
last: tableSeries.length ? tableSeries[tableSeries.length - 1].value : 0,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function computeWarehouseSourceRows(events, spec) {
|
|
73
|
+
const plusEvents = new Set((spec?.source?.event || []).map(String));
|
|
74
|
+
const measure = spec?.source?.measure || 'count';
|
|
75
|
+
const property = spec?.source?.property || null;
|
|
76
|
+
const where = spec?.source?.where;
|
|
77
|
+
const groupKeys = normalizeGroupBy(spec?.source?.groupBy);
|
|
78
|
+
const buckets = new Map();
|
|
79
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
80
|
+
if (!plusEvents.has(String(event?.event))) continue;
|
|
81
|
+
if (typeof where === 'function' && !where(event)) continue;
|
|
82
|
+
const bucketSec = bucketStart(event?.time, spec?.grain || 'day');
|
|
83
|
+
if (!Number.isFinite(bucketSec)) continue;
|
|
84
|
+
const dimensions = Object.fromEntries(groupKeys.map((key) => [key, event?.[key] ?? '']));
|
|
85
|
+
const seriesKey = makeSeriesKey(dimensions, groupKeys);
|
|
86
|
+
const bucketKey = `${bucketSec}|${seriesKey}`;
|
|
87
|
+
const cell = buckets.get(bucketKey) || createSourceCell(dimensions);
|
|
88
|
+
cell.count += 1;
|
|
89
|
+
if (property) {
|
|
90
|
+
const numeric = Number(event?.[property]);
|
|
91
|
+
if (Number.isFinite(numeric)) cell.sum += numeric;
|
|
92
|
+
}
|
|
93
|
+
if (event?.user_id !== undefined && event?.user_id !== null) cell.users.add(String(event.user_id));
|
|
94
|
+
cell.userDays.add(`${resolveDayKey(event?.time)}|${event?.user_id ?? ''}`);
|
|
95
|
+
buckets.set(bucketKey, cell);
|
|
96
|
+
}
|
|
97
|
+
return [...buckets.entries()]
|
|
98
|
+
.sort((left, right) => compareSourceBucketKeys(left[0], right[0]))
|
|
99
|
+
.map(([bucketKey, cell]) => {
|
|
100
|
+
const [bucketSecText, ...seriesParts] = bucketKey.split('|');
|
|
101
|
+
const bucketSec = Number(bucketSecText);
|
|
102
|
+
return {
|
|
103
|
+
...cell.dimensions,
|
|
104
|
+
__seriesKey: seriesParts.join('|'),
|
|
105
|
+
__t: bucketSec,
|
|
106
|
+
value: sourceCellValue(cell, measure, bucketSec, spec?.grain || 'day'),
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function auditWarehouseRows(rows, spec, opts = {}) {
|
|
112
|
+
const normalizedRows = Array.isArray(rows) ? rows : [];
|
|
113
|
+
const expectedColumns = [
|
|
114
|
+
spec?.timeColumn || 'date',
|
|
115
|
+
...normalizeGroupBy(spec?.source?.groupBy),
|
|
116
|
+
spec?.valueColumn || 'value',
|
|
117
|
+
...Object.keys(spec?.columns || {}),
|
|
118
|
+
];
|
|
119
|
+
const bySeries = partitionRowsBySeries(normalizedRows, spec);
|
|
120
|
+
const expectedBucketList = buildExpectedBucketList(opts.datasetStart, opts.datasetEnd, spec);
|
|
121
|
+
const expectedSeriesKeys = inferExpectedSeriesKeys(spec, bySeries, opts.sourceRows || []);
|
|
122
|
+
const seriesKeys = expectedSeriesKeys.length ? expectedSeriesKeys : [...bySeries.keys()];
|
|
123
|
+
const seriesCount = seriesKeys.length || bySeries.size || 1;
|
|
124
|
+
const schemaViolations = countSchemaViolations(normalizedRows, expectedColumns);
|
|
125
|
+
const numericColumns = resolveNumericColumns(normalizedRows, spec, opts.manifestColumns || []);
|
|
126
|
+
const emptyNumericCells = countEmptyNumericCells(normalizedRows, numericColumns);
|
|
127
|
+
const nonMonotonicTime = [...bySeries.values()].reduce((sum, seriesRows) => sum + countSeriesNonMonotonic(seriesRows), 0);
|
|
128
|
+
const denseGaps = spec?.sparse ? 0 : countExpectedSeriesGaps(bySeries, seriesKeys, expectedBucketList);
|
|
129
|
+
const repeatedSparseValues = spec?.sparse
|
|
130
|
+
? [...bySeries.values()].reduce((sum, seriesRows) => sum + countRepeatedSparseValues(seriesRows, spec?.valueColumn || 'value'), 0)
|
|
131
|
+
: 0;
|
|
132
|
+
const missingSeries = countMissingExpectedSeries(bySeries, seriesKeys);
|
|
133
|
+
const missingSparseFirstBuckets = spec?.sparse && expectedBucketList.length
|
|
134
|
+
? countMissingSparseFirstBuckets(bySeries, seriesKeys, expectedBucketList[0])
|
|
135
|
+
: 0;
|
|
136
|
+
const expectedBuckets = expectedBucketCount(opts.datasetStart, opts.datasetEnd, spec);
|
|
137
|
+
const maxRows = expectedBuckets * seriesCount;
|
|
138
|
+
const minRows = spec?.sparse ? seriesCount : maxRows;
|
|
139
|
+
const rowCountViolation = expectedBuckets > 0 && (
|
|
140
|
+
spec?.sparse
|
|
141
|
+
? normalizedRows.length < minRows || normalizedRows.length > maxRows
|
|
142
|
+
: normalizedRows.length !== maxRows
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const failures = [];
|
|
146
|
+
if (schemaViolations > 0) failures.push(`schema mismatch in ${schemaViolations} row(s)`);
|
|
147
|
+
if (!spec?.sparse && denseGaps > 0) failures.push(`dense table has ${denseGaps} bucket gap(s)`);
|
|
148
|
+
if (nonMonotonicTime > 0) failures.push(`time column is non-monotonic in ${nonMonotonicTime} place(s)`);
|
|
149
|
+
if (emptyNumericCells > 0) failures.push(`found ${emptyNumericCells} empty numeric cell(s)`);
|
|
150
|
+
if (repeatedSparseValues > 0) failures.push(`sparse table repeated ${repeatedSparseValues} unchanged consecutive value(s)`);
|
|
151
|
+
if (missingSeries > 0) failures.push(`missing ${missingSeries} expected series`);
|
|
152
|
+
if (missingSparseFirstBuckets > 0) failures.push(`sparse table missed the first bucket for ${missingSparseFirstBuckets} expected series`);
|
|
153
|
+
if (rowCountViolation) failures.push(`row count ${normalizedRows.length} violates ${spec?.sparse ? `range ${minRows}-${maxRows}` : `expected bound ${maxRows}`}`);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
pass: failures.length === 0,
|
|
157
|
+
failures,
|
|
158
|
+
stats: computeWarehouseStats(normalizedRows, spec, opts.sourceRows || [], opts),
|
|
159
|
+
rowCount: normalizedRows.length,
|
|
160
|
+
seriesCount,
|
|
161
|
+
expectedRows: maxRows,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildTableSeries(rows, timeColumn, valueColumn) {
|
|
166
|
+
const buckets = new Map();
|
|
167
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
168
|
+
const bucketSec = coerceBucketSec(row, timeColumn);
|
|
169
|
+
if (!Number.isFinite(bucketSec)) continue;
|
|
170
|
+
const numeric = coerceNumericCell(row?.[valueColumn]);
|
|
171
|
+
const current = buckets.get(bucketSec) || { value: 0, comparable: true };
|
|
172
|
+
current.value = roundTo(current.value + numeric.value, 6);
|
|
173
|
+
current.comparable = current.comparable && !numeric.empty;
|
|
174
|
+
buckets.set(bucketSec, current);
|
|
175
|
+
}
|
|
176
|
+
return [...buckets.entries()]
|
|
177
|
+
.sort((left, right) => left[0] - right[0])
|
|
178
|
+
.map(([bucketSec, value]) => ({ bucketSec, ...value }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildSourceSeries(rows, spec, bucketRange = null) {
|
|
182
|
+
const minusEvents = new Set((spec?.source?.minus || []).map(String));
|
|
183
|
+
const buckets = new Map();
|
|
184
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
185
|
+
if (minusEvents.size > 0) {
|
|
186
|
+
const sourceName = row?.source ?? row?.event ?? row?.eventName ?? row?.series;
|
|
187
|
+
if (sourceName !== undefined && minusEvents.has(String(sourceName))) continue;
|
|
188
|
+
}
|
|
189
|
+
const rawTime = row?.__t ?? row?.time ?? row?.bucket ?? row?.date ?? row?.month;
|
|
190
|
+
const bucketSec = bucketStart(rawTime, spec?.grain || 'day');
|
|
191
|
+
if (!Number.isFinite(bucketSec)) continue;
|
|
192
|
+
const value = Number(row?.value ?? row?.count ?? row?.sum ?? row?.avg ?? 0);
|
|
193
|
+
if (!Number.isFinite(value)) continue;
|
|
194
|
+
buckets.set(bucketSec, (buckets.get(bucketSec) || 0) + value);
|
|
195
|
+
}
|
|
196
|
+
const series = [...buckets.entries()]
|
|
197
|
+
.sort((left, right) => left[0] - right[0])
|
|
198
|
+
.map(([bucketSec, value]) => ({ bucketSec, value: roundTo(value, 6) }));
|
|
199
|
+
if (!Number.isFinite(bucketRange?.start) || !Number.isFinite(bucketRange?.end)) return series;
|
|
200
|
+
const sourceMap = new Map(series.map((entry) => [entry.bucketSec, entry.value]));
|
|
201
|
+
return enumerateBuckets(bucketRange.start, bucketRange.end, spec?.grain || 'day').map((bucketSec) => ({
|
|
202
|
+
bucketSec,
|
|
203
|
+
value: roundTo(sourceMap.get(bucketSec) || 0, 6),
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildComparableSeries(tableSeries, sourceSeries, spec, opts = {}) {
|
|
208
|
+
const sourceMap = new Map(sourceSeries.map((entry) => [entry.bucketSec, entry.value]));
|
|
209
|
+
if (spec?.type === 'point-in-time') {
|
|
210
|
+
const table = [];
|
|
211
|
+
const source = [];
|
|
212
|
+
const firstValueBase = Number(spec?.baseline ?? 0) * Number(spec?.scale ?? 1) * Math.max(1, Number(opts.seriesCount) || 1);
|
|
213
|
+
for (const entry of buildWindowDeltaSeries(tableSeries, firstValueBase)) {
|
|
214
|
+
const sourceValue = sourceMap.get(entry.bucketSec);
|
|
215
|
+
if (!Number.isFinite(sourceValue) || entry.comparable === false) continue;
|
|
216
|
+
table.push(entry.value);
|
|
217
|
+
source.push(sourceValue);
|
|
218
|
+
}
|
|
219
|
+
return { table, source };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const table = [];
|
|
223
|
+
const source = [];
|
|
224
|
+
for (const entry of tableSeries) {
|
|
225
|
+
const sourceValue = sourceMap.get(entry.bucketSec);
|
|
226
|
+
if (!Number.isFinite(sourceValue) || !Number.isFinite(entry.value) || entry.comparable === false) continue;
|
|
227
|
+
table.push(entry.value);
|
|
228
|
+
source.push(sourceValue);
|
|
229
|
+
}
|
|
230
|
+
return { table, source };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function coerceBucketSec(row, timeColumn) {
|
|
234
|
+
if (Number.isFinite(row?.__t)) return row.__t;
|
|
235
|
+
return bucketStart(row?.[timeColumn], inferGrainFromTimeColumn(timeColumn));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function bucketStart(raw, grain) {
|
|
239
|
+
const sec = coerceTimeSec(raw);
|
|
240
|
+
if (!Number.isFinite(sec)) return NaN;
|
|
241
|
+
if (grain === 'day') return Math.floor(sec / DAY_SECONDS) * DAY_SECONDS;
|
|
242
|
+
if (grain === 'week') {
|
|
243
|
+
const dayIndex = Math.floor(sec / DAY_SECONDS);
|
|
244
|
+
return (dayIndex - ((dayIndex + 3) % 7)) * DAY_SECONDS;
|
|
245
|
+
}
|
|
246
|
+
if (grain === 'month') {
|
|
247
|
+
const date = new Date(sec * 1000);
|
|
248
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1) / 1000;
|
|
249
|
+
}
|
|
250
|
+
return NaN;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function coerceTimeSec(raw) {
|
|
254
|
+
if (Number.isFinite(raw)) return Number(raw);
|
|
255
|
+
if (typeof raw !== 'string' || !raw) return NaN;
|
|
256
|
+
const parsed = Date.parse(/T/.test(raw) ? raw : `${raw}T00:00:00Z`);
|
|
257
|
+
return Number.isFinite(parsed) ? parsed / 1000 : NaN;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function coerceNumericCell(value) {
|
|
261
|
+
if (value === '' || value === null || value === undefined) {
|
|
262
|
+
return { value: 0, empty: true };
|
|
263
|
+
}
|
|
264
|
+
const numeric = Number(value);
|
|
265
|
+
if (!Number.isFinite(numeric)) {
|
|
266
|
+
return { value: 0, empty: true };
|
|
267
|
+
}
|
|
268
|
+
return { value: numeric, empty: false };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function countEmptyNumericCells(rows, numericColumns) {
|
|
272
|
+
const columns = Array.isArray(numericColumns) ? numericColumns : [numericColumns];
|
|
273
|
+
let count = 0;
|
|
274
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
275
|
+
for (const column of columns) {
|
|
276
|
+
if (coerceNumericCell(row?.[column]).empty) count += 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return count;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function countNonMonotonicTime(rows, timeColumn) {
|
|
283
|
+
let count = 0;
|
|
284
|
+
let previous = null;
|
|
285
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
286
|
+
const current = coerceBucketSec(row, timeColumn);
|
|
287
|
+
if (!Number.isFinite(current)) continue;
|
|
288
|
+
if (previous !== null && current <= previous) count += 1;
|
|
289
|
+
previous = current;
|
|
290
|
+
}
|
|
291
|
+
return count;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function countGaps(series, grain) {
|
|
295
|
+
let gaps = 0;
|
|
296
|
+
for (let index = 1; index < series.length; index += 1) {
|
|
297
|
+
const prev = series[index - 1].bucketSec;
|
|
298
|
+
const curr = series[index].bucketSec;
|
|
299
|
+
const expected = nextBucket(prev, grain);
|
|
300
|
+
let cursor = expected;
|
|
301
|
+
while (Number.isFinite(cursor) && cursor < curr) {
|
|
302
|
+
gaps += 1;
|
|
303
|
+
cursor = nextBucket(cursor, grain);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return gaps;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function partitionRowsBySeries(rows, spec) {
|
|
310
|
+
const grouped = new Map();
|
|
311
|
+
const keys = normalizeGroupBy(spec?.source?.groupBy);
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
const seriesKey = makeSeriesKey(row, keys);
|
|
314
|
+
const bucketSec = Number.isFinite(row?.__t) ? row.__t : coerceBucketSec(row, spec?.timeColumn || 'date');
|
|
315
|
+
const nextRow = { ...row, __t: bucketSec };
|
|
316
|
+
const list = grouped.get(seriesKey) || [];
|
|
317
|
+
list.push(nextRow);
|
|
318
|
+
grouped.set(seriesKey, list);
|
|
319
|
+
}
|
|
320
|
+
for (const list of grouped.values()) {
|
|
321
|
+
list.sort((left, right) => (left.__t ?? 0) - (right.__t ?? 0));
|
|
322
|
+
}
|
|
323
|
+
return grouped;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function countExpectedSeriesGaps(bySeries, expectedSeriesKeys, expectedBucketList) {
|
|
327
|
+
let gaps = 0;
|
|
328
|
+
for (const seriesKey of expectedSeriesKeys) {
|
|
329
|
+
const bucketSet = new Set((bySeries.get(seriesKey) || []).map((row) => row.__t));
|
|
330
|
+
for (const bucketSec of expectedBucketList) {
|
|
331
|
+
if (!bucketSet.has(bucketSec)) gaps += 1;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return gaps;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function countSeriesNonMonotonic(rows) {
|
|
338
|
+
let count = 0;
|
|
339
|
+
for (let index = 1; index < rows.length; index += 1) {
|
|
340
|
+
if (!Number.isFinite(rows[index].__t) || rows[index].__t <= rows[index - 1].__t) count += 1;
|
|
341
|
+
}
|
|
342
|
+
return count;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function countRepeatedSparseValues(rows, valueColumn) {
|
|
346
|
+
let count = 0;
|
|
347
|
+
for (let index = 1; index < rows.length; index += 1) {
|
|
348
|
+
if (Number(rows[index][valueColumn]) === Number(rows[index - 1][valueColumn])) count += 1;
|
|
349
|
+
}
|
|
350
|
+
return count;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function countMissingExpectedSeries(bySeries, expectedSeriesKeys) {
|
|
354
|
+
let count = 0;
|
|
355
|
+
for (const seriesKey of expectedSeriesKeys) {
|
|
356
|
+
if (!(bySeries.get(seriesKey) || []).length) count += 1;
|
|
357
|
+
}
|
|
358
|
+
return count;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function countMissingSparseFirstBuckets(bySeries, expectedSeriesKeys, firstBucketSec) {
|
|
362
|
+
let count = 0;
|
|
363
|
+
for (const seriesKey of expectedSeriesKeys) {
|
|
364
|
+
const rows = bySeries.get(seriesKey) || [];
|
|
365
|
+
if (!rows.some((row) => row.__t === firstBucketSec)) count += 1;
|
|
366
|
+
}
|
|
367
|
+
return count;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function countSchemaViolations(rows, expectedColumns) {
|
|
371
|
+
const expected = new Set(expectedColumns);
|
|
372
|
+
let count = 0;
|
|
373
|
+
for (const row of rows) {
|
|
374
|
+
const keys = Object.keys(row).filter((key) => key !== '__t');
|
|
375
|
+
const missing = expectedColumns.some((column) => !keys.includes(column));
|
|
376
|
+
const extra = keys.some((key) => !expected.has(key));
|
|
377
|
+
if (missing || extra) count += 1;
|
|
378
|
+
}
|
|
379
|
+
return count;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function expectedBucketCount(datasetStart, datasetEnd, spec) {
|
|
383
|
+
return buildExpectedBucketList(datasetStart, datasetEnd, spec).length;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function buildExpectedBucketList(datasetStart, datasetEnd, spec) {
|
|
387
|
+
const startSec = coerceTimeSec(datasetStart);
|
|
388
|
+
const endSec = coerceTimeSec(datasetEnd);
|
|
389
|
+
if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec < startSec) return [];
|
|
390
|
+
const grain = spec?.grain || 'day';
|
|
391
|
+
const startBucket = shiftBucket(bucketStart(startSec, grain), grain, -Number(spec?.history || 0));
|
|
392
|
+
const endBucket = bucketStart(endSec, grain);
|
|
393
|
+
return enumerateBuckets(startBucket, endBucket, grain);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function createSourceCell(dimensions = {}) {
|
|
397
|
+
return { count: 0, sum: 0, users: new Set(), userDays: new Set(), dimensions };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function sourceCellValue(cell, measure, bucketSec, grain) {
|
|
401
|
+
switch (measure) {
|
|
402
|
+
case 'count':
|
|
403
|
+
return cell.count;
|
|
404
|
+
case 'sum':
|
|
405
|
+
return roundTo(cell.sum, 6);
|
|
406
|
+
case 'avg':
|
|
407
|
+
return cell.count ? roundTo(cell.sum / cell.count, 6) : 0;
|
|
408
|
+
case 'users':
|
|
409
|
+
return cell.users.size;
|
|
410
|
+
case 'dau': {
|
|
411
|
+
const days = bucketDayLength(bucketSec, grain);
|
|
412
|
+
return days ? roundTo(cell.userDays.size / days, 6) : 0;
|
|
413
|
+
}
|
|
414
|
+
default:
|
|
415
|
+
return cell.count;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function nextBucket(bucketSec, grain) {
|
|
420
|
+
if (grain === 'day') return bucketSec + DAY_SECONDS;
|
|
421
|
+
if (grain === 'week') return bucketSec + (7 * DAY_SECONDS);
|
|
422
|
+
if (grain === 'month') {
|
|
423
|
+
const date = new Date(bucketSec * 1000);
|
|
424
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) / 1000;
|
|
425
|
+
}
|
|
426
|
+
return NaN;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function shiftBucket(bucketSec, grain, delta) {
|
|
430
|
+
if (!Number.isFinite(bucketSec) || !Number.isInteger(delta) || delta === 0) return bucketSec;
|
|
431
|
+
if (grain === 'day') return bucketSec + (delta * DAY_SECONDS);
|
|
432
|
+
if (grain === 'week') return bucketSec + (delta * 7 * DAY_SECONDS);
|
|
433
|
+
if (grain === 'month') {
|
|
434
|
+
const date = new Date(bucketSec * 1000);
|
|
435
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + delta, 1) / 1000;
|
|
436
|
+
}
|
|
437
|
+
return NaN;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function enumerateBuckets(startBucket, endBucket, grain) {
|
|
441
|
+
if (!Number.isFinite(startBucket) || !Number.isFinite(endBucket) || endBucket < startBucket) return [];
|
|
442
|
+
const out = [];
|
|
443
|
+
for (let cursor = startBucket; Number.isFinite(cursor) && cursor <= endBucket; cursor = nextBucket(cursor, grain)) {
|
|
444
|
+
out.push(cursor);
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function bucketDayLength(bucketSec, grain) {
|
|
450
|
+
if (!Number.isFinite(bucketSec)) return 0;
|
|
451
|
+
return (nextBucket(bucketSec, grain) - bucketSec) / DAY_SECONDS;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function computeSeamJumpPct(lastBackfillValue, firstSourceValue) {
|
|
455
|
+
if (!Number.isFinite(lastBackfillValue) || !Number.isFinite(firstSourceValue) || firstSourceValue === 0) {
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
|
458
|
+
return (Math.abs(lastBackfillValue - firstSourceValue) / Math.abs(firstSourceValue)) * 100;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function computeTailRatio(sourceSeries) {
|
|
462
|
+
if (!sourceSeries.length) return 0;
|
|
463
|
+
const first = sourceSeries[0].value;
|
|
464
|
+
const last = sourceSeries[sourceSeries.length - 1].value;
|
|
465
|
+
if (!Number.isFinite(first) || first === 0) return 0;
|
|
466
|
+
return last / first;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function mean(values) {
|
|
470
|
+
if (!Array.isArray(values) || values.length === 0) return 0;
|
|
471
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function roundTo(value, decimals) {
|
|
475
|
+
const factor = 10 ** decimals;
|
|
476
|
+
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function arraysEqual(left, right) {
|
|
480
|
+
if (left.length !== right.length) return false;
|
|
481
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
482
|
+
if (!Object.is(left[index], right[index])) return false;
|
|
483
|
+
}
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function resolveDayKey(raw) {
|
|
488
|
+
const sec = coerceTimeSec(raw);
|
|
489
|
+
if (!Number.isFinite(sec)) return 'invalid';
|
|
490
|
+
return new Date(Math.floor(sec / DAY_SECONDS) * DAY_SECONDS * 1000).toISOString().slice(0, 10);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function inferGrainFromTimeColumn(timeColumn) {
|
|
494
|
+
return timeColumn === 'month' ? 'month' : timeColumn === 'week' ? 'week' : 'day';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function normalizeGroupBy(groupBy) {
|
|
498
|
+
if (Array.isArray(groupBy)) return groupBy;
|
|
499
|
+
if (groupBy === undefined || groupBy === null || groupBy === '') return [];
|
|
500
|
+
return [groupBy];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function makeSeriesKey(rowLike, keys) {
|
|
504
|
+
return keys.map((key) => String(rowLike?.[key] ?? '')).join('|');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function compareSourceBucketKeys(left, right) {
|
|
508
|
+
const [leftBucket, ...leftSeries] = String(left).split('|');
|
|
509
|
+
const [rightBucket, ...rightSeries] = String(right).split('|');
|
|
510
|
+
const bucketDiff = Number(leftBucket) - Number(rightBucket);
|
|
511
|
+
if (bucketDiff !== 0) return bucketDiff;
|
|
512
|
+
return leftSeries.join('|').localeCompare(rightSeries.join('|'));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function selectTableSeriesWindow(tableSeries, range, spec) {
|
|
516
|
+
if (!range) return tableSeries;
|
|
517
|
+
if (spec?.type === 'point-in-time') {
|
|
518
|
+
return tableSeries.filter((entry) => entry.bucketSec >= range.start && entry.bucketSec <= range.end);
|
|
519
|
+
}
|
|
520
|
+
const tableMap = new Map(tableSeries.map((entry) => [entry.bucketSec, entry]));
|
|
521
|
+
return enumerateBuckets(range.start, range.end, spec?.grain || 'day').map((bucketSec) => {
|
|
522
|
+
const entry = tableMap.get(bucketSec);
|
|
523
|
+
if (entry) return entry;
|
|
524
|
+
return { bucketSec, value: 0, comparable: true };
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function buildWindowDeltaSeries(tableSeries, firstValueBase = null) {
|
|
529
|
+
const deltas = [];
|
|
530
|
+
let previous = null;
|
|
531
|
+
for (const entry of tableSeries) {
|
|
532
|
+
const value = Number(entry?.value);
|
|
533
|
+
const comparable = entry?.comparable !== false;
|
|
534
|
+
if (!Number.isFinite(value)) continue;
|
|
535
|
+
if (previous === null) {
|
|
536
|
+
deltas.push({
|
|
537
|
+
bucketSec: entry.bucketSec,
|
|
538
|
+
value: roundTo(firstValueBase === null ? 0 : value - firstValueBase, 6),
|
|
539
|
+
comparable,
|
|
540
|
+
});
|
|
541
|
+
} else {
|
|
542
|
+
deltas.push({
|
|
543
|
+
bucketSec: entry.bucketSec,
|
|
544
|
+
value: roundTo(value - previous.value, 6),
|
|
545
|
+
comparable: comparable && previous.comparable,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
previous = { value, comparable };
|
|
549
|
+
}
|
|
550
|
+
return deltas;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function inferBucketRange(tableSeries, sourceSeries) {
|
|
554
|
+
const buckets = [
|
|
555
|
+
...tableSeries.map((entry) => entry.bucketSec),
|
|
556
|
+
...sourceSeries.map((entry) => entry.bucketSec),
|
|
557
|
+
].filter(Number.isFinite);
|
|
558
|
+
if (!buckets.length) return null;
|
|
559
|
+
return {
|
|
560
|
+
start: Math.min(...buckets),
|
|
561
|
+
end: Math.max(...buckets),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function inferExpectedSeriesKeys(spec, bySeries, sourceRows) {
|
|
566
|
+
const keys = normalizeGroupBy(spec?.source?.groupBy);
|
|
567
|
+
if (!keys.length) return [''];
|
|
568
|
+
const combined = new Set();
|
|
569
|
+
for (const row of Array.isArray(sourceRows) ? sourceRows : []) {
|
|
570
|
+
combined.add(makeSeriesKey(row, keys));
|
|
571
|
+
}
|
|
572
|
+
for (const seriesKey of bySeries.keys()) combined.add(seriesKey);
|
|
573
|
+
return [...combined].sort();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function inferComparableSeriesCount(rows, spec, sourceRows) {
|
|
577
|
+
const bySeries = partitionRowsBySeries(rows, spec);
|
|
578
|
+
const expectedSeriesKeys = inferExpectedSeriesKeys(spec, bySeries, sourceRows);
|
|
579
|
+
return expectedSeriesKeys.length || bySeries.size || 1;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function resolveNumericColumns(rows, spec, manifestColumns) {
|
|
583
|
+
const numeric = new Set([spec?.valueColumn || 'value']);
|
|
584
|
+
for (const column of Array.isArray(manifestColumns) ? manifestColumns : []) {
|
|
585
|
+
if (column?.bqType === 'FLOAT64' && column?.name) numeric.add(column.name);
|
|
586
|
+
}
|
|
587
|
+
for (const [name, value] of Object.entries(spec?.columns || {})) {
|
|
588
|
+
if (typeof value === 'number') numeric.add(name);
|
|
589
|
+
}
|
|
590
|
+
for (const name of Object.keys(spec?.columns || {})) {
|
|
591
|
+
if (numeric.has(name)) continue;
|
|
592
|
+
for (const row of rows) {
|
|
593
|
+
const cell = row?.[name];
|
|
594
|
+
if (cell === '' || cell === null || cell === undefined) continue;
|
|
595
|
+
if (Number.isFinite(Number(cell))) {
|
|
596
|
+
numeric.add(name);
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return [...numeric];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function reconstructPointInTimeSeries(rows, spec, sourceActualSeries, sourceRows, rangeOverride = null) {
|
|
605
|
+
const bySeries = partitionRowsBySeries(rows, spec);
|
|
606
|
+
const valueColumn = spec?.valueColumn || 'value';
|
|
607
|
+
const timeColumn = spec?.timeColumn || 'date';
|
|
608
|
+
const emittedSeries = buildTableSeries(rows, timeColumn, valueColumn);
|
|
609
|
+
const range = rangeOverride || inferBucketRange(emittedSeries, sourceActualSeries);
|
|
610
|
+
if (!range) return { aggregateSeries: [], deltaSeries: [] };
|
|
611
|
+
const expectedSeriesKeys = inferExpectedSeriesKeys(spec, bySeries, sourceRows);
|
|
612
|
+
const baselineValue = Number(spec?.baseline ?? 0) * Number(spec?.scale ?? 1);
|
|
613
|
+
const rowMaps = new Map();
|
|
614
|
+
for (const seriesKey of expectedSeriesKeys) {
|
|
615
|
+
rowMaps.set(seriesKey, new Map((bySeries.get(seriesKey) || []).map((row) => [row.__t, row])));
|
|
616
|
+
}
|
|
617
|
+
const states = new Map(expectedSeriesKeys.map((seriesKey) => [seriesKey, baselineValue]));
|
|
618
|
+
const aggregateSeries = [];
|
|
619
|
+
const deltaSeries = [];
|
|
620
|
+
for (const bucketSec of enumerateBuckets(range.start, range.end, spec?.grain || 'day')) {
|
|
621
|
+
let total = 0;
|
|
622
|
+
let delta = 0;
|
|
623
|
+
let comparable = true;
|
|
624
|
+
for (const seriesKey of expectedSeriesKeys) {
|
|
625
|
+
const previous = states.get(seriesKey) ?? baselineValue;
|
|
626
|
+
const row = rowMaps.get(seriesKey)?.get(bucketSec);
|
|
627
|
+
let current = previous;
|
|
628
|
+
if (row) {
|
|
629
|
+
const numeric = coerceNumericCell(row?.[valueColumn]);
|
|
630
|
+
if (numeric.empty) {
|
|
631
|
+
comparable = false;
|
|
632
|
+
} else {
|
|
633
|
+
current = numeric.value;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
total += current;
|
|
637
|
+
delta += current - previous;
|
|
638
|
+
states.set(seriesKey, current);
|
|
639
|
+
}
|
|
640
|
+
aggregateSeries.push({ bucketSec, value: roundTo(total, 6), comparable });
|
|
641
|
+
deltaSeries.push({ bucketSec, value: roundTo(delta, 6), comparable });
|
|
642
|
+
}
|
|
643
|
+
return { aggregateSeries, deltaSeries };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function resolveStatsDisplayRange(emittedSeries, sourceSeries, spec, opts) {
|
|
647
|
+
const windowRange = resolveStatsWindowRange(emittedSeries, sourceSeries, spec, opts);
|
|
648
|
+
if (!windowRange) return inferBucketRange(emittedSeries, sourceSeries);
|
|
649
|
+
return {
|
|
650
|
+
start: shiftBucket(windowRange.start, spec?.grain || 'day', -Number(spec?.history || 0)),
|
|
651
|
+
end: windowRange.end,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function resolveStatsWindowRange(emittedSeries, sourceSeries, spec, opts) {
|
|
656
|
+
const grain = spec?.grain || 'day';
|
|
657
|
+
const datasetStart = coerceTimeSec(opts?.datasetStart);
|
|
658
|
+
const datasetEnd = coerceTimeSec(opts?.datasetEnd);
|
|
659
|
+
if (Number.isFinite(datasetStart) && Number.isFinite(datasetEnd)) {
|
|
660
|
+
return {
|
|
661
|
+
start: bucketStart(datasetStart, grain),
|
|
662
|
+
end: bucketStart(datasetEnd, grain),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const tableBuckets = emittedSeries.map((entry) => entry.bucketSec).filter(Number.isFinite);
|
|
667
|
+
const sourceBuckets = sourceSeries.map((entry) => entry.bucketSec).filter(Number.isFinite);
|
|
668
|
+
if (tableBuckets.length) {
|
|
669
|
+
const start = shiftBucket(Math.min(...tableBuckets), grain, Number(spec?.history || 0));
|
|
670
|
+
const end = Math.max(
|
|
671
|
+
Math.max(...tableBuckets),
|
|
672
|
+
sourceBuckets.length ? Math.max(...sourceBuckets) : -Infinity,
|
|
673
|
+
);
|
|
674
|
+
return { start: Math.min(start, end), end };
|
|
675
|
+
}
|
|
676
|
+
if (sourceBuckets.length) {
|
|
677
|
+
return {
|
|
678
|
+
start: Math.min(...sourceBuckets),
|
|
679
|
+
end: Math.max(...sourceBuckets),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ak--47/dungeon-master",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"@google-cloud/storage": "^7.14.0",
|
|
76
76
|
"ak-tools": "^1.1.12",
|
|
77
77
|
"chance": "^1.1.11",
|
|
78
|
+
"csv-parse": "^7.0.2",
|
|
78
79
|
"dayjs": "^1.11.11",
|
|
79
80
|
"dotenv": "^16.4.5",
|
|
80
81
|
"hyparquet-writer": "^0.6.1",
|
|
@@ -92,7 +93,8 @@
|
|
|
92
93
|
"@vitest/ui": "^2.1.9",
|
|
93
94
|
"nodemon": "^3.1.3",
|
|
94
95
|
"typescript": "^5.6.0",
|
|
95
|
-
"vitest": "^2.1.9"
|
|
96
|
+
"vitest": "^2.1.9",
|
|
97
|
+
"yaml": "^2.9.0"
|
|
96
98
|
},
|
|
97
99
|
"nodemonConfig": {
|
|
98
100
|
"ignore": [
|