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.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +12 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +146 -0
- data/Guardfile +8 -0
- data/LICENSE.txt +22 -0
- data/README.md +161 -0
- data/Rakefile +1 -0
- data/lib/tsuga.rb +11 -0
- data/lib/tsuga/adapter.rb +4 -0
- data/lib/tsuga/adapter/active_record/base.rb +61 -0
- data/lib/tsuga/adapter/active_record/cluster.rb +52 -0
- data/lib/tsuga/adapter/active_record/migration.rb +50 -0
- data/lib/tsuga/adapter/active_record/record.rb +15 -0
- data/lib/tsuga/adapter/active_record/test.rb +73 -0
- data/lib/tsuga/adapter/memory/base.rb +146 -0
- data/lib/tsuga/adapter/memory/cluster.rb +32 -0
- data/lib/tsuga/adapter/memory/test.rb +27 -0
- data/lib/tsuga/adapter/mongoid/base.rb +41 -0
- data/lib/tsuga/adapter/mongoid/cluster.rb +29 -0
- data/lib/tsuga/adapter/mongoid/record.rb +16 -0
- data/lib/tsuga/adapter/mongoid/test.rb +77 -0
- data/lib/tsuga/adapter/sequel/base.rb +57 -0
- data/lib/tsuga/adapter/sequel/cluster.rb +43 -0
- data/lib/tsuga/adapter/sequel/record.rb +15 -0
- data/lib/tsuga/adapter/sequel/test.rb +73 -0
- data/lib/tsuga/adapter/shared.rb +4 -0
- data/lib/tsuga/adapter/shared/cluster.rb +19 -0
- data/lib/tsuga/errors.rb +3 -0
- data/lib/tsuga/model/cluster.rb +147 -0
- data/lib/tsuga/model/point.rb +206 -0
- data/lib/tsuga/model/record.rb +20 -0
- data/lib/tsuga/model/tile.rb +136 -0
- data/lib/tsuga/service/aggregator.rb +175 -0
- data/lib/tsuga/service/clusterer.rb +260 -0
- data/lib/tsuga/service/labeler.rb +20 -0
- data/lib/tsuga/version.rb +3 -0
- data/script/benchmark-aggregator.rb +72 -0
- data/script/benchmark-clusterer.rb +102 -0
- data/spec/adapter/memory/base_spec.rb +174 -0
- data/spec/adapter/memory/cluster_spec.rb +39 -0
- data/spec/adapter/shared/cluster_spec.rb +56 -0
- data/spec/integration/active_record_spec.rb +10 -0
- data/spec/integration/memory_spec.rb +10 -0
- data/spec/integration/mongoid_spec.rb +10 -0
- data/spec/integration/sequel_spec.rb +10 -0
- data/spec/integration/shared.rb +50 -0
- data/spec/model/point_spec.rb +102 -0
- data/spec/model/tile_spec.rb +116 -0
- data/spec/service/aggregator_spec.rb +143 -0
- data/spec/service/clusterer_spec.rb +84 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/mongoid.yml +17 -0
- data/tsuga.gemspec +29 -0
- 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
|
+
|