mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-05 22:33:07 +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