/** * Cadence optimizer — Plan §3.6 (Analyst gap 3). * * Pure function `propose(history, config) → [Proposal]`. Clock and RNG injected * for deterministic tests. * * Model: Beta posterior over engagement rate per (day_of_week, hour_of_day) bucket. * Prior: Beta(1, 19) — weakly informative, centred on 5% baseline. * Threshold: ≥8 posts per bucket AND ≥4 weeks of data. * Change trigger: alt-bucket posterior mean > current * 1.2 AND 95% CIs don't overlap. * Output: Proposals in `/metrics` UI with Approve / Reject / Snooze 4 weeks. * Never auto-applied (RBR). */ export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; export type HourOfDay = | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23; export interface PostObservation { day: DayOfWeek; hour: HourOfDay; publishedAt: Date; impressions: number; reactions: number; comments: number; shares: number; } export interface CadenceConfig { prior: { alpha: number; beta: number }; minSamplesPerBucket: number; minWeeksOfData: number; changeRatio: number; ciAlpha: number; // e.g. 0.05 for 95% CI current: { day: DayOfWeek; hour: HourOfDay }; } export const DEFAULT_CADENCE_CONFIG: CadenceConfig = { prior: { alpha: 1, beta: 19 }, minSamplesPerBucket: 8, minWeeksOfData: 4, changeRatio: 1.2, ciAlpha: 0.05, current: { day: 2, hour: 8 }, // Tue 08:30 AM AST → bucket Tue/8 }; export interface BucketStats { day: DayOfWeek; hour: HourOfDay; n: number; posteriorAlpha: number; posteriorBeta: number; posteriorMean: number; ci95: { lo: number; hi: number }; } export interface Proposal { current: BucketStats; proposed: BucketStats; ratio: number; rationale: string; } const engagementRate = (o: PostObservation): number => { if (o.impressions <= 0) return 0; return Math.min(1, (o.reactions + o.comments + o.shares) / o.impressions); }; const weeksSpanned = (history: readonly PostObservation[], now: Date): number => { if (history.length === 0) return 0; const oldest = history.reduce((acc, o) => Math.min(acc, o.publishedAt.getTime()), now.getTime()); return Math.max(0, (now.getTime() - oldest) / (7 * 24 * 60 * 60 * 1000)); }; /** * Beta CDF inverse approximation via the Wilson score interval — sufficient * for proposal-vs-current comparisons; not for high-precision inference. */ const betaCi = (alpha: number, beta: number, ciAlpha: number): { lo: number; hi: number } => { const n = alpha + beta - 2; const p = (alpha - 1) / Math.max(1, n); const z = ciAlpha <= 0.05 ? 1.96 : 1.64; const denom = 1 + (z * z) / Math.max(1, n); const centre = (p + (z * z) / (2 * Math.max(1, n))) / denom; const margin = (z * Math.sqrt((p * (1 - p)) / Math.max(1, n) + (z * z) / (4 * Math.max(1, n) * Math.max(1, n)))) / denom; return { lo: Math.max(0, centre - margin), hi: Math.min(1, centre + margin) }; }; const bucketStats = ( obs: readonly PostObservation[], cfg: CadenceConfig, day: DayOfWeek, hour: HourOfDay, ): BucketStats => { let alpha = cfg.prior.alpha; let beta = cfg.prior.beta; let n = 0; for (const o of obs) { if (o.day !== day || o.hour !== hour) continue; const e = engagementRate(o); alpha += e; beta += 1 - e; n++; } const total = alpha + beta; const mean = alpha / total; return { day, hour, n, posteriorAlpha: alpha, posteriorBeta: beta, posteriorMean: mean, ci95: betaCi(alpha, beta, cfg.ciAlpha), }; }; const ciOverlap = (a: { lo: number; hi: number }, b: { lo: number; hi: number }): boolean => !(a.hi < b.lo || b.hi < a.lo); export interface ProposeArgs { history: readonly PostObservation[]; config?: CadenceConfig; now?: Date; candidateBuckets?: ReadonlyArray<{ day: DayOfWeek; hour: HourOfDay }>; } export const propose = (args: ProposeArgs): readonly Proposal[] => { const cfg = args.config ?? DEFAULT_CADENCE_CONFIG; const now = args.now ?? new Date(); const weeks = weeksSpanned(args.history, now); if (weeks < cfg.minWeeksOfData) return []; const candidates = args.candidateBuckets ?? ([ { day: 1, hour: 8 }, { day: 2, hour: 8 }, { day: 3, hour: 8 }, { day: 4, hour: 8 }, { day: 5, hour: 8 }, { day: 1, hour: 12 }, { day: 2, hour: 12 }, { day: 3, hour: 12 }, { day: 4, hour: 12 }, { day: 5, hour: 12 }, ] as const); const cur = bucketStats(args.history, cfg, cfg.current.day, cfg.current.hour); if (cur.n < cfg.minSamplesPerBucket) return []; const proposals: Proposal[] = []; for (const c of candidates) { if (c.day === cfg.current.day && c.hour === cfg.current.hour) continue; const alt = bucketStats(args.history, cfg, c.day, c.hour); if (alt.n < cfg.minSamplesPerBucket) continue; const ratio = alt.posteriorMean / cur.posteriorMean; if (ratio < cfg.changeRatio) continue; if (ciOverlap(cur.ci95, alt.ci95)) continue; proposals.push({ current: cur, proposed: alt, ratio, rationale: `Alt bucket day=${c.day} hour=${c.hour} mean=${alt.posteriorMean.toFixed(3)} > current mean=${cur.posteriorMean.toFixed(3)} by ${(ratio * 100 - 100).toFixed(1)}%, 95% CIs disjoint.`, }); } proposals.sort((a, b) => b.ratio - a.ratio); return proposals; };