Files
stargue-publishing-engine/apps/scheduler/src/cadence.ts
Angelo B. J. Luidens a4e4306fb1 Stage 4 partial: cadence optimizer + publish-loop state machine
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>
2026-04-26 12:59:46 -04:00

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;
};