VulcanJS / Vulcan

๐ŸŒ‹ A toolkit to quickly build apps with React, GraphQL & Meteor
http://vulcanjs.org
MIT License
7.98k stars 1.89k forks source link

Make voting algorithm extensible/customizable #1458

Open xavxyz opened 7 years ago

xavxyz commented 7 years ago

This is a starter issue, highly inspired by the great job of the folks at Hoodie. ๐Ÿ‘

๐ŸŽƒ๐Ÿ™€๐Ÿ‘•๐Ÿ”ญ๐Ÿ˜ป Hacktoberfest: Trick or Treat!

If you havenโ€™t yet, sign up for Hacktoberfest to earn an exclusive T-Shirt.

We are sure you can learn a cool trick or two in the process on how to hack & customize Telescope Nova! ๐Ÿ”ญ

๐Ÿค” What you will need to know

Meteor, React, Telescope Nova callbacks handling (video).

Most of the Telescope Nova structure is extensible. A big part of that is done thanks to callbacks as Nova uses a system of successive callbacks to perform some action.

The callback below is for example run on the client when a post is submitted:

function PostsNewDuplicateLinksCheck (post, user) {
  if(!!post.url) {
    Posts.checkForSameUrl(post.url);
  }
  return post;
}
Telescope.callbacks.add("posts.new.sync", PostsNewDuplicateLinksCheck);

๐ŸŽฏ The Goal

The voting algorithm is currently self-contained in nova:voting/lib/scoring.js (code)

