@ak--47/dungeon-master 1.4.2 → 1.4.3

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.
@@ -261,7 +261,7 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
261
261
  * PATTERN: Users with 5-15 "inventory checked" events sit in the
262
262
  * "engaged-but-focused" sweet spot — every "purchase order created"
263
263
  * event gets quantity boosted ~25%. Users with 16 or more inventory
264
- * checks are over-engaged (paralysis); ~30% of their "purchase order
264
+ * checks are over-engaged (paralysis); ~60% of their "purchase order
265
265
  * created" events are dropped. No flag is stamped — discoverable only
266
266
  * by binning users on inventory-check COUNT and comparing PO totals.
267
267
  *
@@ -283,12 +283,43 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
283
283
  * - Event: "purchase order created"
284
284
  * - Measure: Total events per user
285
285
  * - Compare cohort C vs cohort A
286
- * - Expected: cohort C has ~ 30% fewer POs per user
286
+ * - Expected: cohort C has ~ 60% fewer POs per user
287
287
  *
288
288
  * REAL-WORLD ANALOGUE: A focused operations team that monitors
289
289
  * stock just enough places larger, more confident orders; an
290
290
  * obsessive checker is paralysed and orders less.
291
291
  *
292
+ * -------------------------------------------------------------------
293
+ * 10. ONBOARDING TIME-TO-CONVERT (funnel-post hook)
294
+ * -------------------------------------------------------------------
295
+ *
296
+ * PATTERN: Enterprise-tier users complete the Onboarding funnel
297
+ * 1.4x faster (time gaps scaled by 0.71). Small-business and trial
298
+ * users complete it 1.3x slower (gaps scaled by 1.3). The hook
299
+ * iterates over the funnel-post event array, compresses or stretches
300
+ * the inter-step time gaps based on the user's company_tier from
301
+ * meta.profile, then rewrites each event's timestamp.
302
+ *
303
+ * HOW TO FIND IT IN MIXPANEL:
304
+ *
305
+ * Report 1: Onboarding TTC by Company Tier
306
+ * - Report type: Funnels
307
+ * - Steps: "account created" -> "inventory checked" -> "integration connected" -> "report generated"
308
+ * - Breakdown: user property "company_tier"
309
+ * - Metric: Median time to convert
310
+ * - Expected: enterprise median TTC ~ 0.71x of small_business/trial TTC
311
+ * (e.g., enterprise ~ 36h vs small_business ~ 66h)
312
+ *
313
+ * NOTE: This effect is visible ONLY in Mixpanel funnel median TTC.
314
+ * Cross-event MIN->MIN SQL queries on raw events do NOT show this
315
+ * because funnel-post mutates timestamps after event generation but
316
+ * before storage.
317
+ *
318
+ * REAL-WORLD ANALOGUE: Enterprise customers have dedicated IT teams
319
+ * and onboarding specialists who move through setup, integration,
320
+ * and first reporting much faster than small businesses configuring
321
+ * the platform themselves.
322
+ *
292
323
  * ===================================================================
293
324
  * EXPECTED METRICS SUMMARY
294
325
  * ===================================================================
@@ -304,7 +335,8 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
304
335
  * Enterprise Profiles | warehouse_count | 3 | 10 | 3.3x
305
336
  * Small-Biz Conversion Drop | funnel conversion | 30% | 20% | 0.65x
306
337
  * Inventory-Check Magic Num | sweet PO quantity | 1x | 1.25x | 1.25x
307
- * Inventory-Check Magic Num | over POs/user | 1x | 0.7x | -30%
338
+ * Inventory-Check Magic Num | over POs/user | 1x | 0.4x | -60%
339
+ * Onboarding TTC | funnel median TTC | 1x | 0.71x | 1.4x faster (enterprise)
308
340
  */
309
341
 
310
342
  /** @type {Config} */
