@ak--47/dungeon-master 1.5.4 → 1.6.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 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* Insights view shows a deliberate shape (e.g., users with 5–20 cohort events
|
|
7
7
|
* have 2x the metric event count of users with <5).
|
|
8
8
|
*
|
|
9
|
-
* Mechanism: classify the user into a bin
|
|
10
|
-
*
|
|
9
|
+
* Mechanism: classify the user into a bin from their `cohortEvent` activity —
|
|
10
|
+
* distinct calendar days by default (v1.6, matching Mixpanel's frequency axis;
|
|
11
|
+
* see `binByDistinctPeriods`), or total events with `binBy: 'events'` — then
|
|
12
|
+
* `scaleEventCount(events, targetEvent, multipliers[bin])` to scale that
|
|
11
13
|
* user's count of the target event up or down.
|
|
12
14
|
*
|
|
13
15
|
* Identity & schema constraints:
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import { binUsersByEventCount } from '../hook-helpers/cohort.js';
|
|
24
|
+
import { binByDistinctPeriods } from '../verify/counting.js';
|
|
22
25
|
import { scaleEventCount } from '../hook-helpers/mutate.js';
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -30,14 +33,21 @@ import { scaleEventCount } from '../hook-helpers/mutate.js';
|
|
|
30
33
|
* @param {string} opts.targetEvent - Event whose count is scaled per bin.
|
|
31
34
|
* @param {Record<string, number>} opts.multipliers - Bin name → multiplier (1 = no-op,
|
|
32
35
|
* 2 = double, 0.5 = halve). Bins absent from this map use multiplier 1.
|
|
36
|
+
* @param {('events'|'distinctDays')} [opts.binBy='distinctDays'] - Cohort axis.
|
|
37
|
+
* `'distinctDays'` (default since v1.6) bins by distinct calendar days with
|
|
38
|
+
* `cohortEvent` via `binByDistinctPeriods` — the axis Mixpanel's frequency
|
|
39
|
+
* breakdown and the local emulator actually use. `'events'` restores the
|
|
40
|
+
* pre-1.6 total-event-count axis.
|
|
33
41
|
* @returns {{ bin: string|null, delta: number }} Bin assigned + signed delta from
|
|
34
42
|
* scaleEventCount (positive = clones added; negative = events dropped).
|
|
35
43
|
*/
|
|
36
|
-
export function applyFrequencyByFrequency(events, _profile, { cohortEvent, bins, targetEvent, multipliers }) {
|
|
44
|
+
export function applyFrequencyByFrequency(events, _profile, { cohortEvent, bins, targetEvent, multipliers, binBy = 'distinctDays' }) {
|
|
37
45
|
if (!events || !cohortEvent || !bins || !targetEvent || !multipliers) {
|
|
38
46
|
return { bin: null, delta: 0 };
|
|
39
47
|
}
|
|
40
|
-
const bin =
|
|
48
|
+
const bin = binBy === 'events'
|
|
49
|
+
? binUsersByEventCount(events, cohortEvent, bins)
|
|
50
|
+
: binByDistinctPeriods(events, cohortEvent, bins, 'day');
|
|
41
51
|
if (!bin) return { bin: null, delta: 0 };
|
|
42
52
|
const factor = multipliers[bin];
|
|
43
53
|
if (typeof factor !== 'number' || factor === 1) return { bin, delta: 0 };
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Pattern: Funnel Frequency Breakdown.
|
|
3
3
|
*
|
|
4
4
|
* Inside a `funnel-post` hook, vary the user's completion of the funnel by their
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* `cohortEvent` activity (anywhere in the dataset, not just the funnel) —
|
|
6
|
+
* distinct calendar days by default (v1.6), or total events with
|
|
7
|
+
* `binBy: 'events'`. Used when you want Mixpanel's funnel report — broken down
|
|
8
|
+
* by per-user frequency of an activity event — to show e.g. "users active 5+
|
|
9
|
+
* days with X are 1.4x as likely to complete this funnel."
|
|
9
10
|
*
|
|
10
11
|
* Mechanism: for users in a "drop-prone" bin, drop the funnel's final step
|
|
11
12
|
* event(s) per the `dropMultipliers` config (1 = drop none, 0 = drop all).
|
|
@@ -15,13 +16,17 @@
|
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { binUsersByEventCount } from '../hook-helpers/cohort.js';
|
|
19
|
+
import { binByDistinctPeriods } from '../verify/counting.js';
|
|
18
20
|
import { dropEventsWhere } from '../hook-helpers/mutate.js';
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* @param {Array<Object>} allUserEvents - Full per-user event history (read-only;
|
|
22
24
|
* used to count `cohortEvent`). When called inside `funnel-post`, derive this
|
|
23
25
|
* from `meta.profile` or pass the user's accumulated events from a closure.
|
|
24
|
-
* When `null`, falls back to counting cohortEvent inside `funnelEvents
|
|
26
|
+
* When `null`, falls back to counting cohortEvent inside `funnelEvents` —
|
|
27
|
+
* note the fallback makes the default `'distinctDays'` axis degenerate (a
|
|
28
|
+
* single funnel run rarely spans more than one calendar day); pass
|
|
29
|
+
* `binBy: 'events'` when only funnel events are available.
|
|
25
30
|
* @param {Object} _profile
|
|
26
31
|
* @param {Array<Object>} funnelEvents - Funnel events produced by `makeFunnel`
|
|
27
32
|
* (mutated in place).
|
|
@@ -32,15 +37,22 @@ import { dropEventsWhere } from '../hook-helpers/mutate.js';
|
|
|
32
37
|
* for the FINAL step event. 1 = always keep, 0 = always drop.
|
|
33
38
|
* @param {string} [opts.finalStep] - Event name of the final step. Defaults to the
|
|
34
39
|
* last event in `funnelEvents` (in time order).
|
|
40
|
+
* @param {('events'|'distinctDays')} [opts.binBy='distinctDays'] - Cohort axis.
|
|
41
|
+
* `'distinctDays'` (default since v1.6) bins by distinct calendar days with
|
|
42
|
+
* `cohortEvent` via `binByDistinctPeriods` — the axis Mixpanel's funnel
|
|
43
|
+
* frequency breakdown and the local emulator use. `'events'` restores the
|
|
44
|
+
* pre-1.6 total-event-count axis.
|
|
35
45
|
* @returns {{ bin: string|null, droppedFinal: boolean }}
|
|
36
46
|
*/
|
|
37
47
|
export function applyFunnelFrequencyBreakdown(allUserEvents, _profile, funnelEvents, opts) {
|
|
38
|
-
const { cohortEvent, bins, dropMultipliers, finalStep } = opts || {};
|
|
48
|
+
const { cohortEvent, bins, dropMultipliers, finalStep, binBy = 'distinctDays' } = opts || {};
|
|
39
49
|
if (!funnelEvents || !cohortEvent || !bins || !dropMultipliers) {
|
|
40
50
|
return { bin: null, droppedFinal: false };
|
|
41
51
|
}
|
|
42
52
|
const sourceForBin = allUserEvents || funnelEvents;
|
|
43
|
-
const bin =
|
|
53
|
+
const bin = binBy === 'events'
|
|
54
|
+
? binUsersByEventCount(sourceForBin, cohortEvent, bins)
|
|
55
|
+
: binByDistinctPeriods(sourceForBin, cohortEvent, bins, 'day');
|
|
44
56
|
if (!bin) return { bin: null, droppedFinal: false };
|
|
45
57
|
const keepRate = dropMultipliers[bin];
|
|
46
58
|
if (typeof keepRate !== 'number' || keepRate >= 1) return { bin, droppedFinal: false };
|
|
@@ -54,11 +66,11 @@ export function applyFunnelFrequencyBreakdown(allUserEvents, _profile, funnelEve
|
|
|
54
66
|
}
|
|
55
67
|
if (!stepName) return { bin, droppedFinal: false };
|
|
56
68
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
69
|
+
// Deterministic drop decision without consuming the seeded RNG stream:
|
|
70
|
+
// hash the funnel's first insert_id (fallback: time) through
|
|
71
|
+
// simpleHashFloat — FNV-1a over the full string, quantized to 1/1000 —
|
|
72
|
+
// and drop when the hash lands under (1 - keepRate). Per-funnel stable,
|
|
73
|
+
// so reruns with the same seed drop the same funnels.
|
|
62
74
|
const dropProb = 1 - keepRate;
|
|
63
75
|
const seed = funnelEvents[0] && (funnelEvents[0].insert_id || funnelEvents[0].time) || '';
|
|
64
76
|
const det = simpleHashFloat(String(seed));
|
|
@@ -10,5 +10,5 @@
|
|
|
10
10
|
export { applyFrequencyByFrequency } from './frequency-by-frequency.js';
|
|
11
11
|
export { applyFunnelFrequencyBreakdown } from './funnel-frequency-breakdown.js';
|
|
12
12
|
export { applyAggregateByBin } from './aggregate-per-user-by-bin.js';
|
|
13
|
-
export { applyTTCBySegment } from './time-to-convert-by-segment.js';
|
|
13
|
+
export { applyTTCBySegment, applyTTCBySegmentV2 } from './time-to-convert-by-segment.js';
|
|
14
14
|
export { applyAttributedBySource } from './attributed-by-source.js';
|
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pattern: Time to Convert, broken down by user segment.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* keyed off of a user-profile property value (e.g., trial users convert 3x
|
|
6
|
-
* slower than enterprise). Lets Mixpanel's TTC funnel report — broken down by
|
|
7
|
-
* a profile property — show a deliberate spread.
|
|
4
|
+
* Two generations:
|
|
8
5
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* - `applyTTCBySegmentV2` (v1.6, use this) — `everything` hook. Finds the
|
|
7
|
+
* user's FIRST in-order occurrence of the funnel steps across their whole
|
|
8
|
+
* stream and scales those gaps. This is the sequence Mixpanel's greedy
|
|
9
|
+
* funnel engine actually measures (HOOKS.md §2.2 / recipe 4.14), so the
|
|
10
|
+
* scaling shows up in the TTC report and in the local verifier.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* for
|
|
12
|
+
* - `applyTTCBySegment` (deprecated) — `funnel-post` hook. Scales one funnel
|
|
13
|
+
* run's internal gaps. Mixpanel's TTC uses the FIRST occurrence of each
|
|
14
|
+
* step per user, not the gaps inside any one run — so for usage funnels
|
|
15
|
+
* (and any run that isn't the user's first) the scaling never reaches the
|
|
16
|
+
* report. Kept for compatibility; emits a one-time console warning.
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
|
-
import { scaleFunnelTTC } from '../hook-helpers/timing.js';
|
|
19
|
+
import { scaleFunnelTTC, findFirstSequence } from '../hook-helpers/timing.js';
|
|
20
|
+
|
|
21
|
+
let warnedDeprecated = false;
|
|
21
22
|
|
|
22
23
|
/**
|
|
24
|
+
* @deprecated since v1.6 — use `applyTTCBySegmentV2` from an `everything`
|
|
25
|
+
* hook. This funnel-post variant scales gaps inside ONE funnel run, but
|
|
26
|
+
* Mixpanel's TTC report measures the FIRST occurrence of each step per user
|
|
27
|
+
* (greedy engine, HOOKS.md §2.2) — so unless the run happens to be the
|
|
28
|
+
* user's first occurrence of those steps (`isFirstFunnel`), the scaling
|
|
29
|
+
* never moves the report.
|
|
30
|
+
*
|
|
23
31
|
* @param {Array<Object>} funnelEvents - Mutated in place.
|
|
24
32
|
* @param {Object} profile - The user's profile (must contain `segmentKey`).
|
|
25
33
|
* @param {Object} opts
|
|
@@ -28,6 +36,10 @@ import { scaleFunnelTTC } from '../hook-helpers/timing.js';
|
|
|
28
36
|
* @returns {{ segmentValue: any, factor: number, shifted: number }}
|
|
29
37
|
*/
|
|
30
38
|
export function applyTTCBySegment(funnelEvents, profile, { segmentKey, factors }) {
|
|
39
|
+
if (!warnedDeprecated) {
|
|
40
|
+
warnedDeprecated = true;
|
|
41
|
+
console.warn('[dungeon-master] applyTTCBySegment is deprecated — use applyTTCBySegmentV2 from an `everything` hook (funnel-post scaling only reaches Mixpanel TTC for isFirstFunnel runs). This warning fires once.');
|
|
42
|
+
}
|
|
31
43
|
if (!funnelEvents || !funnelEvents.length || !profile || !segmentKey || !factors) {
|
|
32
44
|
return { segmentValue: null, factor: 1, shifted: 0 };
|
|
33
45
|
}
|
|
@@ -39,3 +51,53 @@ export function applyTTCBySegment(funnelEvents, profile, { segmentKey, factors }
|
|
|
39
51
|
const shifted = scaleFunnelTTC(funnelEvents, factor);
|
|
40
52
|
return { segmentValue, factor, shifted };
|
|
41
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scale the user's funnel time-to-convert by a profile-segment factor —
|
|
57
|
+
* operating on the FIRST in-order occurrence of `steps` across the user's
|
|
58
|
+
* whole stream, which is what Mixpanel's greedy funnel engine measures
|
|
59
|
+
* (recipe 4.14). Call from the `everything` hook.
|
|
60
|
+
*
|
|
61
|
+
* Mechanism: `findFirstSequence(events, steps, maxGapMinutes)` locates the
|
|
62
|
+
* greedy first-occurrence chain; `scaleFunnelTTC` multiplies each matched
|
|
63
|
+
* step's offset from the first step by `factors[profile[segmentKey]]`. The
|
|
64
|
+
* anchor step's time is unchanged; only the matched step events move.
|
|
65
|
+
*
|
|
66
|
+
* Conversion-window caveat: a factor > 1 can push the final step past the
|
|
67
|
+
* funnel's `conversionWindowDays` cap, where Mixpanel's strict-`<` rule
|
|
68
|
+
* excludes the conversion entirely — clamp your factors the way recipe 4.28
|
|
69
|
+
* does when scaling up. Timestamp moves are session-safe in v1.6 (P2.1
|
|
70
|
+
* re-derives `session_id` after the everything hook).
|
|
71
|
+
*
|
|
72
|
+
* @param {Array<Object>} events - User's FULL event stream (mutated in place).
|
|
73
|
+
* @param {Object} profile - The user's profile (must contain `segmentKey`).
|
|
74
|
+
* @param {Object} opts
|
|
75
|
+
* @param {string} opts.segmentKey - Profile property name to look up.
|
|
76
|
+
* @param {Record<string, number>} opts.factors - Profile-value → TTC factor
|
|
77
|
+
* (0.5 halves the TTC, 2 doubles it; 1 or missing = no-op).
|
|
78
|
+
* @param {string[]} opts.steps - Ordered funnel step event names.
|
|
79
|
+
* @param {number} [opts.maxGapMinutes=43200] - Max gap between CONSECUTIVE
|
|
80
|
+
* matched steps, in minutes (default 30 days). NOTE this is a per-gap
|
|
81
|
+
* bound, NOT Mixpanel's conversion window: Mixpanel's window caps the
|
|
82
|
+
* TOTAL time from step 0 to the final step, so a k-step sequence matched
|
|
83
|
+
* here can span up to (k−1) × maxGapMinutes — well past a same-length
|
|
84
|
+
* conversion window. When targeting a funnel report, keep total intended
|
|
85
|
+
* TTC (gaps × factor) under the funnel's `conversionWindowDays`.
|
|
86
|
+
* @returns {{ segmentValue: any, factor: number, shifted: number }} `shifted`
|
|
87
|
+
* = number of step events whose timestamps moved (0 when the user has no
|
|
88
|
+
* qualifying sequence or the factor is a no-op).
|
|
89
|
+
*/
|
|
90
|
+
export function applyTTCBySegmentV2(events, profile, { segmentKey, factors, steps, maxGapMinutes = 43200 }) {
|
|
91
|
+
if (!events || !events.length || !profile || !segmentKey || !factors || !Array.isArray(steps) || steps.length < 2) {
|
|
92
|
+
return { segmentValue: null, factor: 1, shifted: 0 };
|
|
93
|
+
}
|
|
94
|
+
const segmentValue = profile[segmentKey];
|
|
95
|
+
const factor = factors[segmentValue];
|
|
96
|
+
if (typeof factor !== 'number' || factor === 1) {
|
|
97
|
+
return { segmentValue, factor: 1, shifted: 0 };
|
|
98
|
+
}
|
|
99
|
+
const seq = findFirstSequence(events, steps, maxGapMinutes);
|
|
100
|
+
if (!seq) return { segmentValue, factor, shifted: 0 };
|
|
101
|
+
const shifted = scaleFunnelTTC(seq, factor);
|
|
102
|
+
return { segmentValue, factor, shifted };
|
|
103
|
+
}
|
|
@@ -459,6 +459,7 @@ export async function userLoop(context) {
|
|
|
459
459
|
|
|
460
460
|
// ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
|
|
461
461
|
let userChurned = false;
|
|
462
|
+
let userChurnTimeMs = null; // P2.2: timestamp of the churn event that ended the user
|
|
462
463
|
const sessionTimeout = config.sessionTimeout || 30;
|
|
463
464
|
|
|
464
465
|
// Standalone identity stamping mode: pre-existing or post-auth users get both
|
|
@@ -600,6 +601,8 @@ export async function userLoop(context) {
|
|
|
600
601
|
const userReturns = returnLikelihood > 0 && chance.bool({ likelihood: returnLikelihood * 100 });
|
|
601
602
|
if (!userReturns) {
|
|
602
603
|
userChurned = true;
|
|
604
|
+
const t = typeof ev.time === 'string' ? Date.parse(ev.time) : Number(ev.time);
|
|
605
|
+
if (Number.isFinite(t)) userChurnTimeMs = t;
|
|
603
606
|
break;
|
|
604
607
|
}
|
|
605
608
|
}
|
|
@@ -611,6 +614,20 @@ export async function userLoop(context) {
|
|
|
611
614
|
// Remove events flagged as future timestamps (before dungeon hooks see them)
|
|
612
615
|
usersEvents = usersEvents.filter(e => !e._drop);
|
|
613
616
|
|
|
617
|
+
// v1.6 (P2.2): churn is a hard activity boundary. Breaking the budget
|
|
618
|
+
// loop stopped GENERATION, but already-generated events carry
|
|
619
|
+
// independent TimeSoup timestamps over a shuffled active-day plan — so
|
|
620
|
+
// churned users kept events dated after their churn. Filter to
|
|
621
|
+
// time <= churnTime (the churn event itself survives: its own
|
|
622
|
+
// timestamp satisfies <=).
|
|
623
|
+
if (userChurned && userChurnTimeMs !== null) {
|
|
624
|
+
usersEvents = usersEvents.filter(e => {
|
|
625
|
+
if (!e || !e.time) return true;
|
|
626
|
+
const t = typeof e.time === 'string' ? Date.parse(e.time) : Number(e.time);
|
|
627
|
+
return Number.isFinite(t) ? t <= userChurnTimeMs : true;
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
614
631
|
// Feature 3: Engagement decay — filter behavioral events
|
|
615
632
|
const userDecay = persona?.engagementDecay || globalEngagementDecay;
|
|
616
633
|
if (userDecay && userDecay.model !== 'none' && usersEvents.length > 0) {
|
|
@@ -751,6 +768,30 @@ export async function userLoop(context) {
|
|
|
751
768
|
console.warn(`⚠️ Dropped ${droppedFuture} future-dated event(s) for user ${distinct_id}`);
|
|
752
769
|
}
|
|
753
770
|
|
|
771
|
+
// v1.6 (P2.1): re-derive session_ids on the FINAL event set. The first
|
|
772
|
+
// assignSessionIds pass (above) runs before the everything hook so hooks
|
|
773
|
+
// can read session_id — but time-mutating hooks (TTC scaling, injected
|
|
774
|
+
// bursts) leave stale labels that disagree with what Mixpanel computes
|
|
775
|
+
// from timestamps at query time. Relabeling is deterministic: IDs hash
|
|
776
|
+
// from (userKey + first event time of the session), so sessions whose
|
|
777
|
+
// events didn't move keep their exact IDs. The per-session sticky-device
|
|
778
|
+
// rewrite is intentionally NOT re-run — device stamping is hook-visible
|
|
779
|
+
// identity state, and relabeling sessions must not mutate identity fields.
|
|
780
|
+
if (hasSessionIds && usersEvents.length > 0) {
|
|
781
|
+
// assignSessionIds requires ascending time order. Relabel over a
|
|
782
|
+
// sorted shallow copy so `autoSortAfterEverything: false` users keep
|
|
783
|
+
// their chosen array order — stamps land on the shared event objects.
|
|
784
|
+
const sortedView = [...usersEvents].sort((a, b) => {
|
|
785
|
+
const ta = (a && typeof a.time === 'string') ? Date.parse(a.time) : Number(a && a.time);
|
|
786
|
+
const tb = (b && typeof b.time === 'string') ? Date.parse(b.time) : Number(b && b.time);
|
|
787
|
+
if (!Number.isFinite(ta) && !Number.isFinite(tb)) return 0;
|
|
788
|
+
if (!Number.isFinite(ta)) return 1;
|
|
789
|
+
if (!Number.isFinite(tb)) return -1;
|
|
790
|
+
return ta - tb;
|
|
791
|
+
});
|
|
792
|
+
u.assignSessionIds(sortedView, sessionTimeout);
|
|
793
|
+
}
|
|
794
|
+
|
|
754
795
|
// Store all user data (skip profile push when a hook returned null
|
|
755
796
|
// for type='user' — see dropUserProfile above).
|
|
756
797
|
if (!dropUserProfile) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/ak--47/dungeon-master/lib/templates/story-spec.schema.json",
|
|
4
|
+
"title": "Dungeon stories export (v1.6 story-spec)",
|
|
5
|
+
"description": "Schema for the `stories` named export of a JS dungeon file. Stories pin each engineered hook effect to a mechanical assertion over emulateBreakdown output (or a DuckDB SQL escape hatch), so verification is computed — not re-derived by an LLM. Verdict tiers per assertion: NAILED = observed within ±10% of target; STRONG = passes floor (or target when no floor); WEAK = fails floor but effect direction correct, or cohort < minCohort; NONE = no measurable effect / selection empty; INVERSE = effect direction opposite the assertion. Story verdict = worst assertion.",
|
|
6
|
+
"type": "array",
|
|
7
|
+
"minItems": 1,
|
|
8
|
+
"items": { "$ref": "#/definitions/story" },
|
|
9
|
+
"definitions": {
|
|
10
|
+
"story": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["id", "hook", "archetype", "narrative", "assertions"],
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"description": "Unique story id, conventionally '<hook>-<slug>' (e.g. 'H3-fraud-bursts')."
|
|
19
|
+
},
|
|
20
|
+
"hook": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"pattern": "^(?:H|Hook\\s*)?\\d+$",
|
|
23
|
+
"description": "Numbered hook this story verifies — matches the dungeon's HOOK STORIES doc block ('H3' or 'Hook 3' styles)."
|
|
24
|
+
},
|
|
25
|
+
"archetype": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": [
|
|
28
|
+
"cohort-count-scale",
|
|
29
|
+
"cohort-prop-scale",
|
|
30
|
+
"temporal-inflection",
|
|
31
|
+
"funnel-conversion-by-segment",
|
|
32
|
+
"funnel-ttc-by-segment",
|
|
33
|
+
"retention-divergence",
|
|
34
|
+
"frequency-sweet-spot",
|
|
35
|
+
"attribution-bias",
|
|
36
|
+
"experiment-lift",
|
|
37
|
+
"lifecycle-wave",
|
|
38
|
+
"path-share",
|
|
39
|
+
"session-shape",
|
|
40
|
+
"composition-drift",
|
|
41
|
+
"bespoke"
|
|
42
|
+
],
|
|
43
|
+
"description": "Closed enum of engineered-effect shapes, seeded from the corpus's real story types."
|
|
44
|
+
},
|
|
45
|
+
"narrative": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"minLength": 1,
|
|
48
|
+
"description": "One-to-three sentence human story the data tells."
|
|
49
|
+
},
|
|
50
|
+
"mixpanelReport": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"description": "Free-form report pointer for humans and /create-project — NOT interpreted by the runner."
|
|
53
|
+
},
|
|
54
|
+
"assertions": {
|
|
55
|
+
"type": "array",
|
|
56
|
+
"minItems": 1,
|
|
57
|
+
"items": { "$ref": "#/definitions/assertion" }
|
|
58
|
+
},
|
|
59
|
+
"intentionalDeviations": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"items": { "type": "string" },
|
|
62
|
+
"description": "Documented strict-bar / limitation notes (e.g. 'tail_ratio < 0.4 by design — churn story')."
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"assertion": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"required": ["breakdown"],
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"anyOf": [
|
|
71
|
+
{ "required": ["expect"] },
|
|
72
|
+
{ "required": ["assert"] }
|
|
73
|
+
],
|
|
74
|
+
"properties": {
|
|
75
|
+
"breakdown": { "$ref": "#/definitions/breakdown" },
|
|
76
|
+
"select": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"minProperties": 1,
|
|
79
|
+
"description": "Named row-sets over the breakdown result rows. Keys are the names referenced by expect.metric.",
|
|
80
|
+
"propertyNames": { "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$" },
|
|
81
|
+
"additionalProperties": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"required": ["where"],
|
|
84
|
+
"additionalProperties": false,
|
|
85
|
+
"properties": {
|
|
86
|
+
"where": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"minProperties": 1,
|
|
89
|
+
"description": "Column → value (equality) or { op, value } comparison. All clauses must match (AND).",
|
|
90
|
+
"additionalProperties": {
|
|
91
|
+
"oneOf": [
|
|
92
|
+
{ "type": ["string", "number", "boolean", "null"] },
|
|
93
|
+
{
|
|
94
|
+
"type": "object",
|
|
95
|
+
"required": ["op", "value"],
|
|
96
|
+
"additionalProperties": false,
|
|
97
|
+
"properties": {
|
|
98
|
+
"op": { "type": "string", "enum": ["==", "!=", ">=", "<=", ">", "<"] },
|
|
99
|
+
"value": { "type": ["string", "number", "boolean", "null"] }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"expect": { "$ref": "#/definitions/expect" },
|
|
109
|
+
"assert": {
|
|
110
|
+
"description": "JS-only escape hatch: (rows, ctx) => { pass, detail?, verdict? }. Function-valued — JSON Schema cannot validate it; every use requires a comment justifying why the declarative grammar can't express it. JSON dungeons cannot carry it (dungeon-to-json drops extra exports).",
|
|
111
|
+
"not": { "type": ["string", "number", "boolean", "array", "null"] }
|
|
112
|
+
},
|
|
113
|
+
"minCohort": {
|
|
114
|
+
"type": "number",
|
|
115
|
+
"exclusiveMinimum": 0,
|
|
116
|
+
"description": "Population floor — when any named selection's cohort is below this, the verdict caps at WEAK."
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"breakdown": {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"required": ["type"],
|
|
123
|
+
"properties": {
|
|
124
|
+
"type": { "type": "string", "minLength": 1 }
|
|
125
|
+
},
|
|
126
|
+
"if": {
|
|
127
|
+
"properties": { "type": { "const": "duckdb" } }
|
|
128
|
+
},
|
|
129
|
+
"then": {
|
|
130
|
+
"required": ["type", "sql"],
|
|
131
|
+
"properties": {
|
|
132
|
+
"type": { "const": "duckdb" },
|
|
133
|
+
"sql": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"minLength": 1,
|
|
136
|
+
"description": "DuckDB SQL escape hatch, for bespoke shapes only. The runner shells out to the `duckdb` CLI (no npm dep) and substitutes the literal token {{PREFIX}} with the run's data prefix path (e.g. data/verify-<name>), so globs read read_json_auto('{{PREFIX}}-EVENTS*.json'). Result rows feed select/expect like emulator rows. Disk mode only — skipped (with a warning) under --in-memory."
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"else": {
|
|
141
|
+
"description": "Anything other than 'duckdb' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
"expect": {
|
|
145
|
+
"type": "object",
|
|
146
|
+
"required": ["metric", "op", "target"],
|
|
147
|
+
"additionalProperties": false,
|
|
148
|
+
"properties": {
|
|
149
|
+
"metric": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"pattern": "^\\s*([A-Za-z_$][A-Za-z0-9_$]*\\.[A-Za-z_$][A-Za-z0-9_$]*|[0-9]+(?:\\.[0-9]+)?)(\\s*[/\\-]\\s*([A-Za-z_$][A-Za-z0-9_$]*\\.[A-Za-z_$][A-Za-z0-9_$]*|[0-9]+(?:\\.[0-9]+)?))?\\s*$",
|
|
152
|
+
"description": "Exactly three forms: single ref '<name>.<column>', ratio '<a>.<col> / <b>.<col>', or difference '<a>.<col> - <b>.<col>'. At most one binary operator; operands are refs or numeric literals (at least one ref — runtime-enforced). If a selection matches multiple rows, count-like columns sum and value-like columns error (single-row selection enforced). No free-form expressions."
|
|
153
|
+
},
|
|
154
|
+
"op": { "type": "string", "enum": [">=", "<=", ">", "<", "between"] },
|
|
155
|
+
"target": {
|
|
156
|
+
"oneOf": [
|
|
157
|
+
{ "type": "number" },
|
|
158
|
+
{
|
|
159
|
+
"type": "array",
|
|
160
|
+
"items": { "type": "number" },
|
|
161
|
+
"minItems": 2,
|
|
162
|
+
"maxItems": 2,
|
|
163
|
+
"description": "[lo, hi] — only valid with op 'between'."
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
"description": "The designed value — derive it from the same exported knob constants the hook uses so the assertion cannot drift from the mechanism."
|
|
167
|
+
},
|
|
168
|
+
"floor": {
|
|
169
|
+
"type": "number",
|
|
170
|
+
"description": "Optional STRONG bound — the minimum acceptable effect. Omitted → target doubles as the floor."
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -7,7 +7,7 @@ import { validateFunctionCall } from './function-registry.js';
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Evaluate a value that might contain function calls
|
|
10
|
-
* @param {
|
|
10
|
+
* @param {unknown} value - The value to evaluate (could be object with functionName, array, or primitive)
|
|
11
11
|
* @returns {string} - JavaScript code string
|
|
12
12
|
*/
|
|
13
13
|
export function evaluateValue(value) {
|
package/lib/utils/utils.js
CHANGED
|
@@ -289,8 +289,8 @@ function datesBetween(start, end) {
|
|
|
289
289
|
|
|
290
290
|
/**
|
|
291
291
|
* returns a random date
|
|
292
|
-
* @param {
|
|
293
|
-
* @param {
|
|
292
|
+
* @param {string | number | Date | dayjs.Dayjs | null} [start]
|
|
293
|
+
* @param {string | number | Date | dayjs.Dayjs | null} [end]
|
|
294
294
|
*/
|
|
295
295
|
function day(start, end) {
|
|
296
296
|
const chance = getChance();
|
|
@@ -399,8 +399,9 @@ function choose(value) {
|
|
|
399
399
|
|
|
400
400
|
// if the thing has a .next() method, call that (e.g., generators/iterators)
|
|
401
401
|
try {
|
|
402
|
-
|
|
403
|
-
|
|
402
|
+
const iterCandidate = /** @type {{ next?: unknown }} */ (/** @type {unknown} */ (value));
|
|
403
|
+
if (value && typeof iterCandidate.next === 'function') {
|
|
404
|
+
return iterCandidate.next();
|
|
404
405
|
}
|
|
405
406
|
} catch (e) {
|
|
406
407
|
console.error(`Error occurred while calling next(): ${e}`);
|