tg_geometry 0.1.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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +385 -0
  6. data/Rakefile +129 -0
  7. data/benchmark/_support.rb +115 -0
  8. data/benchmark/batch_packed_vs_loop.rb +27 -0
  9. data/benchmark/falcon_concurrency.rb +25 -0
  10. data/benchmark/flat_vs_rtree.rb +27 -0
  11. data/benchmark/gvl_threshold.rb +41 -0
  12. data/benchmark/objectspace_memsize.rb +17 -0
  13. data/benchmark/parse_throughput.rb +38 -0
  14. data/benchmark/rss_stability.rb +70 -0
  15. data/docs/ACTIVE_RECORD.md +26 -0
  16. data/docs/ARCHITECTURE.md +130 -0
  17. data/docs/AUTO_STRATEGY.md +15 -0
  18. data/docs/BENCHMARKING.md +75 -0
  19. data/docs/CASUAL_EXAMPLE.md +618 -0
  20. data/docs/CONCURRENCY.md +65 -0
  21. data/docs/ERROR_HANDLING.md +55 -0
  22. data/docs/EXPANSION_E_TO_H_STATUS.md +51 -0
  23. data/docs/FORMAT_COVERAGE.md +23 -0
  24. data/docs/FULL_TG_API_COVERAGE.md +109 -0
  25. data/docs/LIMITATIONS.md +61 -0
  26. data/docs/LOW_LEVEL_GEOMETRY.md +121 -0
  27. data/docs/MEMORY_OWNERSHIP.md +94 -0
  28. data/docs/RACTOR.md +40 -0
  29. data/docs/REGISTRY.md +37 -0
  30. data/docs/RELEASE_CHECKLIST.md +39 -0
  31. data/ext/tg_geometry/extconf.rb +91 -0
  32. data/ext/tg_geometry/tg_geometry_ext.c +3054 -0
  33. data/ext/tg_geometry/tg_geometry_vendor_rtree.c +1 -0
  34. data/ext/tg_geometry/tg_geometry_vendor_tg.c +24 -0
  35. data/ext/tg_geometry/vendor/.vendored +16 -0
  36. data/ext/tg_geometry/vendor/rtree/LICENSE +20 -0
  37. data/ext/tg_geometry/vendor/rtree/README.md +202 -0
  38. data/ext/tg_geometry/vendor/rtree/VERSION +3 -0
  39. data/ext/tg_geometry/vendor/rtree/rtree.c +840 -0
  40. data/ext/tg_geometry/vendor/rtree/rtree.h +105 -0
  41. data/ext/tg_geometry/vendor/tg/LICENSE +19 -0
  42. data/ext/tg_geometry/vendor/tg/README.md +197 -0
  43. data/ext/tg_geometry/vendor/tg/VERSION +3 -0
  44. data/ext/tg_geometry/vendor/tg/tg.c +16010 -0
  45. data/ext/tg_geometry/vendor/tg/tg.h +359 -0
  46. data/lib/tg/geometry/active_record_source.rb +57 -0
  47. data/lib/tg/geometry/registry.rb +119 -0
  48. data/lib/tg/geometry/version.rb +7 -0
  49. data/lib/tg/geometry.rb +6 -0
  50. data/lib/tg_geometry.rb +3 -0
  51. data/script/vendor_libs.rb +264 -0
  52. data/spec/block_10_rtree_strategy_spec.rb +82 -0
  53. data/spec/block_11_rtree_order_spec.rb +53 -0
  54. data/spec/block_12_batch_packed_spec.rb +55 -0
  55. data/spec/block_13_error_hardening_spec.rb +65 -0
  56. data/spec/block_14_memory_gc_hardening_spec.rb +116 -0
  57. data/spec/block_1_skeleton_spec.rb +45 -0
  58. data/spec/block_20_concurrency_spec.rb +157 -0
  59. data/spec/block_20_fuzz_spec.rb +145 -0
  60. data/spec/block_2_vendor_spec.rb +79 -0
  61. data/spec/block_3_geom_parse_spec.rb +89 -0
  62. data/spec/block_4_geom_api_spec.rb +90 -0
  63. data/spec/block_5_rect_api_spec.rb +96 -0
  64. data/spec/block_6_index_build_spec.rb +111 -0
  65. data/spec/block_7_index_owned_geometry_spec.rb +143 -0
  66. data/spec/block_8_index_borrowed_geometry_spec.rb +106 -0
  67. data/spec/block_9_flat_query_spec.rb +65 -0
  68. data/spec/expansion_a_auto_strategy_spec.rb +14 -0
  69. data/spec/expansion_b_registry_spec.rb +47 -0
  70. data/spec/expansion_c_active_record_source_spec.rb +42 -0
  71. data/spec/expansion_d_format_coverage_spec.rb +30 -0
  72. data/spec/expansion_e_low_level_geometry_spec.rb +82 -0
  73. data/spec/expansion_i_ractor_spec.rb +25 -0
  74. data/spec/expansion_j_full_tg_api_coverage_spec.rb +114 -0
  75. data/spec/spec_helper.rb +15 -0
  76. metadata +157 -0
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "fileutils"
6
+ require "open3"
7
+ require "optparse"
8
+ require "tmpdir"
9
+
10
+ VENDOR_DIR = File.expand_path("../ext/tg_geometry/vendor", __dir__)
11
+ MANIFEST_PATH = File.join(VENDOR_DIR, ".vendored")
12
+
13
+ PINS = {
14
+ tg: {
15
+ repo: "https://github.com/tidwall/tg.git",
16
+ ref: "main",
17
+ commit: "caf840504eaab4563280cf4ab16d618f69a23720",
18
+ sha256: "f8e0d904055c209b2a23a2200456f9374ab95cadb69e61b5721d7f8e2500e705",
19
+ target: "tg",
20
+ files: %w[tg.c tg.h LICENSE README.md]
21
+ },
22
+ rtree: {
23
+ repo: "https://github.com/tidwall/rtree.c.git",
24
+ ref: "v0.5.3",
25
+ commit: "5717a8a1eb373428ebaae8c1c623f186ec46461f",
26
+ sha256: "4afc86cbd3abe03730206031a5aff5b8b29d37b055fc356052f6f06e1d1f9a61",
27
+ target: "rtree",
28
+ files: %w[rtree.c rtree.h LICENSE README.md]
29
+ }
30
+ }.freeze
31
+
32
+ NORMALIZED_MTIME = Time.utc(2000, 1, 1).freeze
33
+ MANIFEST_HEADER = "# tg_geometry vendor manifest. Do not edit by hand. Regenerate with: ruby script/vendor_libs.rb --sync"
34
+
35
+ options = { mode: :sync }
36
+ OptionParser.new do |opts|
37
+ opts.banner = "Usage: vendor_libs.rb [--verify | --sync] [--force]"
38
+ opts.on("--verify", "Verify existing vendor tree against pinned files, VERSION metadata, and tree SHA256") { options[:mode] = :verify }
39
+ opts.on("--sync", "Rebuild the vendor tree only when pinned content is missing or changed") { options[:mode] = :sync }
40
+ opts.on("--force", "Force a fresh clone and rebuild even when the vendor tree is current") { options[:force] = true }
41
+ end.parse!
42
+
43
+ def sh!(cmd)
44
+ system(*cmd) || abort("command failed: #{cmd.join(" ")}")
45
+ end
46
+
47
+ def capture!(*cmd)
48
+ out, status = Open3.capture2(*cmd)
49
+ abort("command failed: #{cmd.join(" ")}") unless status.success?
50
+
51
+ out.strip
52
+ end
53
+
54
+ def vendor_candidate?(path)
55
+ return false if File.symlink?(path)
56
+ return false unless File.file?(path)
57
+ return false if path.split(File::SEPARATOR).any? { |seg| seg.start_with?(".") && seg != "." && seg != ".." }
58
+
59
+ true
60
+ end
61
+
62
+ def normalize_tree!(directory)
63
+ Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH).each do |path|
64
+ base = File.basename(path)
65
+ next if base == "." || base == ".."
66
+ next if File.symlink?(path)
67
+
68
+ if File.file?(path)
69
+ File.chmod(0o644, path)
70
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, path)
71
+ elsif File.directory?(path)
72
+ File.chmod(0o755, path)
73
+ end
74
+ end
75
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, directory) if File.directory?(directory)
76
+ end
77
+
78
+ def copy_entry!(source_root, target_root, relative)
79
+ src = File.join(source_root, relative)
80
+ abort "missing required upstream file: #{src}" unless File.file?(src)
81
+ abort "refusing to vendor symlink: #{src}" if File.symlink?(src)
82
+
83
+ dest = File.join(target_root, relative)
84
+ FileUtils.mkdir_p(File.dirname(dest))
85
+ FileUtils.cp(src, dest, preserve: false)
86
+ end
87
+
88
+ def write_version!(target, pin)
89
+ version = [
90
+ "repo=#{pin.fetch(:repo)}",
91
+ "ref=#{pin.fetch(:ref)}",
92
+ "commit=#{pin.fetch(:commit)}"
93
+ ].join("\n") + "\n"
94
+ File.write(File.join(target, "VERSION"), version)
95
+ end
96
+
97
+ def tree_sha256_for(directory)
98
+ entries = Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
99
+ .reject { |path| File.directory?(path) || File.symlink?(path) || %w[. ..].include?(File.basename(path)) }
100
+ .sort
101
+
102
+ digest = Digest::SHA256.new
103
+ entries.each do |path|
104
+ relative = path.sub(/\A#{Regexp.escape(directory)}\/?/, "")
105
+ digest << relative << "\0"
106
+ digest << File.binread(path)
107
+ digest << "\0"
108
+ end
109
+ digest.hexdigest
110
+ end
111
+
112
+ def vendor_one!(name, pin)
113
+ target = File.join(VENDOR_DIR, pin.fetch(:target))
114
+ FileUtils.rm_rf(target)
115
+ FileUtils.mkdir_p(target)
116
+
117
+ Dir.mktmpdir("tg-geometry-#{name}-") do |tmpdir|
118
+ clone_dir = File.join(tmpdir, pin.fetch(:target))
119
+ sh!(["git", "clone", "--filter=blob:none", pin.fetch(:repo), clone_dir])
120
+ sh!(["git", "-C", clone_dir, "fetch", "--depth", "1", "origin", pin.fetch(:commit)])
121
+ sh!(["git", "-C", clone_dir, "checkout", "--detach", pin.fetch(:commit)])
122
+
123
+ actual_commit = capture!("git", "-C", clone_dir, "rev-parse", "HEAD")
124
+ abort "commit mismatch for #{name}: expected #{pin.fetch(:commit)}, got #{actual_commit}" unless actual_commit == pin.fetch(:commit)
125
+
126
+ pin.fetch(:files).each { |relative| copy_entry!(clone_dir, target, relative) }
127
+ end
128
+
129
+ write_version!(target, pin)
130
+ normalize_tree!(target)
131
+
132
+ pin.merge(tree_sha256: tree_sha256_for(target))
133
+ end
134
+
135
+ def manifest_body_lines(results)
136
+ lines = ["gem=tg_geometry", "libraries=#{PINS.keys.join(",")}"]
137
+ results.each do |name, data|
138
+ prefix = name.to_s
139
+ lines << "#{prefix}_repo=#{data.fetch(:repo)}"
140
+ lines << "#{prefix}_ref=#{data.fetch(:ref)}"
141
+ lines << "#{prefix}_commit=#{data.fetch(:commit)}"
142
+ lines << "#{prefix}_target=#{data.fetch(:target)}"
143
+ lines << "#{prefix}_files=#{(data.fetch(:files) + ["VERSION"]).join(",")}"
144
+ lines << "#{prefix}_tree_sha256=#{data.fetch(:tree_sha256)}"
145
+ end
146
+ lines
147
+ end
148
+
149
+ def manifest_signature(body_lines)
150
+ Digest::SHA256.hexdigest(body_lines.join("\n") + "\n")
151
+ end
152
+
153
+ def write_manifest(results)
154
+ body = manifest_body_lines(results)
155
+ sig = manifest_signature(body)
156
+ content = ([MANIFEST_HEADER] + body + ["manifest_sha256=#{sig}"]).join("\n") + "\n"
157
+ FileUtils.mkdir_p(File.dirname(MANIFEST_PATH))
158
+ File.write(MANIFEST_PATH, content)
159
+ File.chmod(0o644, MANIFEST_PATH)
160
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, MANIFEST_PATH)
161
+ end
162
+
163
+ def expected_vendor_results
164
+ PINS.each_with_object({}) do |(name, pin), results|
165
+ results[name] = pin.merge(tree_sha256: pin.fetch(:sha256))
166
+ end
167
+ end
168
+
169
+ def print_results(prefix, results)
170
+ puts prefix
171
+ results.each do |name, data|
172
+ puts " #{name}: commit=#{data.fetch(:commit)} tree_sha256=#{data.fetch(:tree_sha256)}"
173
+ end
174
+ end
175
+
176
+ def parse_kv_file(path)
177
+ return {} unless File.file?(path)
178
+
179
+ File.readlines(path, chomp: true).each_with_object({}) do |line, hash|
180
+ next if line.empty? || line.start_with?("#")
181
+
182
+ key, value = line.split("=", 2)
183
+ hash[key] = value if key && value
184
+ end
185
+ end
186
+
187
+ def verify_vendor_tree
188
+ failures = []
189
+
190
+ PINS.each do |name, pin|
191
+ target = File.join(VENDOR_DIR, pin.fetch(:target))
192
+ unless Dir.exist?(target)
193
+ failures << "#{name}: vendor directory missing (#{target})"
194
+ next
195
+ end
196
+
197
+ (pin.fetch(:files) + ["VERSION"]).each do |relative|
198
+ path = File.join(target, relative)
199
+ failures << "#{name}: missing vendored file #{relative}" unless File.file?(path)
200
+ end
201
+
202
+ version = parse_kv_file(File.join(target, "VERSION"))
203
+ failures << "#{name}: VERSION repo mismatch" unless version["repo"] == pin.fetch(:repo)
204
+ failures << "#{name}: VERSION ref mismatch" unless version["ref"] == pin.fetch(:ref)
205
+ failures << "#{name}: VERSION commit mismatch" unless version["commit"] == pin.fetch(:commit)
206
+
207
+ next unless failures.none? { |failure| failure.start_with?("#{name}:") }
208
+
209
+ actual_sha256 = tree_sha256_for(target)
210
+ expected_sha256 = pin.fetch(:sha256)
211
+ failures << "#{name}: tree_sha256 mismatch: expected #{expected_sha256}, got #{actual_sha256}" unless actual_sha256 == expected_sha256
212
+ end
213
+
214
+ expected_results = expected_vendor_results
215
+ manifest = parse_kv_file(MANIFEST_PATH)
216
+ if manifest.empty?
217
+ failures << "manifest: missing #{MANIFEST_PATH}"
218
+ else
219
+ expected_body = manifest_body_lines(expected_results)
220
+ expected_signature = manifest_signature(expected_body)
221
+
222
+ expected_body.each do |line|
223
+ key, expected_value = line.split("=", 2)
224
+ failures << "manifest: #{key} mismatch" unless manifest[key] == expected_value
225
+ end
226
+
227
+ failures << "manifest: manifest_sha256 mismatch" unless manifest["manifest_sha256"] == expected_signature
228
+ end
229
+
230
+ failures
231
+ end
232
+
233
+ case options.fetch(:mode)
234
+ when :verify
235
+ failures = verify_vendor_tree
236
+ if failures.empty?
237
+ print_results("vendor verify: ok", expected_vendor_results)
238
+ exit 0
239
+ end
240
+
241
+ failures.each { |failure| warn failure }
242
+ exit 1
243
+ when :sync
244
+ unless options[:force]
245
+ failures = verify_vendor_tree
246
+ if failures.empty?
247
+ print_results("vendor sync: up to date", expected_vendor_results)
248
+ exit 0
249
+ end
250
+ end
251
+
252
+ FileUtils.rm_rf(VENDOR_DIR)
253
+ FileUtils.mkdir_p(VENDOR_DIR)
254
+
255
+ results = {}
256
+ PINS.each do |name, pin|
257
+ results[name] = vendor_one!(name, pin)
258
+ end
259
+
260
+ write_manifest(results)
261
+ print_results("vendor sync: ok", results)
262
+ else
263
+ abort "unknown mode: #{options.fetch(:mode)}"
264
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 10 exact rtree accounting" do
6
+ let(:zone_a) { TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") }
7
+ let(:zone_b) { TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))") }
8
+
9
+ it "builds an rtree index and reports rtree native bytes in debug builds" do
10
+ index = TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :rtree)
11
+
12
+ expect(index.strategy).to eq(:rtree)
13
+ if index.respond_to?(:_rtree_bytes_for_test)
14
+ expect(index._rtree_bytes_for_test).to be > 0
15
+ end
16
+ end
17
+
18
+ it "does not build an rtree for empty indexes" do
19
+ index = TG::Geometry::Index.build([], via: :geojson, strategy: :rtree)
20
+
21
+ expect(index.strategy).to eq(:rtree)
22
+ expect(index.size).to eq(0)
23
+ expect(index._rtree_bytes_for_test).to eq(0) if index.respond_to?(:_rtree_bytes_for_test)
24
+ end
25
+
26
+ it "returns rtree bytes to zero after dispose in debug builds" do
27
+ index = TG::Geometry::Index.build([[:a, zone_a]], via: :geom, strategy: :rtree)
28
+
29
+ skip "TG_DEBUG_TEST hooks are not enabled" unless index.respond_to?(:_force_dispose_for_test!)
30
+
31
+ expect(index._rtree_bytes_for_test).to be > 0
32
+ index._force_dispose_for_test!
33
+ index._force_dispose_for_test!
34
+ expect(index._rtree_bytes_for_test).to eq(0)
35
+ expect(index._entries_bytes_for_test).to eq(0)
36
+ expect(index._initialized_entries_for_test).to eq(0)
37
+ end
38
+
39
+ it "cleans up after rtree allocation failure in debug builds" do
40
+ skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_next_rtree_alloc!)
41
+
42
+ TG::Geometry._debug_reset_test_hooks!
43
+ TG::Geometry._debug_fail_next_rtree_alloc!
44
+
45
+ expect do
46
+ TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :rtree)
47
+ end.to raise_error(NoMemoryError)
48
+ ensure
49
+ TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
50
+ end
51
+
52
+ it "cleans up after rtree insert allocation failure in debug builds" do
53
+ skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_rtree_alloc_after!)
54
+
55
+ TG::Geometry._debug_reset_test_hooks!
56
+ TG::Geometry._debug_fail_rtree_alloc_after!(1)
57
+
58
+ expect do
59
+ TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :rtree)
60
+ end.to raise_error(NoMemoryError)
61
+ ensure
62
+ TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
63
+ end
64
+
65
+ it "allows two threads to build separate rtree indexes without cross-accounting" do
66
+ indexes = Queue.new
67
+
68
+ threads = 2.times.map do |i|
69
+ Thread.new do
70
+ geom = TG::Geometry.parse_wkt("POLYGON ((#{i * 20} 0, #{i * 20 + 10} 0, #{i * 20 + 10} 10, #{i * 20} 10, #{i * 20} 0))")
71
+ indexes << TG::Geometry::Index.build([[i, geom]], via: :geom, strategy: :rtree)
72
+ end
73
+ end
74
+ threads.each(&:join)
75
+
76
+ built = 2.times.map { indexes.pop }
77
+ expect(built.map(&:size)).to eq([1, 1])
78
+ if built.all? { |idx| idx.respond_to?(:_rtree_bytes_for_test) }
79
+ expect(built.map(&:_rtree_bytes_for_test)).to all(be > 0)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 11 rtree ordered results" do
6
+ let(:entries) do
7
+ [
8
+ [:first, TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")],
9
+ [:second, TG::Geometry.parse_wkt("POLYGON ((2 2, 12 2, 12 12, 2 12, 2 2))")],
10
+ [:third, TG::Geometry.parse_wkt("POLYGON ((4 4, 14 4, 14 14, 4 14, 4 4))")]
11
+ ]
12
+ end
13
+
14
+ it "matches flat result order for point queries" do
15
+ flat = TG::Geometry::Index.build(entries, via: :geom, strategy: :flat)
16
+ rtree = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
17
+
18
+ expect(rtree.find_covering(5, 5)).to eq(flat.find_covering(5, 5))
19
+ expect(rtree.covering_ids(5, 5)).to eq(flat.covering_ids(5, 5))
20
+ expect(rtree.covering_ids(100, 100)).to eq([])
21
+ end
22
+
23
+ it "matches flat result order for rect queries" do
24
+ flat = TG::Geometry::Index.build(entries, via: :geom, strategy: :flat)
25
+ rtree = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
26
+
27
+ expect(rtree.intersecting_rect(3, 3, 6, 6)).to eq(flat.intersecting_rect(3, 3, 6, 6))
28
+ expect(rtree.intersecting_rect(100, 100, 101, 101)).to eq([])
29
+ end
30
+
31
+ it "handles many overlapping matches deterministically" do
32
+ many = 50.times.map do |i|
33
+ [i, TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")]
34
+ end
35
+
36
+ index = TG::Geometry::Index.build(many, via: :geom, strategy: :rtree)
37
+
38
+ expect(index.find_covering(5, 5)).to eq(0)
39
+ expect(index.covering_ids(5, 5)).to eq((0...50).to_a)
40
+ end
41
+
42
+ it "handles match buffer allocation failure in debug builds" do
43
+ skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_next_match_buffer_alloc!)
44
+
45
+ index = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
46
+ TG::Geometry._debug_reset_test_hooks!
47
+ TG::Geometry._debug_fail_next_match_buffer_alloc!
48
+
49
+ expect { index.covering_ids(5, 5) }.to raise_error(NoMemoryError)
50
+ ensure
51
+ TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 12 packed batch API" do
6
+ let(:zone_a) { TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") }
7
+ let(:zone_b) { TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))") }
8
+
9
+ def pack_points(points)
10
+ points.flatten.pack("d*")
11
+ end
12
+
13
+ it "returns one result per input point" do
14
+ index = TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]], via: :geom, strategy: :flat)
15
+
16
+ packed = pack_points([[1.0, 1.0], [25.0, 25.0], [100.0, 100.0]])
17
+ expect(index.covering_ids_batch_packed(packed)).to eq([:a, :b, nil])
18
+ end
19
+
20
+ it "supports empty input" do
21
+ index = TG::Geometry::Index.build([[:a, zone_a]], via: :geom, strategy: :flat)
22
+
23
+ expect(index.covering_ids_batch_packed("".b)).to eq([])
24
+ end
25
+
26
+ it "rejects invalid input type, length, and non-finite values" do
27
+ index = TG::Geometry::Index.build([[:a, zone_a]], via: :geom, strategy: :flat)
28
+
29
+ expect { index.covering_ids_batch_packed(Object.new) }.to raise_error(TypeError)
30
+ expect { index.covering_ids_batch_packed("123".b) }.to raise_error(TG::Geometry::ArgumentError)
31
+ expect { index.covering_ids_batch_packed([Float::NAN, 1.0].pack("d*")) }
32
+ .to raise_error(TG::Geometry::ArgumentError)
33
+ end
34
+
35
+ it "matches scalar find_covering for flat and rtree strategies" do
36
+ points = [[1.0, 1.0], [25.0, 25.0], [100.0, 100.0], [0.0, 5.0]]
37
+
38
+ %i[flat rtree].each do |strategy|
39
+ index = TG::Geometry::Index.build([[:a, zone_a], [:b, zone_b]],
40
+ via: :geom,
41
+ strategy: strategy)
42
+ packed = pack_points(points)
43
+ expected = points.map { |lon, lat| index.find_covering(lon, lat) }
44
+
45
+ expect(index.covering_ids_batch_packed(packed)).to eq(expected)
46
+ end
47
+ end
48
+
49
+ it "returns the same VALUE ids, including mutable ids" do
50
+ id = +"zone-a"
51
+ index = TG::Geometry::Index.build([[id, zone_a]], via: :geom, strategy: :flat)
52
+
53
+ expect(index.covering_ids_batch_packed(pack_points([[1.0, 1.0]])).first).to equal(id)
54
+ end
55
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 13 error hardening" do
6
+ let(:geojson) { '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}' }
7
+ let(:wkb) { TG::Geometry.parse_geojson(geojson).to_wkb }
8
+
9
+ it "raises ParseError for malformed GeoJSON at first, middle, and last positions" do
10
+ bad = "not geojson"
11
+
12
+ [
13
+ [[1, bad], [2, geojson], [3, geojson]],
14
+ [[1, geojson], [2, bad], [3, geojson]],
15
+ [[1, geojson], [2, geojson], [3, bad]]
16
+ ].each do |entries|
17
+ expect { TG::Geometry::Index.build(entries, via: :geojson, strategy: :flat) }
18
+ .to raise_error(TG::Geometry::ParseError)
19
+ end
20
+ end
21
+
22
+ it "raises ParseError for malformed WKB at first, middle, and last positions" do
23
+ bad = "not wkb".b
24
+
25
+ [
26
+ [[1, bad], [2, wkb], [3, wkb]],
27
+ [[1, wkb], [2, bad], [3, wkb]],
28
+ [[1, wkb], [2, wkb], [3, bad]]
29
+ ].each do |entries|
30
+ expect { TG::Geometry::Index.build(entries, via: :wkb, strategy: :flat) }
31
+ .to raise_error(TG::Geometry::ParseError)
32
+ end
33
+ end
34
+
35
+ it "supports debug OOM simulation for entries allocation" do
36
+ skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry.respond_to?(:_debug_fail_next_entries_alloc!)
37
+
38
+ geom = TG::Geometry.parse_geojson(geojson)
39
+ TG::Geometry._debug_reset_test_hooks!
40
+ TG::Geometry._debug_fail_next_entries_alloc!
41
+
42
+ expect do
43
+ TG::Geometry::Index.build([[1, geom]], via: :geom, strategy: :flat)
44
+ end.to raise_error(NoMemoryError)
45
+ ensure
46
+ TG::Geometry._debug_reset_test_hooks! if TG::Geometry.respond_to?(:_debug_reset_test_hooks!)
47
+ end
48
+
49
+ it "keeps dispose idempotent and clears byte counters in debug builds" do
50
+ skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry::Index.method_defined?(:_force_dispose_for_test!)
51
+
52
+ index = TG::Geometry::Index.build([[1, geojson]], via: :geojson, strategy: :rtree)
53
+
54
+ expect(index._entries_bytes_for_test).to be > 0
55
+ expect(index._owned_geom_bytes_for_test).to be > 0
56
+ expect(index._rtree_bytes_for_test).to be > 0
57
+
58
+ index._force_dispose_for_test!
59
+ index._force_dispose_for_test!
60
+
61
+ expect(index._entries_bytes_for_test).to eq(0)
62
+ expect(index._owned_geom_bytes_for_test).to eq(0)
63
+ expect(index._rtree_bytes_for_test).to eq(0)
64
+ end
65
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "objspace"
5
+
6
+ RSpec.describe "Release Core Block 14 memory, GC, and compaction hardening" do
7
+ let(:small_polygon_wkt) { "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" }
8
+ let(:small_polygon_geojson) { '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}' }
9
+ let(:malformed_geojson) { '{"type":"Polygon","coordinates":[' }
10
+
11
+ def compact_if_supported
12
+ GC.start
13
+ GC.compact if GC.respond_to?(:compact)
14
+ end
15
+
16
+ it "survives a parse/free loop under GC.stress" do
17
+ old_stress = GC.stress
18
+ GC.stress = true
19
+
20
+ 50.times do
21
+ geom = TG::Geometry.parse_wkt(small_polygon_wkt)
22
+ expect(geom).to be_frozen
23
+ expect(geom.covers_xy?(5.0, 5.0)).to be(true)
24
+ end
25
+ ensure
26
+ GC.stress = old_stress
27
+ end
28
+
29
+ it "survives index build/free loops for owned and borrowed geometry" do
30
+ geom = TG::Geometry.parse_wkt(small_polygon_wkt)
31
+
32
+ 25.times do
33
+ owned = TG::Geometry::Index.build([["owned", small_polygon_geojson]], via: :geojson, strategy: :rtree)
34
+ borrowed = TG::Geometry::Index.build([["borrowed", geom]], via: :geom, strategy: :rtree)
35
+
36
+ expect(owned.find_covering(5, 5)).to eq("owned")
37
+ expect(borrowed.find_covering(5, 5)).to eq("borrowed")
38
+ end
39
+
40
+ compact_if_supported
41
+ end
42
+
43
+ it "survives failed build loops without leaving initialized native state observable" do
44
+ 20.times do
45
+ expect do
46
+ TG::Geometry::Index.build([[1, small_polygon_geojson], [2, malformed_geojson]], via: :geojson, strategy: :rtree)
47
+ end.to raise_error(TG::Geometry::ParseError)
48
+ end
49
+
50
+ compact_if_supported
51
+ end
52
+
53
+ it "survives repeated query loops for flat and rtree strategies" do
54
+ entries = [
55
+ [:a, TG::Geometry.parse_wkt(small_polygon_wkt)],
56
+ [:b, TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))")]
57
+ ]
58
+
59
+ %i[flat rtree].each do |strategy|
60
+ index = TG::Geometry::Index.build(entries, via: :geom, strategy: strategy)
61
+
62
+ 250.times do
63
+ expect(index.find_covering(5, 5)).to eq(:a)
64
+ expect(index.covering_ids(25, 25)).to eq([:b])
65
+ expect(index.intersecting_rect(0, 0, 1, 1)).to eq([:a])
66
+ end
67
+ end
68
+ end
69
+
70
+ it "keeps via: :geom borrowed owners alive through GC.compact" do
71
+ geom = TG::Geometry.parse_wkt(small_polygon_wkt)
72
+ index = TG::Geometry::Index.build([["zone", geom]], via: :geom, strategy: :rtree)
73
+
74
+ geom = nil
75
+ compact_if_supported
76
+
77
+ expect(index.find_covering(5, 5)).to eq("zone")
78
+ expect(index.covering_ids(0, 5)).to eq(["zone"])
79
+ end
80
+
81
+ it "keeps via: :geojson owned geometries usable through GC.compact" do
82
+ index = TG::Geometry::Index.build([["zone", small_polygon_geojson]], via: :geojson, strategy: :rtree)
83
+
84
+ compact_if_supported
85
+
86
+ expect(index.find_covering(5, 5)).to eq("zone")
87
+ expect(index.covering_ids(10, 10)).to eq(["zone"])
88
+ end
89
+
90
+ it "reports native memory through ObjectSpace.memsize_of for geom and index" do
91
+ geom = TG::Geometry.parse_wkt(small_polygon_wkt)
92
+ borrowed = TG::Geometry::Index.build([["zone", geom]], via: :geom, strategy: :flat)
93
+ owned = TG::Geometry::Index.build([["zone", small_polygon_geojson]], via: :geojson, strategy: :rtree)
94
+
95
+ expect(ObjectSpace.memsize_of(geom)).to be > ObjectSpace.memsize_of(Object.new)
96
+ expect(ObjectSpace.memsize_of(borrowed)).to be > ObjectSpace.memsize_of(Object.new)
97
+ expect(ObjectSpace.memsize_of(owned)).to be > ObjectSpace.memsize_of(borrowed)
98
+ end
99
+
100
+ it "returns rtree, owned geometry, and entries bytes to zero after dispose in debug builds" do
101
+ skip "TG_DEBUG_TEST hooks are not enabled" unless TG::Geometry::Index.method_defined?(:_force_dispose_for_test!)
102
+
103
+ index = TG::Geometry::Index.build([["zone", small_polygon_geojson]], via: :geojson, strategy: :rtree)
104
+
105
+ expect(index._entries_bytes_for_test).to be > 0
106
+ expect(index._owned_geom_bytes_for_test).to be > 0
107
+ expect(index._rtree_bytes_for_test).to be > 0
108
+
109
+ index._force_dispose_for_test!
110
+
111
+ expect(index._entries_bytes_for_test).to eq(0)
112
+ expect(index._owned_geom_bytes_for_test).to eq(0)
113
+ expect(index._rtree_bytes_for_test).to eq(0)
114
+ expect(index._initialized_entries_for_test).to eq(0)
115
+ end
116
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Release Core Block 1 skeleton" do
6
+ it "compiles the native extension" do
7
+ expect(File.file?(EXT_SO)).to be(true)
8
+ end
9
+
10
+ it "loads through the canonical require path" do
11
+ expect(defined?(TG::Geometry)).to eq("constant")
12
+ expect(TG::Geometry::VERSION).to be_a(String)
13
+ end
14
+
15
+ it "defines the required error hierarchy" do
16
+ expect(TG::Geometry::Error).to be < StandardError
17
+ expect(TG::Geometry::ParseError).to be < TG::Geometry::Error
18
+ expect(TG::Geometry::ArgumentError).to be < ::ArgumentError
19
+ expect(TG::Geometry::FrozenIndexError).to be < TG::Geometry::Error
20
+ end
21
+
22
+ it "defines required public classes under TG::Geometry" do
23
+ expect(TG::Geometry::Geom).to be_a(Class)
24
+ expect(TG::Geometry::Rect).to be_a(Class)
25
+ expect(TG::Geometry::Index).to be_a(Class)
26
+ end
27
+
28
+ it "does not expose forbidden top-level TG API classes or methods" do
29
+ expect(TG).not_to respond_to(:parse)
30
+ expect(TG.const_defined?(:Geom, false)).to be(false)
31
+ expect(TG.const_defined?(:Rect, false)).to be(false)
32
+ expect(TG.const_defined?(:Index, false)).to be(false)
33
+ expect(TG.const_defined?(:Error, false)).to be(false)
34
+ expect(TG.const_defined?(:ParseError, false)).to be(false)
35
+ end
36
+
37
+ it "disables manual allocation for Geom and Index" do
38
+ expect { TG::Geometry::Geom.allocate }.to raise_error(TypeError)
39
+ expect { TG::Geometry::Index.allocate }.to raise_error(TypeError)
40
+ end
41
+
42
+ it "keeps Rect constructible with explicit coordinates" do
43
+ expect(TG::Geometry::Rect.new(0, 0, 0, 0)).to be_a(TG::Geometry::Rect)
44
+ end
45
+ end