Pure-function pieces that need no Redis/DB to verify (11 new tests): - apps/scheduler/src/cadence.ts: Beta posterior model, propose(history, config)→[Proposal]; threshold gates (≥8 samples, ≥4 weeks, ≥20% ratio, disjoint 95% CIs); never auto-applied (RBR) - apps/scheduler/src/publish-loop.ts: state-machine transition function for pending→queued→dispatching→published with retry-vs-DLQ branch on failure and cancellation path What defers to live Redis + Postgres + LinkedIn: - 4.1 BullMQ queue + Redis enqueue→consume integration test - 4.2 End-to-end publish loop trace (POST /api/content → DB → BullMQ → LinkedIn fake → publication row) - 4.3 Chaos test for idempotency on retry 117/117 tests pass cumulative across all packages and apps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
173 lines
5.2 KiB
TypeScript
173 lines
5.2 KiB
TypeScript
/**
|
|
* 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;
|
|
};
|