core relevance algorithm

This commit is contained in:
Danish Arora 2025-08-11 12:13:10 +05:30
parent c0497610c3
commit 3a257770ab
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
2 changed files with 471 additions and 0 deletions

View File

@ -0,0 +1,141 @@
import { RelevanceCalculator } from './relevance';
import { Post, Comment, Cell, User } from '@/types';
import { MessageType, VoteMessage } from '@/lib/waku/types';
import { expect, describe, beforeEach, it } from 'vitest';
import { UserVerificationStatus } from './types';
describe('RelevanceCalculator', () => {
let calculator: RelevanceCalculator;
let mockUserVerificationStatus: UserVerificationStatus;
beforeEach(() => {
calculator = new RelevanceCalculator();
mockUserVerificationStatus = {
'user1': { isVerified: true, hasENS: true, hasOrdinal: false },
'user2': { isVerified: false, hasENS: false, hasOrdinal: false },
'user3': { isVerified: true, hasENS: false, hasOrdinal: true }
};
});
describe('calculatePostScore', () => {
it('should calculate base score for a new post', () => {
const post: Post = {
id: '1',
cellId: 'cell1',
authorAddress: 'user2',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
upvotes: [],
downvotes: []
};
const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus);
expect(result.score).toBeGreaterThan(0);
expect(result.details.baseScore).toBe(10);
expect(result.details.isVerified).toBe(false);
});
it('should apply verification bonus for verified author', () => {
const post: Post = {
id: '1',
cellId: 'cell1',
authorAddress: 'user1',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
upvotes: [],
downvotes: []
};
const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus);
expect(result.details.isVerified).toBe(true);
expect(result.details.authorVerificationBonus).toBeGreaterThan(0);
});
it('should apply moderation penalty', () => {
const post: Post = {
id: '1',
cellId: 'cell1',
authorAddress: 'user2',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
upvotes: [],
downvotes: [],
moderated: true
};
const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus);
expect(result.details.isModerated).toBe(true);
expect(result.details.moderationPenalty).toBe(0.5);
});
it('should calculate engagement bonuses', () => {
const post: Post = {
id: '1',
cellId: 'cell1',
authorAddress: 'user2',
title: 'Test Post',
content: 'Test content',
timestamp: Date.now(),
upvotes: [],
downvotes: []
};
const votes: VoteMessage[] = [
{ id: 'vote1', targetId: '1', value: 1, author: 'user1', timestamp: Date.now(), type: MessageType.VOTE },
{ id: 'vote2', targetId: '1', value: 1, author: 'user3', timestamp: Date.now(), type: MessageType.VOTE }
];
const comments: Comment[] = [
{ id: 'comment1', postId: '1', authorAddress: 'user1', content: 'Test comment', timestamp: Date.now(), upvotes: [], downvotes: [] }
];
const result = calculator.calculatePostScore(post, votes, comments, mockUserVerificationStatus);
expect(result.details.upvotes).toBe(2);
expect(result.details.comments).toBe(1);
expect(result.details.verifiedUpvotes).toBe(2);
expect(result.details.verifiedCommenters).toBe(1);
});
});
describe('timeDecay', () => {
it('should apply time decay to older posts', () => {
const now = Date.now();
const oneDayAgo = now - (24 * 60 * 60 * 1000);
const oneWeekAgo = now - (7 * 24 * 60 * 60 * 1000);
const recentPost: Post = {
id: '1',
cellId: 'cell1',
authorAddress: 'user2',
title: 'Recent Post',
content: 'Recent content',
timestamp: now,
upvotes: [],
downvotes: []
};
const oldPost: Post = {
id: '2',
cellId: 'cell1',
authorAddress: 'user2',
title: 'Old Post',
content: 'Old content',
timestamp: oneWeekAgo,
upvotes: [],
downvotes: []
};
const recentResult = calculator.calculatePostScore(recentPost, [], [], mockUserVerificationStatus);
const oldResult = calculator.calculatePostScore(oldPost, [], [], mockUserVerificationStatus);
expect(recentResult.score).toBeGreaterThan(oldResult.score);
});
});
});

330
src/lib/forum/relevance.ts Normal file
View File