@@ -902,7 +934,7 @@ const config = {
902
934
 
903
935
  // -- HOOK 9: INVENTORY-CHECK MAGIC NUMBER (no flags) ------
904
936
  // Sweet 5-15 inventory checks → +25% PO quantity.
905
- // Over 16+ → drop 45% of PO created events.
937
+ // Over 16+ → drop 60% of PO created events (high rate to overcome persona event multiplier dilution).
906
938
  const invCheckCount = record.filter(e => e.event === 'inventory checked').length;
907
939
  if (invCheckCount >= 5 && invCheckCount <= 15) {
908
940
  record.forEach(e => {
@@ -912,7 +944,7 @@ const config = {
912
944
  });
913
945
  } else if (invCheckCount >= 16) {
914
946
  for (let i = record.length - 1; i >= 0; i--) {
915
- if (record[i].event === 'purchase order created' && chance.bool({ likelihood: 45 })) {
947
+ if (record[i].event === 'purchase order created' && chance.bool({ likelihood: 60 })) {
916
948
  record.splice(i, 1);
917
949
  }
918
950
  }
@@ -148,8 +148,9 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
148
148
  * ───────────────────────────────────────────────────────────────
149
149
  *
150
150
  * PATTERN: Users whose average response_time_hours on "message sent"
151
- * events is < 2 hours get additional "offer accepted" events cloned.
152
- * Fast responders close more deals.
151
+ * events is <= 4 hours get additional "offer accepted" events cloned
152
+ * (2x existing + 60% of offer_received). Slow responders (> 4h avg)
153
+ * lose 60% of offer_accepted events. Fast responders close more deals.
153
154
  *
154
155
  * HOW TO FIND IT IN MIXPANEL:
155
156
  *
@@ -885,27 +886,50 @@ const config = {
885
886
  }
886
887
 
887
888
  // ── HOOK 5: RESPONSE TIME → CONVERSION ───────────
888
- // Users with low avg response_time on messages get extra
889
- // offer_accepted clones; slow responders lose half their accepts.
890
- const messages = events.filter(e => e.event === "message sent" && e.response_time_hours);
891
- if (messages.length > 0) {
892
- const avgResponseTime = messages.reduce((sum, m) => sum + m.response_time_hours, 0) / messages.length;
889
+ // Deterministic fast/slow cohorts using user hash.
890
+ // Fast responders (~40%): response_time_hours set to 1-4,
891
+ // extra offer_accepted clones. Slow (~60%): set to 8-36,
892
+ // lose 60% of offer_accepted events.
893
+ const msgEvents = events.filter(e => e.event === "message sent");
894
+ if (msgEvents.length > 0) {
895
+ const uid = msgEvents[0].user_id || "";
896
+ const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % 5 < 2; // ~40%
897
+ // Stamp response_time_hours to create the measurable correlation
898
+ msgEvents.forEach(m => {
899
+ m.response_time_hours = isFast
900
+ ? chance.floating({ min: 0.5, max: 4, fixed: 1 })
901
+ : chance.floating({ min: 8, max: 36, fixed: 1 });
902
+ });
893
903
  const templateOffer = events.find(e => e.event === "offer accepted");
894
- if (avgResponseTime <= 11 && templateOffer) {
895
- // Fast responders: clone every offer_received into an offer_accepted
904
+ if (isFast && templateOffer) {
905
+ // Fast responders: clone existing offer_accepted 2x each
906
+ const existingAccepts = events.filter(e => e.event === "offer accepted");
907
+ existingAccepts.forEach(accept => {
908
+ for (let c = 0; c < 2; c++) {
909
+ events.push({
910
+ ...accept,
911
+ time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
912
+ user_id: accept.user_id,
913
+ response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
914
+ });
915
+ }
916
+ });
917
+ // Also clone from offer_received → offer_accepted
896
918
  const offers = events.filter(e => e.event === "offer received");
897
919
  offers.forEach(offer => {
898
- events.push({
899
- ...templateOffer,
900
- time: dayjs(offer.time).add(chance.integer({ min: 1, max: 4 }), "hours").toISOString(),
901
- user_id: offer.user_id,
902
- response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
903
- });
920
+ if (chance.bool({ likelihood: 60 })) {
921
+ events.push({
922
+ ...templateOffer,
923
+ time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
924
+ user_id: offer.user_id,
925
+ response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
926
+ });
927
+ }
904
928
  });
905
- } else if (avgResponseTime >= 25 && templateOffer) {
906
- // Slow responders: drop half of offer_accepted events
929
+ } else if (!isFast) {
930
+ // Slow responders: drop 60% of offer_accepted events
907
931
  for (let i = events.length - 1; i >= 0; i--) {
908
- if (events[i].event === "offer accepted" && chance.bool({ likelihood: 50 })) {
932
+ if (events[i].event === "offer accepted" && chance.bool({ likelihood: 60 })) {
909
933
  events.splice(i, 1);
910
934
  }
911
935
  }
@@ -13,6 +13,7 @@ import utc from "dayjs/plugin/utc.js";
13
13
  import "dotenv/config";
14
14
  import * as u from "../../lib/utils/utils.js";
15
15
  import * as v from "ak-tools";
16
+ import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
16
17
 
17
18
  dayjs.extend(utc);
18
19
  const chance = u.initChance(SEED);
@@ -46,10 +47,9 @@ const chance = u.initChance(SEED);
46
47
  * ═══════════════════════════════════════════════════════════════════════════════
47
48
  * ANALYTICS HOOKS (10 hooks)
48
49
  *
49
- * Adds 10. CORE VIEWING LOOP TIME-TO-CONVERT: premium 0.71x faster, free 1.25x
50
- * slower (funnel-post). Discover via funnel median TTC by subscription_plan.
51
- * NOTE (funnel-post measurement): visible only via Mixpanel funnel median TTC.
52
- * Cross-event MIN→MIN SQL queries on raw events do NOT show this.
50
+ * Adds 10. CORE VIEWING LOOP: premium 0.67x watch_duration_min, free 1.4x
51
+ * (property scaling in everything hook). Discover via Insights playback
52
+ * completed Avg watch_duration_min breakdown subscription_plan.
53
53
  * ═══════════════════════════════════════════════════════════════════════════════
54
54
  *
55
55
  * NOTE: All cohort effects are HIDDEN — discoverable only via behavioral cohorts
@@ -120,16 +120,20 @@ const chance = u.initChance(SEED);
120
120
  * 4. AD FATIGUE CHURN (everything)
121
121
  * ───────────────────────────────────────────────────────────────────────────────
122
122
  *
123
- * PATTERN: Free-tier users with 10+ ad impressions lose 50% of events after
124
- * day 45 of their lifecycle. No flag discover via cohort retention.
123
+ * PATTERN: Users with 5+ ad impressions in first 45 days lose 95% of events
124
+ * after day 45 of their lifecycle. Applies to all tiers (not just free).
125
+ * No flag — discover via cohort retention. The very high drop rate overcomes
126
+ * the inherent ~3x activity confound (heavy-ad users are naturally much more
127
+ * active). Runs last in the hook chain so event-adding hooks (binge-watching,
128
+ * subtitle) can't re-inflate the post-d45 count.
125
129
  *
126
130
  * HOW TO FIND IT IN MIXPANEL:
127
131
  *
128
132
  * Report 1: Retention by Ad Exposure Cohort
129
133
  * - Report type: Retention
130
- * - Cohort A: free-tier users with >= 10 "ad impression"
131
- * - Cohort B: free-tier users with < 10 ads
132
- * - Expected: A ~ 50% retention drop after day 45
134
+ * - Cohort A: users with >= 5 "ad impression" in first 45 days
135
+ * - Cohort B: users with < 5 ads
136
+ * - Expected: heavy_ad avg_post_d45_events < light_ad
133
137
  *
134
138
  * REAL-WORLD ANALOGUE: Ad-supported tiers carry a tolerance ceiling.
135
139
  *
@@ -223,8 +227,10 @@ const chance = u.initChance(SEED);
223
227
  *
224
228
  * PATTERN: Users in the 4-6 recommendation-clicked sweet spot get 1.25x
225
229
  * watch_duration_min on playback-completed events. Users with 7+ rec clicks
226
- * are over-engaged (decision fatigue); 30% of their playback-completed events
227
- * drop. No flag discover by binning users on rec-click count.
230
+ * are over-engaged (decision fatigue); watch_duration_min is halved and 55%
231
+ * of their playback-completed events are dropped. The aggressive suppression
232
+ * overcomes the inherent engagement confound. No flag — discover by binning
233
+ * users on rec-click count.
228
234
  *
229
235
  * HOW TO FIND IT IN MIXPANEL:
230
236
  *
@@ -241,12 +247,34 @@ const chance = u.initChance(SEED);
241
247
  * - Cohort C: users with >= 7 "recommendation clicked"
242
248
  * - Cohort A: users with 4-6
243
249
  * - Event: "playback completed"
244
- * - Measure: Total per user
245
- * - Expected: C ~ 30% fewer completions per user vs A
250
+ * - Measure: Average of "watch_duration_min"
251
+ * - Expected: C (over) has lower avg watch_duration_min than A (sweet)
246
252
  *
247
253
  * REAL-WORLD ANALOGUE: A few good recs surface a watchworthy title; too many
248
254
  * clicks signals indecision and drives abandonment.
249
255
  *
256
+ * ───────────────────────────────────────────────────────────────────────────────
257
+ * 10. CORE VIEWING LOOP — SUBSCRIPTION PLAN PROPERTY SCALING (everything)
258
+ * ───────────────────────────────────────────────────────────────────────────────
259
+ *
260
+ * PATTERN: Premium subscribers have 0.67x watch_duration_min on "playback
261
+ * completed" (efficient viewers), free users have 1.4x (lingering viewers).
262
+ * Scales the existing watch_duration_min property — no flag. Applied BEFORE
263
+ * weekend/subtitle/rec-click hooks so each subsequent effect amplifies from
264
+ * the plan-adjusted base.
265
+ *
266
+ * HOW TO FIND IT IN MIXPANEL:
267
+ *
268
+ * Report 1: Avg Watch Duration by Subscription Plan
269
+ * - Report type: Insights
270
+ * - Event: "playback completed"
271
+ * - Measure: Average of "watch_duration_min"
272
+ * - Breakdown: "subscription_plan"
273
+ * - Expected: premium < standard < free, free/premium ratio >= 2x
274
+ *
275
+ * REAL-WORLD ANALOGUE: Premium subscribers binge curated content efficiently;
276
+ * free-tier users browse and linger with ad interruptions.
277
+ *
250
278
  * ═══════════════════════════════════════════════════════════════════════════════
251
279
  * EXPECTED METRICS SUMMARY
252
280
  * ═══════════════════════════════════════════════════════════════════════════════
@@ -256,13 +284,14 @@ const chance = u.initChance(SEED);
256
284
  * Genre Funnel Conversion | documentary funnel conv | 1x | 0.7x | -30%
257
285
  * Binge-Watching | completions per streak | 1x | ~ 1.5x | 1.5x
258
286
  * Weekend vs Weekday | weekend watch_duration | 1x | ~ 1.5x | 1.5x
259
- * Ad Fatigue Churn | retention free+10+ads | 1x | ~ 0.5x | -50%
287
+ * Ad Fatigue Churn | heavy_ad post_d45 events| 1x | < light_ad | -93%
260
288
  * New Release Spike | blockbuster id share | 0% | ~ 20% | n/a
261
289
  * Kids Profile Safety | animation/doc share | baseline | + 15% | n/a
262
290
  * Rec Engine Improvement | content-rated post day60| 1x | ~ 1.5x | 1.5x
263
291
  * Subtitle Users | completion % | 68% | 85% | 1.25x
264
292
  * Rec-Click Magic Number | sweet watch duration | 1x | 1.25x | 1.25x
265
- * Rec-Click Magic Number | over completions/user | 1x | 0.7x | -30%
293
+ * Rec-Click Magic Number | over watch_duration_min | 1x | 0.5x | -50%
294
+ * Core Viewing Loop | free/premium duration | 1x | >= 2x | 2.09x
266
295
  */
267
296
 
268
297
  // Generate consistent content IDs for lookup tables and events
@@ -544,27 +573,6 @@ const config = {
544
573
  lookupTables: [],
545
574
 
546
575
  hook: function (record, type, meta) {
547
- // Hook #10 (T2C): CORE VIEWING LOOP TIME-TO-CONVERT (funnel-post)
548
- // Premium subscribers complete browse→play funnel 1.4x faster
549
- // (factor 0.71); free users 1.25x slower (factor 1.25).
550
- if (type === "funnel-post") {
551
- const segment = meta?.profile?.subscription_plan;
552
- if (Array.isArray(record) && record.length > 1) {
553
- const factor = (
554
- segment === "premium" ? 0.71 :
555
- segment === "free" ? 1.25 :
556
- 1.0
557
- );
558
- if (factor !== 1.0) {
559
- for (let i = 1; i < record.length; i++) {
560
- const prev = dayjs(record[i - 1].time);
561
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
562
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
563
- }
564
- }
565
- }
566
- }
567
-
568
576
  if (type === "event") {
569
577
  // Hook #6: KIDS PROFILE SAFETY — 15% of selections/starts get genre
570
578
  // restricted to animation or documentary. Mutates existing genre prop.
@@ -594,6 +602,30 @@ const config = {
594
602
  });
595
603
  }
596
604
 
605
+ // HOOK 10: CORE VIEWING LOOP TTC — premium 0.67x, free 1.4x.
606
+ // Timestamp shift for Mixpanel funnel TTC + property scale for
607
+ // Insights. Applied first so weekend/subtitle/rec-click hooks
608
+ // amplify from the plan-adjusted base.
609
+ {
610
+ const plan = profile ? profile.subscription_plan : "free";
611
+ const ttcFactor = plan === "premium" ? 0.67 : plan === "free" ? 1.4 : 1.0;
612
+ if (ttcFactor !== 1.0) {
613
+ // Timestamp shift: affects Mixpanel funnel TTC
614
+ const viewSeq = findFirstSequence(
615
+ userEvents,
616
+ ["content browsed", "content selected", "playback started", "playback completed"],
617
+ 60 * 24 * 7
618
+ );
619
+ if (viewSeq) scaleFunnelTTC(viewSeq, ttcFactor);
620
+ // Property scale: affects Insights AVG reports
621
+ for (const e of userEvents) {
622
+ if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
623
+ e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor * 10) / 10;
624
+ }
625
+ }
626
+ }
627
+ }
628
+
597
629
  // Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
598
630
  // switch to the blockbuster id. Mutates existing content_id/content_type props.
599
631
  // (Moved from event hook to everything hook per L1: temporal checks belong here.)
@@ -613,6 +645,28 @@ const config = {
613
645
  }
614
646
  }
615
647
 
648
+ // Hook #10: CORE VIEWING LOOP — subscription_plan property scaling.
649
+ // Premium users watch more efficiently (shorter durations), free users
650
+ // linger (longer durations). Scales watch_duration_min on playback
651
+ // completed events BEFORE H3/H8/H9 so each subsequent hook amplifies
652
+ // from the plan-adjusted base.
653
+ // Discover via: Insights → playback completed → Avg watch_duration_min → breakdown subscription_plan.
654
+ {
655
+ const plan = stampPlan;
656
+ const factor = (
657
+ plan === "premium" ? 0.67 :
658
+ plan === "free" ? 1.4 :
659
+ 1.0
660
+ );
661
+ if (factor !== 1.0) {
662
+ for (const e of userEvents) {
663
+ if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
664
+ e.watch_duration_min = Math.round(e.watch_duration_min * factor);
665
+ }
666
+ }
667
+ }
668
+ }
669
+
616
670
  // Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
617
671
  // No flag — analyst breaks down by day of week.
618
672
  for (const e of userEvents) {
@@ -646,15 +700,12 @@ const config = {
646
700
  // Identify behavioral patterns (no flags written)
647
701
  let consecutiveCompletions = 0;
648
702
  let maxConsecutiveCompletions = 0;
649
- let adImpressionCount = 0;
650
- let isFreeTier = false;
703
+ let earlyAdCount = 0;
651
704
  let hasSubtitlesEnabled = false;
652
705
  let recClickCount = 0;
653
706
 
707
+ const adCutoff = firstEventTime.add(45, 'days');
654
708
  userEvents.forEach((event, idx) => {
655
- if (idx === 0 && event.subscription_plan) {
656
- isFreeTier = event.subscription_plan === "free";
657
- }
658
709
  if (event.event === "playback completed") {
659
710
  consecutiveCompletions++;
660
711
  if (consecutiveCompletions > maxConsecutiveCompletions) {
@@ -663,7 +714,7 @@ const config = {
663
714
  } else if (event.event !== "playback started") {
664
715
  consecutiveCompletions = 0;
665
716
  }
666
- if (event.event === "ad impression") adImpressionCount++;
717
+ if (event.event === "ad impression" && dayjs(event.time).isBefore(adCutoff)) earlyAdCount++;
667
718
  if (event.event === "subtitle toggled" && event.action === "enabled") hasSubtitlesEnabled = true;
668
719
  if (event.event === "recommendation clicked") recClickCount++;
669
720
  });
@@ -708,18 +759,6 @@ const config = {
708
759
  }
709
760
  }
710
761
 
711
- // Hook #4: AD FATIGUE CHURN — free-tier users w/ 10+ ads lose 50% of
712
- // events after day 45. No flag — discover via cohort retention.
713
- if (isFreeTier && adImpressionCount >= 10) {
714
- const churnCutoff = firstEventTime.add(45, 'days');
715
- for (let i = userEvents.length - 1; i >= 0; i--) {
716
- const evt = userEvents[i];
717
- if (dayjs(evt.time).isAfter(churnCutoff) && chance.bool({ likelihood: 50 })) {
718
- userEvents.splice(i, 1);
719
- }
720
- }
721
- }
722
-
723
762
  // Hook #8: SUBTITLE USERS WATCH MORE — 1.25x completion_percent (cap 100),
724
763
  // 1.15x watch_duration_min, plus 20% extra cloned playback completions.
725
764
  // No flag — discover via cohort builder on subtitle-toggled-enabled.
@@ -756,7 +795,9 @@ const config = {
756
795
 
757
796
  // Hook #9: RECOMMENDATION-CLICKED MAGIC NUMBER (no flags)
758
797
  // Sweet 4-6 rec clicks → +25% watch_duration_min on playback completed.
759
- // Over 7+ → drop 30% of playback completed events (rec fatigue).
798
+ // Over 7+ → halve watch_duration_min AND drop 55% of playback completed
799
+ // events (rec fatigue). Aggressive suppression overcomes the inherent
800
+ // engagement confound (high-rec-click users are naturally more active).
760
801
  if (recClickCount >= 4 && recClickCount <= 6) {
761
802
  userEvents.forEach(e => {
762
803
  if (e.event === 'playback completed' && typeof e.watch_duration_min === 'number') {
@@ -765,8 +806,35 @@ const config = {
765
806
  });
766
807
  } else if (recClickCount >= 7) {
767
808
  for (let i = userEvents.length - 1; i >= 0; i--) {
768
- if (userEvents[i].event === 'playback completed' && chance.bool({ likelihood: 30 })) {
769
- userEvents.splice(i, 1);
809
+ const evt = userEvents[i];
810
+ if (evt.event === 'playback completed') {
811
+ // Halve watch duration for surviving events
812
+ if (typeof evt.watch_duration_min === 'number') {
813
+ evt.watch_duration_min = Math.round(evt.watch_duration_min * 0.5);
814
+ }
815
+ // Drop 55% of completions
816
+ if (chance.bool({ likelihood: 55 })) {
817
+ userEvents.splice(i, 1);
818
+ }
819
+ }
820
+ }
821
+ }
822
+
823
+ // Hook #4: AD FATIGUE CHURN — users w/ 5+ early ad impressions lose
824
+ // nearly all events after day 45. Runs LAST so event-adding hooks
825
+ // (binge-watching, subtitle) can't re-inflate the post-d45 count.
826
+ // Applies to ALL tiers (ad fatigue affects anyone exposed to heavy ads,
827
+ // regardless of plan). 95% drop overcomes the ~3x activity confound.
828
+ if (earlyAdCount >= 5) {
829
+ const churnCutoff = firstEventTime.add(45, 'days');
830
+ for (let i = userEvents.length - 1; i >= 0; i--) {
831
+ const evt = userEvents[i];
832
+ if (dayjs(evt.time).isAfter(churnCutoff)) {
833
+ // Keep only ~5% of post-d45 events (drop 95%)
834
+ const keep = (i % 20) === 0;
835
+ if (!keep) {
836
+ userEvents.splice(i, 1);
837
+ }
770
838
  }
771
839
  }
772
840
  }
@@ -247,6 +247,30 @@ const chance = u.initChance(SEED);
247
247
  * REAL-WORLD ANALOGUE: Focused buyers commit; obsessive browsers
248
248
  * tire-kick and never make the leap.
249
249
  *
250
+ * -------------------------------------------------------------------
251
+ * 10. TOUR FUNNEL TIME-TO-CONVERT (funnel-post)
252
+ * -------------------------------------------------------------------
253
+ *
254
+ * PATTERN: Premier-tier agents move users through the Tour funnel
255
+ * (property viewed -> tour scheduled -> offer submitted) 1.4x faster
256
+ * (factor 0.71). Standard-tier agents complete it 1.3x slower
257
+ * (factor 1.3). The hook intercepts funnel-post arrays, computes the
258
+ * time gap between consecutive steps, and scales each gap by the
259
+ * tier-specific factor before rewriting the step timestamps.
260
+ *
261
+ * HOW TO FIND IT IN MIXPANEL:
262
+ *
263
+ * Report 1: Tour Funnel Median TTC by Agent Tier
264
+ * - Report type: Funnels
265
+ * - Steps: "property viewed" -> "tour scheduled" -> "offer submitted"
266
+ * - Measure: Median time to convert
267
+ * - Breakdown: "agent_tier" (user property / SCD)
268
+ * - Expected: Premier ~ 0.71x baseline; Standard ~ 1.3x baseline
269
+ *
270
+ * REAL-WORLD ANALOGUE: Premier agents have larger networks, faster
271
+ * scheduling workflows, and prioritized showing slots, translating
272
+ * to shorter tour-to-offer cycles.
273
+ *
250
274
  * ===================================================================
251
275
  * EXPECTED METRICS SUMMARY
252
276
  * ===================================================================
@@ -263,6 +287,7 @@ const chance = u.initChance(SEED);
263
287
  * Cold-Lead Churn | non-save post-day-14 | 1x | 0.1x | -90%
264
288
  * View-Count Magic Number | sweet offer_price | 1x | 1.3x | 1.3x
265
289
  * View-Count Magic Number | over offers/user | 1x | 0.65x | -35%
290
+ * Tour Funnel TTC | median TTC by tier | 1x | 0.71/1.3x| ~ 1.8x range
266
291
  */
267
292
 
268
293
  /** @type {Config} */