Skip to content

Commit 13ba0f4

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@c70f4f20392057f3c3c518cdc2a3a1fd96ca7a19
1 parent ed11e2e commit 13ba0f4

2 files changed

Lines changed: 83 additions & 0 deletions

File tree

‎common/src/__tests__/freebuff-ads-glide.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
effectiveDailyBudgetCents,
1616
engagementsForDailyBudget,
1717
glidedDailyBudgetCents,
18+
deliverySpacingSeconds,
1819
deliveryWindowLimit,
1920
type BudgetGlide,
2021
} from '../constants/freebuff-ads'
@@ -272,3 +273,47 @@ describe('deliveryWindowLimit', () => {
272273
expect(limit(0, '2026-08-28T03')).toBe(0)
273274
})
274275
})
276+
277+
describe('deliverySpacingSeconds', () => {
278+
const gap = (cap: number, windowKey = '2026-08-28T09', jitterBps = 2_500) =>
279+
deliverySpacingSeconds({ capEngagements: cap, seed: SEED, windowKey, jitterBps })
280+
281+
test('a day divided into the day: 300/day is roughly five minutes apart', () => {
282+
const value = gap(300)
283+
expect(value).toBeGreaterThan(200)
284+
expect(value).toBeLessThan(380)
285+
})
286+
287+
test('a smaller cap is spaced further apart', () => {
288+
// The tail of a taper delivers less often, not in a shorter burst.
289+
expect(gap(50)).toBeGreaterThan(gap(300))
290+
expect(gap(300)).toBeGreaterThan(gap(2_000))
291+
})
292+
293+
test('an hour of spacing roughly reproduces the hourly ceiling', () => {
294+
// Spacing and the window limit are two expressions of the same rate; if
295+
// they disagreed badly, one of them would be doing nothing.
296+
const perHour = 3_600 / gap(300)
297+
expect(perHour).toBeGreaterThan(9)
298+
expect(perHour).toBeLessThan(18)
299+
})
300+
301+
test('bounded at both ends', () => {
302+
// A huge cap still gets a floor, so delivery is never instantaneous...
303+
expect(gap(1_000_000)).toBe(15)
304+
// ...and a tiny one never waits longer than the window it lives in.
305+
expect(gap(1)).toBe(3_600)
306+
})
307+
308+
test('stable within the hour, different across hours', () => {
309+
expect(gap(300, '2026-08-28T09')).toBe(gap(300, '2026-08-28T09'))
310+
const hours = Array.from({ length: 12 }, (_, h) =>
311+
gap(300, `2026-08-28T${String(h).padStart(2, '0')}`),
312+
)
313+
expect(new Set(hours).size).toBeGreaterThan(6)
314+
})
315+
316+
test('a zero cap has no spacing to compute', () => {
317+
expect(gap(0)).toBe(0)
318+
})
319+
})

‎common/src/constants/freebuff-ads.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,44 @@ export function deliveryWindowLimit(params: {
308308
return Math.max(1, Math.min(cap, Math.round(jittered)))
309309
}
310310

311+
/**
312+
* The smallest gap between two deliveries on a tapering campaign, in seconds.
313+
*
314+
* The window limit above bounds an HOUR; it does nothing inside one. Thirteen
315+
* engagements are allowed in the first minute of the window and then nothing
316+
* for fifty-nine — and because the window is trailing, the next batch becomes
317+
* available exactly as the last one ages out, so the campaign settles into a
318+
* pulse. That is the reset problem again at a smaller scale: bursty, and
319+
* something a user can learn the rhythm of.
320+
*
321+
* Spacing turns the hour's allowance into a drip: a day's cap divided into the
322+
* day, jittered per hour so it is a rhythm rather than a metronome.
323+
*
324+
* This gates OFFERING only. It is deliberately not applied when a submission
325+
* arrives: someone who was shown a post and did the work must not be refused
326+
* because another user's engagement landed forty seconds earlier. The daily
327+
* cap and the window are what enforce totals; this shapes when the feed hands
328+
* work out.
329+
*/
330+
export function deliverySpacingSeconds(params: {
331+
capEngagements: number
332+
seed: string
333+
/** The Pacific hour, `YYYY-MM-DDTHH`. */
334+
windowKey: string
335+
jitterBps: number
336+
}): number {
337+
const cap = Math.max(0, Math.floor(params.capEngagements))
338+
if (cap <= 0) return 0
339+
const even = 86_400 / cap
340+
const unit =
341+
(glideHash(`gap:${params.seed}:${params.windowKey}`) / 0xffffffff) * 2 - 1
342+
const jittered = even * (1 + (unit * params.jitterBps) / 10_000)
343+
// A floor, so a campaign still on a large cap is spaced rather than
344+
// instantaneous, and a ceiling of an hour, so the tail of a taper does not
345+
// spend the day waiting on a gap longer than the window it lives in.
346+
return Math.min(3_600, Math.max(15, Math.round(jittered)))
347+
}
348+
311349
/** Snap an arbitrary cent amount onto the ladder the slider offers. Applied
312350
* server-side as well as in the UI: the API is public and a hand-rolled
313351
* request must not be able to buy $10.37/day. */

0 commit comments

Comments
 (0)