likes 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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