Skip to content

Commit 13f6f1a

Browse files
committed
feat: randomize mobile feed ad positions via adJitter
Add optional `adJitter` to `FeedAdTemplate` so ad positions can vary deterministically per feed while keeping average cadence unchanged. Jitter only activates when the `feed_ad_template` GrowthBook flag sets `adJitter` under the `default` key, so only the mobile/1-column list layout is affected. - Extend FeedAdTemplate with optional adJitter - Pure getAdSlotIndex helper with seeded FNV-1a hash keyed off feedQueryKey - Clamp jitter to floor((adRepeat - 1) / 2) so consecutive ads never overlap or reorder - Unit tests for parity, window bounds, determinism, and clamp Made-with: Cursor
1 parent 8be478b commit 13f6f1a

3 files changed

Lines changed: 163 additions & 11 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { getAdSlotIndex } from './useFeed';
2+
3+
describe('getAdSlotIndex', () => {
4+
const seed = '["feed","my-feed"]';
5+
6+
it('matches modulo math when adJitter is 0', () => {
7+
const adStart = 2;
8+
const adRepeat = 5;
9+
const positions: number[] = [];
10+
for (let index = 0; index < 40; index += 1) {
11+
const n = getAdSlotIndex({ index, adStart, adRepeat, seed });
12+
if (n !== undefined) {
13+
positions.push(index);
14+
}
15+
}
16+
expect(positions).toEqual([2, 7, 12, 17, 22, 27, 32, 37]);
17+
});
18+
19+
it('returns undefined for indices before the first possible ad slot', () => {
20+
const adStart = 2;
21+
const adRepeat = 5;
22+
const adJitter = 2;
23+
expect(
24+
getAdSlotIndex({ index: -1, adStart, adRepeat, adJitter, seed }),
25+
).toBeUndefined();
26+
});
27+
28+
it('keeps jittered positions inside the expected window per slot', () => {
29+
const adStart = 2;
30+
const adRepeat = 5;
31+
const adJitter = 2;
32+
const windows = new Map<number, number[]>();
33+
for (let index = 0; index < 60; index += 1) {
34+
const n = getAdSlotIndex({
35+
index,
36+
adStart,
37+
adRepeat,
38+
adJitter,
39+
seed,
40+
});
41+
if (n !== undefined) {
42+
const list = windows.get(n) ?? [];
43+
list.push(index);
44+
windows.set(n, list);
45+
}
46+
}
47+
Array.from(windows.entries()).forEach(([n, hits]) => {
48+
expect(hits).toHaveLength(1);
49+
const center = adStart + n * adRepeat;
50+
expect(hits[0]).toBeGreaterThanOrEqual(center - adJitter);
51+
expect(hits[0]).toBeLessThanOrEqual(center + adJitter);
52+
});
53+
expect(windows.size).toBeGreaterThanOrEqual(10);
54+
});
55+
56+
it('is deterministic for the same seed and slot index', () => {
57+
const args = {
58+
adStart: 2,
59+
adRepeat: 5,
60+
adJitter: 2,
61+
seed,
62+
};
63+
const runA: Array<number | undefined> = [];
64+
const runB: Array<number | undefined> = [];
65+
for (let index = 0; index < 40; index += 1) {
66+
runA.push(getAdSlotIndex({ index, ...args }));
67+
runB.push(getAdSlotIndex({ index, ...args }));
68+
}
69+
expect(runA).toEqual(runB);
70+
});
71+
72+
it('produces different positions for different seeds', () => {
73+
const args = { adStart: 2, adRepeat: 5, adJitter: 2 };
74+
const collect = (s: string): number[] => {
75+
const hits: number[] = [];
76+
for (let index = 0; index < 60; index += 1) {
77+
if (getAdSlotIndex({ index, ...args, seed: s }) !== undefined) {
78+
hits.push(index);
79+
}
80+
}
81+
return hits;
82+
};
83+
const seedA = '["feed","user-a"]';
84+
const seedB = '["feed","user-b"]';
85+
expect(collect(seedA)).not.toEqual(collect(seedB));
86+
});
87+
88+
it('clamps jitter so consecutive ads never overlap or reorder', () => {
89+
const adStart = 2;
90+
const adRepeat = 5;
91+
const adJitter = 100;
92+
const hits: number[] = [];
93+
for (let index = 0; index < 200; index += 1) {
94+
if (
95+
getAdSlotIndex({ index, adStart, adRepeat, adJitter, seed }) !==
96+
undefined
97+
) {
98+
hits.push(index);
99+
}
100+
}
101+
expect(hits.length).toBeGreaterThan(5);
102+
for (let i = 1; i < hits.length; i += 1) {
103+
expect(hits[i]).toBeGreaterThan(hits[i - 1]);
104+
}
105+
});
106+
});

