@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
package/lib/utils/utils.js
CHANGED
|
@@ -63,6 +63,51 @@ function resetValueCaches() {
|
|
|
63
63
|
winnerEntryCache = new WeakMap();
|
|
64
64
|
winnerCache.clear();
|
|
65
65
|
weightedArrayCache.clear();
|
|
66
|
+
autoPowerLawEnabled = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// v1.7.0 (P2-1): run-level switch for the implicit power-law draw on 3–19-item
|
|
70
|
+
// unique-string arrays. `choose()` is a free function with no config access, so
|
|
71
|
+
// the orchestrator sets this from `validatedConfig.autoPowerLaw` at run start
|
|
72
|
+
// (mirrors how the seeded chance singleton is managed). resetValueCaches()
|
|
73
|
+
// restores the default so a prior run's opt-out never leaks.
|
|
74
|
+
let autoPowerLawEnabled = true;
|
|
75
|
+
|
|
76
|
+
/** @param {boolean} enabled */
|
|
77
|
+
function setAutoPowerLaw(enabled) {
|
|
78
|
+
autoPowerLawEnabled = enabled !== false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {boolean} */
|
|
82
|
+
function getAutoPowerLaw() {
|
|
83
|
+
return autoPowerLawEnabled;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* v1.7.0 (P1-1): true for a value function that DECLARES a parameter (`(ctx) => …`)
|
|
88
|
+
* and is not a bound native (`chance.animal.bind(chance)` reports the native's
|
|
89
|
+
* arity but cannot read ctx). Context-aware functions skip the source-string
|
|
90
|
+
* cache and, on funnel steps, resolve inside makeEvent with the real event ctx.
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @returns {value is (ctx?: any) => any}
|
|
93
|
+
*/
|
|
94
|
+
function isContextAware(value) {
|
|
95
|
+
return typeof value === 'function' && value.length >= 1 && !Function.prototype.toString.call(value).includes('[native code]');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* v1.7.0 (P2-1): `{ __weights: { free: 60, pro: 30, enterprise: 10 } }` — the
|
|
100
|
+
* declarative weighted form. True when `value` carries a non-empty `__weights`
|
|
101
|
+
* object whose values are finite, non-negative numbers.
|
|
102
|
+
* @param {unknown} value
|
|
103
|
+
* @returns {value is { __weights: Record<string, number> }}
|
|
104
|
+
*/
|
|
105
|
+
function isWeightsForm(value) {
|
|
106
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
107
|
+
const w = /** @type {any} */ (value).__weights;
|
|
108
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) return false;
|
|
109
|
+
const entries = Object.entries(w);
|
|
110
|
+
return entries.length > 0 && entries.every(([, n]) => typeof n === 'number' && Number.isFinite(n) && n >= 0);
|
|
66
111
|
}
|
|
67
112
|
|
|
68
113
|
// v1.5.1: dataset-window state moved to AsyncLocalStorage scope
|
|
@@ -400,15 +445,24 @@ function objectList(template, options = {}) {
|
|
|
400
445
|
* similar to pick
|
|
401
446
|
* @param {ValueValid} value
|
|
402
447
|
*/
|
|
403
|
-
function choose(value) {
|
|
448
|
+
function choose(value, ctx = undefined) {
|
|
404
449
|
if (value instanceof ListValue) return value;
|
|
405
450
|
const chance = getChance();
|
|
406
451
|
|
|
407
|
-
//
|
|
452
|
+
// v1.7.0 (P2-1): declarative weights. The author's numbers ARE the
|
|
453
|
+
// distribution — no power law, no winner memo. Zero-weight keys never draw.
|
|
454
|
+
if (isWeightsForm(value)) {
|
|
455
|
+
const entries = Object.entries(value.__weights).filter(([, n]) => n > 0);
|
|
456
|
+
if (!entries.length) return "";
|
|
457
|
+
return chance.weighted(entries.map(([k]) => k), entries.map(([, n]) => n));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// most of the time this will receive a list of strings;
|
|
408
461
|
// when that is the case, we need to ensure some 'keywords' like 'variant' or 'test' aren't in the array
|
|
409
462
|
// next we want to see if the array is unweighted ... i.e. no dupe strings and each string only occurs once ['a', 'b', 'c', 'd']
|
|
410
463
|
// if all these are true we will pickAWinner(value)()
|
|
411
|
-
|
|
464
|
+
// v1.7.0 (P2-1): `autoPowerLaw: false` skips this branch → uniform pickone below.
|
|
465
|
+
if (autoPowerLawEnabled && Array.isArray(value) && value.length > 2 && value.length < 20 && value.every(item => typeof item === 'string')) {
|
|
412
466
|
// ensure terms 'variant' 'group' 'experiment' or 'population' are NOT in any of the items
|
|
413
467
|
if (!value.some(item => item.includes('variant') || item.includes('group') || item.includes('experiment') || item.includes('population'))) {
|
|
414
468
|
// check to make sure that each element in the array only occurs once...
|
|
@@ -439,21 +493,32 @@ function choose(value) {
|
|
|
439
493
|
// Functions tagged noCache (pickAWinner closures) skip the source-string
|
|
440
494
|
// cache: their toString() is identical across instances, so caching by
|
|
441
495
|
// source would hand one property's expansion to every other property.
|
|
496
|
+
//
|
|
497
|
+
// v1.7.0 (P1-1): value functions receive a `ValueContext` (`{ profile,
|
|
498
|
+
// event, time, config }`, members optional). Any function with declared
|
|
499
|
+
// arity >= 1 is context-aware and ALSO skips the source-string cache —
|
|
500
|
+
// its result legitimately differs per user/event, so caching by source
|
|
501
|
+
// would freeze the first evaluation and hand it to every later caller.
|
|
502
|
+
// Zero-arity functions keep the pre-1.7 cache behavior exactly.
|
|
442
503
|
while (typeof value === 'function') {
|
|
443
|
-
|
|
444
|
-
|
|
504
|
+
// Bound natives (`chance.animal.bind(chance)`) report the native's arity
|
|
505
|
+
// and cannot read ctx; call them exactly as before (no argument) so the
|
|
506
|
+
// options object chance methods take never sees the context.
|
|
507
|
+
const funcString = value.toString();
|
|
508
|
+
const isNative = funcString.includes('[native code]');
|
|
509
|
+
if (/** @type {any} */ (value).noCache === true || (value.length >= 1 && !isNative)) {
|
|
510
|
+
const result = isNative ? value() : value(ctx);
|
|
445
511
|
if (result instanceof ListValue) return result;
|
|
446
512
|
value = result;
|
|
447
513
|
continue;
|
|
448
514
|
}
|
|
449
|
-
const funcString = value.toString();
|
|
450
515
|
|
|
451
516
|
if (weightedArrayCache.has(funcString)) {
|
|
452
517
|
value = weightedArrayCache.get(funcString);
|
|
453
518
|
break;
|
|
454
519
|
}
|
|
455
520
|
|
|
456
|
-
const result = value();
|
|
521
|
+
const result = isNative ? value() : value(ctx);
|
|
457
522
|
if (result instanceof ListValue) return result;
|
|
458
523
|
if (Array.isArray(result) && result.length > 10) {
|
|
459
524
|
// Cache large arrays (likely weighted arrays)
|
|
@@ -463,6 +528,8 @@ function choose(value) {
|
|
|
463
528
|
}
|
|
464
529
|
|
|
465
530
|
if (value instanceof ListValue) return value;
|
|
531
|
+
// A function may return the weighted form.
|
|
532
|
+
if (isWeightsForm(value)) return choose(value, ctx);
|
|
466
533
|
|
|
467
534
|
if (Array.isArray(value) && value.length === 0) {
|
|
468
535
|
return ""; // Return empty string if the array is empty
|
|
@@ -748,10 +815,26 @@ function streamJSON(filePath, data, options = {}) {
|
|
|
748
815
|
});
|
|
749
816
|
}
|
|
750
817
|
|
|
818
|
+
function csvRow(item, columns) {
|
|
819
|
+
return columns.map(col => {
|
|
820
|
+
const value = item[col];
|
|
821
|
+
|
|
822
|
+
if (value === null || value === undefined) {
|
|
823
|
+
return '';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const serialized = typeof value === 'object'
|
|
827
|
+
? JSON.stringify(value)
|
|
828
|
+
: value.toString();
|
|
829
|
+
|
|
830
|
+
return `"${serialized.replace(/"/g, '""')}"`;
|
|
831
|
+
}).join(',');
|
|
832
|
+
}
|
|
833
|
+
|
|
751
834
|
function streamCSV(filePath, data, options = {}) {
|
|
752
835
|
return new Promise((resolve, reject) => {
|
|
753
836
|
let writeStream;
|
|
754
|
-
const { gzip = false } = options;
|
|
837
|
+
const { gzip = false, fixedColumns } = options;
|
|
755
838
|
|
|
756
839
|
if (filePath?.startsWith('gs://')) {
|
|
757
840
|
const { uri, bucket, file } = parseGCSUri(filePath);
|
|
@@ -775,20 +858,12 @@ function streamCSV(filePath, data, options = {}) {
|
|
|
775
858
|
}
|
|
776
859
|
}
|
|
777
860
|
|
|
778
|
-
|
|
779
|
-
const columns = getUniqueKeys(data); // Assuming getUniqueKeys properly retrieves all keys
|
|
861
|
+
const columns = Array.isArray(fixedColumns) ? fixedColumns : getUniqueKeys(data);
|
|
780
862
|
|
|
781
|
-
// Stream the header
|
|
782
863
|
writeStream.write(columns.join(',') + '\n');
|
|
783
864
|
|
|
784
|
-
// Stream each data row
|
|
785
865
|
data.forEach(item => {
|
|
786
|
-
|
|
787
|
-
// Ensure all nested objects are properly stringified
|
|
788
|
-
if (typeof item[key] === "object") item[key] = JSON.stringify(item[key]);
|
|
789
|
-
}
|
|
790
|
-
const row = columns.map(col => item[col] ? `"${item[col].toString().replace(/"/g, '""')}"` : "").join(',');
|
|
791
|
-
writeStream.write(row + '\n');
|
|
866
|
+
writeStream.write(csvRow(item, columns) + '\n');
|
|
792
867
|
});
|
|
793
868
|
|
|
794
869
|
writeStream.end();
|
|
@@ -1303,7 +1378,7 @@ META
|
|
|
1303
1378
|
* @param {Config} config
|
|
1304
1379
|
*/
|
|
1305
1380
|
function buildFileNames(config) {
|
|
1306
|
-
const { format = "csv", groupKeys = [], lookupTables = [] } = config;
|
|
1381
|
+
const { format = "csv", groupKeys = [], lookupTables = [], warehouseMetrics = [] } = config;
|
|
1307
1382
|
let extension = "";
|
|
1308
1383
|
extension = format === "csv" ? "csv" : "json";
|
|
1309
1384
|
// const current = dayjs.utc().format("MM-DD-HH");
|
|
@@ -1321,10 +1396,12 @@ function buildFileNames(config) {
|
|
|
1321
1396
|
eventFiles: [path.join(writeDir, `${simName}-EVENTS.${extension}`)],
|
|
1322
1397
|
userFiles: [path.join(writeDir, `${simName}-USERS.${extension}`)],
|
|
1323
1398
|
adSpendFiles: [],
|
|
1399
|
+
standaloneFiles: [],
|
|
1324
1400
|
scdFiles: [],
|
|
1325
1401
|
mirrorFiles: [],
|
|
1326
1402
|
groupFiles: [],
|
|
1327
1403
|
lookupFiles: [],
|
|
1404
|
+
warehouseFiles: [],
|
|
1328
1405
|
folder: writeDir,
|
|
1329
1406
|
};
|
|
1330
1407
|
//add ad spend files
|
|
@@ -1332,6 +1409,11 @@ function buildFileNames(config) {
|
|
|
1332
1409
|
writePaths.adSpendFiles.push(path.join(writeDir, `${simName}-AD-SPEND.${extension}`));
|
|
1333
1410
|
}
|
|
1334
1411
|
|
|
1412
|
+
//add standalone (identity-less snapshot) files — v1.8.0
|
|
1413
|
+
if (Array.isArray(config?.standaloneEvents) && config.standaloneEvents.length > 0) {
|
|
1414
|
+
writePaths.standaloneFiles.push(path.join(writeDir, `${simName}-STANDALONE.${extension}`));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1335
1417
|
//add SCD files
|
|
1336
1418
|
const scdKeys = Object.keys(config?.scdProps || {});
|
|
1337
1419
|
for (const key of scdKeys) {
|
|
@@ -1358,6 +1440,15 @@ function buildFileNames(config) {
|
|
|
1358
1440
|
);
|
|
1359
1441
|
}
|
|
1360
1442
|
|
|
1443
|
+
for (const warehouseMetric of warehouseMetrics) {
|
|
1444
|
+
const metricName = warehouseMetric?.name;
|
|
1445
|
+
if (typeof metricName !== 'string') continue;
|
|
1446
|
+
const metricFormat = warehouseMetric?.format || format || 'csv';
|
|
1447
|
+
writePaths.warehouseFiles.push(
|
|
1448
|
+
path.join(writeDir, `${simName}-WAREHOUSE-${metricName}.${metricFormat}`)
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1361
1452
|
//add mirror files
|
|
1362
1453
|
const mirrorProps = config?.mirrorProps || {};
|
|
1363
1454
|
if (Object.keys(mirrorProps).length) {
|
|
@@ -1864,6 +1955,10 @@ export {
|
|
|
1864
1955
|
person,
|
|
1865
1956
|
pickAWinner,
|
|
1866
1957
|
resetValueCaches,
|
|
1958
|
+
setAutoPowerLaw,
|
|
1959
|
+
getAutoPowerLaw,
|
|
1960
|
+
isWeightsForm,
|
|
1961
|
+
isContextAware,
|
|
1867
1962
|
quickHash,
|
|
1868
1963
|
weighArray,
|
|
1869
1964
|
validateEventConfig,
|
|
@@ -1877,6 +1972,7 @@ export {
|
|
|
1877
1972
|
generateUser,
|
|
1878
1973
|
optimizedBoxMuller,
|
|
1879
1974
|
buildFileNames,
|
|
1975
|
+
csvRow,
|
|
1880
1976
|
streamJSON,
|
|
1881
1977
|
streamCSV,
|
|
1882
1978
|
streamParquet,
|
package/lib/verify/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { verifyDungeon } from './verify-dungeon.js';
|
|
|
17
17
|
// `evaluateStories` / `applyFunnelDefaults`. Use the RETURN value; it does not
|
|
18
18
|
// enrich the config you pass in.
|
|
19
19
|
export { validateDungeonConfig } from '../core/config-validator.js';
|
|
20
|
+
export { pearson, computeWarehouseStats, computeWarehouseSourceRows, auditWarehouseRows } from './warehouse.js';
|
|
20
21
|
export { deriveExpectedSchema, validateSchema } from './schema-validator.js';
|
|
21
22
|
export {
|
|
22
23
|
evaluateFunnel,
|
|
@@ -41,6 +41,14 @@ export function deriveExpectedSchema(config) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// v1.7.0 (P1-2): stickyEventProps are engine-stamped on every event of every
|
|
45
|
+
// user — legal on every event type, not flag stamping.
|
|
46
|
+
if (Array.isArray(config.stickyEventProps)) {
|
|
47
|
+
for (const key of config.stickyEventProps) {
|
|
48
|
+
if (typeof key === 'string' && key) globalKeys.add(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
if (config.hasLocation) {
|
|
45
53
|
for (const k of LOCATION_KEYS) globalKeys.add(k);
|
|
46
54
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { emulateBreakdown } from './emulate-breakdown.js';
|
|
29
29
|
import { applyFunnelDefaults } from './verify-dungeon.js';
|
|
30
|
+
import { computeWarehouseSourceRows, computeWarehouseStats } from './warehouse.js';
|
|
30
31
|
|
|
31
32
|
/** Closed archetype enum — MUST match lib/templates/story-spec.schema.json (unit-tested). */
|
|
32
33
|
export const STORY_ARCHETYPES = [
|
|
@@ -322,6 +323,9 @@ export function validateStories(stories) {
|
|
|
322
323
|
err(ap, 'breakdown: required object with a string `type`');
|
|
323
324
|
} else if (a.breakdown.type === 'duckdb' && (typeof a.breakdown.sql !== 'string' || !a.breakdown.sql)) {
|
|
324
325
|
err(ap, 'breakdown: type "duckdb" requires a non-empty `sql`');
|
|
326
|
+
} else if ((a.breakdown.type === 'warehouse' || a.breakdown.type === 'warehouse-stats')
|
|
327
|
+
&& (typeof a.breakdown.table !== 'string' || !a.breakdown.table.trim())) {
|
|
328
|
+
err(ap, `breakdown: type "${a.breakdown.type}" requires a non-empty \`table\``);
|
|
325
329
|
}
|
|
326
330
|
if (a.expect === undefined && typeof a.assert !== 'function') {
|
|
327
331
|
err(ap, 'requires `expect` or a function-valued `assert`');
|
|
@@ -339,8 +343,8 @@ export function validateStories(stories) {
|
|
|
339
343
|
for (const [name, spec] of Object.entries(a.select)) {
|
|
340
344
|
if (!NAME_RE.test(name)) err(ap, `select: name "${name}" must be an identifier`);
|
|
341
345
|
selectNames.add(name);
|
|
342
|
-
if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object'
|
|
343
|
-
err(ap, `select.${name}: requires a
|
|
346
|
+
if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object') {
|
|
347
|
+
err(ap, `select.${name}: requires a \`where\` object`);
|
|
344
348
|
continue;
|
|
345
349
|
}
|
|
346
350
|
for (const [col, cond] of Object.entries(spec.where)) {
|
|
@@ -406,8 +410,8 @@ export function storiesToChecks(stories) {
|
|
|
406
410
|
for (const story of stories) {
|
|
407
411
|
story.assertions.forEach((assertion, i) => {
|
|
408
412
|
const name = `${story.id}[${i}]`;
|
|
409
|
-
if (assertion.breakdown.type === 'duckdb') {
|
|
410
|
-
console.warn(`[dungeon-master] ${name}:
|
|
413
|
+
if (assertion.breakdown.type === 'duckdb' || assertion.breakdown.type === 'warehouse' || assertion.breakdown.type === 'warehouse-stats') {
|
|
414
|
+
console.warn(`[dungeon-master] ${name}: ${assertion.breakdown.type} assertions only run in disk mode via scripts/verify-stories.mjs — skipped in verifyDungeon in-memory checks.`);
|
|
411
415
|
return;
|
|
412
416
|
}
|
|
413
417
|
checks.push({
|
|
@@ -441,11 +445,34 @@ export function storiesToChecks(stories) {
|
|
|
441
445
|
* auto-build over large profile sets is wasteful.
|
|
442
446
|
* @param {(sql: string) => Promise<Array<Object>>} [opts.runSql] - duckdb
|
|
443
447
|
* executor (provided by the CLI in disk mode). Absent → duckdb assertions
|
|
444
|
-
* report NONE
|
|
448
|
+
* report NONE unless skipDiskOnlyDuckdb is set.
|
|
449
|
+
* @param {Record<string, Array<Object>>} [opts.warehouseRows] - Loaded
|
|
450
|
+
* warehouse rows keyed by table name.
|
|
451
|
+
* @param {Record<string, Object>} [opts.warehouseSpecs] - Resolved warehouse
|
|
452
|
+
* metric specs keyed by table name.
|
|
453
|
+
* @param {string|number} [opts.datasetStart] - Dataset window start for
|
|
454
|
+
* in-window warehouse stats comparisons.
|
|
455
|
+
* @param {string|number} [opts.datasetEnd] - Dataset window end for in-window
|
|
456
|
+
* warehouse stats comparisons.
|
|
457
|
+
* @param {boolean} [opts.skipDiskOnlyDuckdb] - Mark duckdb assertions skipped
|
|
458
|
+
* instead of NONE when no SQL executor is available.
|
|
459
|
+
* @param {(message: string) => void} [opts.onWarning] - Warning sink for
|
|
460
|
+
* skipped disk-only assertions.
|
|
445
461
|
* @returns {Promise<Array<{ id: string, hook: string, archetype: string, verdict: string, assertions: Array<{ name: string, verdict: string, observed: number|null, detail: string }> }>>}
|
|
446
462
|
*/
|
|
447
463
|
export async function evaluateStories(stories, events, opts) {
|
|
448
|
-
const {
|
|
464
|
+
const {
|
|
465
|
+
profiles,
|
|
466
|
+
funnels,
|
|
467
|
+
runSql,
|
|
468
|
+
identityMap,
|
|
469
|
+
warehouseRows,
|
|
470
|
+
warehouseSpecs,
|
|
471
|
+
datasetStart,
|
|
472
|
+
datasetEnd,
|
|
473
|
+
skipDiskOnlyDuckdb,
|
|
474
|
+
onWarning,
|
|
475
|
+
} = opts || {};
|
|
449
476
|
const v = validateStories(stories);
|
|
450
477
|
if (!v.valid) {
|
|
451
478
|
throw new Error(`evaluateStories: invalid stories:\n ${v.errors.join('\n ')}`);
|
|
@@ -460,10 +487,21 @@ export async function evaluateStories(stories, events, opts) {
|
|
|
460
487
|
try {
|
|
461
488
|
if (assertion.breakdown.type === 'duckdb') {
|
|
462
489
|
if (!runSql) {
|
|
463
|
-
|
|
490
|
+
if (skipDiskOnlyDuckdb) {
|
|
491
|
+
onWarning?.(`[dungeon-master] ${name}: duckdb assertions only run in disk mode (scripts/verify-stories.mjs) — skipped in-memory.`);
|
|
492
|
+
results.push({ name, verdict: 'SKIPPED', observed: null, detail: 'duckdb assertion requires disk mode (skipped in-memory)' });
|
|
493
|
+
} else {
|
|
494
|
+
results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
|
|
495
|
+
}
|
|
464
496
|
continue;
|
|
465
497
|
}
|
|
466
498
|
rows = await runSql(assertion.breakdown.sql);
|
|
499
|
+
} else if (assertion.breakdown.type === 'warehouse') {
|
|
500
|
+
rows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
|
|
501
|
+
} else if (assertion.breakdown.type === 'warehouse-stats') {
|
|
502
|
+
const spec = resolveWarehouseSpec(assertion.breakdown.table, warehouseSpecs);
|
|
503
|
+
const tableRows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
|
|
504
|
+
rows = [computeWarehouseStats(tableRows, spec, computeWarehouseSourceRows(events, spec), { datasetStart, datasetEnd })];
|
|
467
505
|
} else {
|
|
468
506
|
const bArgs = applyFunnelDefaults(assertion.breakdown, funnels, profiles);
|
|
469
507
|
if (identityMap && bArgs.identityMap === undefined) bArgs.identityMap = identityMap;
|
|
@@ -476,8 +514,33 @@ export async function evaluateStories(stories, events, opts) {
|
|
|
476
514
|
const res = evaluateAssertion(rows, assertion, { events, profiles });
|
|
477
515
|
results.push({ name, ...res });
|
|
478
516
|
}
|
|
479
|
-
const
|
|
517
|
+
const counted = results.filter((result) => result.verdict !== 'SKIPPED');
|
|
518
|
+
const worst = counted.length
|
|
519
|
+
? counted.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED')
|
|
520
|
+
: 'SKIPPED';
|
|
480
521
|
out.push({ id: story.id, hook: story.hook, archetype: story.archetype, verdict: worst, assertions: results });
|
|
481
522
|
}
|
|
482
523
|
return out;
|
|
483
524
|
}
|
|
525
|
+
|
|
526
|
+
function resolveWarehouseSpec(table, warehouseSpecs) {
|
|
527
|
+
const spec = warehouseSpecs?.[table];
|
|
528
|
+
if (!spec) throw new Error(`warehouse table "${table}" is not available`);
|
|
529
|
+
return spec;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveWarehouseRows(table, warehouseRows, warehouseSpecs) {
|
|
533
|
+
const spec = resolveWarehouseSpec(table, warehouseSpecs);
|
|
534
|
+
const rows = warehouseRows?.[table];
|
|
535
|
+
if (!Array.isArray(rows)) throw new Error(`warehouse rows for table "${table}" are not available`);
|
|
536
|
+
return rows.map((row) => withWarehouseTimestamp(row, spec));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function withWarehouseTimestamp(row, spec) {
|
|
540
|
+
if (Number.isFinite(row?.__t)) return row;
|
|
541
|
+
const raw = row?.[spec.timeColumn];
|
|
542
|
+
const parsed = typeof raw === 'string'
|
|
543
|
+
? Date.parse(/T/.test(raw) ? raw : `${raw}T00:00:00Z`)
|
|
544
|
+
: NaN;
|
|
545
|
+
return { ...row, __t: Number.isFinite(parsed) ? parsed / 1000 : null };
|
|
546
|
+
}
|