@ak--47/dungeon-master 1.6.5 → 1.7.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/types.d.ts CHANGED
@@ -7,10 +7,60 @@
7
7
  type Primitives = string | number | boolean | Date | Record<string, any>;
8
8
 
9
9
  /**
10
- * A "validValue" can be a primitive, an array of valid values, or a thunk that
11
- * returns one. Configs use this everywhere properties are user-defined.
10
+ * v1.7.0 (P1-1) context handed to every property value function. Every member
11
+ * is optional: group profiles, lookup tables, ad spend and mirror props have no
12
+ * user, and `event` / `time` exist only while an event is being built.
13
+ *
14
+ * Existing zero-arity value functions keep working untouched — JavaScript ignores
15
+ * the extra argument. A function that DECLARES a parameter (`(ctx) => …`) is
16
+ * treated as context-aware and is never served from the source-string cache, so
17
+ * its result may legitimately differ per user or per event.
18
+ *
19
+ * @example
20
+ * userProps: {
21
+ * plan: ['free', 'pro'],
22
+ * revenue: (ctx) => ctx.profile.plan === 'pro' ? 100 : 10, // profile keys resolve in declaration order
23
+ * },
24
+ * superProps: {
25
+ * plan_on_event: (ctx) => ctx.profile.plan, // or use `stickyEventProps: ['plan']`
26
+ * total: (ctx) => ctx.event.price * ctx.event.quantity, // event props resolve in declaration order
27
+ * }
12
28
  */
13
- export type ValueValid = Primitives | ValueValid[] | (() => ValueValid);
29
+ export interface ValueContext {
30
+ /** The user's resolved profile (partially built while userProps resolve). Undefined for group/lookup/ad-spend/mirror values. */
31
+ profile?: Record<string, any>;
32
+ /** The partially-built event record (identity + time set; earlier properties already resolved). Undefined outside event generation. */
33
+ event?: Record<string, any>;
34
+ /** The event's timestamp in unix milliseconds, when an event is being built. */
35
+ time?: number;
36
+ /** The validated dungeon config. */
37
+ config: Dungeon;
38
+ }
39
+
40
+ /**
41
+ * v1.7.0 (P2-1) — declarative weighted value. The numbers ARE the distribution:
42
+ * `{ __weights: { free: 60, pro: 30, enterprise: 10 } }` draws `free` 60% of the
43
+ * time. No automatic power law, no per-run winner; zero-weight keys never draw.
44
+ * Keys are strings (object keys), so numeric values come back as strings.
45
+ */
46
+ export interface WeightedValue {
47
+ __weights: Record<string, number>;
48
+ }
49
+
50
+ /**
51
+ * A "validValue" can be a primitive, an array of valid values, a declarative
52
+ * weighted form, or a function that returns one. Configs use this everywhere
53
+ * properties are user-defined.
54
+ *
55
+ * **Arrays of 3–19 unique strings get an automatic power-law draw** (~45% / 25% /
56
+ * 15% / decaying tail, one stable winner per array per run). Repeated entries are
57
+ * the weights (`['card', 'card', 'apple_pay']` = 2:1) and skip the power law.
58
+ * Opt out globally with `autoPowerLaw: false`, or state the distribution with
59
+ * `{ __weights }`.
60
+ *
61
+ * v1.7.0: the function arm receives a `ValueContext`. Zero-arity functions still work.
62
+ */
63
+ export type ValueValid = Primitives | ValueValid[] | WeightedValue | ((ctx?: ValueContext) => ValueValid);
14
64
 
15
65
  /**
16
66
  * Mixpanel data residency region. Matches the set `mixpanel-import` accepts.
@@ -76,6 +126,12 @@ export interface DungeonSwitches {
76
126
  hasBrowser?: boolean;
77
127
  isAnonymous?: boolean;
78
128
  alsoInferFunnels?: boolean;
129
+ /** v1.7.0 — see `Dungeon.singleCountry`. */
130
+ singleCountry?: string;
131
+ /** v1.7.0 — see `Dungeon.campaignPerUser`. */
132
+ campaignPerUser?: boolean;
133
+ /** v1.7.0 — see `Dungeon.stickyEventProps`. */
134
+ stickyEventProps?: string[];
79
135
  }