packages/shared/src/hooks/useFeed.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,50 @@ export interface UseFeedOptionalParams<T> {
150150
onEmptyFeed?: () => void;
151151
}
152152

153+
/* eslint-disable no-bitwise -- intentional bitwise ops for FNV-1a hash */
154+
const hashSeed = (key: string, n: number): number => {
155+
let h = 2166136261 >>> 0;
156+
const s = `${key}:${n}`;
157+
for (let i = 0; i < s.length; i += 1) {
158+
h ^= s.charCodeAt(i);
159+
h = Math.imul(h, 16777619) >>> 0;
160+
}
161+
return h;
162+
};
163+
/* eslint-enable no-bitwise */
164+
165+
export const getAdSlotIndex = ({
166+
index,
167+
adStart,
168+
adRepeat,
169+
adJitter = 0,
170+
seed,
171+
}: {
172+
index: number;
173+
adStart: number;
174+
adRepeat: number;
175+
adJitter?: number;
176+
seed: string;
177+
}): number | undefined => {
178+
if (adRepeat <= 0) {
179+
return undefined;
180+
}
181+
const safeJitter = Math.max(
182+
0,
183+
Math.min(adJitter, Math.floor((adRepeat - 1) / 2)),
184+
);
185+
if (index < adStart - safeJitter) {
186+
return undefined;
187+
}
188+
const n = Math.max(0, Math.round((index - adStart) / adRepeat));
189+
const offset =
190+
safeJitter === 0
191+
? 0
192+
: (hashSeed(seed, n) % (safeJitter * 2 + 1)) - safeJitter;
193+
const pos = adStart + n * adRepeat + offset;
194+
return pos === index ? n : undefined;
195+
};
196+
153197
export default function useFeed<T>(
154198
feedQueryKey: QueryKey,
155199
pageSize: number,
@@ -318,21 +362,20 @@ export default function useFeed<T>(
318362
adTemplate?.adStart ??
319363
featureFeedAdTemplate.defaultValue.default.adStart;
320364
const adRepeat = adTemplate?.adRepeat ?? pageSize + 1;
365+
const adJitter = adTemplate?.adJitter ?? 0;
366+
367+
const adPage = getAdSlotIndex({
368+
index,
369+
adStart,
370+
adRepeat,
371+
adJitter,
372+
seed: JSON.stringify(feedQueryKey),
373+
});
321374

322-
const adIndex = index - adStart; // 0-based index from adStart
323-
324-
// if adIndex is negative, it means we are not supposed to show ads yet based on adStart
325-
if (adIndex < 0) {
326-
return undefined;
327-
}
328-
const adMatch = adIndex % adRepeat === 0; // should ad be shown at this index based on adRepeat
329-
330-
if (!adMatch) {
375+
if (adPage === undefined) {
331376
return undefined;
332377
}
333378

334-
const adPage = adIndex / adRepeat; // page number for ad
335-
336379
if (isLoading) {
337380
return createPlaceholderItem(adPage);
338381
}
@@ -365,8 +408,10 @@ export default function useFeed<T>(
365408
isLoading,
366409
adTemplate?.adStart,
367410
adTemplate?.adRepeat,
411+
adTemplate?.adJitter,
368412
adsUpdatedAt,
369413
pageSize,
414+
feedQueryKey,
370415
],
371416
);
372417

packages/shared/src/lib/feed.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export const getFeedName = (
274274
export type FeedAdTemplate = {
275275
adStart: number;
276276
adRepeat?: number;
277+
adJitter?: number;
277278
};
278279

279280
export function usePostLogEvent() {

0 commit comments

Comments
 (0)