Fix score calculation

This commit is contained in:
SachaG 2017-09-30 08:37:15 +09:00
parent 67a18135d9
commit 171969485b
9 changed files with 147 additions and 104 deletions

View file

@ -2,14 +2,14 @@ import Users from 'meteor/vulcan:users';
import { addCallback } from 'meteor/vulcan:core'; import { addCallback } from 'meteor/vulcan:core';
import { Comments } from '../../../modules/comments/index.js'; import { Comments } from '../../../modules/comments/index.js';
import { operateOnItem } from 'meteor/vulcan:voting'; import { performVoteServer } from 'meteor/vulcan:voting';
/** /**
* @summary Make users upvote their own new comments * @summary Make users upvote their own new comments
*/ */
function CommentsNewUpvoteOwnComment(comment) { function CommentsNewUpvoteOwnComment(comment) {
var commentAuthor = Users.findOne(comment.userId); var commentAuthor = Users.findOne(comment.userId);
return {...comment, ...operateOnItem(Comments, comment, commentAuthor, 'upvote', false)}; return {...comment, ...performVoteServer({ document: comment, voteType: 'upvote', collection: Comments, user: commentAuthor })};
} }
addCallback('comments.new.sync', CommentsNewUpvoteOwnComment); addCallback('comments.new.async', CommentsNewUpvoteOwnComment);

View file

@ -7,14 +7,14 @@ Voting callbacks
import { Posts } from '../../../modules/posts/index.js'; import { Posts } from '../../../modules/posts/index.js';
import Users from 'meteor/vulcan:users'; import Users from 'meteor/vulcan:users';
import { addCallback } from 'meteor/vulcan:core'; import { addCallback } from 'meteor/vulcan:core';
import { operateOnItem } from 'meteor/vulcan:voting'; import { performVoteServer } from 'meteor/vulcan:voting';
/** /**
* @summary Make users upvote their own new posts * @summary Make users upvote their own new posts
*/ */
function PostsNewUpvoteOwnPost(post) { function PostsNewUpvoteOwnPost(post) {
var postAuthor = Users.findOne(post.userId); var postAuthor = Users.findOne(post.userId);
return {...post, ...operateOnItem(Posts, post, postAuthor, 'upvote', false)}; return {...post, ...performVoteServer({ document: post, voteType: 'upvote', collection: Posts, user: postAuthor })};
} }
addCallback('posts.new.sync', PostsNewUpvoteOwnPost); addCallback('posts.new.async', PostsNewUpvoteOwnPost);

View file

@ -19,6 +19,7 @@ export const withVote = component => {
power power
} }
baseScore baseScore
score
} }
`).join('\n')} `).join('\n')}
} }

View file

@ -0,0 +1,21 @@
export const recalculateScore = item => {
// Age Check
const postedAt = item.postedAt.valueOf();
const now = new Date().getTime();
const age = now - postedAt;
const ageInHours = age / (60 * 60 * 1000);
// time decay factor
const f = 1.3;
// use baseScore if defined, if not just use 0
const baseScore = item.baseScore || 0;
// HN algorithm
const newScore = Math.round((baseScore / Math.pow(ageInHours + 2, f))*1000000)/1000000;
return newScore;
};

View file