80
136
 
81
137
  /**
@@ -309,10 +365,63 @@ export interface Dungeon {
309
365
  sessionTimeout?: number;
310
366
  /** If true, auto-generates funnels from the events array in addition to any explicit funnels. */
311
367
  alsoInferFunnels?: boolean;
312
- /** Restrict all location data to a single country (e.g., "US", "GB"). */
368
+ /**
369
+ * Restrict all location data (`hasLocation`) to one country. Accepts the ISO
370
+ * code (`"US"`, `"GB"`) or the full name (`"United States"`), case-insensitive.
371
+ *
372
+ * v1.7.0: a value that matches no country in the location template THROWS at
373
+ * validation. Before 1.7.0 a miss (including `"US"`, which only matched by full
374
+ * name) silently emptied the location pool and deleted every geo property from
375
+ * events and profiles. Also accepted inside `switches`.
376
+ */
313
377
  singleCountry?: string;
314
- /** If true, stops generation at exactly numEvents (forces concurrency=1). Without this, event count is approximate. */
378
+ /**
379
+ * If true, the run delivers exactly `numEvents` events (forces `concurrency: 1`).
380
+ *
381
+ * v1.7.0: exact. Per-user budgets are scaled during the run so the total lands on
382
+ * the target, and the last user's stream is trimmed with a seeded uniform sample
383
+ * so the count never exceeds `numEvents`. If the users' capacity (rate × active
384
+ * days, minus drops) cannot reach the target, the run stops short and
385
+ * `result.warnings` carries a `numEvents` entry with `requested` / `applied`.
386
+ * Before 1.7.0 the flag stopped on the generated count (drops included) and
387
+ * never topped up, landing ~6% short with 4x headroom available.
388
+ *
389
+ * Without this flag the count is approximate (rate × users × days, thinned by
390
+ * born-late users and drops).
391
+ */
315
392
  strictEventCount?: boolean;
393
+ /**
394
+ * v1.7.0 (P1-2) — profile properties projected onto every event of the user.
395
+ * Each key must be declared in `userProps`, a persona's `properties`, or
396
+ * `superProps` (schema-first; the validator throws otherwise). Keys from the
397
+ * profile copy the profile's value (after the `user` hook). Keys declared only
398
+ * in `superProps` are resolved ONCE per user and held constant instead of
399
+ * re-rolled per event. Sticky values land after `superProps` and before the
400
+ * `event` hook, so hooks remain the final authority.
401
+ *
402
+ * The declarative alternative to `superProps: { plan: (ctx) => ctx.profile.plan }`.
403
+ * Also accepted inside `switches`.
404
+ */
405
+ stickyEventProps?: string[];
406
+ /**
407
+ * v1.7.0 (P1-4) — one acquisition campaign per user. With `hasCampaigns: true`,
408
+ * each user draws one campaign template at birth; its `utm_source`,
409
+ * `utm_campaign`, `utm_medium`, `utm_content`, `utm_term` are stamped on the
410
+ * profile and every sampled touchpoint carries those same values instead of a
411
+ * fresh random template per event. Any UTM key already on the profile (from a
412
+ * persona's `properties` or the `user` hook) wins over the draw, so
413
+ * `personas: [{ name: 'paid', properties: { utm_source: 'google', utm_medium: 'cpc' } }]`
414
+ * makes "paid search converts better" declarative. Default `false`. Also
415
+ * accepted inside `switches`.
416
+ */
417
+ campaignPerUser?: boolean;
418
+ /**
419
+ * v1.7.0 (P2-1) — set `false` to turn off the automatic power-law draw on
420
+ * arrays of 3–19 unique strings for the whole run (uniform picks instead).
421
+ * Default `true` (pre-1.7 behavior). Prefer `{ __weights }` when you want a
422
+ * specific distribution.
423
+ */
424
+ autoPowerLaw?: boolean;
316
425
  /** Internal flag for UI-triggered jobs (affects SCD credential handling). */
