tsuga 0.0.1

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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/.rspec +3 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +12 -0
  6. data/Gemfile +16 -0
  7. data/Gemfile.lock +146 -0
  8. data/Guardfile +8 -0
  9. data/LICENSE.txt +22 -0
  10. data/README.md +161 -0
  11. data/Rakefile +1 -0
  12. data/lib/tsuga.rb +11 -0
  13. data/lib/tsuga/adapter.rb +4 -0
  14. data/lib/tsuga/adapter/active_record/base.rb +61 -0
  15. data/lib/tsuga/adapter/active_record/cluster.rb +52 -0
  16. data/lib/tsuga/adapter/active_record/migration.rb +50 -0
  17. data/lib/tsuga/adapter/active_record/record.rb +15 -0
  18. data/lib/tsuga/adapter/active_record/test.rb +73 -0
  19. data/lib/tsuga/adapter/memory/base.rb +146 -0
  20. data/lib/tsuga/adapter/memory/cluster.rb +32 -0
  21. data/lib/tsuga/adapter/memory/test.rb +27 -0
  22. data/lib/tsuga/adapter/mongoid/base.rb +41 -0
  23. data/lib/tsuga/adapter/mongoid/cluster.rb +29 -0
  24. data/lib/tsuga/adapter/mongoid/record.rb +16 -0
  25. data/lib/tsuga/adapter/mongoid/test.rb +77 -0
  26. data/lib/tsuga/adapter/sequel/base.rb +57 -0
  27. data/lib/tsuga/adapter/sequel/cluster.rb +43 -0
  28. data/lib/tsuga/adapter/sequel/record.rb +15 -0
  29. data/lib/tsuga/adapter/sequel/test.rb +73 -0
  30. data/lib/tsuga/adapter/shared.rb +4 -0
  31. data/lib/tsuga/adapter/shared/cluster.rb +19 -0
  32. data/lib/tsuga/errors.rb +3 -0
  33. data/lib/tsuga/model/cluster.rb +147 -0
  34. data/lib/tsuga/model/point.rb +206 -0
  35. data/lib/tsuga/model/record.rb +20 -0
  36. data/lib/tsuga/model/tile.rb +136 -0
  37. data/lib/tsuga/service/aggregator.rb +175 -0
  38. data/lib/tsuga/service/clusterer.rb +260 -0
  39. data/lib/tsuga/service/labeler.rb +20 -0
  40. data/lib/tsuga/version.rb +3 -0
  41. data/script/benchmark-aggregator.rb +72 -0
  42. data/script/benchmark-clusterer.rb +102 -0
  43. data/spec/adapter/memory/base_spec.rb +174 -0
  44. data/spec/adapter/memory/cluster_spec.rb +39 -0
  45. data/spec/adapter/shared/cluster_spec.rb +56 -0
  46. data/spec/integration/active_record_spec.rb +10 -0
  47. data/spec/integration/memory_spec.rb +10 -0
  48. data/spec/integration/mongoid_spec.rb +10 -0
  49. data/spec/integration/sequel_spec.rb +10 -0
  50. data/spec/integration/shared.rb +50 -0
  51. data/spec/model/point_spec.rb +102 -0
  52. data/spec/model/tile_spec.rb +116 -0
  53. data/spec/service/aggregator_spec.rb +143 -0
  54. data/spec/service/clusterer_spec.rb +84 -0
  55. data/spec/spec_helper.rb +26 -0
  56. data/spec/support/mongoid.yml +17 -0
  57. data/tsuga.gemspec +29 -0
  58. metadata +226 -0