@ -0,0 +1,330 @@
import { Post, Comment, Cell, User } from '@/types';
import { VoteMessage } from '@/lib/waku/types';
import { RelevanceScoreDetails, UserVerificationStatus } from './types';
export class RelevanceCalculator {
private static readonly BASE_SCORES = {
POST: 10,
COMMENT: 5,
CELL: 15
};
private static readonly ENGAGEMENT_SCORES = {
UPVOTE: 1,
COMMENT: 0.5
};
private static readonly VERIFICATION_BONUS = 1.25; // 25% increase
private static readonly VERIFIED_UPVOTE_BONUS = 0.1;
private static readonly VERIFIED_COMMENTER_BONUS = 0.05;
private static readonly DECAY_RATE = 0.1; // λ = 0.1
private static readonly MODERATION_PENALTY = 0.5; // 50% reduction
/**
* Calculate relevance score for a post
*/
calculatePostScore(
post: Post,
votes: VoteMessage[],
comments: Comment[],
userVerificationStatus: UserVerificationStatus
): { score: number; details: RelevanceScoreDetails } {
let score = this.applyBaseScore('POST');
const baseScore = score;
const upvotes = votes.filter(vote => vote.value === 1);
const engagementScore = this.applyEngagementScore(upvotes, comments);
score += engagementScore;
const { bonus: authorVerificationBonus, isVerified } = this.applyAuthorVerificationBonus(
score,
post.authorAddress,
userVerificationStatus
);
score += authorVerificationBonus;
const { bonus: verifiedUpvoteBonus, verifiedUpvotes } = this.applyVerifiedUpvoteBonus(
upvotes,
userVerificationStatus
);
score += verifiedUpvoteBonus;
const { bonus: verifiedCommenterBonus, verifiedCommenters } = this.applyVerifiedCommenterBonus(
comments,
userVerificationStatus
);
score += verifiedCommenterBonus;
const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, post.timestamp);
score = decayedScore;
const { penalizedScore, penalty: moderationPenalty } = this.applyModerationPenalty(score, post.moderated || false);
score = penalizedScore;
const finalScore = Math.max(0, score); // Ensure non-negative score
return {
score: finalScore,
details: {
baseScore,
engagementScore,
authorVerificationBonus,
verifiedUpvoteBonus,
verifiedCommenterBonus,
timeDecayMultiplier,
moderationPenalty,
finalScore,
isVerified,
upvotes: upvotes.length,
comments: comments.length,
verifiedUpvotes,
verifiedCommenters,
daysOld,
isModerated: post.moderated || false
}
};
}
/**
* Calculate relevance score for a comment
*/
calculateCommentScore(
comment: Comment,
votes: VoteMessage[],
userVerificationStatus: UserVerificationStatus
): { score: number; details: RelevanceScoreDetails } {
// Apply base score
let score = this.applyBaseScore('COMMENT');
const baseScore = score;
const upvotes = votes.filter(vote => vote.value === 1);
const engagementScore = this.applyEngagementScore(upvotes, []);
score += engagementScore;
const { bonus: authorVerificationBonus, isVerified } = this.applyAuthorVerificationBonus(
score,
comment.authorAddress,
userVerificationStatus
);
score += authorVerificationBonus;
const { bonus: verifiedUpvoteBonus, verifiedUpvotes } = this.applyVerifiedUpvoteBonus(
upvotes,
userVerificationStatus
);
score += verifiedUpvoteBonus;
const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, comment.timestamp);
score = decayedScore;
const { penalizedScore, penalty: moderationPenalty } = this.applyModerationPenalty(score, comment.moderated || false);
score = penalizedScore;
const finalScore = Math.max(0, score); // Ensure non-negative score
return {
score: finalScore,
details: {
baseScore,
engagementScore,
authorVerificationBonus,
verifiedUpvoteBonus,
verifiedCommenterBonus: 0, // Comments don't have commenters
timeDecayMultiplier,
moderationPenalty,
finalScore,
isVerified,
upvotes: upvotes.length,
comments: 0, // Comments don't have comments
verifiedUpvotes,
verifiedCommenters: 0,
daysOld,
isModerated: comment.moderated || false
}
};
}
/**
* Calculate relevance score for a cell
*/
calculateCellScore(
cell: Cell,
posts: Post[],
userVerificationStatus: UserVerificationStatus
): { score: number; details: RelevanceScoreDetails } {
// Apply base score
let score = this.applyBaseScore('CELL');
const baseScore = score;
// Calculate cell-specific engagement
const cellPosts = posts.filter(post => post.cellId === cell.id);
const totalUpvotes = cellPosts.reduce((sum, post) => {
return sum + (post.upvotes?.length || 0);
}, 0);
const activityScore = cellPosts.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT;
const engagementBonus = totalUpvotes * 0.1; // Small bonus for cell activity
const engagementScore = activityScore + engagementBonus;
score += engagementScore;
const mostRecentPost = cellPosts.reduce((latest, post) => {
return post.timestamp > latest.timestamp ? post : latest;
}, { timestamp: Date.now() });
const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, mostRecentPost.timestamp);
score = decayedScore;
const finalScore = Math.max(0, score); // Ensure non-negative score
return {
score: finalScore,
details: {
baseScore,
engagementScore,
authorVerificationBonus: 0, // Cells don't have authors in the same way
verifiedUpvoteBonus: 0,
verifiedCommenterBonus: 0,
timeDecayMultiplier,
moderationPenalty: 1, // Cells aren't moderated
finalScore,
isVerified: false, // Cells don't have verification status
upvotes: totalUpvotes,
comments: cellPosts.length,
verifiedUpvotes: 0,
verifiedCommenters: 0,
daysOld,
isModerated: false
}
};
}
/**
* Check if a user is verified (has ENS or ordinal ownership)
*/
isUserVerified(user: User): boolean {
return !!(user.ensOwnership || user.ordinalOwnership);
}
/**
* Build user verification status map from users array
*/
buildUserVerificationStatus(users: User[]): UserVerificationStatus {
const status: UserVerificationStatus = {};
users.forEach(user => {
status[user.address] = {
isVerified: this.isUserVerified(user),
hasENS: !!user.ensOwnership,
hasOrdinal: !!user.ordinalOwnership
};
});
return status;
}
/**
* Apply base score to the current score
*/
private applyBaseScore(type: 'POST' | 'COMMENT' | 'CELL'): number {
return RelevanceCalculator.BASE_SCORES[type];
}
/**
* Apply engagement score based on upvotes and comments
*/
private applyEngagementScore(
upvotes: VoteMessage[],
comments: Comment[] = []
): number {
const upvoteScore = upvotes.length * RelevanceCalculator.ENGAGEMENT_SCORES.UPVOTE;
const commentScore = comments.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT;
return upvoteScore + commentScore;
}
/**
* Apply author verification bonus
*/
private applyAuthorVerificationBonus(
score: number,
authorAddress: string,
userVerificationStatus: UserVerificationStatus
): { bonus: number; isVerified: boolean } {
const authorStatus = userVerificationStatus[authorAddress];
const isVerified = authorStatus?.isVerified || false;
if (isVerified) {
const bonus = score * (RelevanceCalculator.VERIFICATION_BONUS - 1);
return { bonus, isVerified };
}
return { bonus: 0, isVerified };
}
/**
* Apply verified upvote bonus
*/
private applyVerifiedUpvoteBonus(
upvotes: VoteMessage[],
userVerificationStatus: UserVerificationStatus
): { bonus: number; verifiedUpvotes: number } {
const verifiedUpvotes = upvotes.filter(vote => {
const voterStatus = userVerificationStatus[vote.author];
return voterStatus?.isVerified;
});
const bonus = verifiedUpvotes.length * RelevanceCalculator.VERIFIED_UPVOTE_BONUS;
return { bonus, verifiedUpvotes: verifiedUpvotes.length };
}
/**
* Apply verified commenter bonus
*/
private applyVerifiedCommenterBonus(
comments: Comment[],
userVerificationStatus: UserVerificationStatus
): { bonus: number; verifiedCommenters: number } {
const verifiedCommenters = new Set<string>();
comments.forEach(comment => {
const commenterStatus = userVerificationStatus[comment.authorAddress];
if (commenterStatus?.isVerified) {
verifiedCommenters.add(comment.authorAddress);
}
});
const bonus = verifiedCommenters.size * RelevanceCalculator.VERIFIED_COMMENTER_BONUS;
return { bonus, verifiedCommenters: verifiedCommenters.size };
}
/**
* Apply time decay to a score
*/
private applyTimeDecay(score: number, timestamp: number): {
decayedScore: number;
multiplier: number;
daysOld: number
} {
const daysOld = (Date.now() - timestamp) / (1000 * 60 * 60 * 24);
const multiplier = Math.exp(-RelevanceCalculator.DECAY_RATE * daysOld);
const decayedScore = score * multiplier;
return { decayedScore, multiplier, daysOld };
}
/**
* Apply moderation penalty
*/
private applyModerationPenalty(
score: number,
isModerated: boolean
): { penalizedScore: number; penalty: number } {
if (isModerated) {
const penalizedScore = score * RelevanceCalculator.MODERATION_PENALTY;
return { penalizedScore, penalty: RelevanceCalculator.MODERATION_PENALTY };
}
return { penalizedScore: score, penalty: 1 };
}
}