317
426
  isUIJob?: boolean;
318
427
 
@@ -441,6 +550,16 @@ export interface Dungeon {
441
550
  *
442
551
  * Example: `{ day1: 0.40, day7: 0.20, day30: 0.08 }` produces a curve that
443
552
  * approximates a typical product's 30-day retention.
553
+ *
554
+ * **Day 1 has a floor the curve cannot move (v1.7.0 doc, P1-6).** The curve
555
+ * picks which UTC days a user gets a SESSION; retention counts EVENTS. A funnel
556
+ * opened on the birth day spills its later steps across the following
557
+ * `timeToConvert` hours regardless of the day plan, so day-1 retention sits
558
+ * near 0.85 for funnel-driven dungeons no matter what `day1` says (measured
559
+ * 0.885 for an asked 0.15; days 7 and 30 followed the curve). Treat `day1` as
560
+ * governed by session and funnel structure and verify the curve from day 7 on.
561
+ * To lower day 1, shorten `timeToConvert` on funnels users enter on birth, or
562
+ * use an `everything` hook to drop next-day spill.
444
563
  */
445
564
  retentionCurve?: {
446
565
  type?: 'logarithmic' | 'linear';
@@ -532,13 +651,27 @@ export type MacroPreset = "flat" | "steady" | "growth" | "viral" | "decline";
532
651
 
533
652
  /**
534
653
  * Macro configuration object — fine-grained big-picture trend control.
654
+ *
655
+ * **Canonical spelling:** `macro: { preset, ...overrides }`. The top-level
656
+ * `bornRecentBias` / `percentUsersBornInDataset` / `preExistingSpread` keys are
657
+ * a legacy alias and win over the object when both are set.
658
+ *
659
+ * **Two shapes, two contracts (v1.7.0, R2-1):**
660
+ * - `{ preset: 'growth', percentUsersBornInDataset: 50 }` — a NAMED preset is a
661
+ * shape contract, so its born% cap applies (flat 12, steady 12, growth 30,
662
+ * viral 55, decline 5). Values above the cap are clamped and reported in
663
+ * `result.warnings` (`key: 'percentUsersBornInDataset'`).
664
+ * - `{ bornRecentBias: 0.3, percentUsersBornInDataset: 50 }` — NO `preset` is a
665
+ * custom macro: you own the shape, no cap applies, overrides are honored as
666
+ * written (missing fields fill from `flat`). Before 1.7.0 this shape was
667
+ * silently capped at 12.
535
668
  */
536
669
  export type MacroConfig = {
537
- /** Use a named macro preset as the base, then override individual fields. */
670
+ /** Use a named macro preset as the base, then override individual fields. Omit for a custom, uncapped macro. */
538
671
  preset?: MacroPreset;
539
- /** Bias for birth dates. -1..1; negative = early skew, positive = recent skew, 0 = uniform. */
672
+ /** Bias for birth dates. -1..1; negative = early skew, positive = recent skew, 0 = uniform. User-explicit values are clamped to [-0.5, 0.5]. */
540
673
  bornRecentBias?: number;
541
- /** Percentage of users born in dataset window (0..100). */
674
+ /** Percentage of users born in dataset window (0..100). Capped per named preset; uncapped without `preset`. */
542
675
  percentUsersBornInDataset?: number;
543
676
  /** "pinned" = pre-existing users stack at FIXED_BEGIN; "uniform" = spread across [FIXED_BEGIN-30d, FIXED_BEGIN]. */
544
677
  preExistingSpread?: "pinned" | "uniform";
@@ -835,6 +968,10 @@ export interface RuntimeState {
835
968
  eventCount: number;
836
969
  storedEventCount: number;
837
970
  userCount: number;
971
+ /** v1.7.0 (R2-5): profiles pushed to storage (ticks at push time; batch-mode safe). */
972
+ profilesGenerated: number;
973
+ /** v1.7.0 (R2-5): `_drop`-flagged profiles pushed to storage (never sent to /engage). */
974
+ profilesDropped: number;
838
975
  isBatchMode: boolean;
839
976
  verbose: boolean;
840
977
  }
@@ -897,6 +1034,14 @@ export interface Context {
897
1034
  incrementUsers(): void;
898
1035
  incrementStoredEvents(count?: number): void;
899
1036
  setStorage(storage: Storage): void;
1037
+ /** v1.7.0 (P2-2): record or bump an aggregated runtime warning (keyed by `entry.key`). */
1038
+ addWarning(entry: EngineWarning): void;
1039
+ /** v1.7.0 (P2-2): aggregated runtime warnings collected so far. */
1040
+ getWarnings(): EngineWarning[];
1041
+ /** v1.7.0 (R2-5) */
1042
+ incrementProfilesGenerated(): void;
1043
+ /** v1.7.0 (R2-5) */
1044
+ incrementProfilesDropped(): void;
900
1045
 
901
1046
  // State getter methods
902
1047
  getOperations(): number;
@@ -926,7 +1071,15 @@ export interface EventConfig {
926
1071
  properties?: Record<string, ValueValid>;
927
1072
  /** If true, this is the user's first-ever event (e.g., "sign up"). Used to create onboarding funnels. */
928
1073
  isFirstEvent?: boolean;
929
- /** If true, generating this event signals the user has churned. The user stops producing further events unless returnLikelihood allows them to come back. */
1074
+ /**
1075
+ * If true, generating this event signals the user has churned. The user stops
1076
+ * producing further events unless returnLikelihood allows them to come back.
1077
+ *
1078
+ * A churn event in the standalone pool is drawn by weight like any other, so it
1079
+ * ends every user after roughly `total weight / its weight` events — which caps
1080
+ * per-user volume and washes out `personas[].eventMultiplier` (measured 1.04x
1081
+ * for an asked 3x). Keep its weight low, or drive churn from a hook.
1082
+ */
930
1083
  isChurnEvent?: boolean;
931
1084
  /** Probability (0-1) that a churned user returns and continues generating events. 0 = permanent churn, 1 = always returns. Only used when isChurnEvent is true. Default: 0 */
932
1085
  returnLikelihood?: number;
@@ -1070,10 +1223,33 @@ export interface Funnel {
1070
1223
  */
1071
1224
  props?: Record<string, ValueValid>;
1072
1225
  /**
1073
- * funnel conditions (user properties) are used to filter users who are eligible for the funnel
1074
- * these conditions must match the current user's profile for the user to be eligible for the funnel
1226
+ * Profile conditions a user must satisfy to be offered this funnel (AND across
1227
+ * keys). The only engine mechanism that makes ONE segment convert differently on
1228
+ * ONE funnel — `personas[].conversionModifier` applies to every funnel.
1229
+ *
1230
+ * Each value is a scalar (strict equality) or an operator map:
1231
+ * `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte` (v1.7.0). Operators within
1232
+ * one key AND together. No `or`.
1233
+ *
1234
+ * The duplicate-funnel idiom: two funnels with the same `name` and `sequence`,
1235
+ * different `conditions` and rates.
1236
+ *
1237
+ * @example
1238
+ * userProps: { platform: ['iOS', 'Android'], seats: [1, 5, 10, 20] },
1239
+ * funnels: [
1240
+ * { name: 'Checkout', sequence: [...], conditions: { platform: 'iOS' }, conversionRate: 80 },
1241
+ * { name: 'Checkout', sequence: [...], conditions: { platform: 'Android' }, conversionRate: 40 },
1242
+ * { name: 'Upgrade', sequence: [...], conditions: { seats: { gte: 10 }, platform: { in: ['iOS', 'Android'] } } },
1243
+ * ]
1244
+ *
1245
+ * Validation (v1.7.0): function values, bare arrays, unknown operators, and
1246
+ * `in`/`nin` without an array THROW — those shapes silently never matched before.
1247
+ * A key not declared in `userProps` / `superProps` / any persona's `properties`
1248
+ * produces a `result.warnings` entry (only a `user` hook could supply it). Users
1249
+ * who match no funnel at all fall through to standalone events; the run reports
1250
+ * how many under `key: 'funnels.conditions'`.
1075
1251
  */
1076
- conditions?: Record<string, ValueValid>;
1252
+ conditions?: FunnelConditions;
1077
1253
  /**
1078
1254
  * Experiment configuration for this funnel.
1079
1255
  *
@@ -1131,7 +1307,9 @@ export interface Funnel {
1131
1307
  }>;
1132
1308
 
1133
1309
  /** @internal Resolved experiment config set by config-validator. */
1134
- _experiment?: { name: string; variants: Array<{ name: string; conversionMultiplier: number; ttcMultiplier: number; weight: number }>; startUnix: number | null; sticky: boolean };
1310
+ _experiment?: { name: string; variants: Array<{ name: string; conversionMultiplier: number; ttcMultiplier: number; weight: number }>; startUnix: number | null; sticky: boolean; stampProfile: boolean };
1311
+ /** @internal v1.7.0 — set by the validator on the engine-synthesized catch-all funnel. */
1312
+ _catchAll?: boolean;
1135
1313
  /** @internal Set by funnels.js during experiment handling. */
1136
1314
  _experimentName?: string;
1137
1315
  /** @internal Set by funnels.js during experiment handling. */
@@ -1214,8 +1392,43 @@ export interface ExperimentConfig {
1214
1392
  * variants across passes.
1215
1393
  */
1216
1394
  sticky?: boolean;
1395
+ /**
1396
+ * v1.7.0 (P0-2) — write the assigned variant onto the user profile as
1397
+ * `"Experiment: <name>": "<variant>"` so a funnel can be broken down by variant
1398
+ * without building a cohort from the exposure event. Default `true`. Stamped
1399
+ * lazily when the user is first exposed (respects `startDaysBeforeEnd`), so
1400
+ * never-exposed users carry no property and the `user` hook does not see it;
1401
+ * the `everything` hook does. Ignored when `sticky: false` (a re-rolled variant
1402
+ * has no single per-user value). The variant is NOT stamped on downstream funnel
1403
+ * step events (that would add undeclared columns).
1404
+ */
1405
+ stampProfile?: boolean;
1217
1406
  }
1218
1407
 
1408
+ /**
1409
+ * v1.7.0 (P0-1) — one funnel condition: a scalar (strict equality) or an operator map.
1410
+ */
1411
+ export type FunnelCondition = Primitives | FunnelConditionOperators;
1412
+
1413
+ /** Operator map for a funnel condition. Every operator present must hold (AND). */
1414
+ export interface FunnelConditionOperators {
1415
+ /** strict equality */
1416
+ eq?: Primitives;
1417
+ /** strict inequality (a missing profile key satisfies it) */
1418
+ neq?: Primitives;
1419
+ /** value is one of these */
1420
+ in?: Primitives[];
1421
+ /** value is none of these (a missing profile key satisfies it) */
1422
+ nin?: Primitives[];
1423
+ gt?: number | string;
1424
+ gte?: number | string;
1425
+ lt?: number | string;
1426
+ lte?: number | string;
1427
+ }
1428
+
1429
+ /** Map of profile key → condition. AND across keys. */
1430
+ export type FunnelConditions = Record<string, FunnelCondition>;
1431
+
1219
1432
  /** A single variant in an experiment. */
1220
1433
  export interface ExperimentVariant {
1221
1434
  /** Display name — appears in the "Variant name" property on $experiment_started. */
@@ -1328,11 +1541,47 @@ export interface GroupProfileSchema {
1328
1541
  */
1329
1542
  export interface ImportResults {
1330
1543
  events: ImportResult;
1331
- users: ImportResult;
1544
+ /**
1545
+ * v1.7.0 (R2-5): mixpanel-import's receipt plus two engine counters, so the
1546
+ * consumer can reconcile without guessing:
1547
+ * `generated - dropped_anonymous - failed === success`.
1548
+ */
1549
+ users: ImportResult & {
1550
+ /** Profiles the engine pushed to storage (bots included). */
1551
+ generated: number;
1552
+ /** `_drop`-flagged anonymous non-converters that were never sent to /engage. */
1553
+ dropped_anonymous: number;
1554
+ };
1332
1555
  groups: ImportResult[];
1333
1556
  }
1334
1557
  type ImportResult = import("mixpanel-import").ImportResults;
1335
1558
 
1559
+ /**
1560
+ * v1.7.0 (P2-2) — one value the engine changed or flagged. `result.warnings` is
1561
+ * always present (empty array when nothing was touched), regardless of `verbose`.
1562
+ *
1563
+ * Validator clamps come first (`severity: 'clamp'`, `applied !== requested`),
1564
+ * then run-level aggregates: conversionRate saturation per funnel and source
1565
+ * (`key: 'funnels[<name>].conversionRate:<source>'`), users matching no
1566
+ * conditioned funnel (`'funnels.conditions'`), churn washing out a persona
1567
+ * multiplier (`'personas.eventMultiplier'`), and a strictEventCount shortfall
1568
+ * (`'numEvents'`). Aggregated entries carry `count`.
1569
+ */
1570
+ export interface EngineWarning {
1571
+ /** Config path the entry is about, e.g. `percentUsersBornInDataset`, `funnels[Checkout].conversionRate:persona "power" conversionModifier`. */
1572
+ key: string;
1573
+ /** What the config (or a modifier) asked for. Undefined when nothing was requested (auto-set). */
1574
+ requested?: unknown;
1575
+ /** What the engine used. Equals `requested` for pure warnings. */
1576
+ applied?: unknown;
1577
+ /** Plain-language explanation and what to change. */
1578
+ reason: string;
1579
+ /** `clamp` = a value was changed; `warn` = flagged, nothing changed. */
1580
+ severity: 'clamp' | 'warn';
1581
+ /** Number of occurrences folded into this entry (runtime aggregates only). */
1582
+ count?: number;
1583
+ }
1584
+
1336
1585
  /**
1337
1586
  * the end result of the data generation
1338
1587
  */
@@ -1353,6 +1602,11 @@ export type Result = {
1353
1602
  lookupTableData: LookupTableData[][];
1354
1603
  /** Mixpanel import results (only populated when a token was provided). */
1355
1604
  importResults?: ImportResults;
1605
+ /**
1606
+ * v1.7.0 (P2-2): every value the engine clamped or flagged this run. Always
1607
+ * present, even when empty. See `EngineWarning`.
1608
+ */
1609
+ warnings: EngineWarning[];
1356
1610
  /** Absolute paths of all files written to disk. */
1357
1611
  files?: string[];
1358
1612
  /** Timing information. */
@@ -1454,34 +1708,38 @@ export interface Persona {
1454
1708
  name: string;
1455
1709
  /** Relative weight for persona assignment (higher = more users get this persona). */
1456
1710
  weight: number;
1457
- /** Multiplier for number of events this persona generates (1.0 = normal). */
1711
+ /**
1712
+ * Multiplier on the persona's whole per-user event budget (1.0 = normal). The
1713
+ * budget drives BOTH usage-funnel passes and standalone events, so a 3x persona
1714
+ * runs ~3x as many funnel passes (measured 2.9–3.2x).
1715
+ *
1716
+ * **`isChurnEvent` caps it.** A churn event in the standalone pool ends each
1717
+ * user after roughly the same number of events regardless of budget, so the
1718
+ * multiplier washes out (measured 1.04x for an asked 3x with a weight-1 churn
1719
+ * event among 16 weight units). When more than half the users churn and a
1720
+ * persona multiplier is in play, `result.warnings` carries
1721
+ * `key: 'personas.eventMultiplier'`. Lower the churn event's weight, raise
1722
+ * `returnLikelihood`, or drive churn from a hook.
1723
+ */
1458
1724
  eventMultiplier?: number;
1459
- /** Multiplier for funnel conversion rates (1.0 = normal, 1.3 = 30% better). */
1725
+ /** Multiplier for funnel conversion rates (1.0 = normal, 1.3 = 30% better). Applies to every funnel; for one segment on one funnel use `Funnel.conditions`. */
1460
1726
  conversionModifier?: number;
1461
1727
  /**
1462
- * Base churn rate for this persona (0-1).
1463
- * @deprecated unimplemented; no-op. The validator defaults it to 0 but no
1464
- * generator reads it. Model churn with hooks instead (HOOKS.md §3 cohort
1465
- * atoms + `engagementDecay`).
1728
+ * v1.7.0 (P1-3) multiplier for funnel `timeToConvert` (1.0 = normal,
1729
+ * 0.5 = converts twice as fast). Composes after an experiment's `ttcMultiplier`
1730
+ * and before the `funnel-pre` hook. Must be a positive number.
1466
1731
  */
1467
- churnRate?: number;
1732
+ ttcModifier?: number;
1468
1733
  /** Properties merged into user profiles for this persona. */
1469
1734
  properties?: Record<string, ValueValid>;
1470
1735
  /**
1471
- * Limit how long this persona is active (e.g., trial users active for 14 days).
1472
- * @deprecated — unimplemented; no-op. Declared surface only; nothing in lib/
1473
- * reads it. Bound activity windows with an `everything` hook (drop events
1474
- * outside the window) instead.
1736
+ * Per-persona engagement decay override. This one IS implemented
1737
+ * (`user-loop.js` reads `persona.engagementDecay` before the global one).
1738
+ *
1739
+ * v1.7.0 removed the never-implemented `churnRate`, `activeWindow`, and
1740
+ * `soupOverride` from this type. The validator still accepts and warns on them.
1475
1741
  */
1476
- activeWindow?: { maxDays: number };
1477
- /** Per-persona engagement decay override. */
1478
1742
  engagementDecay?: EngagementDecay;
1479
- /**
1480
- * Per-persona soup/timing override.
1481
- * @deprecated — unimplemented; no-op. Declared surface only; nothing in lib/
1482
- * reads it. Use the top-level `soup` config for timing shape.
1483
- */
1484
- soupOverride?: SoupConfig;
1485
1743
  }
1486
1744
 
1487
1745
  /**
@@ -1496,7 +1754,18 @@ export interface WorldEvent {
1496
1754
  startDay: number;
1497
1755
  /** Duration in days (0.25 = 6 hours, null = permanent from startDay onward). */
1498
1756
  duration?: number | null;
1499
- /** Volume multiplier during this event (3.0 = 3x events, 0.1 = 90% drop). */
1757
+ /**
1758
+ * Volume multiplier during this event (3.0 = 3x events, 0.1 = 90% drop).
1759
+ *
1760
+ * Below 1: affected events are dropped at random so volume falls to the multiple.
1761
+ * Above 1 (v1.7.0, P0-3): affected in-window events are CLONED — `floor(m - 1)`
1762
+ * copies plus one more with probability `frac(m)` (2.5 = one guaranteed clone
1763
+ * and a 50% second) — each with a fresh `insert_id` and a timestamp spread
1764
+ * uniformly across the window (never past the dataset end). Measured 3.06x for
1765
+ * an asked 3x; before 1.7.0 values above 1 were a silent no-op (measured 1.08x).
1766
+ * Clones exist before `engagementDecay` and every hook, so they are visible to
1767
+ * `everything`. The same rule applies to `aftermath.volumeMultiplier`.
1768
+ */
1500
1769
  volumeMultiplier?: number;
1501
1770
  /** Conversion rate modifier during this event. */
1502
1771
  conversionModifier?: number;