@@ -0,0 +1,174 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/adapter/memory/base'
3
+
4
+ describe Tsuga::Adapter::Memory::Base do
5
+ let(:stuff_class) do
6
+ Class.new.class_eval do
7
+ include Tsuga::Adapter::Memory::Base
8
+ attr_accessor :foo
9
+ self
10
+ end
11
+ end
12
+
13
+ let(:stuff) { stuff_class.new }
14
+
15
+ describe '#persist!' do
16
+ it 'returns the record' do
17
+ stuff.persist!.object_id.should == stuff.object_id
18
+ end
19
+
20
+ it 'makes objects retrievable' do
21
+ stuff.persist!
22
+ expect { stuff_class.find_by_id(stuff.id) }.not_to raise_error
23
+ end
24
+
25
+ it 'persists only current state' do
26
+ stuff.foo = 1
27
+ stuff.persist!
28
+ stuff.foo = 2
29
+
30
+ stuff_class.find_by_id(stuff.id).foo.should == 1
31
+ end
32
+ end
33
+
34
+ describe '#destroy' do
35
+ it 'removes records' do
36
+ id = stuff.persist!.id
37
+ stuff.destroy
38
+ expect { stuff_class.find_by_id(id) }.to raise_error
39
+ end
40
+
41
+ it 'clears record id' do
42
+ stuff.persist!.destroy.id.should be_nil
43
+ end
44
+ end
45
+
46
+ describe '.find' do
47
+ it 'retrives record by ID' do
48
+ stuff.persist!
49
+ stuff_class.find_by_id(stuff.id).id.should == stuff.id
50
+ end
51
+
52
+ it 'fails if not persisted' do
53
+ stuff = stuff_class.new
54
+ expect { stuff_class.find_by_id(stuff.id) }.to raise_error(Tsuga::RecordNotFound)
55
+ end
56
+
57
+ it 'fails if ID is unknown' do
58
+ expect { stuff_class.find_by_id(123) }.to raise_error(Tsuga::RecordNotFound)
59
+ end
60
+ end
61
+
62
+ describe '.find_each' do
63
+ it 'yields nothing is no records present' do
64
+ expect { |b| stuff_class.find_each(&b) }.not_to yield_control
65
+ end
66
+
67
+ it 'yields each persisted record' do
68
+ id0 = stuff_class.new.persist!.id
69
+ id1 = stuff_class.new.id
70
+ id2 = stuff_class.new.persist!.id
71
+ expect { |b| stuff_class.find_each(&b) }.to yield_control.twice
72
+ end
73
+
74
+ it 'allows writes while iterating' do
75
+ stuff_class.new.persist!
76
+ expect {
77
+ stuff_class.find_each { |r| stuff_class.new.persist! }
78
+ }.not_to raise_error
79
+ end
80
+ end
81
+
82
+ describe '.delete_all' do
83
+ it 'forgets persisted records' do
84
+ stuff.persist!
85
+ stuff_class.delete_all
86
+ expect { stuff_class.find_by_id(stuff.id) }.to raise_error
87
+ end
88
+ end
89
+
90
+ describe '.collect_ids' do
91
+ it 'returns a set' do
92
+ stuff_class.collect_ids.should be_a_kind_of(Set)
93
+ end
94
+
95
+ it 'returns all ids' do
96
+ stuff_class.new.persist!
97
+ stuff_class.new.persist!
98
+ stuff_class.collect_ids.to_a.should =~ [1,2]
99
+ end
100
+ end
101
+
102
+ describe '.scoped' do
103
+ it 'returns a scope' do
104
+ stuff_class.scoped.should be_a_kind_of(described_class::Scope)
105
+ end
106
+ end
107
+
108
+ describe described_class::Scope do
109
+ subject { stuff_class.scoped(lambda { |record| record.id % 2 == 0 }) }
110
+
111
+ before do
112
+ stuff1 = stuff_class.new.persist! # id 1
113
+ stuff2 = stuff_class.new.persist! # id 2
114
+ end
115
+
116
+ describe '#find' do
117
+ it 'finds selected items' do
118
+ expect { subject.find_by_id(2) }.not_to raise_error
119
+ end
120
+
121
+ it 'filters out unscoped items' do
122
+ expect { subject.find_by_id(1) }.to raise_error
123
+ end
124
+ end
125
+
126
+ describe '#delete_all' do
127
+ it 'deletes selected items' do
128
+ subject.delete_all
129
+ expect { stuff_class.find_by_id(2) }.to raise_error
130
+ end
131
+
132
+ it 'leaves filtered items' do
133
+ subject.delete_all
134
+ expect { stuff_class.find_by_id(1) }.not_to raise_error
135
+ end
136
+ end
137
+
138
+ describe '#find_each' do
139
+ it 'yields selected items' do
140
+ expect { |b| subject.find_each(&b) }.to yield_control.once
141
+ end
142
+ end
143
+
144
+ describe '#collect_ids' do
145
+ it 'returns selected ids' do
146
+ subject.collect_ids.to_a.should == [2]
147
+ end
148
+ end
149
+ end
150
+
151
+ describe 'defining named scopes' do
152
+ before do
153
+ stuff_class.class_eval do
154
+ def self.id_multiple_of(n)
155
+ scoped(lambda { |r| r.id % n == 0 })
156
+ end
157
+ end
158
+
159
+ 10.times { stuff_class.new.persist! }
160
+ end
161
+
162
+ it 'works' do
163
+ expect { |b|
164
+ stuff_class.id_multiple_of(3).find_each(&b)
165
+ }.to yield_control.exactly(3).times
166
+ end
167
+
168
+ it 'is chainable' do
169
+ expect { |b|
170
+ stuff_class.id_multiple_of(3).id_multiple_of(2).find_each(&b)
171
+ }.to yield_control.exactly(1).times
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,39 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/adapter/memory/cluster'
3
+
4
+ describe Tsuga::Adapter::Memory::Cluster do
5
+ let(:concretion_class) do
6
+ Class.new.class_eval do
7
+ include Tsuga::Adapter::Memory::Cluster
8
+ self
9
+ end
10
+ end
11
+
12
+ let(:concretion) { concretion_class.new }
13
+
14
+ describe '#persist!' do
15
+ it 'returns the record' do
16
+ concretion.persist!.object_id.should == concretion.object_id
17
+ end
18
+
19
+ it 'makes objects retrievable' do
20
+ concretion.persist!
21
+ expect { concretion_class.find_by_id(concretion.id) }.not_to raise_error
22
+ end
23
+ end
24
+
25
+ describe '#destroy' do
26
+ it 'removes records' do
27
+ id = concretion.persist!.id
28
+ concretion.destroy
29
+ expect { concretion_class.find_by_id(id) }.to raise_error
30
+ end
31
+ end
32
+
33
+ describe '.find' do
34
+ it 'retrives record by ID' do
35
+ concretion.persist!
36
+ concretion_class.find_by_id(concretion.id).id.should == concretion.id
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/adapter/shared/cluster'
3
+ require 'tsuga/adapter/memory/cluster'
4
+
5
+ describe Tsuga::Adapter::Shared::Cluster do
6
+ let(:concretion_class) do
7
+ Class.new.class_eval do
8
+ include Tsuga::Adapter::Memory::Cluster
9
+ self
10
+ end
11
+ end
12
+
13
+ let(:concretion) { concretion_class.new }
14
+
15
+ describe '#children' do
16
+ let(:records) { (1..5).map { concretion_class.new.persist! } }
17
+
18
+ it 'retrieves child records' do
19
+ concretion.children_type = concretion.class.name
20
+ concretion.children_ids = records.map(&:id)
21
+ concretion.children.map(&:id).should == records.map(&:id)
22
+ end
23
+
24
+ it 'works with no children' do
25
+ concretion.children_type = concretion.class.name
26
+ concretion.children_ids = []
27
+ concretion.children.should == []
28
+ end
29
+ end
30
+
31
+ describe '#leaves' do
32
+ # setup: r1 -> r2 -> r3 ; r4 isolated
33
+ before do
34
+ @r1 = concretion_class.new.persist!
35
+ @r2 = concretion_class.new.persist!
36
+ @r3 = concretion_class.new.persist!
37
+ @r4 = concretion_class.new.persist!
38
+
39
+ @r2.children_type = concretion_class.name
40
+ @r2.children_ids = [@r1.id]
41
+ @r2.persist!
42
+
43
+ @r3.children_type = concretion_class.name
44
+ @r3.children_ids = [@r2.id]
45
+ @r3.persist!
46
+ end
47
+
48
+ it 'follows branches' do
49
+ @r3.leaves.map(&:id).should == [@r1.id]
50
+ end
51
+
52
+ it 'works with no children' do
53
+ @r4.leaves.map(&:id).should == [@r4.id]
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/adapter/active_record/test'
3
+ require 'integration/shared'
4
+
5
+ describe 'integration' do
6
+ describe 'active_record adapter' do
7
+ let(:adapter) { Tsuga::Adapter::ActiveRecord::Test.clusters }
8
+ it_should_behave_like 'an adapter suitable for clustering'
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/adapter/memory/test'
3
+ require 'integration/shared'
4
+
5
+ describe 'integration' do
6
+ describe 'memory adapter' do
7
+ let(:adapter) { Tsuga::Adapter::Memory::Test.clusters }
8
+ it_should_behave_like 'an adapter suitable for clustering'
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/adapter/mongoid/test'
3
+ require 'integration/shared'
4
+
5
+ describe 'integration' do
6
+ describe 'mongoid adapter' do
7
+ let(:adapter) { Tsuga::Adapter::Mongoid::Test.clusters }
8
+ it_should_behave_like 'an adapter suitable for clustering'
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/adapter/sequel/test'
3
+ require 'integration/shared'
4
+
5
+ describe 'integration' do
6
+ describe 'sequel adapter' do
7
+ let(:adapter) { Tsuga::Adapter::Sequel::Test.clusters }
8
+ it_should_behave_like 'an adapter suitable for clustering'
9
+ end
10
+ end
@@ -0,0 +1,50 @@
1
+ require 'tsuga/service/clusterer'
2
+ require 'tsuga/model/point'
3
+
4
+ shared_examples_for 'an adapter suitable for clustering' do
5
+ let(:clusterer) { Tsuga::Service::Clusterer.new(source: records, adapter: adapter) }
6
+ let(:records) { ArrayWithFindEach.new }
7
+
8
+ def make_record(lat, lng)
9
+ records << OpenStruct.new(lat:lat, lng:lng)
10
+ end
11
+
12
+ before { adapter.delete_all }
13
+
14
+ context 'with random records' do
15
+ let(:count) { 10 }
16
+ before do
17
+ count.times { make_record(rand, rand) }
18
+ clusterer.run
19
+ end
20
+
21
+ let :toplevel_cluster do
22
+ id = adapter.at_depth(3).collect_ids.first
23
+ adapter.find_by_id(id)
24
+ end
25
+
26
+ let :barycenter do
27
+ sum_lat = 0
28
+ sum_lng = 0
29
+ records.each { |r| sum_lat += r.lat ; sum_lng += r.lng }
30
+ Tsuga::Model::Point.new(lat: sum_lat/count, lng: sum_lng/count)
31
+ end
32
+
33
+ it 'toplevel cluster has correct weight' do
34
+ toplevel_cluster.weight.should == count
35
+ end
36
+
37
+ it 'toplevel cluster is centered' do
38
+ toplevel_cluster.lat
39
+ (toplevel_cluster & barycenter).should < 1e-6 # 10 micro degrees ~ 1 meter at equator
40
+ end
41
+
42
+ it 'all depths have same total weight' do
43
+ 3.upto(19) do |depth|
44
+ total_weight = 0
45
+ adapter.at_depth(depth).find_each { |c| total_weight += c.weight }
46
+ total_weight.should == 10
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,102 @@
1
+ require 'spec_helper'
2
+ require 'tsuga/model/point'
3
+
4
+ describe Tsuga::Model::Point do
5
+ describe '#distance_to' do
6
+ let(:p00) { described_class.new(lat:0, lng:0) }
7
+ let(:p01) { described_class.new(lat:0, lng:1) }
8
+ let(:p11) { described_class.new(lat:1, lng:1) }
9
+
10
+ it 'is zero for the same point' do
11
+ p00.distance_to(p00).should be_within(1e-6).of(0)
12
+ end
13
+
14
+ it 'is 1 for points 1 degree apart' do
15
+ p00.distance_to(p01).should be_within(1e-6).of(1)
16
+ end
17
+
18
+ it 'uses euclidian distance' do
19
+ p00.distance_to(p11).should be_within(1e-6).of(Math.sqrt(2))
20
+ end
21
+ end
22
+
23
+ describe '#lat=, #lng=' do
24
+ let(:result) { subject.geohash }
25
+
26
+ it 'converts latitude and longitude to a geohash' do
27
+ subject.lat = -90
28
+ subject.lng = -180
29
+ result.should == '00000000000000000000000000000000'
30
+ end
31
+
32
+ it 'is ok for the highest hash' do
33
+ subject.lat = 90 - 1e-8
34
+ subject.lng = 180 - 1e-8
35
+ result.should == '33333333333333333333333333333333'
36
+ end
37
+
38
+ it 'is ok or equator/greenwhich' do
39
+ subject.lat = 0
40
+ subject.lng = 0
41
+ result.should == '30000000000000000000000000000000'
42
+ end
43
+
44
+ it 'fails when lat/lng missing' do
45
+ subject.lat = nil
46
+ subject.lng = 0
47
+ result.should be_nil
48
+ end
49
+
50
+ it 'fails when lat out of bounds' do
51
+ expect { subject.lat = 90 }.to raise_error(ArgumentError)
52
+ end
53
+
54
+ it 'fails when lng out of bounds' do
55
+ expect { subject.lng = 180 }.to raise_error(ArgumentError)
56
+ end
57
+ end
58
+
59
+ describe '#lat #lng' do
60
+ it 'computes coordinates from hash' do
61
+ subject.geohash = '22222222222222222222222222222222'
62
+ subject.lat.should be_within(1e-6).of(-90)
63
+ subject.lng.should be_within(1e-6).of(180)
64
+ end
65
+
66
+ it 'is ok or equator/greenwhich' do
67
+ subject.geohash = '30000000000000000000000000000000'
68
+ subject.lat.should be_within(1e-6).of(0)
69
+ subject.lng.should be_within(1e-6).of(0)
70
+ end
71
+ end
72
+
73
+ describe '#prefix' do
74
+ it 'returns a prefix of the geohash' do
75
+ subject.geohash = '12332100000000000000000000000000'
76
+ subject.prefix(6).should == '123321'
77
+ end
78
+ end
79
+
80
+ describe '(comparison)' do
81
+ it 'preserves lat-order' do
82
+ described_class.new(lat:45, lng:2).geohash.should <
83
+ described_class.new(lat:46, lng:2).geohash
84
+ end
85
+
86
+ it 'preserves lng-order' do
87
+ described_class.new(lat:45, lng:2).geohash.should <
88
+ described_class.new(lat:45, lng:3).geohash
89
+ end
90
+
91
+ it 'preserves order around greenwich' do
92
+ described_class.new(lat:45, lng:-1).geohash.should <
93
+ described_class.new(lat:45, lng: 1).geohash
94
+ end
95
+
96
+ it 'preserves order around equator' do
97
+ described_class.new(lat:-1, lng:2).geohash.should <
98
+ described_class.new(lat: 1, lng:2).geohash
99
+ end
100
+ end
101
+ end
102
+