sojorn/_legacy/supabase/functions/_shared/ranking.ts
2026-02-15 00:33:24 -06:00

134 lines
3.6 KiB
TypeScript

/**
* Ranking Algorithm for sojorn Feed
*
* Design intent:
* - Attention moves slowly.
* - Nothing competes for dominance.
* - Clean content is protected from suppression.
*
* Principles:
* - Saves > likes (intentional curation over quick reaction)
* - Steady appreciation over time > viral spikes
* - Low block rate = content is not harmful
* - Low trusted report rate = content is genuinely clean
* - Ignore comment count (we don't reward arguments)
* - Ignore report spikes on high-CIS posts (brigading protection)
*/
export interface PostForRanking {
id: string;
created_at: string;
cis_score: number;
tone_label: string;
save_count: number;
like_count: number;
view_count: number;
author_harmony_score: number;
author_tier: string;
blocks_received_24h: number;
trusted_reports: number;
total_reports: number;
}
/**
* Calculate calm velocity score
* Measures steady appreciation rather than viral spikes
*/
function calculateCalmVelocity(post: PostForRanking): number {
const ageInHours = (Date.now() - new Date(post.created_at).getTime()) / (1000 * 60 * 60);
if (ageInHours === 0 || post.view_count === 0) return 0;
// Saves are weighted 3x more than likes
const engagementScore = post.save_count * 3 + post.like_count;
// Engagement rate (relative to views)
const engagementRate = engagementScore / Math.max(post.view_count, 1);
// Calm velocity = steady engagement over time (not spiky)
// Using logarithmic scaling to prevent runaway viral effects
const velocity = Math.log1p(engagementRate * 100) / Math.log1p(ageInHours + 1);
return velocity;
}
/**
* Calculate safety score
* Lower score = content has triggered negative signals
*/
function calculateSafetyScore(post: PostForRanking): number {
let score = 1.0;
// Penalize if blocks received in last 24h
if (post.blocks_received_24h > 0) {
score -= post.blocks_received_24h * 0.2;
}
// Penalize trusted reports heavily
if (post.trusted_reports > 0) {
score -= post.trusted_reports * 0.3;
}
// Ignore report spikes if CIS is high (brigading protection)
if (post.cis_score < 0.7 && post.total_reports > 2) {
score -= 0.15;
}
return Math.max(score, 0);
}
/**
* Calculate author influence multiplier
* Based on harmony score and tier
*/
function calculateAuthorInfluence(post: PostForRanking): number {
const harmonyMultiplier = post.author_harmony_score / 100; // 0-1 range
const tierMultiplier: Record<string, number> = {
new: 0.5,
trusted: 1.0,
established: 1.3,
restricted: 0.2,
};
return harmonyMultiplier * (tierMultiplier[post.author_tier] || 1.0);
}
/**
* Calculate final ranking score for sojorn feed
*/
export function calculateRankingScore(post: PostForRanking): number {
// Base score from content integrity
const cisBonus = post.cis_score;
// Tone eligibility (handled in feed query, but can boost here)
const toneBonus = post.tone_label === 'positive' ? 1.2 : post.tone_label === 'neutral' ? 1.0 : 0.8;
// Calm velocity (steady appreciation)
const velocity = calculateCalmVelocity(post);
// Safety (no blocks or trusted reports)
const safety = calculateSafetyScore(post);
// Author influence
const influence = calculateAuthorInfluence(post);
// Final score
const score = cisBonus * toneBonus * velocity * safety * influence;
return score;
}
/**
* Rank posts for feed
* Returns sorted array with scores attached
*/
export function rankPosts(posts: PostForRanking[]): Array<PostForRanking & { rank_score: number }> {
return posts
.map((post) => ({
...post,
rank_score: calculateRankingScore(post),
}))
.sort((a, b) => b.rank_score - a.rank_score);
}