@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
|
@@ -90,8 +90,10 @@
|
|
|
90
90
|
* trend-interval end and the data-pull range — fix-round C6).
|
|
91
91
|
* @property {boolean} [graceperiod=true] - Enable the 2-second grace window
|
|
92
92
|
* on ordering checks. Disable only for tests that need strict ordering.
|
|
93
|
-
* @property {boolean} [reentry=false] - When true, after
|
|
94
|
-
*
|
|
93
|
+
* @property {boolean} [reentry=false] - When true, restart after the inclusive
|
|
94
|
+
* 2-second completion grace. An event recording the ordered last step and
|
|
95
|
+
* matching the ordered first step also anchors the next attempt immediately.
|
|
96
|
+
* `graceperiod: false` disables the completion wait. Increments `completions`.
|
|
95
97
|
* Attempts ALSO restart when the conversion window expires (fix-round
|
|
96
98
|
* B2+C5): an incoming event past the window from step 0 finalizes the
|
|
97
99
|
* live attempt as a drop-off and processes against a fresh one — ARB
|
|
@@ -124,8 +126,8 @@
|
|
|
124
126
|
* fresh one processes the same event (funnel_query.cpp:1608-1613 — for
|
|
125
127
|
* GENERAL_WO_REPEAT termination checks ONLY history_is_past_conversion_
|
|
126
128
|
* window, not history_is_mutable; re-birth :1663-1680). Contrast
|
|
127
|
-
* `reentry: true` (GENERAL), which also restarts
|
|
128
|
-
*
|
|
129
|
+
* `reentry: true` (GENERAL), which also restarts after completion grace or
|
|
130
|
+
* exclusion, permitting repeat conversions within one window.
|
|
129
131
|
* Requires `countMode: 'totals'`; mutually exclusive with `reentry` and
|
|
130
132
|
* `sessionScoped`.
|
|
131
133
|
* @property {boolean} [sessionScoped=false] - **@deprecated — verifier-only, NOT
|
|
@@ -373,7 +375,7 @@ function emptyResult(trackStepProperties) {
|
|
|
373
375
|
* @returns {{ result: FunnelResult, nextIdx: number, terminatedByExclusion: boolean, expiredByWindow: boolean }}
|
|
374
376
|
*/
|
|
375
377
|
function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
376
|
-
const { windowCheck, graceperiod, trackStepProperties, anchorOk, isAnyOrder, prevAnchor, nextAnchor, expireOnWindow = false, woRepeat = false } = options;
|
|
378
|
+
const { windowCheck, graceperiod, trackStepProperties, anchorOk, isAnyOrder, prevAnchor, nextAnchor, expireOnWindow = false, woRepeat = false, restartSharedEdge = false } = options;
|
|
377
379
|
const numSteps = steps.length;
|
|
378
380
|
// Per-SLOT recorded candidates (history->steps): the latest match for
|
|
379
381
|
// anchors, the first eligible match for active any-order chunk members.
|
|
@@ -402,8 +404,9 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
|
402
404
|
let reached = -1;
|
|
403
405
|
let terminatedByExclusion = false;
|
|
404
406
|
let expiredByWindow = false;
|
|
407
|
+
let sharedEdgeCompleted = false;
|
|
405
408
|
let tailAnchorMs = 0; // terminating exclusion time, or last-step time on completion
|
|
406
|
-
let endIdx = -1; //
|
|
409
|
+
let endIdx = -1; // first event eligible for the next attempt
|
|
407
410
|
let i = startIdx;
|
|
408
411
|
|
|
409
412
|
// Does exclusion `ex` apply to gap g? afterStep/beforeStep bound the
|
|
@@ -586,8 +589,10 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
|
586
589
|
|
|
587
590
|
if (reached === numSteps - 1 && !terminatedByExclusion) {
|
|
588
591
|
tailAnchorMs = timeAtPos(numSteps - 1);
|
|
589
|
-
|
|
590
|
-
|
|
592
|
+
sharedEdgeCompleted = !woRepeat && numSteps > 1 && s === numSteps - 1
|
|
593
|
+
&& !isAnyOrder[0] && !isAnyOrder[numSteps - 1] && eventMatchesStep(ev, steps[0]);
|
|
594
|
+
endIdx = sharedEdgeCompleted && restartSharedEdge ? i : i + 1;
|
|
595
|
+
// Ordinary completions remain open for the 2s exclusion tail.
|
|
591
596
|
}
|
|
592
597
|
return true;
|
|
593
598
|
};
|
|
@@ -606,10 +611,10 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
|
606
611
|
// nextIdx = i, not i + 1. For GENERAL_WO_REPEAT (woRepeat) expiry is
|
|
607
612
|
// the ONLY termination — it fires even on completed/excluded attempts
|
|
608
613
|
// (:1611-1613). For GENERAL (the reentry loop) completion/exclusion
|
|
609
|
-
// keep their own restart below
|
|
610
|
-
//
|
|
614
|
+
// keep their own restart below; independent window expiry also
|
|
615
|
+
// finalizes a completion still inside its grace period.
|
|
611
616
|
if (expireOnWindow && reached >= 0
|
|
612
|
-
&& (woRepeat ||
|
|
617
|
+
&& (woRepeat || !terminatedByExclusion)
|
|
613
618
|
&& !windowCheck(t, timeAtPos(0), ev, eventAtPos(0))
|
|
614
619
|
) {
|
|
615
620
|
expiredByWindow = true;
|
|
@@ -654,9 +659,15 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
|
654
659
|
// the event on their own: anchors need reached < slot; claimed
|
|
655
660
|
// any-order chunk members are first-match-sealed — so
|
|
656
661
|
// exclusions-only here matches ARB.)
|
|
657
|
-
if (!
|
|
658
|
-
|
|
659
|
-
|
|
662
|
+
if (!graceperiod || t > tailAnchorMs + OUT_OF_ORDER_MS) {
|
|
663
|
+
endIdx = i;
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
endIdx = i + 1;
|
|
667
|
+
if (hasExclusions) {
|
|
668
|
+
for (let g = 0; g < numSteps - 1; g++) {
|
|
669
|
+
if (tryExclusionAtGap(g, ev, t)) break;
|
|
670
|
+
}
|
|
660
671
|
}
|
|
661
672
|
continue;
|
|
662
673
|
}
|
|
@@ -693,6 +704,7 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
|
693
704
|
break;
|
|
694
705
|
}
|
|
695
706
|
}
|
|
707
|
+
if (sharedEdgeCompleted) break;
|
|
696
708
|
}
|
|
697
709
|
|
|
698
710
|
// Result surfaces the PATH (positions), not the slot table: position p was
|
|
@@ -772,6 +784,17 @@ function runOneAttempt(sorted, startIdx, steps, exclusionSteps, options) {
|
|
|
772
784
|
* @returns {FunnelResult | FunnelResult[]}
|
|
773
785
|
*/
|
|
774
786
|
export function evaluateFunnel(events, steps, options = {}) {
|
|
787
|
+
return evaluateFunnelWithContext(events, steps, options);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* @param {Array<Object>} events
|
|
792
|
+
* @param {FunnelStep[]} steps
|
|
793
|
+
* @param {FunnelOptions} options
|
|
794
|
+
* @param {Map<Object, number>} [fullStreamOrdinals]
|
|
795
|
+
* @returns {FunnelResult | FunnelResult[]}
|
|
796
|
+
*/
|
|
797
|
+
function evaluateFunnelWithContext(events, steps, options, fullStreamOrdinals) {
|
|
775
798
|
if (options.countMode === 'sessions') {
|
|
776
799
|
// Mixpanel's funnel "count by Sessions" is an API rewrite, not an
|
|
777
800
|
// engine mode: count_type session REQUIRES window (session, 1) and
|
|
@@ -790,14 +813,14 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
790
813
|
if (typeof options.conversionWindowMs === 'number' || (cw != null && !(cw.unit === 'sessions' && cw.n === 1))) {
|
|
791
814
|
throw new Error("evaluateFunnel: cannot use countMode 'sessions' without conversion window = 1 session");
|
|
792
815
|
}
|
|
793
|
-
return
|
|
816
|
+
return evaluateFunnelWithContext(events, steps, {
|
|
794
817
|
...options,
|
|
795
818
|
countMode: 'totals',
|
|
796
819
|
reentry: false,
|
|
797
820
|
woRepeat: true,
|
|
798
821
|
conversionWindowMs: undefined,
|
|
799
822
|
conversionWindow: { unit: 'sessions', n: 1 },
|
|
800
|
-
});
|
|
823
|
+
}, fullStreamOrdinals);
|
|
801
824
|
}
|
|
802
825
|
if (!Array.isArray(steps) || steps.length === 0) {
|
|
803
826
|
const empty = emptyResult(options.trackStepProperties);
|
|
@@ -871,7 +894,7 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
871
894
|
// the data-pull range (:402, :1408-1412) — the timeBucket wrapper's
|
|
872
895
|
// [start, stop + n×day) spill slice mirrors exactly that. Spec P1.6.1's
|
|
873
896
|
// dual per-step condition was a misreading; dropped per fix-round C6.
|
|
874
|
-
const ordinals = sessionOrdinals(sorted);
|
|
897
|
+
const ordinals = fullStreamOrdinals ?? sessionOrdinals(sorted);
|
|
875
898
|
windowCheck = (t, t0, ev, step0Ev) => {
|
|
876
899
|
const o = ordinals.get(ev);
|
|
877
900
|
const o0 = ordinals.get(step0Ev);
|
|
@@ -891,7 +914,7 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
891
914
|
}
|
|
892
915
|
const allResults = [];
|
|
893
916
|
for (const [sid, evs] of bySession) {
|
|
894
|
-
const sub =
|
|
917
|
+
const sub = evaluateFunnelWithContext(evs, steps, { ...options, sessionScoped: false }, fullStreamOrdinals);
|
|
895
918
|
if (Array.isArray(sub)) {
|
|
896
919
|
for (const r of sub) { r.sessionId = sid; allResults.push(r); }
|
|
897
920
|
} else {
|
|
@@ -952,7 +975,7 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
952
975
|
const completedAttempts = [];
|
|
953
976
|
let idx = 0;
|
|
954
977
|
let lastResult = emptyResult(trackStepProperties);
|
|
955
|
-
const reentryOpts = { ...opts, expireOnWindow: true };
|
|
978
|
+
const reentryOpts = { ...opts, expireOnWindow: true, restartSharedEdge: true };
|
|
956
979
|
while (idx < sorted.length) {
|
|
957
980
|
const { result, nextIdx, terminatedByExclusion } = runOneAttempt(sorted, idx, normSteps, exclusionSteps, reentryOpts);
|
|
958
981
|
// Always advance — runOneAttempt returns nextIdx > idx when it processed an event.
|
|
@@ -990,7 +1013,8 @@ export function evaluateFunnel(events, steps, options = {}) {
|
|
|
990
1013
|
* `Map<propertyValue, FunnelResult>`.
|
|
991
1014
|
*
|
|
992
1015
|
* Each sub-funnel runs independently (a user CAN convert in one HPC value
|
|
993
|
-
* group and drop off in another simultaneously).
|
|
1016
|
+
* group and drop off in another simultaneously). Session windows derive
|
|
1017
|
+
* ordinals from the full user stream before routing events to HPC buckets.
|
|
994
1018
|
*
|
|
995
1019
|
* Reference: `funnel_query.cpp` lines 749-784 (`aggregate_hash_get_key_cursor`).
|
|
996
1020
|
*
|
|
@@ -1017,6 +1041,9 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
|
1017
1041
|
throw new Error('evaluateFunnelHPC: an anyOrder block cannot be the first funnel step');
|
|
1018
1042
|
}
|
|
1019
1043
|
const step0Name = flat[0].event;
|
|
1044
|
+
const fullStreamOrdinals = options.countMode === 'sessions' || options.conversionWindow?.unit === 'sessions'
|
|
1045
|
+
? sessionOrdinals((events || []).filter(ev => ev && typeof ev.event === 'string'))
|
|
1046
|
+
: undefined;
|
|
1020
1047
|
|
|
1021
1048
|
// Bucket events by HPC value. The step-0 events define the universe of
|
|
1022
1049
|
// HPC values for this user; later events only populate buckets whose
|
|
@@ -1042,15 +1069,15 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
|
1042
1069
|
|
|
1043
1070
|
const out = new Map();
|
|
1044
1071
|
for (const [v, evs] of valueBuckets) {
|
|
1045
|
-
out.set(v,
|
|
1072
|
+
out.set(v, evaluateFunnelWithContext(evs, steps, options, fullStreamOrdinals));
|
|
1046
1073
|
}
|
|
1047
1074
|
return out;
|
|
1048
1075
|
}
|
|
1049
1076
|
|
|
1050
1077
|
/**
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1078
|
+
* Merge reached `stepProperties` in recorded path order for FIRST_TOUCH or
|
|
1079
|
+
* LAST_TOUCH. Undefined never replaces a defined value; null never replaces
|
|
1080
|
+
* a non-null defined value. STEP returns its selected snapshot unchanged.
|
|
1054
1081
|
*
|
|
1055
1082
|
* @param {FunnelResult} result
|
|
1056
1083
|
* @param {'first'|'last' | { step: number }} mode
|
|
@@ -1058,8 +1085,21 @@ export function evaluateFunnelHPC(events, steps, holdProperty, options = {}) {
|
|
|
1058
1085
|
*/
|
|
1059
1086
|
export function resolveFunnelSegment(result, mode) {
|
|
1060
1087
|
if (!result || !Array.isArray(result.stepProperties) || !result.stepProperties.length) return undefined;
|
|
1061
|
-
if (mode === 'first')
|
|
1062
|
-
|
|
1088
|
+
if (mode === 'first' || mode === 'last') {
|
|
1089
|
+
const merged = {};
|
|
1090
|
+
const snapshots = result.stepProperties.slice(0, result.reached + 1);
|
|
1091
|
+
if (mode === 'first') snapshots.reverse();
|
|
1092
|
+
for (const snapshot of snapshots) {
|
|
1093
|
+
for (const [key, value] of Object.entries(snapshot || {})) {
|
|
1094
|
+
const current = Object.prototype.hasOwnProperty.call(merged, key) ? merged[key] : undefined;
|
|
1095
|
+
if (current === undefined || (current === null && value !== undefined)
|
|
1096
|
+
|| (value !== undefined && value !== null)) {
|
|
1097
|
+
Object.defineProperty(merged, key, { value, enumerable: true, configurable: true, writable: true });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return merged;
|
|
1102
|
+
}
|
|
1063
1103
|
if (mode && typeof mode === 'object' && typeof mode.step === 'number') {
|
|
1064
1104
|
return result.stepProperties[mode.step];
|
|
1065
1105
|
}
|
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,
|
|
@@ -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
|
+
}
|