acts_as_meritocracy 0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []