@ak--47/dungeon-master 1.3.0 → 1.4.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/array-of-object-lookup.js +0 -2
  3. package/dungeons/technical/hook-helpers-verify.js +89 -0
  4. package/dungeons/technical/identity-model-verify.js +47 -0
  5. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  6. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  7. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  8. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  9. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  10. package/dungeons/technical/simple.js +3 -4
  11. package/dungeons/technical/simplest-schema.json +5 -0
  12. package/dungeons/technical/text-generation.js +1 -1
  13. package/dungeons/vertical/ai-platform.js +216 -164
  14. package/dungeons/vertical/community.js +74 -41
  15. package/dungeons/vertical/crypto.js +343 -233
  16. package/dungeons/vertical/dating.js +246 -292
  17. package/dungeons/vertical/devtools.js +179 -70
  18. package/dungeons/vertical/ecommerce.js +275 -122
  19. package/dungeons/vertical/education.js +345 -312
  20. package/dungeons/vertical/fintech.js +445 -314
  21. package/dungeons/vertical/fitness.js +199 -165
  22. package/dungeons/vertical/food-delivery.js +309 -330
  23. package/dungeons/vertical/gaming.js +387 -241
  24. package/dungeons/vertical/healthcare.js +149 -66
  25. package/dungeons/vertical/insurance-application.js +173 -75
  26. package/dungeons/vertical/logistics.js +107 -12
  27. package/dungeons/vertical/marketplace.js +171 -76
  28. package/dungeons/vertical/media.js +259 -402
  29. package/dungeons/vertical/real-estate.js +336 -318
  30. package/dungeons/vertical/sass.js +294 -288
  31. package/dungeons/vertical/social.js +289 -234
  32. package/dungeons/vertical/travel.js +66 -20
  33. package/index.js +17 -17
  34. package/lib/core/config-validator.js +226 -175
  35. package/lib/core/context.js +10 -24
  36. package/lib/core/storage.js +6 -1
  37. package/lib/generators/events.js +57 -104
  38. package/lib/generators/funnels.js +212 -93
  39. package/lib/generators/mirror.js +3 -2
  40. package/lib/generators/product-names.js +1 -1
  41. package/lib/generators/scd.js +2 -1
  42. package/lib/generators/text.js +1 -1
  43. package/lib/hook-helpers/_internal.js +23 -0
  44. package/lib/hook-helpers/cohort.js +124 -0
  45. package/lib/hook-helpers/identity.js +56 -0
  46. package/lib/hook-helpers/index.js +44 -0
  47. package/lib/hook-helpers/inject.js +99 -0
  48. package/lib/hook-helpers/mutate.js +151 -0
  49. package/lib/hook-helpers/timing.js +99 -0
  50. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  51. package/lib/hook-patterns/attributed-by-source.js +72 -0
  52. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  53. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  54. package/lib/hook-patterns/index.js +14 -0
  55. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  56. package/lib/orchestrators/user-loop.js +143 -293
  57. package/lib/utils/utils.js +97 -52
  58. package/lib/verify/emulate-breakdown.js +281 -0
  59. package/lib/verify/index.js +12 -0
  60. package/lib/verify/verify-dungeon.js +61 -0
  61. package/package.json +6 -4
  62. package/scripts/smoke-test-all.mjs +162 -0
  63. package/scripts/verify-runner.mjs +72 -24
  64. package/types.d.ts +434 -225
  65. package/dungeons/technical/ad-spend-schema.json +0 -128
  66. package/dungeons/technical/anonymous-users-schema.json +0 -92
  67. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  68. package/dungeons/technical/experiments-schema.json +0 -203
  69. package/dungeons/technical/foobar-schema.json +0 -362
  70. package/dungeons/technical/group-analytics-schema.json +0 -241
  71. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  72. package/dungeons/technical/nested-objects-schema.json +0 -145
  73. package/dungeons/technical/retention-cadence-schema.json +0 -37
  74. package/dungeons/technical/sanity-schema.json +0 -185
  75. package/dungeons/technical/scale-test-schema.json +0 -70
  76. package/dungeons/technical/scd-schema.json +0 -467
  77. package/dungeons/technical/simple-schema.json +0 -362
  78. package/dungeons/technical/text-generation-schema.json +0 -1062
  79. package/dungeons/vertical/ai-platform-schema.json +0 -617
  80. package/dungeons/vertical/community-schema.json +0 -579
  81. package/dungeons/vertical/crypto-schema.json +0 -546
  82. package/dungeons/vertical/dating-schema.json +0 -401
  83. package/dungeons/vertical/devtools-schema.json +0 -601
  84. package/dungeons/vertical/ecommerce-schema.json +0 -604
  85. package/dungeons/vertical/education-schema.json +0 -5686
  86. package/dungeons/vertical/fintech-schema.json +0 -630
  87. package/dungeons/vertical/fitness-schema.json +0 -530
  88. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  89. package/dungeons/vertical/gaming-schema.json +0 -2703
  90. package/dungeons/vertical/healthcare-schema.json +0 -549
  91. package/dungeons/vertical/insurance-application-schema.json +0 -485
  92. package/dungeons/vertical/logistics-schema.json +0 -574
  93. package/dungeons/vertical/marketplace-schema.json +0 -533
  94. package/dungeons/vertical/media-schema.json +0 -4749
  95. package/dungeons/vertical/real-estate-schema.json +0 -527
  96. package/dungeons/vertical/sass-schema.json +0 -3128
  97. package/dungeons/vertical/social-schema.json +0 -620
  98. package/dungeons/vertical/travel-schema.json +0 -580
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Pattern: Attribute conversions by source.
3
+ *
4
+ * For each user, copy a property value from a "touch" event onto a downstream
5
+ * "conversion" event so Mixpanel's "Conversions by Source" attribution analysis
6
+ * shows the configured weighted distribution. The pattern doesn't invent the
7
+ * source distribution — it preserves whatever the touch events already carry —
8
+ * BUT it lets you bias the conversion completion rate per source via `weights`
9
+ * (probability of stamping = weight ÷ max(weight) ).
10
+ *
11
+ * Mechanism: walk the user's event stream; when a `downstreamEvent` event fires,
12
+ * look back at the most-recent (or first) `sourceEvent` and copy
13
+ * `sourceEvent[sourceProperty]` onto the downstream event. Skip stamping
14
+ * probabilistically per `weights[sourceValue]`.
15
+ *
16
+ * Identity & schema: the destination property must already exist on
17
+ * `downstreamEvent` in the dungeon schema (we OVERWRITE the value, not invent it).
18
+ */
19
+
20
+ /**
21
+ * @param {Array<Object>} events - User's event stream (mutated in place).
22
+ * @param {Object} _profile
23
+ * @param {Object} opts
24
+ * @param {string} opts.sourceEvent - Event whose property we copy from.
25
+ * @param {string} opts.sourceProperty - Property on `sourceEvent` to copy.
26
+ * @param {string} opts.downstreamEvent - Event whose property we overwrite.
27
+ * @param {string} [opts.downstreamProperty] - Defaults to `sourceProperty`.
28
+ * @param {Record<string, number>} opts.weights - Source value → relative weight
29
+ * (probability of stamping = weight / maxWeight; missing entries = 0).
30
+ * @param {'firstTouch'|'lastTouch'} [opts.model] - Default 'firstTouch'.
31
+ * @returns {{ stamped: number, skipped: number }}
32
+ */
33
+ export function applyAttributedBySource(events, _profile, opts) {
34
+ const { sourceEvent, sourceProperty, downstreamEvent, downstreamProperty, weights, model = 'firstTouch' } = opts || {};
35
+ if (!events || !sourceEvent || !sourceProperty || !downstreamEvent || !weights) {
36
+ return { stamped: 0, skipped: 0 };
37
+ }
38
+ const destProp = downstreamProperty || sourceProperty;
39
+ const sorted = events.slice().sort((a, b) => Date.parse(a.time) - Date.parse(b.time));
40
+ const maxWeight = Math.max(...Object.values(weights), 0);
41
+ if (maxWeight <= 0) return { stamped: 0, skipped: 0 };
42
+
43
+ let stamped = 0;
44
+ let skipped = 0;
45
+ const touches = []; // accumulated source events in time order
46
+ for (const ev of sorted) {
47
+ if (!ev) continue;
48
+ if (ev.event === sourceEvent && ev[sourceProperty] !== undefined) {
49
+ touches.push(ev);
50
+ continue;
51
+ }
52
+ if (ev.event === downstreamEvent && touches.length) {
53
+ const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
54
+ const val = touch[sourceProperty];
55
+ const w = weights[val] || 0;
56
+ const prob = w / maxWeight; // 0..1
57
+ // Deterministic per-user pseudo-RNG keyed on the downstream event's
58
+ // insert_id (or time fallback) — keeps verification reproducible.
59
+ const seed = ev.insert_id || ev.time || '';
60
+ const r = simpleHashFloat(String(seed));
61
+ if (r < prob) {
62
+ ev[destProp] = val;
63
+ stamped++;
64
+ } else {
65
+ skipped++;
66
+ }
67
+ }
68
+ }
69
+ return { stamped, skipped };
70
+ }
71
+
72
+ import { simpleHashFloat } from '../hook-helpers/_internal.js';
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Pattern: Frequency × Frequency.
3
+ *
4
+ * Engineers the joint distribution of `count(metricEvent)` × `count(cohortEvent)`
5
+ * per user, so Mixpanel's "Frequency Distribution of A by per-user count of B"
6
+ * Insights view shows a deliberate shape (e.g., users with 5–20 cohort events
7
+ * have 2x the metric event count of users with <5).
8
+ *
9
+ * Mechanism: classify the user into a bin based on their `cohortEvent` count,
10
+ * then `scaleEventCount(events, targetEvent, multipliers[bin])` to scale that
11
+ * user's count of the target event up or down.
12
+ *
13
+ * Identity & schema constraints:
14
+ * - Operates on the user's full event stream — call from the `everything` hook.
15
+ * - Does NOT add new properties; uses existing event names defined in the dungeon
16
+ * schema.
17
+ * - Cloned events have their `insert_id` stripped (mutate.scaleEventCount handles
18
+ * that), so the engine's batch writer can re-stamp them downstream.
19
+ */
20
+
21
+ import { binUsersByEventCount } from '../hook-helpers/cohort.js';
22
+ import { scaleEventCount } from '../hook-helpers/mutate.js';
23
+
24
+ /**
25
+ * @param {Array<Object>} events - User's event stream (mutated in place).
26
+ * @param {Object} _profile - User profile (unused, kept for API symmetry).
27
+ * @param {Object} opts
28
+ * @param {string} opts.cohortEvent - Event whose per-user count classifies the user.
29
+ * @param {Record<string, [number, number]>} opts.bins - Bin name → [lo, hi).
30
+ * @param {string} opts.targetEvent - Event whose count is scaled per bin.
31
+ * @param {Record<string, number>} opts.multipliers - Bin name → multiplier (1 = no-op,
32
+ * 2 = double, 0.5 = halve). Bins absent from this map use multiplier 1.
33
+ * @returns {{ bin: string|null, delta: number }} Bin assigned + signed delta from
34
+ * scaleEventCount (positive = clones added; negative = events dropped).
35
+ */
36
+ export function applyFrequencyByFrequency(events, _profile, { cohortEvent, bins, targetEvent, multipliers }) {
37
+ if (!events || !cohortEvent || !bins || !targetEvent || !multipliers) {
38
+ return { bin: null, delta: 0 };
39
+ }
40
+ const bin = binUsersByEventCount(events, cohortEvent, bins);
41
+ if (!bin) return { bin: null, delta: 0 };
42
+ const factor = multipliers[bin];
43
+ if (typeof factor !== 'number' || factor === 1) return { bin, delta: 0 };
44
+ const delta = scaleEventCount(events, targetEvent, factor);
45
+ return { bin, delta };
46
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pattern: Funnel Frequency Breakdown.
3
+ *
4
+ * Inside a `funnel-post` hook, vary the user's completion of the funnel by their
5
+ * count of `cohortEvent` (anywhere in the dataset, not just the funnel). Used
6
+ * when you want Mixpanel's funnel report — broken down by per-user count of an
7
+ * activity event — to show e.g. "users who did 5+ X are 1.4x as likely to
8
+ * complete this funnel."
9
+ *
10
+ * Mechanism: for users in a "drop-prone" bin, drop the funnel's final step
11
+ * event(s) per the `dropMultipliers` config (1 = drop none, 0 = drop all).
12
+ *
13
+ * Schema-first: does not add new event properties or invent events. Operates on
14
+ * the funnelEvents array passed by the funnel-post hook.
15
+ */
16
+
17
+ import { binUsersByEventCount } from '../hook-helpers/cohort.js';
18
+ import { dropEventsWhere } from '../hook-helpers/mutate.js';
19
+
20
+ /**
21
+ * @param {Array<Object>} allUserEvents - Full per-user event history (read-only;
22
+ * used to count `cohortEvent`). When called inside `funnel-post`, derive this
23
+ * from `meta.profile` or pass the user's accumulated events from a closure.
24
+ * When `null`, falls back to counting cohortEvent inside `funnelEvents`.
25
+ * @param {Object} _profile
26
+ * @param {Array<Object>} funnelEvents - Funnel events produced by `makeFunnel`
27
+ * (mutated in place).
28
+ * @param {Object} opts
29
+ * @param {string} opts.cohortEvent
30
+ * @param {Record<string, [number, number]>} opts.bins
31
+ * @param {Record<string, number>} opts.dropMultipliers - Bin name → keep-rate (0..1)
32
+ * for the FINAL step event. 1 = always keep, 0 = always drop.
33
+ * @param {string} [opts.finalStep] - Event name of the final step. Defaults to the
34
+ * last event in `funnelEvents` (in time order).
35
+ * @returns {{ bin: string|null, droppedFinal: boolean }}
36
+ */
37
+ export function applyFunnelFrequencyBreakdown(allUserEvents, _profile, funnelEvents, opts) {
38
+ const { cohortEvent, bins, dropMultipliers, finalStep } = opts || {};
39
+ if (!funnelEvents || !cohortEvent || !bins || !dropMultipliers) {
40
+ return { bin: null, droppedFinal: false };
41
+ }
42
+ const sourceForBin = allUserEvents || funnelEvents;
43
+ const bin = binUsersByEventCount(sourceForBin, cohortEvent, bins);
44
+ if (!bin) return { bin: null, droppedFinal: false };
45
+ const keepRate = dropMultipliers[bin];
46
+ if (typeof keepRate !== 'number' || keepRate >= 1) return { bin, droppedFinal: false };
47
+
48
+ // Identify the final step. If finalStep is named, use it; otherwise pick the
49
+ // latest-in-time event in the funnel as the final step.
50
+ let stepName = finalStep;
51
+ if (!stepName) {
52
+ const sorted = funnelEvents.slice().sort((a, b) => Date.parse(a.time) - Date.parse(b.time));
53
+ stepName = sorted.length ? sorted[sorted.length - 1].event : null;
54
+ }
55
+ if (!stepName) return { bin, droppedFinal: false };
56
+
57
+ // Coin-flip drop using a deterministic-ish heuristic — this is called from a
58
+ // non-RNG context (funnel-post), so use Math.random would break determinism.
59
+ // Instead, use a hash on the funnel's first event's insert_id (deterministic
60
+ // per-call) modulo 1000 / 1000 vs. (1 - keepRate). For simplicity we use
61
+ // chance from utils when available.
62
+ const dropProb = 1 - keepRate;
63
+ const seed = funnelEvents[0] && (funnelEvents[0].insert_id || funnelEvents[0].time) || '';
64
+ const det = simpleHashFloat(String(seed));
65
+ if (det < dropProb) {
66
+ const before = funnelEvents.length;
67
+ dropEventsWhere(funnelEvents, e => e && e.event === stepName);
68
+ return { bin, droppedFinal: funnelEvents.length < before };
69
+ }
70
+ return { bin, droppedFinal: false };
71
+ }
72
+
73
+ import { simpleHashFloat } from '../hook-helpers/_internal.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @ak--47/dungeon-master/hook-patterns — Phase 4 pattern barrel.
3
+ *
4
+ * Patterns are higher-level recipes built on Phase 3 atoms. Each one engineers
5
+ * the kind of distribution / table shape Mixpanel surfaces in a specific report.
6
+ * Pair with `verifyDungeon` + `emulateBreakdown` from `../verify` to assert the
7
+ * pattern is producing what you expect.
8
+ */
9
+
10
+ export { applyFrequencyByFrequency } from './frequency-by-frequency.js';
11
+ export { applyFunnelFrequencyBreakdown } from './funnel-frequency-breakdown.js';
12
+ export { applyAggregateByBin } from './aggregate-per-user-by-bin.js';
13
+ export { applyTTCBySegment } from './time-to-convert-by-segment.js';
14
+ export { applyAttributedBySource } from './attributed-by-source.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pattern: Time to Convert, broken down by user segment.
3
+ *
4
+ * Inside a `funnel-post` hook, scale the funnel's time-to-convert by a factor
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.
8
+ *
9
+ * Mechanism: read `profile[segmentKey]`, look up the multiplier in `factors`,
10
+ * and `scaleFunnelTTC(funnelEvents, factor)`. The first event's time is the
11
+ * anchor (unchanged); subsequent steps' offsets from it are scaled.
12
+ *
13
+ * Caveat: Mixpanel's "Time to Convert" funnel report uses the time between the
14
+ * FIRST event of step A and the FIRST event of step B per user, not the actual
15
+ * gap inside any one funnel run. The scaled funnel run will reflect in TTC only
16
+ * when this funnel is the user's first occurrence of those steps — which it is
17
+ * for an `isFirstFunnel`. For usage funnels, document this caveat to authors.
18
+ */
19
+
20
+ import { scaleFunnelTTC } from '../hook-helpers/timing.js';
21
+
22
+ /**
23
+ * @param {Array<Object>} funnelEvents - Mutated in place.
24
+ * @param {Object} profile - The user's profile (must contain `segmentKey`).
25
+ * @param {Object} opts
26
+ * @param {string} opts.segmentKey - Profile property name to look up.
27
+ * @param {Record<string, number>} opts.factors - Profile-value → TTC factor.
28
+ * @returns {{ segmentValue: any, factor: number, shifted: number }}
29
+ */
30
+ export function applyTTCBySegment(funnelEvents, profile, { segmentKey, factors }) {
31
+ if (!funnelEvents || !funnelEvents.length || !profile || !segmentKey || !factors) {
32
+ return { segmentValue: null, factor: 1, shifted: 0 };
33
+ }
34
+ const segmentValue = profile[segmentKey];
35
+ const factor = factors[segmentValue];
36
+ if (typeof factor !== 'number' || factor === 1) {
37
+ return { segmentValue, factor: 1, shifted: 0 };
38
+ }
39
+ const shifted = scaleFunnelTTC(funnelEvents, factor);
40
+ return { segmentValue, factor, shifted };
41
+ }