@ -2,6 +2,7 @@ import { runCallbacksAsync, runCallbacks, addCallback } from 'meteor/vulcan:core
import { createError } from 'apollo-errors'; import { createError } from 'apollo-errors';
import Votes from './votes/collection.js'; import Votes from './votes/collection.js';
import Users from 'meteor/vulcan:users'; import Users from 'meteor/vulcan:users';
import { recalculateScore } from './scoring.js';
/* /*
@ -61,10 +62,10 @@ Add a vote of a specific type on the client
const addVoteClient = ({ document, collection, voteType, user, voteId }) => { const addVoteClient = ({ document, collection, voteType, user, voteId }) => {
const newDocument = { const newDocument = {
_id: document._id, ...document,
baseScore: document.baseScore || 0, baseScore: document.baseScore || 0,
__typename: collection.options.typeName, __typename: collection.options.typeName,
currentUserVotes: document.currentUserVotes || [] currentUserVotes: document.currentUserVotes || [],
}; };
// create new vote and add it to currentUserVotes array // create new vote and add it to currentUserVotes array
@ -73,6 +74,7 @@ const addVoteClient = ({ document, collection, voteType, user, voteId }) => {
// increment baseScore // increment baseScore
newDocument.baseScore += vote.power; newDocument.baseScore += vote.power;
newDocument.score = recalculateScore(newDocument);
return newDocument; return newDocument;
} }
@ -84,6 +86,8 @@ Add a vote of a specific type on the server
*/ */
const addVoteServer = ({ document, collection, voteType, user, voteId }) => { const addVoteServer = ({ document, collection, voteType, user, voteId }) => {
const newDocument = _.clone(document);
// create vote and insert it // create vote and insert it
const vote = createVote({ document, collectionName: collection.options.collectionName, voteType, user, voteId }); const vote = createVote({ document, collectionName: collection.options.collectionName, voteType, user, voteId });
delete vote.__typename; delete vote.__typename;
@ -92,7 +96,10 @@ const addVoteServer = ({ document, collection, voteType, user, voteId }) => {
// update document score // update document score
collection.update({_id: document._id}, {$inc: {baseScore: vote.power }}); collection.update({_id: document._id}, {$inc: {baseScore: vote.power }});
return vote; newDocument.baseScore += vote.power;
newDocument.score = recalculateScore(newDocument);
return newDocument;
} }
/* /*
@ -106,6 +113,7 @@ const cancelVoteClient = ({ document, voteType }) => {
if (vote) { if (vote) {
// subtract vote scores // subtract vote scores
newDocument.baseScore -= vote.power; newDocument.baseScore -= vote.power;
newDocument.score = recalculateScore(newDocument);
const newVotes = _.reject(document.currentUserVotes, vote => vote.voteType === voteType); const newVotes = _.reject(document.currentUserVotes, vote => vote.voteType === voteType);
@ -124,6 +132,7 @@ Clear *all* votes for a given document and user (client)
const clearVotesClient = ({ document }) => { const clearVotesClient = ({ document }) => {
const newDocument = _.clone(document); const newDocument = _.clone(document);
newDocument.baseScore -= calculateTotalPower(document.currentUserVotes); newDocument.baseScore -= calculateTotalPower(document.currentUserVotes);
newDocument.score = recalculateScore(newDocument);
newDocument.currentUserVotes = []; newDocument.currentUserVotes = [];
return newDocument return newDocument
} }
@ -134,11 +143,15 @@ Clear all votes for a given document and user (server)
*/ */
const clearVotesServer = ({ document, user, collection }) => { const clearVotesServer = ({ document, user, collection }) => {
const newDocument = _.clone(document);
const votes = Votes.find({ documentId: document._id, userId: user._id}).fetch(); const votes = Votes.find({ documentId: document._id, userId: user._id}).fetch();
if (votes.length) { if (votes.length) {
Votes.remove({documentId: document._id}); Votes.remove({documentId: document._id});
collection.update({_id: document._id}, {$inc: {baseScore: -calculateTotalPower(votes) }}); collection.update({_id: document._id}, {$inc: {baseScore: -calculateTotalPower(votes) }});
newDocument.baseScore -= calculateTotalPower(votes);
newDocument.score = recalculateScore(newDocument);
} }
return newDocument;
} }
/* /*
@ -148,6 +161,8 @@ Cancel votes of a specific type on a given document (server)
*/ */
const cancelVoteServer = ({ document, voteType, collection, user }) => { const cancelVoteServer = ({ document, voteType, collection, user }) => {
const newDocument = _.clone(document);
const vote = Votes.findOne({documentId: document._id, userId: user._id, voteType}) const vote = Votes.findOne({documentId: document._id, userId: user._id, voteType})
// remove vote object // remove vote object
@ -156,7 +171,10 @@ const cancelVoteServer = ({ document, voteType, collection, user }) => {
// update document score // update document score
collection.update({_id: document._id}, {$inc: {baseScore: -vote.power }}); collection.update({_id: document._id}, {$inc: {baseScore: -vote.power }});
return vote; newDocument.baseScore -= vote.power;
newDocument.score = recalculateScore(newDocument);
return newDocument;
} }
/* /*
@ -245,15 +263,15 @@ export const performVoteClient = ({ document, collection, voteType = 'upvote', u
Server-side database operation Server-side database operation
*/ */
export const performVoteServer = ({ documentId, voteType = 'upvote', collection, voteId, user }) => { export const performVoteServer = ({ documentId, document, voteType = 'upvote', collection, voteId, user }) => {
const collectionName = collection.options.collectionName; const collectionName = collection.options.collectionName;
const document = collection.findOne(documentId); document = document || collection.findOne(documentId);
// console.log('// performVoteMutation') console.log('// performVoteMutation')
// console.log('collectionName: ', collectionName) console.log('collectionName: ', collectionName)
// console.log('document: ', collection.findOne(documentId)) console.log('document: ', document)
// console.log('voteType: ', voteType) console.log('voteType: ', voteType)
const voteOptions = {document, collection, voteType, user, voteId}; const voteOptions = {document, collection, voteType, user, voteId};
@ -267,7 +285,7 @@ export const performVoteServer = ({ documentId, voteType = 'upvote', collection,
// console.log('action: cancel') // console.log('action: cancel')
// runCallbacks(`votes.cancel.sync`, document, collection, user); // runCallbacks(`votes.cancel.sync`, document, collection, user);
cancelVoteServer(voteOptions); document = cancelVoteServer(voteOptions);
// runCallbacksAsync(`votes.cancel.async`, vote, document, collection, user); // runCallbacksAsync(`votes.cancel.async`, vote, document, collection, user);
} else { } else {
@ -275,17 +293,17 @@ export const performVoteServer = ({ documentId, voteType = 'upvote', collection,
// console.log('action: vote') // console.log('action: vote')
if (voteTypes[voteType].exclusive) { if (voteTypes[voteType].exclusive) {
clearVotesServer(voteOptions) document = clearVotesServer(voteOptions)
} }
// runCallbacks(`votes.${voteType}.sync`, document, collection, user); // runCallbacks(`votes.${voteType}.sync`, document, collection, user);
addVoteServer(voteOptions); document = addVoteServer(voteOptions);
// runCallbacksAsync(`votes.${voteType}.async`, vote, document, collection, user); // runCallbacksAsync(`votes.${voteType}.async`, vote, document, collection, user);
} }
const newDocument = collection.findOne(documentId); // const newDocument = collection.findOne(documentId);
newDocument.__typename = collection.options.typeName; document.__typename = collection.options.typeName;
return newDocument; return document;
} }

View file

@ -12,14 +12,15 @@ import { updateScore } from './scoring.js';
* @param {object} collection - The collection the item belongs to * @param {object} collection - The collection the item belongs to
* @param {string} operation - The operation being performed * @param {string} operation - The operation being performed
*/ */
function updateItemScore(item, user, collection, operation, context) { // function updateItemScore(item, user, collection, operation, context) {
updateScore({collection: collection, item: item, forceUpdate: true}); // updateScore({collection: collection, item: item, forceUpdate: true});
} // }
// addCallback("upvote.async", updateItemScore);
// addCallback("downvote.async", updateItemScore);
// addCallback("cancelUpvote.async", updateItemScore);
// addCallback("cancelDownvote.async", updateItemScore);
addCallback("upvote.async", updateItemScore);
addCallback("downvote.async", updateItemScore);
addCallback("cancelUpvote.async", updateItemScore);
addCallback("cancelDownvote.async", updateItemScore);
/** /**
@ -29,48 +30,47 @@ addCallback("cancelDownvote.async", updateItemScore);
* @param {object} collection - The collection the item belongs to * @param {object} collection - The collection the item belongs to
* @param {string} operation - The operation being performed * @param {string} operation - The operation being performed
*/ */
function updateUser(item, user, collection, operation, context) { // function updateUser(item, user, collection, operation, context) {
// uncomment for debug // // uncomment for debug
// console.log(item); // // console.log(item);
// console.log(user); // // console.log(user);
// console.log(collection._name); // // console.log(collection._name);
// console.log(operation); // // console.log(operation);
const update = {}; // const update = {};
const votePower = getVotePower(user); // const votePower = getVotePower(user);
const vote = { // const vote = {
itemId: item._id, // itemId: item._id,
votedAt: new Date(), // votedAt: new Date(),
power: votePower // power: votePower
}; // };
const collectionName = Utils.capitalize(collection._name); // const collectionName = Utils.capitalize(collection._name);
switch (operation) { // switch (operation) {
case "upvote": // case "upvote":
update.$addToSet = {[`upvoted${collectionName}`]: vote}; // update.$addToSet = {[`upvoted${collectionName}`]: vote};
break; // break;
case "downvote": // case "downvote":
update.$addToSet = {[`downvoted${collectionName}`]: vote}; // update.$addToSet = {[`downvoted${collectionName}`]: vote};
break; // break;
case "cancelUpvote": // case "cancelUpvote":
update.$pull = {[`upvoted${collectionName}`]: {itemId: item._id}}; // update.$pull = {[`upvoted${collectionName}`]: {itemId: item._id}};
break; // break;
case "cancelDownvote": // case "cancelDownvote":
update.$pull = {[`downvoted${collectionName}`]: {itemId: item._id}}; // update.$pull = {[`downvoted${collectionName}`]: {itemId: item._id}};
break; // break;
} // }
Users.update({_id: user._id}, update); // Users.update({_id: user._id}, update);
} // }
addCallback("upvote.async", updateUser);
addCallback("downvote.async", updateUser);
addCallback("cancelUpvote.async", updateUser);
addCallback("cancelDownvote.async", updateUser);
// addCallback("upvote.async", updateUser);
// addCallback("downvote.async", updateUser);
// addCallback("cancelUpvote.async", updateUser);
// addCallback("cancelDownvote.async", updateUser);
/** /**
* @summary Update the karma of the item's owner * @summary Update the karma of the item's owner
@ -79,19 +79,19 @@ addCallback("cancelDownvote.async", updateUser);
* @param {object} collection - The collection the item belongs to * @param {object} collection - The collection the item belongs to
* @param {string} operation - The operation being performed * @param {string} operation - The operation being performed
*/ */
function updateKarma(item, user, collection, operation, context) { // function updateKarma(item, user, collection, operation, context) {
const votePower = getVotePower(user); // const votePower = getVotePower(user);
const karmaAmount = (operation === "upvote" || operation === "cancelDownvote") ? votePower : -votePower; // const karmaAmount = (operation === "upvote" || operation === "cancelDownvote") ? votePower : -votePower;
// only update karma is the operation isn't done by the item's author // // only update karma is the operation isn't done by the item's author
if (item.userId !== user._id) { // if (item.userId !== user._id) {
Users.update({_id: item.userId}, {$inc: {"karma": karmaAmount}}); // Users.update({_id: item.userId}, {$inc: {"karma": karmaAmount}});
} // }
} // }
addCallback("upvote.async", updateKarma); // addCallback("upvote.async", updateKarma);
addCallback("downvote.async", updateKarma); // addCallback("downvote.async", updateKarma);
addCallback("cancelUpvote.async", updateKarma); // addCallback("cancelUpvote.async", updateKarma);
addCallback("cancelDownvote.async", updateKarma); // addCallback("cancelDownvote.async", updateKarma);

View file

@ -1,4 +1,4 @@
import { getSetting, registerSetting } from 'meteor/vulcan:core'; import { getSetting, registerSetting, debug } from 'meteor/vulcan:core';
import { updateScore } from './scoring.js'; import { updateScore } from './scoring.js';
import { VoteableCollections } from '../modules/make_voteable.js'; import { VoteableCollections } from '../modules/make_voteable.js';
@ -7,7 +7,7 @@ registerSetting('voting.scoreUpdateInterval', 60, 'How often to update scores, i
// TODO use a node cron or at least synced-cron // TODO use a node cron or at least synced-cron
Meteor.startup(function () { Meteor.startup(function () {
const scoreInterval = parseInt(getSetting('voting.scoreUpdateInterval', 60)); const scoreInterval = parseInt(getSetting('voting.scoreUpdateInterval'));
if (scoreInterval > 0) { if (scoreInterval > 0) {
@ -15,25 +15,29 @@ Meteor.startup(function () {
// active items get updated every N seconds // active items get updated every N seconds
Meteor.setInterval(function () { Meteor.setInterval(function () {
let updatedDocuments = 0; let updatedDocuments = 0;
// console.log('tick ('+scoreInterval+')'); // console.log('tick ('+scoreInterval+')');
collection.find({'inactive': {$ne : true}}).forEach(document => { collection.find({'inactive': {$ne : true}}).forEach(document => {
updatedDocuments += updateScore({collection, item: document}); updatedDocuments += updateScore({collection, item: document});
}); });
// console.log(`Updated ${updatedDocuments} active documents in collection ${collection.options.collectionName}`)
debug(`[vulcan:voting] Updated scores for ${updatedDocuments} active documents in collection ${collection.options.collectionName}`)
}, scoreInterval * 1000); }, scoreInterval * 1000);
// inactive items get updated every hour // inactive items get updated every hour
Meteor.setInterval(function () { Meteor.setInterval(function () {
let updatedDocuments = 0; let updatedDocuments = 0;
collection.find({'inactive': true}).forEach(document => { collection.find({'inactive': true}).forEach(document => {
updatedDocuments += updateScore({collection, item: document}); updatedDocuments += updateScore({collection, item: document});
}); });
// console.log(`Updated ${updatedDocuments} inactive documents in collection ${collection.options.collectionName}`) debug(`[vulcan:voting] Updated scores for ${updatedDocuments} inactive documents in collection ${collection.options.collectionName}`)
}, 3600 * 1000); }, 3600 * 1000);

View file

@ -1,6 +1,5 @@
import './graphql.js'; import './graphql.js';
import './cron.js'; import './cron.js';
import './callbacks.js';
import './scoring.js'; import './scoring.js';
export * from '../modules/index.js'; export * from '../modules/index.js';

View file

@ -1,14 +1,19 @@
import { recalculateScore } from '../modules/scoring.js';
/*
Update a document's score if necessary.
Returns how many documents have been updated (1 or 0).
*/
export const updateScore = ({collection, item, forceUpdate}) => { export const updateScore = ({collection, item, forceUpdate}) => {
// Status Check
if (!!item.status && item.status !== 2) // if item has a status and is not approved, don't update its score
return 0;
// Age Check // Age Check
// If for some reason item doesn't have a "postedAt" property, abort // If for some reason item doesn't have a "postedAt" property, abort
if (!item.postedAt) // Or, if post has been scheduled in the future, don't update its score
if (!item.postedAt || postedAt > now)
return 0; return 0;
const postedAt = item.postedAt.valueOf(); const postedAt = item.postedAt.valueOf();
@ -16,9 +21,6 @@ export const updateScore = ({collection, item, forceUpdate}) => {
const age = now - postedAt; const age = now - postedAt;
const ageInHours = age / (60 * 60 * 1000); const ageInHours = age / (60 * 60 * 1000);
if (postedAt > now) // if post has been scheduled in the future, don't update its score
return 0;
// For performance reasons, the database is only updated if the difference between the old score and the new score // For performance reasons, the database is only updated if the difference between the old score and the new score
// is meaningful enough. To find out, we calculate the "power" of a single vote after n days. // is meaningful enough. To find out, we calculate the "power" of a single vote after n days.
// We assume that after n days, a single vote will not be powerful enough to affect posts' ranking order. // We assume that after n days, a single vote will not be powerful enough to affect posts' ranking order.
@ -29,23 +31,21 @@ export const updateScore = ({collection, item, forceUpdate}) => {
const n = 30; const n = 30;
// x = score increase amount of a single vote after n days (for n=100, x=0.000040295) // x = score increase amount of a single vote after n days (for n=100, x=0.000040295)
const x = 1/Math.pow(n*24+2,1.3); const x = 1/Math.pow(n*24+2,1.3);
// time decay factor
const f = 1.3;
// use baseScore if defined, if not just use 0
const baseScore = item.baseScore || 0;
// HN algorithm // HN algorithm
const newScore = baseScore / Math.pow(ageInHours + 2, f); const newScore = recalculateScore(item);
// console.log(now)
// console.log(age)
// console.log(ageInHours)
// console.log(baseScore)
// console.log(newScore)
// Note: before the first time updateScore runs on a new item, its score will be at 0 // Note: before the first time updateScore runs on a new item, its score will be at 0
const scoreDiff = Math.abs(item.score - newScore); const scoreDiff = Math.abs(item.score || 0 - newScore);
// console.log('// now: ', now)
// console.log('// age: ', age)
// console.log('// ageInHours: ', ageInHours)
// console.log('// baseScore: ', baseScore)
// console.log('// item.score: ', item.score)
// console.log('// newScore: ', newScore)
// console.log('// scoreDiff: ', scoreDiff)
// console.log('// x: ', x)
// only update database if difference is larger than x to avoid unnecessary updates // only update database if difference is larger than x to avoid unnecessary updates
if (forceUpdate || scoreDiff > x) { if (forceUpdate || scoreDiff > x) {