likes 0.1.0 → 0.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 6148191736d05b2de8dce7c05ad3ec5781a70735
4
- data.tar.gz: fdc47aec630718afa84d9c79f25e7a82bf71e8e5
3
+ metadata.gz: ec87d59328fd9af20cf8ac632d146dbfa8d62479
4
+ data.tar.gz: 26d0b83be096dc1150ac797545abdbf6170de08e
5
5
  SHA512:
6
- metadata.gz: 6f3b0458b662807a8ba0558be63846421a8d95a1bb0d64729419aeb08b58d0f6dfafa0fe4ff91fd3fae464833152d7ef8827fdea901106bf69dc360d0bee7033
7
- data.tar.gz: 6e8d085e9022c1ead5877416f48b250bcd2036e62ce442d345156556bb2cfc461961efecf78cc0a89f181511983b071783f29087b20df26fd91a4f0726b37c8d
6
+ metadata.gz: fd78634d24a31e845a6f08ac785d2a8698be63e82f6fc6822a2c8011fcb61f526b37582b53e7b3cad7136f49b585e9d41375b150ea0d8bf943abb29214b92f3a
7
+ data.tar.gz: aaf4af28de77e664cce38e5cd06973c2b6dfafa9a1066e48c5874e605a3184ac0b2f438a1061d81dd5d8e0069476d6b44b24272345ffd4b6b51571b9eea50747
data/.travis.yml ADDED
@@ -0,0 +1,13 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.8.7
4
+ - 1.9.3
5
+ - 2.0.0
6
+ - 2.1
7
+ - 2.2
8
+ - jruby-18mode
9
+ - jruby-19mode
10
+ - rbx
11
+
12
+ script: bundle exec rspec
13
+ bundler_args: --without development
data/Gemfile CHANGED
@@ -4,4 +4,8 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
6
  gem "rspec"
7
- gem "yard"
7
+
8
+ group :development do
9
+ gem "yard"
10
+ gem "reek"
11
+ end
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Likes
2
2
 