As suggested in an old issue (two years ago! #421), this would be awesome to be able to make our own algorithm without modifying the core and from our custom package.

A simple set of callbacks is the one for filtering posts view: postsParameters.

This can be a good starting point to take inspiration to create callbacks for Telescope.updateScore function : "scoring.all" callbacks.

The current Telescope.updateScore content would be broken in several functions, these functions being then added as callbacks. These callbacks would be created and run likely the other Telescope Nova's callbacks.

All these callbacks would be added thanks to Telescope.callbacks.add("scoring.all", myAlgoExtension) and run inside Telescope.updateScore with something like:

newScore = Telescope.callbacks.run("scoring.all", score, otherParams)

Nice to have: We could have a general algorithm ("scoring.all") and some specific algorithm for posts or comments: "scoring.posts" & "scoring.comments". This would be based on the collection argument passed to the function Telescope.updateScore.

Nice to have: This would also be the occasion to update this package to ES6 syntax.

Nice to have: We could also have an example in the my-custom-package example.

๐Ÿ“‹ Step by Step

Ping us here, in the Telescope Slack room or on Twitter if you have any questions ๐Ÿ˜‰

s4kh commented 7 years ago

Hi, willing to work on this. Questions: Where do I have to create the callbacks(pieces of scoring.js)? To break down into pieces, can somebody provide me some guidelines, group some logic together etc?

xavxyz commented 7 years ago

Hey @S4KH!

You can create the different functions in scoring.js ๐Ÿ‘

The function Telescope.updateScore takes an object with 3 keys and is for instance called like that:

// post is a post document
Telescope.updateScore({collection: Posts, item: post, forceUpdate: true})

It is called when clicking an down/upvote button or in a cron (see nova-voting/lib/server/cron.js). -> If the function is called with forceUpdate, the return value doesn't matter, we want the function to update the collection's item with a new score (click a button). -> If the function is called without forceUpdate, the return value corresponds to the value added to the existing score (the update takes place in the cron)

Currently, the structure of this function is :

Telescope.updateScore = ({ collection, item, forceUpdate }) => {
  // status check

  // age check (not published or scheduled in the future)

  // power algorithm 

  // get a score

  // get a score diff (absolute value)

  // update item score : if forceUpdate is true or the score diff is relevant (not too small) and set it to active
  // or if it's an old item set it to inactive and don't touch its score
}

The first checks & the power algorithms could be default callbacks that we provide. A callback function will take at least two arguments, the current score being modified and the item, and will return a new score.

To write a callback have a look at nova-posts/lib/callbacks.js, nova-posts/lib/parameters.js (this one is a smaller file and may be easier to read through), nova-comments/lib/callbacks.js.

We would do Telescope.callbacks.add("scoring.all", checkPostDate); to add a function.

And to get a new score inside the "big" function: const newScore = Telescope.callbacks.run("scoring.all");. After that being the diff and updates

Is it clearer? ๐ŸŒฎ

edit: oops wrong button, sorry for the closing! ๐Ÿ˜…

s4kh commented 7 years ago

Thanks for the quick response :+1: I was wondering will all the sections of the current updateScore break down into callbacks?

// status check
// age check (not published or scheduled in the future)
// power algorithm 
// get a score
// get a score diff (absolute value)
// update item score : if forceUpdate is true or the score diff is relevant (not too small) and set it to active
// or if it's an old item set it to inactive and don't touch its score`

If yes, what are the suitable namings of them?

xavxyz commented 7 years ago

Good question, only the first 3 parts should be broken in callbacks! ๐Ÿ‘

Find naming that feels right, in ~camelCase, maybe something like ScoreStatusCheck, ScoreAgeCheck, ..

s4kh commented 7 years ago

:clap: for the quick reply. Sorry for posting the entire file. Just asking if I am doing correctly. Btw when I press login to view the username password inputs nothing is displayed inside the modal.

Did something like this:

import Telescope from 'meteor/nova:lib';

Telescope.updateScore = function (args) {
  const collection = args.collection;
  const item = args.item;
  const forceUpdate = args.forceUpdate;

  // console.log(item)

  // Status Check

  // Age Check

  // 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.
  // We assume that after n days, a single vote will not be powerful enough to affect posts' ranking order.
  // Note: sites whose posts regularly get a lot of votes can afford to use a lower n.

  //power algo

  // console.log(now)
  // console.log(age)
  // console.log(ageInHours)
  // console.log(baseScore)
  // console.log(newScore)
  const newScore = Telescope.callbacks.run("scoring.all");
  // Note: before the first time updateScore runs on a new item, its score will be at 0
  const scoreDiff = Math.abs(item.score - newScore);

  // only update database if difference is larger than x to avoid unnecessary updates
  if (forceUpdate || scoreDiff > x){
    collection.update(item._id, {$set: {score: newScore, inactive: false}});
    return 1;
  }else if(ageInHours > n*24){
    // only set a post as inactive if it's older than n days
    collection.update(item._id, {$set: {inactive: true}});
  }
  return 0;
};

// ------------------------------------- scoring.all -------------------------------- //

/**
 * @summary Check if item is scorable based on its status
 */
function ItemStatusCheck (item) {
  if (!!item.status && item.status !== 2) // if item has a status and is not approved, don't update its score
    return 0;

  return 1;
}
Telescope.callbacks.add("scoring.all", ItemStatusCheck);

/**
 * @summary Check if item is scorable based on its age
 */
function ItemAgeCheck (item) {

    // If for some reason item doesn't have a "postedAt" property, abort
    if (!item.postedAt)
      return 0;

    const postedAt = item.postedAt.valueOf();
    const now = new Date().getTime();
    const age = now - postedAt;
    const ageInHours = age / (60 * 60 * 1000);

    if (postedAt > now) // if post has been scheduled in the future, don't update its score
      return 0;

    return ageInHours;
}
Telescope.callbacks.add("scoring.all", ItemAgeCheck);

/**
 * @summary PowerAlgorithm
 */
function PowerAlgorithm (item, ageInHours) {  

  // n =  number of days after which a single vote will not have a big enough effect to trigger a score update
  //      and posts can become inactive
  const n = 30;
  // 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);
  // 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 = baseScore / Math.pow(ageInHours + 2, f);

  return newScore;
}
Telescope.callbacks.add("scoring.all", PowerAlgorithm);
xavxyz commented 7 years ago

For the problem with the dropdown, checkout on devel and run npm install again, it's a problem caused by a bug in latest version of react-bootstrap.

On a global view, we are close to it! However, beware of the scope of the variables like x or ageInHours, they are defined in some function and used in others in what you pasted. Also, the callbacks should only take the score as an argument and return a newScore. It could be init as const score = item.baseScore || 0 before running the callbacks.

s4kh commented 7 years ago

The sad thing is still can't login :cry:. Followed your instructions still can't login.

xavxyz commented 7 years ago

On your fork, this line causes trouble: https://github.com/S4KH/Telescope/blob/856a0c43397f026018576d41047e923ce39a5347/package.json#L26

Do a git pull to get the latest ๐Ÿ‘