From 2bcda004cf4b45ba10b438304650b25e29a1d40b Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 27 Mar 2026 06:07:07 +0000 Subject: [PATCH] [#288] Add author reputation signal to trending algorithm Implement computeAuthorReputation() combining follower_count, x_followers_count, x_verified, neynar_score, and quotient_score as the sixth trend signal at 20% weight. Batch-fetches writer user rows via primary_address and verified_addresses to avoid N+1 queries. Rebalances existing signal weights accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ranking.ts | 103 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/lib/ranking.ts b/lib/ranking.ts index 3dde64d3..ef3ae028 100644 --- a/lib/ranking.ts +++ b/lib/ranking.ts @@ -1,22 +1,62 @@ import { type Address, formatUnits } from "viem"; import { get24hPriceChange, getTokenTVL } from "./price"; import { STORY_FACTORY } from "./contracts/constants"; -import type { Database, Storyline } from "./supabase"; +import type { Database, Storyline, User } from "./supabase"; import type { SupabaseClient } from "@supabase/supabase-js"; interface RankedStoryline extends Storyline { trendScore: number; } +/** + * Compute author reputation score from social/on-chain signals. + * + * Normalized to 0-1 from five sub-signals: + * - Farcaster follower count (log-scaled) + * - X/Twitter follower count (log-scaled) + * - X verified status (boolean boost) + * - Neynar social score (already 0-1) + * - Quotient on-chain score (0-1000 → 0-1) + */ +function computeAuthorReputation(user: User | null): number { + if (!user) return 0; + + // Farcaster followers: log-scaled, cap at ~100k + const fcFollowers = user.follower_count ?? 0; + const fcSignal = Math.min(1, Math.log10(1 + fcFollowers) / 5); + + // X/Twitter followers: log-scaled, cap at ~100k + const xFollowers = Number(user.x_followers_count ?? 0); + const xSignal = Math.min(1, Math.log10(1 + xFollowers) / 5); + + // X verified: boolean boost + const verifiedSignal = user.x_verified ? 1 : 0; + + // Neynar score: already 0-1 + const neynarSignal = Math.min(1, Math.max(0, Number(user.neynar_score ?? 0))); + + // Quotient score: range 0-1000, normalize to 0-1 + const quotientSignal = Math.min(1, Math.max(0, Number(user.quotient_score ?? 0) / 1000)); + + return ( + fcSignal * 0.25 + + xSignal * 0.2 + + verifiedSignal * 0.1 + + neynarSignal * 0.25 + + quotientSignal * 0.2 + ); +} + /** * Compute trending score for a storyline. * - * Composite of 5 signals (each normalized to ~0-1 range): + * Composite of 6 signals (each normalized to ~0-1 range): * - weightedRating: Bayesian rating accounting for rating count (0-5 → 0-1) * - priceChange24h: 24h price change % (clamped, mapped to 0-1) * - tvl: reserve balance (log-scaled, using actual token decimals) * - continuationRate: plots per day since creation * - recency: boost for recently updated stories based on last_plot_time + * - authorReputation: writer's social/on-chain reputation */ function computeTrendScore( avgRating: number, @@ -27,8 +67,9 @@ function computeTrendScore( plotCount: number, createdAt: string | null, lastPlotTime: string | null, + authorReputation: number, ): number { - // Bayesian weighted rating signal (0-1), weight: 0.25 + // Bayesian weighted rating signal (0-1), weight: 0.20 const priorCount = 5; const priorMean = 3.0; const weightedRating = @@ -36,12 +77,12 @@ function computeTrendScore( (ratingCount + priorCount); const ratingSignal = weightedRating / 5; - // Price change signal (0-1), weight: 0.20 + // Price change signal (0-1), weight: 0.15 const pc = priceChange ?? 0; const clampedPc = Math.max(-100, Math.min(200, pc)); const priceSignal = (clampedPc + 100) / 300; - // TVL signal (0-1), weight: 0.20 + // TVL signal (0-1), weight: 0.15 let tvlSignal = 0; if (tvlRaw !== null && tvlRaw > BigInt(0)) { const tvlFloat = Number(formatUnits(tvlRaw, tvlDecimals)); @@ -56,7 +97,7 @@ function computeTrendScore( contSignal = Math.min(1, (plotCount / ageDays) / 5); } - // Recency signal (0-1), weight: 0.20 + // Recency signal (0-1), weight: 0.15 // Uses last_plot_time (falls back to createdAt) with inverse-time decay const recencyRef = lastPlotTime ?? createdAt; let recencySignal = 0; @@ -66,12 +107,16 @@ function computeTrendScore( recencySignal = 1 / (1 + Math.max(0, daysSince)); } + // Author reputation signal (0-1), weight: 0.20 + const reputationSignal = authorReputation; + return ( - ratingSignal * 0.25 + - priceSignal * 0.2 + - tvlSignal * 0.2 + + ratingSignal * 0.2 + + priceSignal * 0.15 + + tvlSignal * 0.15 + contSignal * 0.15 + - recencySignal * 0.2 + recencySignal * 0.15 + + reputationSignal * 0.2 ); } @@ -116,7 +161,7 @@ async function fetchCandidatesAndRatings( } const storylines = merged; - if (storylines.length === 0) return { storylines, ratingMap: new Map() }; + if (storylines.length === 0) return { storylines, ratingMap: new Map(), userMap: new Map() }; // Batch: fetch all ratings for candidate storyline IDs in one query const storylineIds = storylines.map((sl) => sl.storyline_id); @@ -141,7 +186,37 @@ async function fetchCandidatesAndRatings( } } - return { storylines, ratingMap }; + // Batch: fetch user rows for all unique writer addresses + const writerAddresses = [...new Set(storylines.map((sl) => sl.writer_address.toLowerCase()))]; + const userMap = new Map(); + if (writerAddresses.length > 0) { + // Query by primary_address and verified_addresses in parallel + const [{ data: byPrimary }, { data: byVerified }] = await Promise.all([ + supabase + .from("users") + .select("*") + .in("primary_address", writerAddresses), + supabase + .from("users") + .select("*") + .overlaps("verified_addresses", writerAddresses), + ]); + + for (const user of [...(byPrimary ?? []), ...(byVerified ?? [])] as User[]) { + // Map by primary_address + if (user.primary_address) { + userMap.set(user.primary_address.toLowerCase(), user); + } + // Map by each verified_address + if (user.verified_addresses) { + for (const addr of user.verified_addresses) { + userMap.set(addr.toLowerCase(), user); + } + } + } + } + + return { storylines, ratingMap, userMap }; } /** Shared: enrich a storyline with on-chain signals */ @@ -172,13 +247,14 @@ export async function getTrendingStorylines( genre?: string, lang?: string, ): Promise { - const { storylines, ratingMap } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang); + const { storylines, ratingMap, userMap } = await fetchCandidatesAndRatings(supabase, writerType, genre, lang); if (storylines.length === 0) return []; const enriched = await Promise.all( storylines.map(async (sl): Promise => { const rating = ratingMap.get(sl.storyline_id) ?? { avg: 0, count: 0 }; const { priceChange, tvlRaw, tvlDecimals } = await enrichWithOnChain(sl); + const author = userMap.get(sl.writer_address.toLowerCase()) ?? null; const trendScore = computeTrendScore( rating.avg, @@ -189,6 +265,7 @@ export async function getTrendingStorylines( sl.plot_count, sl.block_timestamp, sl.last_plot_time, + computeAuthorReputation(author), ); return { ...sl, trendScore };