3
+ [![Build Status](https://travis-ci.org/waterlink/likes.svg?branch=master)](https://travis-ci.org/waterlink/likes)
4
+
3
5
  Give it a list of people and their likings and it will tell what else could these people like.
4
6
 
5
7
  This is a ruby gem.
data/lib/likes.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  require "likes/version"
2
+ require "likes/support"
3
+ require "likes/engines"
2
4
  require "likes/like"
3
5
  require "likes/set"
4
6
 
@@ -0,0 +1,9 @@
1
+ require "likes/engines/protocol"
2
+ require "likes/engines/best_intersection_size"
3
+ require "likes/engines/best_relative_intersection_size"
4
+ require "likes/engines/fast_jaccard_similarity"
5
+
6
+ module Links
7
+ module Engines
8
+ end
9
+ end
@@ -0,0 +1,151 @@
1
+ module Likes
2
+ module Engines
3
+ # Job: Understands which items could be recommended
4
+ #
5
+ # Calculates intersection sizes to all other person likings and
6
+ # chooses maximum
7
+ #
8
+ # Worst approximation of execution time:
9
+ #
10
+ # Given K = how much likes target person has, in reasonable
11
+ # situations it is not very big number. But in theory can be as
12
+ # high as P
13
+ #
14
+ # Given N = how much distinct people we have
15
+ #
16
+ # Given P = how much distinct items we have
17
+ #
18
+ # Complexity: O(NK) * O(hash operations ~ log N + log P)
19
+ #
20
+ # @see BestRelativeIntersectionSize
21
+ class BestIntersectionSize
22
+
23
+ # Creates new instance of BestIntersectionSize engine
24
+ #
25
+ # @param [Person#==] person The person to provide
26
+ # recommendations for
27
+ # @param [Hash<Person, Array<Item>>] likes_of Input data in form
28
+ # of map person => [item]
29
+ # @param [Hash<Item, Array<Person>>] liked Input data in form of
30
+ # map item => [person]
31
+ #
32
+ # @param [Factory<Intersections>#new(Person)]
33
+ # intersections_factory Knows how to find best intersection
34
+ # candidates
35
+ def initialize(person, likes_of, liked, intersections_factory=Intersections)
36
+ @intersections = intersections_factory.build(person)
37
+ @likes_of = likes_of
38
+ @liked = liked
39
+ @its_likes = likes_of.fetch(person)
40
+ add_similar_tastes
41
+ end
42
+
43
+ # Solves the problem and returns recommendation list
44
+ #
45
+ # @return [Array<Item>] Returns list of recommended items
46
+ def solve
47
+ solution_candidate(intersections.next_people_with_similar_tastes)
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :intersections, :likes_of, :liked, :its_likes
53
+
54
+ def solution_candidate(candidates)
55
+ return [] if candidates.empty?
56
+ non_empty_solution(_solution_candidate(candidates))
57
+ end
58
+
59
+ def _solution_candidate(candidates)
60
+ candidates.map { |other_person, _|
61
+ likes_of.fetch(other_person) - its_likes
62
+ }.flatten.uniq
63
+ end
64
+
65
+ def non_empty_solution(solution)
66
+ return solve if solution.empty?
67
+ solution
68
+ end
69
+
70
+ def add_similar_tastes
71
+ its_likes.each do |item|
72
+ intersections.add_similar_tastes(liked.fetch(item))
73
+ end
74
+ end
75
+
76
+ # @private
77
+ # Job: Null object for size transformation logic
78
+ class NullSizeTransform
79
+ def call(_, size)
80
+ size
81
+ end
82
+
83
+ alias_method :[], :call
84
+ end
85
+
86
+ # @private
87
+ # Job: Understands similar tastes
88
+ class Intersections
89
+ NO_LIMIT = Object.new.freeze
90
+
91
+ def self.build(person)
92
+ new(person, NullSizeTransform.new)
93
+ end
94
+
95
+ def initialize(person, size_transform)
96
+ @sizes = {}
97
+ @person = person
98
+ @best_size = NO_LIMIT
99
+ @size_transform = size_transform
100
+ end
101
+
102
+ def add_similar_tastes(people)
103
+ people.each do |other_person|
104
+ next if person == other_person
105
+ sizes[other_person] = sizes.fetch(other_person, 0) + 1
106
+ end
107
+ end
108
+
109
+ def next_people_with_similar_tastes
110
+ candidates_with(next_best_size)
111
+ end
112
+
113
+ private
114
+
115
+ attr_reader :sizes, :person, :best_size, :size_transform
116
+
117
+ def candidates_with(intersection_size)
118
+ return [] if no_limit?(intersection_size)
119
+ transformed_sizes.select { |_, size|
120
+ Support::FloatWithError.new(intersection_size) == size
121
+ }
122
+ end
123
+
124
+ def next_best_size
125
+ @best_size = get_best_size(best_size)
126
+ end
127
+
128
+ def get_best_size(limit)
129
+ transformed_sizes.
130
+ select { |_, size| in_limit?(size, limit) }.
131
+ map { |_, size| size }.max || NO_LIMIT
132
+ end
133
+
134
+ def transformed_sizes
135
+ @_transformed_sizes ||= Hash[sizes.map { |person, size|
136
+ [person, size_transform[person, size]]
137
+ }]
138
+ end
139
+
140
+ def in_limit?(size, limit)
141
+ return true if no_limit?(limit)
142
+ size < limit
143
+ end
144
+
145
+ def no_limit?(limit)
146
+ NO_LIMIT == limit
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,106 @@
1
+ require "likes/engines/best_intersection_size"
2
+
3
+ module Likes
4
+ module Engines
5
+ # Job: Understands which items could be recommended
6
+ #
7
+ # Calculates relative intersection sizes to all other person
8
+ # likings and chooses maximum. This is Jaccard Similarity of Sets
9
+ # algorithm implementation
10
+ #
11
+ # Relative intersection size = intersection size / union size
12
+ #
13
+ # Worst approximation of execution time:
14
+ #
15
+ # Given K = how much likes target person has, in reasonable
16
+ # situations it is not very big number. But in theory can be as
17
+ # high as P
18
+ #
19
+ # Given P = how much distinct items we have
20
+ #
21
+ # Given N = how much distinct people we have
22
+ #
23
+ # Complexity: O(NK) * O(hash operations ~ log N + log P)
24
+ #
25
+ # @see BestIntersectionSize
26
+ class BestRelativeIntersectionSize
27
+
28
+ # Creates new instance of BestRelativeIntersectionSize engine
29
+ #
30
+ # @param [Person#==] person The person to provide
31
+ # recommendations for
32
+ # @param [Hash<Person, Array<Item>>] likes_of Input data in form
33
+ # of map person => [item]
34
+ # @param [Hash<Item, Array<Person>>] liked Input data in form of
35
+ # map item => [person]
36
+ def initialize(person, likes_of, liked)
37
+ @delegatee = BestIntersectionSize.new(
38
+ person,
39
+ likes_of,
40
+ liked,
41
+ RelativeIntersectionsFactory.new(likes_of)
42
+ )
43
+ end
44
+
45
+ # Solves the problem and returns recommendation list
46
+ #
47
+ # @return [Array<Item>] Returns list of recommended items
48
+ def solve
49
+ delegatee.solve
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :delegatee
55
+
56
+ # @private
57
+ # Job: Understands how needs of Intersections with relative
58
+ # logic
59
+ class RelativeIntersectionsFactory
60
+ def initialize(likes_of)
61
+ @sets_sizes = Hash[likes_of.map { |person, items| [person, items.size] }]
62
+ end
63
+
64
+ def build(person)
65
+ BestIntersectionSize::Intersections.new(person, size_transform(person))
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :sets_sizes
71
+
72
+ def size_transform(person)
73
+ RelativeSizeTransform.new(person, sets_sizes)
74
+ end
75
+ end
76
+
77
+ # @private
78
+ # Job: Understands conversion between absolute and relative
79
+ # intersection sizes
80
+ class RelativeSizeTransform
81
+ def initialize(person, sets_sizes)
82
+ @person = person
83
+ @sets_sizes = sets_sizes
84
+ end
85
+
86
+ def call(other_person, intersection_size)
87
+ 1.0 * intersection_size / union_size(other_person)
88
+ end
89
+
90
+ alias_method :[], :call
91
+
92
+ private
93
+
94
+ attr_reader :person, :sets_sizes
95
+
96
+ def union_size(other_person)
97
+ sets_sizes[other_person] + own_size
98
+ end
99
+
100
+ def own_size
101
+ @_own_size ||= sets_sizes[person]
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,159 @@
1
+ module Likes
2
+ module Engines
3
+ # Job: Understands which items could be recommended
4
+ #
5
+ # Calculates Minhash Signatures and uses them to give approximate
6
+ # relative intersection size. Returns recommendations from best
7
+ # approximate relative intersection sizes
8
+ #
9
+ # Relative intersection size = intersection size / union size
10
+ #
11
+ # Worst approximation of execution time:
12
+ #
13
+ # Given P = how much distinct items we have
14
+ #
15
+ # Given N = how much distinct people we have
16
+ #
17
+ # Given D = depth of this implementation - maximum count of hash functions (~100)
18
+ #
19
+ # Complexity: O(ND + PD) * O(hash operations ~ log N + log P) ~ O(N * log N)
20
+ # @ignore has a lot of memoization :reek:TooManyInstanceVariables
21
+ class FastJaccardSimilarity
22
+
23
+ MAX_HASH_FUNCTIONS_COUNT = 200
24
+ ALLOW_FLUCTUATION = 1.075
25
+ INFINITY = 1.0 / 0
26
+
27
+ # Creates new instance of FastJaccardSimilarity engine
28
+ #
29
+ # @param [Person#==] person The person to provide
30
+ # recommendations for
31
+ # @param [Hash<Person, Array<Item>>] likes_of Input data in form
32
+ # of map person => [item]
33
+ # @param [Hash<Item, Array<Person>>] liked Input data in form of
34
+ # map item => [person]
35
+ def initialize(person, likes_of, liked)
36
+ @person = person
37
+ @likes_of = likes_of
38
+ @liked = liked
39
+ @items = liked.keys
40
+ @people = likes_of.keys
41
+ end
42
+
43
+ # Solves the problem and returns recommendation list
44
+ #
45
+ # @return [Array<Item>] Returns list of recommended items
46
+ def solve
47
+ init_signature
48
+ compute_hashes
49
+ compute_signature
50
+ recommendations
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :person, :likes_of, :liked, :items, :people,
56
+ :signature, :hash_functions, :hashes
57
+
58
+ def init_signature
59
+ @signature = (0...MAX_HASH_FUNCTIONS_COUNT).map {
60
+ Array.new(people.count, INFINITY)
61
+ }
62
+ end
63
+
64
+ def own_column
65
+ @_own_column ||= people.index(person)
66
+ end
67
+
68
+ def similarities
69
+ @_similarities ||= people.
70
+ each_with_index.
71
+ inject({person => -1}) do |similarities, (other_person, column)|
72
+ if person != other_person
73
+ similarities[other_person] = relative_similarity(similarity_for(column), other_person)
74
+ end
75
+ similarities
76
+ end
77
+ end
78
+
79
+ def similarity_for(column)
80
+ signature.each_with_index.select { |signature_row, index|
81
+ signature_row[column] == signature_row[own_column]
82
+ }.count
83
+ end
84
+
85
+ def relative_similarity(similarity, other_person)
86
+ 1.0 * similarity / union_size(other_person)
87
+ end
88
+
89
+ def union_size(other_person)
90
+ own_likes_count + likes_of.fetch(other_person).count
91
+ end
92
+
93
+ def own_likes
94
+ @_own_likes ||= likes_of.fetch(person)
95
+ end
96
+
97
+ def own_likes_count
98
+ @_own_likes_count ||= own_likes.count
99
+ end
100
+
101
+ def best_similarity
102
+ similarities.values.max
103
+ end
104
+
105
+ def candidates
106
+ Hash[similarities.select { |_, similarity|
107
+ best_similarity <= similarity * ALLOW_FLUCTUATION
108
+ }].keys
109
+ end
110
+
111
+ def recommendations
112
+ candidates.map { |other_person, _|
113
+ likes_of.fetch(other_person) - own_likes
114
+ }.flatten.uniq
115
+ end
116
+
117
+ # Full complexity: D * O(likeset size * log N) ~ O(N log N) with big constant
118
+ def compute_signature
119
+ each_like do |_, _, row, column|
120
+ signature_step(row, column)
121
+ end
122
+ end
123
+
124
+ # Complexity: O(likeset size) * O(log N) ~ O(N log N)
125
+ def each_like(&blk)
126
+ items.each_with_index do |item, row|
127
+ each_column_of_likes(item, row, &blk)
128
+ end
129
+ end
130
+
131
+ def each_column_of_likes(item, row, &blk)
132
+ # only columns with 1 in matrix:
133
+ liked.fetch(item).each_with_index do |person, column|
134
+ blk[item, person, row, column]
135
+ end
136
+ end
137
+
138
+ # Complexity: D * O(1)
139
+ def signature_step(row, column)
140
+ signature.each_with_index do |signature_row, index|
141
+ signature_row[column] = [signature_row[column], hashes[index][row]].min
142
+ end
143
+ end
144
+
145
+ def compute_hashes
146
+ @hashes = hash_functions.map { |function|
147
+ function.map(0...items.count)
148
+ }
149
+ end
150
+
151
+ def hash_functions
152
+ @_hash_functions ||= Support::HashFunction.sample(
153
+ MAX_HASH_FUNCTIONS_COUNT,
154
+ items.count
155
+ )
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,34 @@
1
+ module Likes
2
+ module Engines
3
+ # @abstract
4
+ # Defines protocol which any Engine should support
5
+ class Protocol
6
+
7
+ # @ignore :reek:UnusedParameters
8
+ # Creates new instance of engine
9
+ #
10
+ # @param [Person#==] person The person to provide
11
+ # recommendations for
12
+ # @param [Hash<Person, Array<Item>>] likes_of Input data in form
13
+ # of map person => [item]
14
+ # @param [Hash<Item, Array<Person>>] liked Input data in form of
15
+ # map item => [person]
16
+ def initialize(person, likes_of, liked)
17
+ abstract
18
+ end
19
+
20
+ # Solves the problem and returns recommendation list
21
+ #
22
+ # @return [Array<Item>] Returns list of recommended items
23
+ def solve
24
+ abstract
25
+ end
26
+
27
+ private
28
+
29
+ def abstract
30
+ raise NotImplementedError.new("Likes::Engines::Protocol is abstract")
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/likes/set.rb CHANGED
@@ -1,11 +1,20 @@
1
1
  module Likes
2
2
  # Job: Understands patterns in people likings
3
3
  class Set
4
+ # Default engine - simplest one
5
+ #
6
+ # @see Engines::BestIntersectionSize
7
+ DEFAULT_ENGINE = Engines::BestIntersectionSize
8
+
4
9
  # Creates new instance of Set
5
10
  #
6
11
  # @param [Array<Like>] likes List of likes
7
- def initialize(likes)
12
+ # @param [Engines::Protocol] engine Recommendation engine to use
13
+ #
14
+ # @see Engines
15
+ def initialize(likes, engine=DEFAULT_ENGINE)
8
16
  @likes = likes
17
+ @engine = engine
9
18
  build_likes_of
10
19
  build_liked
11
20
  end
@@ -13,29 +22,20 @@ module Likes
13
22
  # Provides list of recommendations for person based on this
14
23
  # likeset
15
24
  #
16
- # Should handle amount of distinct persons <= 10**6 and amount of
25
+ # Should handle amount of distinct people <= 10**6 and amount of
17
26
  # distinct items <= 10**6, but likeset length is <= 10**7, ie it
18
27
  # is advised to use only recent likes (couple of weeks or month)
19
28
  #
20
- # Worst approximation of execution time:
21
- #
22
- # Given K = how much likes target person has, in reasonable
23
- # situations it is not very big number
24
- #
25
- # Given N = how much distinct persons we have
26
- #
27
- # Complexity: O(NK)
28
- #
29
29
  # @param [Person#==] person The person to provide recommendations
30
30
  # for
31
31
  # @return [Array<Item>] List of recommendations for the person
32
32
  def recommendations_for(person)
33
- Recommendations.new(person, likes_of, liked).solve
33
+ engine.new(person, likes_of, liked).solve
34
34
  end
35
35
 
36
36
  private
37
37
 
38
- attr_accessor :likes, :likes_of, :liked
38
+ attr_accessor :likes, :engine, :likes_of, :liked
39
39
 
40
40
  def build_likes_of
41
41
  @likes_of = {}
@@ -50,98 +50,5 @@ module Likes
50
50
  like.add_person_to_map(liked)
51
51
  end
52
52
  end
53
-
54
- # @private
55
- # Job: Understands which items could be recommended
56
- class Recommendations
57
- def initialize(person, likes_of, liked)
58
- @intersections = Intersections.new(person)
59
- @likes_of = likes_of
60
- @liked = liked
61
- @its_likes = likes_of.fetch(person)
62
- add_similar_tastes
63
- end
64
-
65
- def solve
66
- solution_candidate(intersections.next_people_with_similar_tastes)
67
- end
68
-
69
- private
70
-
71
- attr_reader :intersections, :likes_of, :liked, :its_likes
72
-
73
- def solution_candidate(candidates)
74
- return [] if candidates.empty?
75
- non_empty_solution(_solution_candidate(candidates))
76
- end
77
-
78
- def _solution_candidate(candidates)
79
- candidates.map { |other_person, _|
80
- likes_of.fetch(other_person) - its_likes
81
- }.flatten.uniq
82
- end
83
-
84
- def non_empty_solution(solution)
85
- return solve if solution.empty?
86
- solution
87
- end
88
-
89
- def add_similar_tastes
90
- its_likes.each do |item|
91
- intersections.add_similar_tastes(liked.fetch(item))
92
- end
93
- end
94
- end
95
-
96
- # @private
97
- # Job: Understands similar tastes
98
- class Intersections
99
- NO_LIMIT = Object.new.freeze
100
-
101
- def initialize(person)
102
- @sizes = {}
103
- @person = person
104
- @best_size = NO_LIMIT
105
- end
106
-
107
- def add_similar_tastes(people)
108
- people.each do |other_person|
109
- next if person == other_person
110
- sizes[other_person] = sizes.fetch(other_person, 0) + 1
111
- end
112
- end
113
-
114
- def next_people_with_similar_tastes
115
- candidates_with(next_best_size)
116
- end
117
-
118
- private
119
-
120
- attr_reader :sizes, :person
121
-
122
- def candidates_with(intersection_size)
123
- return [] if no_limit?(intersection_size)
124
- sizes.select { |_, size| intersection_size == size }
125
- end
126
-
127
- def next_best_size
128
- @best_size = get_best_size(@best_size)
129
- end
130
-
131
- def get_best_size(limit)
132
- sizes
133
- .select { |_, size| in_limit?(size, limit) }
134
- .map { |_, size| size }.max || NO_LIMIT
135
- end
136
-
137
- def in_limit?(size, limit)
138
- return true if no_limit?(limit)
139
- size < limit
140
- end
141
-
142
- def no_limit?(limit)
143
- NO_LIMIT == limit
144
- end
145
- end
146
53
  end
147
54
  end
@@ -0,0 +1,68 @@
1
+ PrimeFactory = if RUBY_VERSION.to_f > 1.8
2
+ require "prime"
3
+ Prime
4
+ else
5
+ require "mathn"
6
+ Prime.new
7
+ end
8
+
9
+ module Likes
10
+ module Support
11
+ # @private
12
+ # Job: Understands float comparision in computer world
13
+ class FloatWithError
14
+ ALLOWED_ERROR = 1e-9
15
+
16
+ def self.lift(value)
17
+ return value if FloatWithError === value
18
+ FloatWithError.new(value)
19
+ end
20
+
21
+ def initialize(value)
22
+ @value = value
23
+ end
24
+
25
+ def ==(other)
26
+ (self.value - FloatWithError.lift(other).value).abs < ALLOWED_ERROR
27
+ end
28
+
29
+ protected
30
+
31
+ attr_accessor :value
32
+ end
33
+
34
+ # @private
35
+ # Job: Understands random hash function generation
36
+ class HashFunction
37
+ PRIMES = PrimeFactory.first(3200)[-250..-1]
38
+
39
+ def self.sample(count, modulo)
40
+ (0...count).map { new(*(sample_primes(2) + [modulo])) }
41
+ end
42
+
43
+ def initialize(factor, constant, modulo)
44
+ @factor, @constant, @modulo = factor, constant, modulo
45
+ end
46
+
47
+ def call(value)
48
+ (factor * value + constant) % modulo
49
+ end
50
+
51
+ def map(values)
52
+ values.map { |value| call(value) }
53
+ end
54
+
55
+ private
56
+
57
+ attr_accessor :factor, :constant, :modulo
58
+
59
+ def self.sample_primes(count)
60
+ if RUBY_VERSION.to_f > 1.8
61
+ PRIMES.sample(count)
62
+ else
63
+ (0...count).map { PRIMES.choice }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/likes/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Likes
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/spec/fixtures.rb ADDED
@@ -0,0 +1,107 @@
1
+ # @ignore :reek:UtilityFunction
2
+ module Fixtures
3
+ class << self
4
+ def small_likeset
5
+ [
6
+ Likes::Like.new(:person => 1, :item => 5),
7
+
8
+ Likes::Like.new(:person => 3, :item => 5),
9
+ Likes::Like.new(:person => 3, :item => 4),
10
+ ]
11
+ end
12
+
13
+ def small_likeset_likes_of
14
+ {
15
+ 1 => [5],
16
+ 3 => [5, 4],
17
+ }
18
+ end
19
+
20
+ def small_likeset_liked
21
+ {
22
+ 5 => [1, 3],
23
+ 4 => [3],
24
+ }
25
+ end
26
+
27
+ def regular_likeset
28
+ [
29
+ Likes::Like.new(:person => 1, :item => 1),
30
+ Likes::Like.new(:person => 1, :item => 5),
31
+ Likes::Like.new(:person => 1, :item => 4),
32
+ Likes::Like.new(:person => 1, :item => 7),
33
+ Likes::Like.new(:person => 1, :item => 19),
34
+
35
+ Likes::Like.new(:person => 2, :item => 1),
36
+ Likes::Like.new(:person => 2, :item => 3),
37
+ Likes::Like.new(:person => 2, :item => 5),
38
+ Likes::Like.new(:person => 2, :item => 4),
39
+
40
+ Likes::Like.new(:person => 3, :item => 3),
41
+ Likes::Like.new(:person => 3, :item => 2),
42
+ Likes::Like.new(:person => 3, :item => 5),
43
+ Likes::Like.new(:person => 3, :item => 4),
44
+ Likes::Like.new(:person => 3, :item => 9),
45
+ ]
46
+ end
47
+
48
+ def best_intersection_candidate_has_no_other_likings
49
+ [
50
+ Likes::Like.new(:person => 1, :item => 1),
51
+ Likes::Like.new(:person => 1, :item => 5),
52
+ Likes::Like.new(:person => 1, :item => 4),
53
+ Likes::Like.new(:person => 1, :item => 7),
54
+
55
+ Likes::Like.new(:person => 2, :item => 1),
56
+ Likes::Like.new(:person => 2, :item => 3),
57
+ Likes::Like.new(:person => 2, :item => 5),
58
+ Likes::Like.new(:person => 2, :item => 4),
59
+
60
+ Likes::Like.new(:person => 4, :item => 1),
61
+ Likes::Like.new(:person => 4, :item => 5),
62
+ Likes::Like.new(:person => 4, :item => 4),
63
+ Likes::Like.new(:person => 4, :item => 7),
64
+
65
+ Likes::Like.new(:person => 3, :item => 3),
66
+ Likes::Like.new(:person => 3, :item => 2),
67
+ Likes::Like.new(:person => 3, :item => 5),
68
+ Likes::Like.new(:person => 3, :item => 4),
69
+ Likes::Like.new(:person => 3, :item => 9),
70
+ ]
71
+ end
72
+
73
+ def there_is_nothing_to_recommend
74
+ [
75
+ Likes::Like.new(:person => 1, :item => 1),
76
+ Likes::Like.new(:person => 1, :item => 5),
77
+ Likes::Like.new(:person => 1, :item => 4),
78
+ Likes::Like.new(:person => 1, :item => 7),
79
+
80
+ Likes::Like.new(:person => 4, :item => 1),
81
+ Likes::Like.new(:person => 4, :item => 5),
82
+ Likes::Like.new(:person => 4, :item => 7),
83
+ ]
84
+ end
85
+
86
+ def somebody_likes_literally_everything
87
+ [
88
+ Likes::Like.new(:person => 1, :item => 1),
89
+ Likes::Like.new(:person => 1, :item => 5),
90
+
91
+ Likes::Like.new(:person => 2, :item => 1),
92
+ Likes::Like.new(:person => 2, :item => 3),
93
+ Likes::Like.new(:person => 2, :item => 5),
94
+ Likes::Like.new(:person => 2, :item => 4),
95
+ Likes::Like.new(:person => 2, :item => 17),
96
+ Likes::Like.new(:person => 2, :item => 6),
97
+ Likes::Like.new(:person => 2, :item => 19),
98
+ Likes::Like.new(:person => 2, :item => 7),
99
+ Likes::Like.new(:person => 2, :item => 9),
100
+
101
+ Likes::Like.new(:person => 3, :item => 5),
102
+ Likes::Like.new(:person => 3, :item => 4),
103
+ Likes::Like.new(:person => 3, :item => 9),
104
+ ]
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,44 @@
1
+ module Likes
2
+ RSpec.describe "Set with Likes::Engines::BestIntersectionSize" do
3
+ let(:engine) { Engines::BestIntersectionSize }
4
+
5
+ let(:raw_likeset) { Fixtures.regular_likeset }
6
+ let(:likeset) { Set.new(raw_likeset, engine) }
7
+
8
+ describe "#recommendations_for" do
9
+ it "returns a list of items" do
10
+ expect(likeset.recommendations_for(1)).to be_a(Array)
11
+ end
12
+
13
+ it "returns right recommendations" do
14
+ expect(likeset.recommendations_for(1).sort).to eq([3])
15
+ expect(likeset.recommendations_for(2).sort).to eq([2, 7, 9, 19])
16
+ expect(likeset.recommendations_for(3).sort).to eq([1])
17
+ end
18
+
19
+ context "when somebody literally likes everything" do
20
+ let(:raw_likeset) { Fixtures.somebody_likes_literally_everything }
21
+
22
+ it "chooses this person as a candidate for recommendations" do
23
+ expect(likeset.recommendations_for(1).sort).to eq([3, 4, 6, 7, 9, 17, 19])
24
+ end
25
+ end
26
+
27
+ context "when there is an identic match with other person" do
28
+ let(:raw_likeset) { Fixtures.best_intersection_candidate_has_no_other_likings }
29
+
30
+ it "ignores identical candidates" do
31
+ expect(likeset.recommendations_for(1).sort).to eq([3])
32
+ end
33
+ end
34
+
35
+ context "when there is nothing to recommend" do
36
+ let(:raw_likeset) { Fixtures.there_is_nothing_to_recommend }
37
+
38
+ it "returns an empty solution" do
39
+ expect(likeset.recommendations_for(1).sort).to eq([])
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ module Likes
2
+ RSpec.describe "Set with Likes::Engines::BestRelativeIntersectionSize" do
3
+ let(:engine) { Engines::BestRelativeIntersectionSize }
4
+
5
+ let(:raw_likeset) { Fixtures.regular_likeset }
6
+ let(:likeset) { Set.new(raw_likeset, engine) }
7
+
8
+ describe "#recommendations_for" do
9
+ it "returns a list of items" do
10
+ expect(likeset.recommendations_for(1)).to be_a(Array)
11
+ end
12
+
13
+ it "returns right recommendations" do
14
+ expect(likeset.recommendations_for(1).sort).to eq([3])
15
+ expect(likeset.recommendations_for(2).sort).to eq([2, 7, 9, 19])
16
+ expect(likeset.recommendations_for(3).sort).to eq([1])
17
+ end
18
+
19
+ context "when somebody literally likes everything" do
20
+ let(:raw_likeset) { Fixtures.somebody_likes_literally_everything }
21
+
22
+ it "doesn't choose this person as a candidate for recommendations" do
23
+ expect(likeset.recommendations_for(1).sort).to eq([4, 9])
24
+ end
25
+ end
26
+
27
+ context "when there is an identic match with other person" do
28
+ let(:raw_likeset) { Fixtures.best_intersection_candidate_has_no_other_likings }
29
+
30
+ it "ignores identical candidates" do
31
+ expect(likeset.recommendations_for(1).sort).to eq([3])
32
+ end
33
+ end
34
+
35
+ context "when there is nothing to recommend" do
36
+ let(:raw_likeset) { Fixtures.there_is_nothing_to_recommend }
37
+
38
+ it "returns an empty solution" do
39
+ expect(likeset.recommendations_for(1).sort).to eq([])
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ module Likes
2
+ RSpec.describe "Set with Likes::Engines::FastJaccardSimilarity" do
3
+ let(:engine) { Engines::FastJaccardSimilarity }
4
+
5
+ let(:raw_likeset) { Fixtures.regular_likeset }
6
+ let(:likeset) { Set.new(raw_likeset, engine) }
7
+
8
+ describe "#recommendations_for" do
9
+ it "returns a list of items" do
10
+ expect(likeset.recommendations_for(1)).to be_a(Array)
11
+ end
12
+
13
+ it "returns right recommendations" do
14
+ expect(likeset.recommendations_for(1).sort).to eq([3])
15
+ expect(likeset.recommendations_for(2).sort).to eq([2, 7, 9, 19]).or eq([7, 19]).or eq([2, 9])
16
+ expect(likeset.recommendations_for(3).sort).to eq([1])
17
+ end
18
+
19
+ context "when somebody literally likes everything" do
20
+ let(:raw_likeset) { Fixtures.somebody_likes_literally_everything }
21
+
22
+ it "possibly can choose this person as a candidate for recommendations" do
23
+ expect(likeset.recommendations_for(1).sort).to eq([4, 9]).or eq([3, 4, 6, 7, 9, 17, 19])
24
+ end
25
+ end
26
+
27
+ context "when there is an identic match with other person" do
28
+ let(:raw_likeset) { Fixtures.best_intersection_candidate_has_no_other_likings }
29
+
30
+ it "ignores identical candidates" do
31
+ expect(likeset.recommendations_for(1).sort).to eq([3])
32
+ end
33
+ end
34
+
35
+ context "when there is nothing to recommend" do
36
+ let(:raw_likeset) { Fixtures.there_is_nothing_to_recommend }
37
+
38
+ it "returns an empty solution" do
39
+ expect(likeset.recommendations_for(1).sort).to eq([])
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -2,44 +2,44 @@ module Likes
2
2
  RSpec.describe Like do
3
3
  describe "creation" do
4
4
  it "can be created with person and item" do
5
- expect(Like.new(person: 1, item: 2)).to be_a(Like)
5
+ expect(Like.new(:person => 1, :item => 2)).to be_a(Like)
6
6
  end
7
7
 
8
8
  it "cannot be created without person and/or item" do
9
- expect { Like.new(person: 1) }.to raise_error(ArgumentError, /item is required/)
10
- expect { Like.new(item: 2) }.to raise_error(ArgumentError, /person is required/)
9
+ expect { Like.new(:person => 1) }.to raise_error(ArgumentError, /item is required/)
10
+ expect { Like.new(:item => 2) }.to raise_error(ArgumentError, /person is required/)
11
11
  expect { Like.new }.to raise_error(ArgumentError, /is required/)
12
12
  end
13
13
 
14
14
  it "ignores excessive attributes" do
15
- expect(Like.new(person: 1, item: 3, hello: "world")).to eq(Like.new(person: 1, item: 3))
15
+ expect(Like.new(:person => 1, :item => 3, :hello => "world")).to eq(Like.new(:person => 1, :item => 3))
16
16
  end
17
17
  end
18
18
 
19
19
  describe "encapsulation" do
20
20
  it "does not allow to access its data" do
21
- expect { Like.new(person: 1, item: 2).person }.to raise_error(NoMethodError)
22
- expect { Like.new(person: 1, item: 2).person = 3 }.to raise_error(NoMethodError)
21
+ expect { Like.new(:person => 1, :item => 2).person }.to raise_error(NoMethodError)
22
+ expect { Like.new(:person => 1, :item => 2).person = 3 }.to raise_error(NoMethodError)
23
23
 
24
- expect { Like.new(person: 1, item: 2).item }.to raise_error(NoMethodError)
25
- expect { Like.new(person: 1, item: 2).item = 3 }.to raise_error(NoMethodError)
24
+ expect { Like.new(:person => 1, :item => 2).item }.to raise_error(NoMethodError)
25
+ expect { Like.new(:person => 1, :item => 2).item = 3 }.to raise_error(NoMethodError)
26
26
  end
27
27
  end
28
28
 
29
29
  describe "equality" do
30
30
  it "is equal to another like with same person and item" do
31
- expect(Like.new(person: 1, item: 2)).to eq(Like.new(person: 1, item: 2))
31
+ expect(Like.new(:person => 1, :item => 2)).to eq(Like.new(:person => 1, :item => 2))
32
32
  end
33
33
 
34
34
  it "is not equal to another like with different person and/or item" do
35
- expect(Like.new(person: 1, item: 2)).not_to eq(Like.new(person: 3, item: 2))
36
- expect(Like.new(person: 1, item: 2)).not_to eq(Like.new(person: 1, item: 5))
37
- expect(Like.new(person: 1, item: 2)).not_to eq(Like.new(person: 2, item: 4))
35
+ expect(Like.new(:person => 1, :item => 2)).not_to eq(Like.new(:person => 3, :item => 2))
36
+ expect(Like.new(:person => 1, :item => 2)).not_to eq(Like.new(:person => 1, :item => 5))
37
+ expect(Like.new(:person => 1, :item => 2)).not_to eq(Like.new(:person => 2, :item => 4))
38
38
  end
39
39
 
40
40
  it "is not equal to different objects" do
41
- expect(Like.new(person: 1, item: 2)).not_to eq(nil)
42
- expect(Like.new(person: 1, item: 2)).not_to eq(Object.new)
41
+ expect(Like.new(:person => 1, :item => 2)).not_to eq(nil)
42
+ expect(Like.new(:person => 1, :item => 2)).not_to eq(Object.new)
43
43
  end
44
44
  end
45
45
  end
@@ -1,84 +1,27 @@
1
1
  module Likes
2
2
  RSpec.describe Set do
3
- let(:likeset) {
4
- Likes::Set.new([
5
- Likes::Like.new(person: 1, item: 1),
6
- Likes::Like.new(person: 1, item: 5),
7
- Likes::Like.new(person: 1, item: 4),
8
- Likes::Like.new(person: 1, item: 7),
3
+ let(:engine) { class_double(Engines::Protocol) }
4
+ let(:engine_instance) { instance_double(Engines::Protocol) }
9
5
 
10
- Likes::Like.new(person: 2, item: 1),
11
- Likes::Like.new(person: 2, item: 3),
12
- Likes::Like.new(person: 2, item: 5),
13
- Likes::Like.new(person: 2, item: 4),
6
+ let(:raw_likeset) { Fixtures.small_likeset }
7
+ let(:likeset) { Set.new(raw_likeset, engine) }
14
8
 
15
- Likes::Like.new(person: 3, item: 3),
16
- Likes::Like.new(person: 3, item: 2),
17
- Likes::Like.new(person: 3, item: 5),
18
- Likes::Like.new(person: 3, item: 4),
19
- Likes::Like.new(person: 3, item: 9),
20
- ])
21
- }
9
+ let(:proper_likes_of) { Fixtures.small_likeset_likes_of }
10
+ let(:proper_liked) { Fixtures.small_likeset_liked }
22
11
 
23
12
  describe "#recommendations_for" do
24
- it "returns a list of items" do
25
- expect(likeset.recommendations_for(1)).to be_a(Array)
26
- end
27
-
28
- it "returns right recommendations" do
29
- expect(likeset.recommendations_for(1).sort).to eq([3])
30
- expect(likeset.recommendations_for(2).sort).to eq([2, 7, 9])
31
- expect(likeset.recommendations_for(3).sort).to eq([1])
32
- end
33
-
34
- context "when there is an identic match with other person" do
35
- let(:likeset) {
36
- Likes::Set.new([
37
- Likes::Like.new(person: 1, item: 1),
38
- Likes::Like.new(person: 1, item: 5),
39
- Likes::Like.new(person: 1, item: 4),
40
- Likes::Like.new(person: 1, item: 7),
41
-
42
- Likes::Like.new(person: 2, item: 1),
43
- Likes::Like.new(person: 2, item: 3),
44
- Likes::Like.new(person: 2, item: 5),
45
- Likes::Like.new(person: 2, item: 4),
46
-
47
- Likes::Like.new(person: 4, item: 1),
48
- Likes::Like.new(person: 4, item: 5),
49
- Likes::Like.new(person: 4, item: 4),
50
- Likes::Like.new(person: 4, item: 7),
51
-
52
- Likes::Like.new(person: 3, item: 3),
53
- Likes::Like.new(person: 3, item: 2),
54
- Likes::Like.new(person: 3, item: 5),
55
- Likes::Like.new(person: 3, item: 4),
56
- Likes::Like.new(person: 3, item: 9),
57
- ])
58
- }
59
-
60
- it "ignores identical candidates" do
61
- expect(likeset.recommendations_for(1).sort).to eq([3])
62
- end
63
- end
13
+ let(:person) { double("Person") }
14
+ let(:solution) { double("Solution") }
64
15
 
65
- context "when there is nothing to recommend" do
66
- let(:likeset) {
67
- Likes::Set.new([
68
- Likes::Like.new(person: 1, item: 1),
69
- Likes::Like.new(person: 1, item: 5),
70
- Likes::Like.new(person: 1, item: 4),
71
- Likes::Like.new(person: 1, item: 7),
16
+ it "builds proper likes_of and liked and delegates solution to engine instance" do
17
+ expect(engine).
18
+ to receive(:new).
19
+ with(person, proper_likes_of, proper_liked).
20
+ and_return(engine_instance)
72
21
 
73
- Likes::Like.new(person: 4, item: 1),
74
- Likes::Like.new(person: 4, item: 5),
75
- Likes::Like.new(person: 4, item: 7),
76
- ])
77
- }
22
+ expect(engine_instance).to receive(:solve).and_return(solution)
78
23
 
79
- it "returns an empty solution" do
80
- expect(likeset.recommendations_for(1).sort).to eq([])
81
- end
24
+ expect(likeset.recommendations_for(person)).to eq(solution)
82
25
  end
83
26
  end
84
27
  end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "likes"
2
+ require "./spec/fixtures"
2
3
 
3
4
  RSpec.configure do |config|
4
5
  config.expect_with :rspec do |expectations|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: likes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Fedorov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-02-18 00:00:00.000000000 Z
11
+ date: 2015-02-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -48,15 +48,26 @@ extra_rdoc_files: []
48
48
  files:
49
49
  - ".gitignore"
50
50
  - ".rspec"
51
+ - ".travis.yml"
51
52
  - Gemfile
52
53
  - LICENSE.txt
53
54
  - README.md
54
55
  - Rakefile
55
56
  - lib/likes.rb
57
+ - lib/likes/engines.rb
58
+ - lib/likes/engines/best_intersection_size.rb
59
+ - lib/likes/engines/best_relative_intersection_size.rb
60
+ - lib/likes/engines/fast_jaccard_similarity.rb
61
+ - lib/likes/engines/protocol.rb
56
62
  - lib/likes/like.rb
57
63
  - lib/likes/set.rb
64
+ - lib/likes/support.rb
58
65
  - lib/likes/version.rb
59
66
  - likes.gemspec
67
+ - spec/fixtures.rb
68
+ - spec/likes/engines/best_intersection_size_spec.rb
69
+ - spec/likes/engines/best_relative_intersection_size_spec.rb
70
+ - spec/likes/engines/fast_jaccard_similarity_spec.rb
60
71
  - spec/likes/like_spec.rb
61
72
  - spec/likes/set_spec.rb
62
73
  - spec/spec_helper.rb
@@ -86,6 +97,10 @@ specification_version: 4
86
97
  summary: Give it a list of people and their likings and it will tell what else could
87
98
  these people like.
88
99
  test_files:
100
+ - spec/fixtures.rb
101
+ - spec/likes/engines/best_intersection_size_spec.rb
102
+ - spec/likes/engines/best_relative_intersection_size_spec.rb
103
+ - spec/likes/engines/fast_jaccard_similarity_spec.rb
89
104
  - spec/likes/like_spec.rb
90
105
  - spec/likes/set_spec.rb
91
106
  - spec/spec_helper.rb