@ak--47/dungeon-master 1.5.0 → 1.5.2
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/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +24 -11
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +117 -1
|
@@ -15,6 +15,7 @@ import { makeEvent } from "../generators/events.js";
|
|
|
15
15
|
import { makeFunnel } from "../generators/funnels.js";
|
|
16
16
|
import { makeUserProfile } from "../generators/profiles.js";
|
|
17
17
|
import { makeSCD } from "../generators/scd.js";
|
|
18
|
+
import { buildCurveWeightFn, expectedActiveDays } from "../utils/retention-curve.js";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Main user generation loop that creates users, their profiles, events, and SCDs
|
|
@@ -43,6 +44,7 @@ export async function userLoop(context) {
|
|
|
43
44
|
numDays,
|
|
44
45
|
avgEventsPerUserPerDay,
|
|
45
46
|
avgActiveDaysPerUser,
|
|
47
|
+
retentionCurve,
|
|
46
48
|
percentUsersBornInDataset = 15,
|
|
47
49
|
strictEventCount = false,
|
|
48
50
|
bornRecentBias = 0, // -1..1; positive = births skew toward end of window
|
|
@@ -119,7 +121,12 @@ export async function userLoop(context) {
|
|
|
119
121
|
percentComplete: Math.min(100, Math.round((context.getUserCount() / numUsers) * 100))
|
|
120
122
|
});
|
|
121
123
|
|
|
122
|
-
|
|
124
|
+
// v1.5.1: distinct_id sourced from a separate `userChance` when
|
|
125
|
+
// `Dungeon.userSeed` is set. This lets sharded runs (e.g., kodiak
|
|
126
|
+
// Cloud Run Job) generate the SAME user pool with DIFFERENT events
|
|
127
|
+
// across shards. Falls back to the event chance when userSeed is
|
|
128
|
+
// unset so existing dungeons stay byte-identical.
|
|
129
|
+
const userId = u.getUserChance().guid();
|
|
123
130
|
const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix: context.FIXED_NOW, avgDevicePerUser });
|
|
124
131
|
const { distinct_id, created } = user;
|
|
125
132
|
const userDevicePool = (user.anonymousIds && user.anonymousIds.length) ? user.anonymousIds.slice() : null;
|
|
@@ -219,9 +226,14 @@ export async function userLoop(context) {
|
|
|
219
226
|
dataQuality,
|
|
220
227
|
};
|
|
221
228
|
|
|
222
|
-
// Call user hook after profile creation (hooks override persona properties)
|
|
229
|
+
// Call user hook after profile creation (hooks override persona properties).
|
|
230
|
+
// v1.5.1: an explicit `null` return is interpreted as "drop the user
|
|
231
|
+
// PROFILE record" — the user's events still generate normally. Used
|
|
232
|
+
// by sharded runs that have one canonical chunk per bucket emit profiles
|
|
233
|
+
// while every other chunk skips them (see dungeons/user/kodiak/).
|
|
234
|
+
let dropUserProfile = false;
|
|
223
235
|
if (config.hook) {
|
|
224
|
-
await config.hook(profile, "user", {
|
|
236
|
+
const hookedProfile = await config.hook(profile, "user", {
|
|
225
237
|
user,
|
|
226
238
|
config,
|
|
227
239
|
userIsBornInDataset,
|
|
@@ -229,6 +241,9 @@ export async function userLoop(context) {
|
|
|
229
241
|
datasetStart: context.DATASET_START_SECONDS,
|
|
230
242
|
datasetEnd: context.DATASET_END_SECONDS
|
|
231
243
|
});
|
|
244
|
+
if (hookedProfile === null) {
|
|
245
|
+
dropUserProfile = true;
|
|
246
|
+
}
|
|
232
247
|
}
|
|
233
248
|
|
|
234
249
|
// SCD creation
|
|
@@ -268,19 +283,25 @@ export async function userLoop(context) {
|
|
|
268
283
|
: numDays;
|
|
269
284
|
const userEventBudget = ratePerDay * userActiveDays;
|
|
270
285
|
|
|
271
|
-
|
|
286
|
+
// v1.5.1 (TODO #10): per-user event budget.
|
|
287
|
+
//
|
|
288
|
+
// Removed:
|
|
289
|
+
// - the 0.714 magic dampening factor (pre-existing vestigial constant)
|
|
290
|
+
// - the ×5 / ×0.333 "power user" / "low activity" dice rolls
|
|
291
|
+
// (E[dice mult] ≈ 1.62, combined with 0.714 ≈ 1.16x inflation
|
|
292
|
+
// by design — produced 60-100% systematic overshoot on
|
|
293
|
+
// `numEvents` targets across all macros).
|
|
294
|
+
//
|
|
295
|
+
// New: pure normal distribution around `userEventBudget` with
|
|
296
|
+
// `dev = userEventBudget / 3` (~68% of users within ±1σ of target,
|
|
297
|
+
// ~95% within ±2σ). Real heavy-tail behavior should come from
|
|
298
|
+
// `personas` (`eventMultiplier`) — explicit, opt-in, documented.
|
|
299
|
+
let numEventsThisUserWillPreform = Math.max(0, Math.round(chance.normal({
|
|
272
300
|
mean: userEventBudget,
|
|
273
|
-
dev: userEventBudget /
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
// Power users and low-activity users logic
|
|
301
|
+
dev: userEventBudget / 3,
|
|
302
|
+
})));
|
|
277
303
|
if (persona) {
|
|
278
|
-
// Persona-driven event multiplier replaces the old dice rolls
|
|
279
304
|
numEventsThisUserWillPreform *= persona.eventMultiplier;
|
|
280
|
-
} else {
|
|
281
|
-
// Legacy behavior when no personas configured
|
|
282
|
-
chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
|
|
283
|
-
chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
|
|
284
305
|
}
|
|
285
306
|
numEventsThisUserWillPreform = Math.round(numEventsThisUserWillPreform);
|
|
286
307
|
|
|
@@ -300,12 +321,17 @@ export async function userLoop(context) {
|
|
|
300
321
|
// `{ plan, pickedDayBuckets }`. `pickedDayBuckets` flows into
|
|
301
322
|
// `applyEngagementDecay` so the decay filter never drops the last event
|
|
302
323
|
// on a picked day — preserving the configured distinct-day count.
|
|
303
|
-
|
|
324
|
+
// v1.5.1: also fires when `retentionCurve` is set (curve wins over
|
|
325
|
+
// explicit `avgActiveDaysPerUser` — see TODO #4 resolution (b)).
|
|
326
|
+
const hasActiveDaysKnob = (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && Number.isFinite(avgActiveDaysPerUser));
|
|
327
|
+
const hasRetentionCurve = !!retentionCurve;
|
|
328
|
+
const dayPlanResult = (hasActiveDaysKnob || hasRetentionCurve)
|
|
304
329
|
? buildActiveDayPlan({
|
|
305
330
|
adjustedCreated, fixedBegin: context.FIXED_BEGIN, fixedNow: context.FIXED_NOW,
|
|
306
331
|
avgActiveDaysPerUser, userActiveDays,
|
|
307
332
|
numEvents: numEventsThisUserWillPreform,
|
|
308
333
|
dowWeights: soupCfgForActiveDay.dayOfWeekWeights,
|
|
334
|
+
retentionCurve,
|
|
309
335
|
chance,
|
|
310
336
|
})
|
|
311
337
|
: null;
|
|
@@ -422,9 +448,13 @@ export async function userLoop(context) {
|
|
|
422
448
|
|
|
423
449
|
userFirstEventTime = firstAttemptFirstEventTime !== null
|
|
424
450
|
? firstAttemptFirstEventTime
|
|
425
|
-
: adjustedCreated.subtract(noise(), 'seconds').unix();
|
|
451
|
+
: Math.max(adjustedCreated.subtract(noise(), 'seconds').unix(), context.FIXED_BEGIN);
|
|
426
452
|
} else {
|
|
427
|
-
|
|
453
|
+
// v1.5.1: clamp at FIXED_BEGIN so pre-existing-user events stay strictly
|
|
454
|
+
// inside the dataset window. Without this, `noise()` (up to 1 day) can
|
|
455
|
+
// shift the first-event time before FIXED_BEGIN — fine when the dataset
|
|
456
|
+
// is years long, but visible at sub-day chunk windows (kodiak shards).
|
|
457
|
+
userFirstEventTime = Math.max(adjustedCreated.subtract(noise(), 'seconds').unix(), context.FIXED_BEGIN);
|
|
428
458
|
}
|
|
429
459
|
|
|
430
460
|
// ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
|
|
@@ -458,6 +488,14 @@ export async function userLoop(context) {
|
|
|
458
488
|
// out cleanly rather than crashing on `pick([])`.
|
|
459
489
|
const hasUsageFunnels = usageFunnels.length > 0;
|
|
460
490
|
const hasStandaloneEvents = weightedEvents.length > 0;
|
|
491
|
+
// v1.5.1 (TODO #10): shortest usage-funnel length. Used to decide
|
|
492
|
+
// whether the remaining budget can fit another funnel run — funnels
|
|
493
|
+
// add N events at once, so iterations near the budget ceiling
|
|
494
|
+
// systematically overshot. Now we redirect to standalone (or break)
|
|
495
|
+
// when the remaining headroom is smaller than the smallest funnel.
|
|
496
|
+
const minUsageFunnelLen = hasUsageFunnels
|
|
497
|
+
? Math.min(...usageFunnels.map(f => Array.isArray(f.sequence) ? f.sequence.length : 1))
|
|
498
|
+
: Infinity;
|
|
461
499
|
// Hard ceiling on iterations: defends against pathological configs where
|
|
462
500
|
// every funnel produces 0 surviving events (e.g. ttc > numDays so step1
|
|
463
501
|
// uniform anchor always lands too late). Cap at 2× expected iteration count
|
|
@@ -483,7 +521,16 @@ export async function userLoop(context) {
|
|
|
483
521
|
// Nothing to generate: user converted via firstFunnel only and has no
|
|
484
522
|
// usage funnels and no standalone events. Stop attempting.
|
|
485
523
|
if ((!hasUsageFunnels || !userConverted) && !hasStandaloneEvents) break;
|
|
486
|
-
|
|
524
|
+
// v1.5.1 (TODO #10): if the remaining budget can't fit even the
|
|
525
|
+
// shortest usage funnel, prefer a standalone event (or break if
|
|
526
|
+
// no standalone). Without this, the funnel branch added N events
|
|
527
|
+
// at once and overshot `numEvents` by `(funnel_length - 1)` per
|
|
528
|
+
// terminal iteration — measured ~5-15% systematic overshoot.
|
|
529
|
+
const remainingBudget = numEventsThisUserWillPreform - numEventsPreformed;
|
|
530
|
+
const useFunnel = hasUsageFunnels && userConverted && remainingBudget >= minUsageFunnelLen;
|
|
531
|
+
// Budget exhausted for funnels AND no standalone → break.
|
|
532
|
+
if (!useFunnel && !hasStandaloneEvents) break;
|
|
533
|
+
if (useFunnel) {
|
|
487
534
|
const currentFunnel = chance.pickone(usageFunnels);
|
|
488
535
|
const ttcSec = (currentFunnel.timeToConvert || 0) * 3600;
|
|
489
536
|
// Anchor cursor at picked day's start when active-day mode is on,
|
|
@@ -533,7 +580,12 @@ export async function userLoop(context) {
|
|
|
533
580
|
// `earliestTime`, which the now-deleted bunchIntoSessions used to paper
|
|
534
581
|
// over. With bunchIntoSessions removed, TimeSoup is the time source.
|
|
535
582
|
const standaloneEarliest = dayBounds ? dayBounds.earliest : userFirstEventTime;
|
|
536
|
-
|
|
583
|
+
// v1.5.1: pass `config.superProps` so standalone events get super-property
|
|
584
|
+
// stamping. Pre-1.5.1, standalone events received `{}` here, but the
|
|
585
|
+
// validator's auto-funnel (catch-all) consumed all non-strict events so
|
|
586
|
+
// the bug was invisible. The TODO #10 `useFunnel` gate redirects some
|
|
587
|
+
// budget-boundary iterations to standalone, exposing the gap.
|
|
588
|
+
const data = await makeEvent(context, distinct_id, standaloneEarliest, u.pick(weightedEvents), user.anonymousIds, config.superProps || {}, config.groupKeys, false, false, standaloneFeatureCtx, standaloneIdentityCtx);
|
|
537
589
|
numEventsPreformed++;
|
|
538
590
|
newEvents = [data];
|
|
539
591
|
}
|
|
@@ -630,6 +682,14 @@ export async function userLoop(context) {
|
|
|
630
682
|
applyTouchpointCap(usersEvents, config, defaults, chance);
|
|
631
683
|
}
|
|
632
684
|
|
|
685
|
+
// v1.5.1: anonymous non-converters never call $identify in production,
|
|
686
|
+
// so Mixpanel never creates a profile for them. Stamp `_drop: true` so
|
|
687
|
+
// mixpanel-sender skips the /engage push. Stamped BEFORE the everything
|
|
688
|
+
// hook fires so hooks can rescue a profile by deleting the flag.
|
|
689
|
+
if (userIsBornInDataset && !userAuthed) {
|
|
690
|
+
profile._drop = true;
|
|
691
|
+
}
|
|
692
|
+
|
|
633
693
|
// Hook for processing all user events (hooks override everything)
|
|
634
694
|
if (config.hook) {
|
|
635
695
|
// `meta.isPreAuth(event)` predicate bound to this user's auth state.
|
|
@@ -691,8 +751,11 @@ export async function userLoop(context) {
|
|
|
691
751
|
console.warn(`⚠️ Dropped ${droppedFuture} future-dated event(s) for user ${distinct_id}`);
|
|
692
752
|
}
|
|
693
753
|
|
|
694
|
-
// Store all user data
|
|
695
|
-
|
|
754
|
+
// Store all user data (skip profile push when a hook returned null
|
|
755
|
+
// for type='user' — see dropUserProfile above).
|
|
756
|
+
if (!dropUserProfile) {
|
|
757
|
+
await userProfilesData.hookPush(profile);
|
|
758
|
+
}
|
|
696
759
|
|
|
697
760
|
if (Object.keys(userSCD).length) {
|
|
698
761
|
for (const [key, changesArray] of Object.entries(userSCD)) {
|
|
@@ -714,10 +777,24 @@ export async function userLoop(context) {
|
|
|
714
777
|
});
|
|
715
778
|
|
|
716
779
|
userPromises.push(userPromise);
|
|
780
|
+
|
|
781
|
+
// v1.5.1: V8's Promise.all has a hard ceiling (~65K elements). Sharded
|
|
782
|
+
// kodiak runs pass numUsers in the millions per chunk, which blows that
|
|
783
|
+
// limit. Drain the in-flight buffer in batches of 50K to keep Promise.all
|
|
784
|
+
// well under the cap. With p-limit honoring `concurrency`, this is purely
|
|
785
|
+
// a backpressure boundary — no behavior change for existing dungeons that
|
|
786
|
+
// have numUsers under 50K.
|
|
787
|
+
if (userPromises.length >= 50_000) {
|
|
788
|
+
await Promise.all(userPromises);
|
|
789
|
+
userPromises.length = 0;
|
|
790
|
+
}
|
|
717
791
|
}
|
|
718
792
|
|
|
719
|
-
//
|
|
720
|
-
|
|
793
|
+
// Drain whatever's left in the final partial batch.
|
|
794
|
+
if (userPromises.length > 0) {
|
|
795
|
+
await Promise.all(userPromises);
|
|
796
|
+
userPromises.length = 0;
|
|
797
|
+
}
|
|
721
798
|
|
|
722
799
|
// Feature 4: Generate bot users (after regular users)
|
|
723
800
|
if (dataQuality && dataQuality.botUsers > 0) {
|
|
@@ -774,11 +851,15 @@ export function matchConditions(profile, conditions) {
|
|
|
774
851
|
* @param {number} args.userActiveDays - Capacity (max possible distinct days)
|
|
775
852
|
* @param {number} args.numEvents - Total events to schedule for this user
|
|
776
853
|
* @param {number[]} [args.dowWeights] - 7-element soup DOW weights (Sun..Sat)
|
|
854
|
+
* @param {import('../utils/retention-curve.js').RetentionCurveConfig} [args.retentionCurve]
|
|
855
|
+
* v1.5.1 — when set, weights candidate days by curve(day_offset_from_birth)
|
|
856
|
+
* instead of DOW weights AND derives the effective avgActiveDaysPerUser from
|
|
857
|
+
* the curve's sum across the window.
|
|
777
858
|
* @param {Object} args.chance - Seeded chance instance
|
|
778
859
|
* @returns {{ plan: number[], pickedDayBuckets: Set<number> } | null} Day plan
|
|
779
860
|
* + bucket set, or null if not applicable (no candidate days, no events, etc.).
|
|
780
861
|
*/
|
|
781
|
-
function buildActiveDayPlan({ adjustedCreated, fixedBegin, fixedNow, avgActiveDaysPerUser, userActiveDays, numEvents, dowWeights, chance }) {
|
|
862
|
+
function buildActiveDayPlan({ adjustedCreated, fixedBegin, fixedNow, avgActiveDaysPerUser, userActiveDays, numEvents, dowWeights, retentionCurve, chance }) {
|
|
782
863
|
if (!Number.isFinite(numEvents) || numEvents <= 0) return null;
|
|
783
864
|
|
|
784
865
|
// Candidate day buckets: UTC days intersecting [max(adjustedCreated, FIXED_BEGIN), FIXED_NOW].
|
|
@@ -800,16 +881,36 @@ function buildActiveDayPlan({ adjustedCreated, fixedBegin, fixedNow, avgActiveDa
|
|
|
800
881
|
}
|
|
801
882
|
if (!candidateDays.length) return null;
|
|
802
883
|
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
884
|
+
// v1.5.1 (TODO #4): when `retentionCurve` is set, weight candidate days by
|
|
885
|
+
// curve(day_offset_from_birth) — biases active-day selection toward the
|
|
886
|
+
// requested retention shape. Replaces DOW weighting entirely (curve wins).
|
|
887
|
+
// Otherwise: legacy DOW weights.
|
|
888
|
+
let weights;
|
|
889
|
+
if (retentionCurve) {
|
|
890
|
+
const curveFn = buildCurveWeightFn(retentionCurve);
|
|
891
|
+
const birthDay = Math.floor(userStartUnix / dayMs);
|
|
892
|
+
weights = candidateDays.map(daySec => {
|
|
893
|
+
const dayOffset = (daySec / dayMs) - birthDay;
|
|
894
|
+
const w = curveFn(dayOffset);
|
|
895
|
+
return Number.isFinite(w) && w > 0 ? w : 0.0001;
|
|
896
|
+
});
|
|
897
|
+
} else {
|
|
898
|
+
weights = candidateDays.map(daySec => {
|
|
899
|
+
if (!Array.isArray(dowWeights) || dowWeights.length !== 7) return 1;
|
|
900
|
+
const dow = new Date(daySec * 1000).getUTCDay();
|
|
901
|
+
const w = Number(dowWeights[dow]);
|
|
902
|
+
return Number.isFinite(w) && w > 0 ? w : 0.0001;
|
|
903
|
+
});
|
|
904
|
+
}
|
|
810
905
|
|
|
811
906
|
// Draw target active-day count: normal(mean, sd=mean/3), clamped.
|
|
812
|
-
|
|
907
|
+
// v1.5.1: when `retentionCurve` is set, the mean is derived from the curve's
|
|
908
|
+
// sum across the user's window (resolution (b): curve wins, explicit
|
|
909
|
+
// avgActiveDaysPerUser ignored).
|
|
910
|
+
const effectiveMean = retentionCurve
|
|
911
|
+
? Math.max(1, expectedActiveDays(retentionCurve, candidateDays.length))
|
|
912
|
+
: avgActiveDaysPerUser;
|
|
913
|
+
const meanActive = Math.max(1, Math.min(effectiveMean, candidateDays.length));
|
|
813
914
|
const sd = Math.max(0.5, meanActive / 3);
|
|
814
915
|
let targetActiveDays = Math.round(chance.normal({ mean: meanActive, dev: sd }));
|
|
815
916
|
targetActiveDays = Math.max(1, Math.min(targetActiveDays, candidateDays.length));
|