tsuga 0.0.1

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