Engagement Forecaster
The Engagement Forecaster predicts the perfect moment to send notifications, maximizing welcome probability while minimizing disruption. It's fully integrated into the MARL system.
Overview
Mission: Intelligent notification timing for maximum welcome probability
Key Innovation: 200+ context signals + MARL integration (not a siloed service)
Expected Lift: 23%+ CTR improvement (based on Meta research)
Phase: Introduced in Phase 3 (Week 10)
Context Signals
200+ Dimensional Context
interface NotificationContext {
// Temporal (15 features)
timeOfDay: number; // 0-23
dayOfWeek: number; // 0-6
isWeekend: boolean;
isHoliday: boolean;
daysUntilWedding: number;
weeksSinceEngagement: number;
planningPhase: string;
seasonalContext: string;
hourOfWeek: number; // 0-167
timeZone: string;
localTime: number;
businessHours: boolean;
dinnerTime: boolean;
bedTime: boolean;
morningCommute: boolean;
// Behavioral (30 features)
avgSessionDuration: number;
sessionsPerWeek: number;
lastActiveHours: number;
lastActiveDay: string;
typicalActiveHours: number[];
notificationOpenRate: number;
notificationOptOutRate: number;
preferredContentTypes: string[];
engagementPatterns: TimeSeries;
browsingVelocity: number;
saveRate: number;
shareRate: number;
inquiryRate: number;
returnUserProbability: number;
sessionStartTimes: number[];
sessionEndTimes: number[];
averageEngagementDelay: number;
contentPreferences: Map<string, number>;
interactionDepth: number;
multiDayEngagement: boolean;
// ... 10 more behavioral features
// Device/Environment (20 features)
deviceType: string;
osVersion: string;
appVersion: string;
batteryLevel: number;
batteryCharging: boolean;
networkQuality: string;
networkType: string; // wifi, cellular, etc.
screenBrightness: number;
doNotDisturb: boolean;
silentMode: boolean;
locationContext: string; // home, work, commute
motion: string; // stationary, walking, driving
headphonesConnected: boolean;
bluetoothDevices: string[];
recentAppUsage: string[];
backgroundAppsCount: number;
availableStorage: number;
deviceOrientation: string;
screenOnTime: number;
lockScreenFrequency: number;
// Content (25 features)
contentType: string;
contentCategory: string;
qualityScore: number;
personalizationScore: number;
relevanceScore: number;
urgency: number;
vendorAvailability: boolean;
vendorResponseTime: number;
bookingDeadline: Date;
contentRecency: number;
contentPopularity: number;
similarContentEngaged: boolean;
vendorType: string;
priceRange: string;
regionMatch: boolean;
styleMatch: number;
noveltyScore: number;
diversityContribution: number;
collaborativeSignal: number;
contentSentimentScore: number;
visualQualityScore: number;
textComplexity: number;
mediaType: string;
contentLength: number;
ageOfContent: number;
// Social (15 features)
friendActivity: number;
friendsOnline: number;
trendingInRegion: boolean;
trendingGlobally: boolean;
vendorResponsePending: boolean;
partnerActivity: boolean;
partnerLastActive: number;
communityEngagement: number;
socialProof: number;
viralityScore: number;
influencerMention: boolean;
peerRecommendations: number;
groupPlanningActive: boolean;
weddingPartyEngagement: number;
vendorRecommendations: number;
// Historical (20 features)
previousNotificationTimes: number[];
previousOpenTimes: number[];
previousDismissalTimes: number[];
timeSinceLastNotification: number;
notificationFrequency: number;
historicalEngagementByHour: Map<number, number>;
historicalEngagementByDay: Map<number, number>;
bestPerformingTimeSlot: number;
worstPerformingTimeSlot: number;
weekdayVsWeekendPreference: number;
morningVsEveningPreference: number;
immediateVsDelayedEngagement: number;
batchVsInstantPreference: number;
notificationStackingTolerance: number;
quietHoursPreference: TimeRange[];
preferredNotificationTypes: string[];
dismissalPatterns: string[];
engagementSequence: string[];
recoveryTimeAfterDismissal: number;
fatigueLevel: number;
// Predictive (15 features)
predictedActiveWindow: TimeRange;
predictedEngagementProbability: number;
predictedSessionLength: number;
predictedContentInterest: Map<string, number>;
churnRisk: number;
conversionProbability: number;
nextSessionPrediction: Date;
lifetimeValuePrediction: number;
attentionCapacity: number;
cognitiveLoad: number;
decisionFatigue: number;
emotionalState: string;
stressLevel: number;
planningStagePrediction: string;
urgencyAwareness: number;
// Experimental (60+ features)
// Weather, events, competitive activity, etc.
}
Prediction Model
Welcome Probability Prediction
class EngagementForecaster {
private model: NeuralNetwork;
async predictWelcome(
user: User,
content: Content,
context: NotificationContext
): Promise<number> {
// Extract and normalize features
const features = this.extractFeatures(user, content, context);
const normalized = this.normalizeFeatures(features);
// Neural network prediction
const welcomeProb = await this.model.predict(normalized);
return welcomeProb; // 0.0 to 1.0
}
private extractFeatures(
user: User,
content: Content,
context: NotificationContext
): number[] {
return [
// Temporal features
context.timeOfDay / 24,
context.dayOfWeek / 7,
context.isWeekend ? 1 : 0,
context.daysUntilWedding / 365,
// Behavioral features
user.avgSessionDuration / 600, // Normalize to 10 min
user.notificationOpenRate,
context.timeSinceLastNotification / 86400, // Days
// Content features
content.qualityScore,
content.personalizationScore,
content.urgency,
// ... 200+ more features
];
}
}
Send-Time Optimization
async optimizeSendTime(
user: User,
content: Content,
context: NotificationContext
): Promise<Date> {
// Sample candidate times over next 24 hours
const candidates = [];
const now = Date.now();
for (let i = 0; i < 24; i++) {
const candidateTime = now + i * 3600000; // Every hour
const candidateContext = {
...context,
timeOfDay: new Date(candidateTime).getHours(),
// Update other temporal features
};
const welcomeProb = await this.predictWelcome(
user,
content,
candidateContext
);
candidates.push({
time: candidateTime,
probability: welcomeProb
});
}
// Return time with highest probability
const best = candidates.reduce((a, b) =>
a.probability > b.probability ? a : b
);
return new Date(best.time);
}
Decision Logic
Should Send Notification?
async shouldSendNotification(
user: User,
content: Content,
context: NotificationContext
): Promise<NotificationDecision> {
// 1. Predict welcome probability
const welcomeProb = await this.predictWelcome(user, content, context);
// 2. Check threshold
if (welcomeProb < WELCOME_THRESHOLD) {
return {
send: false,
reason: 'low_welcome_probability',
probability: welcomeProb
};
}
// 3. Check fatigue
if (await this.isUserFatigued(user)) {
return {
send: false,
reason: 'user_fatigue',
fatigueScore: await this.getFatigueScore(user)
};
}
// 4. Check quiet hours
if (this.isQuietHours(user, context)) {
return {
send: false,
reason: 'quiet_hours',
quietHoursEnd: this.getQuietHoursEnd(user)
};
}
// 5. Optimize timing
const optimalTime = await this.optimizeSendTime(user, content, context);
return {
send: true,
timing: optimalTime,
probability: welcomeProb,
reason: 'optimal_timing'
};
}
Fatigue Detection
async isUserFatigued(user: User): Promise<boolean> {
const recentNotifications = await this.getRecentNotifications(user, days: 1);
// Too many notifications
if (recentNotifications.length > MAX_DAILY_NOTIFICATIONS) {
return true;
}
// High dismissal rate
const dismissalRate = recentNotifications.filter(n => n.dismissed).length /
recentNotifications.length;
if (dismissalRate > 0.7) {
return true;
}
// Declining engagement
const engagementTrend = this.computeEngagementTrend(recentNotifications);
if (engagementTrend < -0.3) { // 30% decline
return true;
}
return false;
}
MARL Integration
Agent Participation in MAGRPO
class EngagementForecaster extends Agent {
async proposeNotificationCandidates(
user: User,
context: UserContext
): Promise<NotificationCandidate[]> {
// Get relevant content
const candidates = await this.getNotificationCandidates(user);
// Score each candidate
const scored = await Promise.all(
candidates.map(async (content) => {
const notifContext = this.buildNotificationContext(user, context);
const welcomeProb = await this.predictWelcome(user, content, notifContext);
const timing = await this.optimizeSendTime(user, content, notifContext);
return {
content,
welcomeProb,
timing,
score: welcomeProb
};
})
);
return scored.filter(c => c.welcomeProb > WELCOME_THRESHOLD);
}
async updatePolicy(advantage: number): Promise<void> {
// MAGRPO update based on user response
// Positive advantage = notification was well-received
// Negative advantage = notification was poorly received
if (advantage > 0) {
// Reinforce this timing/context pattern
await this.model.train({
features: this.lastContext,
target: 1.0, // High welcome probability
learningRate: BASE_LR * advantage
});
} else {
// Penalize this pattern
await this.model.train({
features: this.lastContext,
target: 0.0, // Low welcome probability
learningRate: BASE_LR * Math.abs(advantage)
});
}
}
}
Reward Signal
computeNotificationReward(interaction: NotificationInteraction): number {
// Highly engaged = high reward
if (interaction.opened && interaction.engaged) {
return 1.0;
}
// Opened but didn't engage = medium reward
if (interaction.opened && !interaction.engaged) {
return 0.5;
}
// Dismissed = negative reward
if (interaction.dismissed) {
return -0.5;
}
// Opted out = large negative reward
if (interaction.optedOut) {
return -2.0;
}
// Ignored = small negative reward
return -0.1;
}
Notification Types
Type-Specific Strategies
const NOTIFICATION_STRATEGIES = {
vendor_discovery: {
welcomeThreshold: 0.7,
urgency: 0.5,
maxDaily: 2,
optimalTimes: ['morning', 'evening']
},
booking_reminder: {
welcomeThreshold: 0.6, // More urgent, lower threshold
urgency: 0.9,
maxDaily: 1,
optimalTimes: ['morning', 'early_afternoon']
},
vendor_response: {
welcomeThreshold: 0.5, // High urgency
urgency: 1.0,
maxDaily: 5, // Important communication
optimalTimes: ['immediately'] // Send ASAP
},
inspiration: {
welcomeThreshold: 0.8, // Only when highly welcome
urgency: 0.2,
maxDaily: 1,
optimalTimes: ['evening', 'weekend']
},
planning_milestone: {
welcomeThreshold: 0.7,
urgency: 0.8,
maxDaily: 1,
optimalTimes: ['morning']
}
};
API Endpoints
Predict Welcome Probability
Endpoint: POST /api/forecaster/predict
Request:
Response:
{
"welcomeProbability": 0.82,
"optimalSendTime": "2025-10-12T19:30:00Z",
"confidence": 0.9,
"reasoning": {
"userTypicallyActiveAt": "19:00-21:00",
"contentRelevance": "high",
"recentEngagementPattern": "positive"
}
}
Get Notification Queue
Endpoint: GET /api/forecaster/queue/:userId
Response:
{
"scheduledNotifications": [
{
"contentId": "content-456",
"type": "vendor_discovery",
"scheduledFor": "2025-10-12T19:30:00Z",
"welcomeProbability": 0.82,
"canCancel": true
},
{
"contentId": "content-789",
"type": "booking_reminder",
"scheduledFor": "2025-10-13T09:00:00Z",
"welcomeProbability": 0.75,
"canCancel": false
}
]
}
Performance Targets
| Metric | Target | Notes |
|---|---|---|
| CTR lift | > 23% | vs naive timing |
| Opt-out rate | < 5% | User annoyance |
| Prediction latency | < 100ms | Real-time |
| Model accuracy | > 75% | Welcome prediction |
| User trust (NPS) | Neutral or + | No negative impact |
Monitoring
Key Metrics
// Track predictions
metrics.histogram('welcome_probability_predicted', welcomeProb);
metrics.histogram('welcome_probability_actual', actualEngagement);
// Track timing optimization
metrics.histogram('send_time_delay_hours', delayHours);
metrics.increment('notification_sent', { type, timing: 'optimal' });
// Track outcomes
metrics.increment('notification_opened', { type });
metrics.increment('notification_dismissed', { type });
metrics.increment('notification_opted_out', { type });
// Track model performance
metrics.gauge('forecaster_accuracy', accuracy);
metrics.gauge('forecaster_ctr_lift', ctrLift);
Related Components
- Orchestrator - Coordinates with feed timing
- Personal Archivist - Provides planning phase context
- Foundation Model - Provides user embeddings