acts_as_meritocracy 0.2

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'http://rubygems.org'
2
+ gemspec
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Nicolas Maisonneuve (n.maisonneuve@gmail.com)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,64 @@
1
+ = meritocracy
2
+ This plugin introduces a mixin to get a weigted voting system for qualitative (categorical) items.
3
+
4
+ As a measure of the quality of the consensus/inter-rater agreement, you can choose between a weighted variant of fleiss kappa (http://en.wikipedia.org/wiki/Fleiss%27_kappa) (by default) or the entropy (http://en.wikipedia.org/wiki/Entropy_(information_theory)) of the vote distribution
5
+ but you can change it. The consensus score is in the interval [0,1]. very high consensus=1 , very low consensus=0
6
+
7
+ NOTE: this voting system has been developed to take (collective) decision about items, not rank items (e.g. by popularity).
8
+ i.e. generally once a certain level of consensus is reached on a given item, a decision is taken and the vote is close.
9
+
10
+ == scenario example
11
+ You have a set of items and would like to classify them according to 4 predefined categories. For that you ask the opinion to the public.
12
+ As some people are better/more reliable than other, their votes can be weighted so they have more power in the collective decision
13
+
14
+ == installation
15
+ To use it, add it to your Gemfile:
16
+
17
+ gem 'acts_as_meritocracy'
18
+
19
+ == Usage
20
+
21
+ class Item < ActiveRecord::Base
22
+
23
+ act_as_meritocracy
24
+ end
25
+
26
+ item= Item.create()
27
+ item.submit_vote(user1, 4) #user1 votes for the decision 4, by default the vote_weight=1
28
+ item.submit_vote(user2, 1, 2) #user2 votes for the decision 1 with a vote_weight=2. A vote_weight=2 means that the voter has a vote equal to 2 normal voters (vote_weight=1)
29
+ item.submit_vote(user1, 1) #user1 can change/update her vote
30
+ item.submit_vote(user3, 2) #user3 vote for decision 2
31
+ item.submit_vote(user4, 3,5) #user3 vote for decision 2
32
+
33
+ item.best_decision #get the most (weighted) voted decision
34
+ =>3
35
+
36
+
37
+ item.nb_votes(true) # get the number of votes - with the weight
38
+ => 1+2+1+5= 9
39
+
40
+ item.nb_votes(false) # get the number of votes - without taking into account the weight
41
+ => 1+1+1+1= 4
42
+
43
+ item.vote_distribution # get the frequency distribution of the decisions [{:decision1=>freq1},{:decision1=>freq2}, ..,{:decision1=>freq3}]. the frequency takes into account the vote weight.
44
+ => [{1=>3},{2=1},{3=5}]
45
+
46
+ # compute the consensus score based on the vote distribution
47
+ # by default I used a variante of the Fleiss Kappa metrics
48
+ item.consensus
49
+ => 0.361
50
+ # An entropy-based computation of the consensus is also proposed
51
+ # (a perfect disagreement between 2 categories give a higher score than for 10 categories, less predictable)
52
+ item.consensus("entropy")
53
+ =>0.063
54
+
55
+ item.votes # retrieve the list of votes {decision, vote_weight) , so item.votes.where(:vote=>1) get the list of votes having decision=1
56
+
57
+ end
58
+
59
+
60
+
61
+ == Copyright
62
+
63
+ Copyright (c) 2012 nm. See MIT-LICENSE.txt for further details.
64
+
@@ -0,0 +1,35 @@
1
+ # encoding: UTF-8
2
+ require 'rubygems'
3
+ require 'bundler' unless defined?(Bundler)
4
+
5
+ $LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
6
+ require 'acts_as_meritocracy/version'
7
+
8
+ begin
9
+ Bundler.setup(:default, :development)
10
+ rescue Bundler::BundlerError => e
11
+ $stderr.puts e.message
12
+ $stderr.puts "Run `bundle install` to install missing gems"
13
+ exit e.status_code
14
+ end
15
+ require 'rake'
16
+
17
+
18
+ require 'rake/testtask'
19
+ Rake::TestTask.new(:test) do |test|
20
+ test.libs << 'lib' << 'test'
21
+ test.test_files = Dir.glob("test/**/*_test.rb")
22
+ test.verbose = true
23
+ end
24
+
25
+ task :build do
26
+ system "gem build acts_as_meritocracy.gemspec"
27
+ end
28
+
29
+ task :release => :build do
30
+
31
+ system "gem push acts_as_meritocracy-#{ActsAsMeritocracy::VERSION}.gem"
32
+ system "rm acts_as_meritocracy-#{ActsAsMeritocracy::VERSION}.gem"
33
+ end
34
+
35
+ task :default => :test
@@ -0,0 +1,6 @@
1
+ require "acts_as_meritocracy/model"
2
+
3
+
4
+ if defined?(ActiveRecord::Base)
5
+ ActiveRecord::Base.send :include, ActsAsMeritocracy
6
+ end
@@ -0,0 +1,157 @@
1
+ module ActsAsMeritocracy #:nodoc:
2
+
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+
9
+ # Example:
10
+ # class User < ActiveRecord::Base
11
+ # acts_as_meritocracy {:min_votes=>, :max_votes=>}}
12
+ # end
13
+ def acts_as_meritocracy(options={})
14
+
15
+ # todo trigger action when decision is reliable
16
+ # acts_as_meritocracy {:quorum=>, :min_consensus=>}}
17
+ #adding class attribute
18
+ #class_attribute :_min_votes, :_max_votes, :_consensus, :_status_update_fct
19
+ #self._min_votes=options[:min_votes] || 3
20
+ #self._max_votes=options[:max_votes] || 6
21
+ #self._consensus=options[:consensus] || 0.7
22
+ #self._status_update_fct=options[:on_status_change] || :decision_updated
23
+
24
+ has_many :votes, :as => :voteable, :dependent => :destroy
25
+ has_many :voters, :through => :votes
26
+ attr_accessible :voters, :score, :num_votes, :entropy # entropy
27
+
28
+
29
+ scope :voted_by, lambda { |user|
30
+ joins(:votes).where("votes.voteable_type = ?", self.name).where("votes.voter_id=?", user.id)
31
+ }
32
+
33
+ include ActsAsMeritocracy::InstanceMethods
34
+ extend ActsAsMeritocracy::SingletonMethods
35
+ end
36
+ end
37
+
38
+ module SingletonMethods
39
+
40
+ end
41
+
42
+ module InstanceMethods
43
+
44
+
45
+ def voted_by?(user)
46
+ self.votes.exists?(:voter_id=>user.id)
47
+ end
48
+
49
+ # number of votes taking into account (or not) the weight of the vote
50
+ # 1 vote with vote_weight=2 ==> 2 votes
51
+ def nb_votes(weighted=true)
52
+ if (weighted)
53
+ self.votes.sum('vote_weight')
54
+ else
55
+ self.votes.count
56
+ end
57
+ end
58
+
59
+ # retrieve the vote distribution
60
+ def vote_distribution
61
+ Vote.select("vote, sum(vote_weight) as freq").where(:voteable_id=>self.id).group(:vote).order("freq DESC")
62
+ end
63
+
64
+ #majority vote (get the decision with the higher number of votes)
65
+ def best_decision(tie_method="random")
66
+ best=nil
67
+ tie=[]
68
+ nb_categories=0
69
+ self.vote_distribution.each { |category|
70
+ if ((best.nil?) || (category.freq>best.freq))
71
+ best=category
72
+ tie=[]
73
+ tie<<category
74
+ # managing tie
75
+ elsif (category.freq==best.freq)
76
+ tie<<category
77
+ end
78
+ nb_categories=nb_categories+1
79
+ }
80
+
81
+ # tie detected
82
+ if (tie.size>1)
83
+ case (tie_method)
84
+ when "random" then
85
+ tie[rand(tie.size)].vote
86
+ when "nodecision" then
87
+ nil
88
+ end
89
+ # no vote
90
+ elsif (best.nil?)
91
+ nil
92
+ # get the most chosen category
93
+ else
94
+ best.vote
95
+ end
96
+ end
97
+
98
+ # Consensus score using the vote distribubtion
99
+ # very high consensus=1 , very low consensus=0
100
+ # could be used as an indicator to measure the difficulty of users to take collectively a decision
101
+ # 2 methods are proposed
102
+ # - Entropy
103
+ # - Fleiss Kappa taking into account the weight (NOTE: I removed the pb of chance in the computation)
104
+ def consensus(method="kappa")
105
+ consensus= case method
106
+ when "entropy" then
107
+ dist=self.vote_distribution
108
+ nb_votes=dist.inject(0) { |sum, category| sum+category.freq }.to_f
109
+ h=self.vote_distribution.inject(0) { |h, category|
110
+ p_j=category.freq.to_f/nb_votes
111
+ h + p_j*Math.log(p_j)
112
+ }
113
+ h=[h, -1].max
114
+ 1+h
115
+
116
+ when "kappa" then
117
+ dist=self.vote_distribution
118
+ nb_votes=dist.inject(0) { |sum, category| sum+category.freq }.to_f
119
+ fact=dist.inject(0) { |fact, category|
120
+ fact+category.freq**2
121
+ }
122
+ (fact-nb_votes)/(nb_votes*(nb_votes-1))
123
+ else
124
+ 0
125
+ end
126
+ consensus
127
+ end
128
+
129
+ #submit or update the vote of someone
130
+ def submit_vote(user, vote, vote_weight=1)
131
+
132
+ #check existence
133
+ v=Vote.where(:voter_id=>user.id, :voteable_id=>self.id, :voteable_type => self.class.name).first
134
+
135
+ #if not create new vote
136
+ if (v.nil?)
137
+ v=Vote.create(:voter=>user,
138
+ :voteable=>self,
139
+ :vote=>vote,
140
+ :vote_weight=>vote_weight)
141
+ #modify existing vote
142
+ else
143
+ v.update_attributes(:vote=>vote, :vote_weight=>vote_weight)
144
+ end
145
+ v
146
+ end
147
+
148
+ protected
149
+
150
+ def trigger_decision
151
+ # if we are enough sure of the decision we trigger an action
152
+ if (nb_votes>=self.class._min_votes && consensus>self.class._consensus_ratio)
153
+ #we trigger an action
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,3 @@
1
+ module ActsAsMeritocracy
2
+ VERSION = '0.2'
3
+ end
@@ -0,0 +1,38 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ module Model
5
+ class MigrationGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+
8
+ desc "Generates migration for Votes models"
9
+
10
+ def self.orm
11
+ Rails::Generators.options[:rails][:orm]
12
+ end
13
+
14
+ def self.source_root
15
+ File.join(File.dirname(__FILE__), 'templates', (orm.to_s unless orm.class.eql?(String)) )
16
+ end
17
+
18
+ def self.orm_has_migration?
19
+ [:active_record].include? orm
20
+ end
21
+
22
+ def self.next_migration_number(dirname)
23
+ if ActiveRecord::Base.timestamped_migrations
24
+ migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
25
+ migration_number += 1
26
+ migration_number.to_s
27
+ else
28
+ "%.3d" % (current_migration_number(dirname) + 1)
29
+ end
30
+ end
31
+
32
+ def create_migration_file
33
+ if self.class.orm_has_migration?
34
+ migration_template 'migration.rb', 'db/migrate/acts_as_taggable_on_migration'
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,20 @@
1
+ class ActsAsTaggableOnMigration < ActiveRecord::Migration
2
+
3
+ def self.up
4
+ create_table :votes, :force => true do |t|
5
+ t.integer :voter_id, :limit => 11
6
+ t.references :voteable, :polymorphic=>true
7
+ t.integer :vote_weight, :default=>1
8
+ t.integer :vote, :default=>1
9
+ t.datetime :updated_at
10
+ t.datetime :created_at
11
+ end
12
+ add_index :votes, :tag_id
13
+ add_index :votes, [:voteable_id, :voteable_type]
14
+ end
15
+
16
+ def self.down
17
+ drop_table :votes
18
+ end
19
+
20
+ end
@@ -0,0 +1,21 @@
1
+
2
+ # inspired by thumb_up gem
3
+ # https://raw.github.com/brady8/thumbs_up/
4
+
5
+ class Vote < ActiveRecord::Base
6
+
7
+ scope :for_voter, lambda { |*args| where(["voter_id = ?", args.first.id]) }
8
+ scope :for_voteable, lambda { |*args| where(["voteable_id = ? AND voteable_type = ?", args.first.id, args.first.class.base_class.name]) }
9
+ scope :recent, lambda { |*args| where(["created_at > ?", (args.first || 2.weeks.ago)]) }
10
+ scope :descending, order("created_at DESC")
11
+
12
+ belongs_to :voteable, :polymorphic => true
13
+
14
+ belongs_to :voter, :class_name=>"User"
15
+
16
+ attr_accessible :vote, :voter, :voteable, :vote_weight
17
+
18
+ # Comment out the line below to allow multiple votes per user.
19
+ validates_uniqueness_of :voteable_id, :scope => [:voteable_type, :voter_id]
20
+
21
+ end
@@ -0,0 +1,10 @@
1
+ RAILS_DEFAULT_LOGGER.info "** acts_as_meritocracy: setting up load paths **"
2
+
3
+ %w{ models controllers helpers }.each do |dir|
4
+ path = File.join(File.dirname(__FILE__), 'lib', dir)
5
+ $LOAD_PATH << path
6
+ ActiveSupport::Dependencies.autoload_paths << path
7
+ ActiveSupport::Dependencies.autoload_once_paths.delete(path)
8
+ end
9
+
10
+ require 'acts_as_meritocracy'
@@ -0,0 +1,55 @@
1
+ require File.join(File.expand_path(File.dirname(__FILE__)), 'test_helper')
2
+
3
+ class MeritocracyTest < Test::Unit::TestCase
4
+ def setup
5
+ Vote.delete_all
6
+ User.delete_all
7
+ Item.delete_all
8
+
9
+
10
+ end
11
+
12
+ def test_acts_as_meritocracy_methods
13
+ user1 = User.create!()
14
+ user2 = User.create!()
15
+ user3 = User.create!()
16
+ user4=User.create!()
17
+
18
+ item = Item.create!()
19
+
20
+ item.submit_vote(user1, 4) #user1 votes for the decision 4, by default the vote_weight=1
21
+ item.submit_vote(user2, 1, 2) #user2 votes for the decision 1 with a vote_weight=2. A vote_weight=2 means that the voter has a vote equal to 2 normal voters (vote_weight=1)
22
+ item.submit_vote(user1, 1) #user1 can change/update her vote
23
+ item.submit_vote(user3, 2) #user3 vote for decision 2
24
+ item.submit_vote(user4, 3, 5) #user3 vote for decision 2
25
+
26
+ assert_equal 9, item.nb_votes
27
+
28
+ dist=item.vote_distribution
29
+
30
+ assert_equal 3, dist[0].vote
31
+ assert_equal 5, dist[0].freq
32
+ assert_equal 1, dist[1].vote
33
+ assert_equal 3, dist[1].freq
34
+
35
+ assert_equal 0.361, item.consensus.round(3) #fleiss kappa taking into account the weight
36
+ assert_equal 0.063, item.consensus("entropy").round(3)
37
+
38
+
39
+ #everyone agrees
40
+ item2 = Item.create!()
41
+ item2.submit_vote(user1, 1)
42
+ item2.submit_vote(user2, 1, 3)
43
+ item2.submit_vote(user3, 1, 2)
44
+ assert_equal 1, item2.consensus
45
+
46
+ #everyone disagrees
47
+ item3 = Item.create!()
48
+ item3.submit_vote(user1, 1)
49
+ item3.submit_vote(user2, 2)
50
+
51
+
52
+ assert_equal 0.0, item3.consensus #entropy
53
+
54
+ end
55
+ end
@@ -0,0 +1,61 @@
1
+ require 'test/unit'
2
+
3
+
4
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
5
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+
7
+
8
+ #require 'simplecov'
9
+ #SimpleCov.start
10
+
11
+ require 'active_record'
12
+ require 'logger'
13
+
14
+
15
+ TEST_DATABASE_FILE = File.join(File.dirname(__FILE__), '..', 'test.sqlite3')
16
+ File.unlink(TEST_DATABASE_FILE) if File.exist?(TEST_DATABASE_FILE)
17
+ ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => TEST_DATABASE_FILE
18
+
19
+ ActiveRecord::Migration.verbose = true
20
+ #ActiveRecord::Base.logger = Logger.new(STDOUT)
21
+ ActiveRecord::Schema.define do
22
+
23
+ create_table :votes, :force => true do |t|
24
+ t.integer :voter_id, :default => false
25
+ t.integer :vote, :default => false
26
+ t.references :voteable, :polymorphic => true, :null => false
27
+ t.integer :vote_weight, :default=>1
28
+ t.timestamps
29
+ end
30
+
31
+ create_table :users, :force => true do |t|
32
+ t.timestamps
33
+ end
34
+
35
+ create_table :items, :force => true do |t|
36
+ end
37
+ end
38
+
39
+
40
+ require 'acts_as_meritocracy'
41
+
42
+ class User < ActiveRecord::Base
43
+ end
44
+
45
+
46
+ class Vote < ActiveRecord::Base
47
+ belongs_to :voteable, :polymorphic => true
48
+ belongs_to :voter, :class_name=>"User"
49
+ attr_accessible :vote, :voter, :voteable, :vote_weight
50
+
51
+ # Comment out the line below to allow multiple votes per user.
52
+ validates_uniqueness_of :voteable_id, :scope => [:voteable_type, :voter_id]
53
+ end
54
+
55
+ class Item < ActiveRecord::Base
56
+ acts_as_meritocracy
57
+ end
58
+
59
+
60
+ class Test::Unit::TestCase
61
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_meritocracy
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.2'
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Nicolas Maisonneuve
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-03-26 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: bundler
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: sqlite3
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: rake
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ description: Weighted majority voting system for qualitative (categorical) items (i.e.assuming
79
+ the set of decision has no natural ordering). As a measure of the quality of the
80
+ consensus/inter-agreement, you can choose between a weighted variant of fleiss Kappa (by
81
+ default) or the entropy of the vote distribution
82
+ email:
83
+ - n.maisonneuve.com
84
+ executables: []
85
+ extensions: []
86
+ extra_rdoc_files: []
87
+ files:
88
+ - lib/acts_as_meritocracy/model.rb
89
+ - lib/acts_as_meritocracy/version.rb
90
+ - lib/acts_as_meritocracy.rb
91
+ - lib/generators/acts_as_meritocracy/migration/migration_generator.rb
92
+ - lib/generators/acts_as_meritocracy/migration/templates/active_record/migration.rb
93
+ - lib/generators/acts_as_meritocracy/migration/templates/active_record/vote.rb
94
+ - rails/init.rb
95
+ - test/meritocracy_test.rb
96
+ - test/test_helper.rb
97
+ - Gemfile
98
+ - MIT-LICENSE
99
+ - README.rdoc
100
+ - Rakefile
101
+ homepage: https://github.com/nmaisonneuve/acts_as_meritocracy
102
+ licenses: []
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ none: false
109
+ requirements:
110
+ - - ! '>='
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ segments:
114
+ - 0
115
+ hash: -127910143
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ none: false
118
+ requirements:
119
+ - - ! '>='
120
+ - !ruby/object:Gem::Version
121
+ version: 1.9.0
122
+ requirements: []
123
+ rubyforge_project:
124
+ rubygems_version: 1.8.21
125
+ signing_key:
126
+ specification_version: 3
127
+ summary: Weighted voting system for deliberation.
128
+ test_files: []