mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
core relevance algorithm
This commit is contained in:
parent
c0497610c3
commit
3a257770ab
141
src/lib/forum/relevance.test.ts
Normal file
141
src/lib/forum/relevance.test.ts
Normal 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
330
src/lib/forum/relevance.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user