tg_geometry 0.1.0 → 0.2.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -79
  3. data/README.md +82 -191
  4. data/Rakefile +3 -3
  5. data/benchmark/falcon_concurrency.rb +1 -1
  6. data/benchmark/feature_source.rb +92 -0
  7. data/docs/ARCHITECTURE.md +29 -107
  8. data/docs/BENCHMARKING.md +20 -1
  9. data/docs/CASUAL_EXAMPLE.md +71 -458
  10. data/docs/CONCURRENCY.md +13 -7
  11. data/docs/ERROR_HANDLING.md +30 -0
  12. data/docs/FEATURE_SOURCE.md +166 -0
  13. data/docs/LIMITATIONS.md +11 -50
  14. data/docs/MEMORY_OWNERSHIP.md +20 -2
  15. data/ext/tg_geometry/extconf.rb +46 -4
  16. data/ext/tg_geometry/tg_geometry_ext.c +2453 -150
  17. data/ext/tg_geometry/tg_geometry_vendor_json.c +17 -0
  18. data/ext/tg_geometry/tg_geometry_vendor_tg.c +3 -0
  19. data/ext/tg_geometry/vendor/.vendored +8 -2
  20. data/ext/tg_geometry/vendor/json/LICENSE +20 -0
  21. data/ext/tg_geometry/vendor/json/VERSION +3 -0
  22. data/ext/tg_geometry/vendor/json/json.c +1024 -0
  23. data/ext/tg_geometry/vendor/json/json.h +207 -0
  24. data/lib/tg/geometry/registry.rb +3 -3
  25. data/lib/tg/geometry/version.rb +1 -1
  26. data/script/vendor_libs.rb +22 -6
  27. data/spec/{expansion_a_auto_strategy_spec.rb → auto_strategy_spec.rb} +1 -1
  28. data/spec/{block_12_batch_packed_spec.rb → batch_packed_spec.rb} +1 -1
  29. data/spec/{block_20_concurrency_spec.rb → concurrency_spec.rb} +1 -1
  30. data/spec/{block_13_error_hardening_spec.rb → error_hardening_spec.rb} +1 -1
  31. data/spec/feature_source_nogvl_spec.rb +51 -0
  32. data/spec/feature_source_spec.rb +268 -0
  33. data/spec/{expansion_d_format_coverage_spec.rb → format_coverage_spec.rb} +1 -1
  34. data/spec/{block_20_fuzz_spec.rb → fuzz_spec.rb} +1 -1
  35. data/spec/{block_4_geom_api_spec.rb → geom_api_spec.rb} +1 -1
  36. data/spec/{block_3_geom_parse_spec.rb → geom_parse_spec.rb} +1 -1
  37. data/spec/{block_8_index_borrowed_geometry_spec.rb → index_borrowed_geometry_spec.rb} +1 -1
  38. data/spec/{block_6_index_build_spec.rb → index_build_spec.rb} +2 -2
  39. data/spec/{block_9_flat_query_spec.rb → index_flat_query_spec.rb} +1 -1
  40. data/spec/{block_7_index_owned_geometry_spec.rb → index_owned_geometry_spec.rb} +1 -1
  41. data/spec/{block_10_rtree_strategy_spec.rb → index_rtree_accounting_spec.rb} +1 -1
  42. data/spec/{block_11_rtree_order_spec.rb → index_rtree_order_spec.rb} +1 -1
  43. data/spec/{block_1_skeleton_spec.rb → load_and_errors_spec.rb} +1 -1
  44. data/spec/{expansion_e_low_level_geometry_spec.rb → low_level_geometry_spec.rb} +1 -1
  45. data/spec/{block_14_memory_gc_hardening_spec.rb → memory_gc_spec.rb} +1 -1
  46. data/spec/{expansion_i_ractor_spec.rb → ractor_spec.rb} +1 -1
  47. data/spec/{block_5_rect_api_spec.rb → rect_api_spec.rb} +1 -1
  48. data/spec/{expansion_b_registry_spec.rb → registry_spec.rb} +1 -1
  49. data/spec/{expansion_j_full_tg_api_coverage_spec.rb → tg_api_coverage_spec.rb} +1 -1
  50. data/spec/{block_2_vendor_spec.rb → vendor_sources_spec.rb} +4 -4
  51. metadata +39 -38
  52. data/docs/ACTIVE_RECORD.md +0 -26
  53. data/docs/AUTO_STRATEGY.md +0 -15
  54. data/docs/EXPANSION_E_TO_H_STATUS.md +0 -51
  55. data/docs/FORMAT_COVERAGE.md +0 -23
  56. data/docs/FULL_TG_API_COVERAGE.md +0 -109
  57. data/docs/LOW_LEVEL_GEOMETRY.md +0 -121
  58. data/docs/RACTOR.md +0 -40
  59. data/docs/REGISTRY.md +0 -37
  60. data/docs/RELEASE_CHECKLIST.md +0 -39
  61. /data/spec/{expansion_c_active_record_source_spec.rb → active_record_source_spec.rb} +0 -0
@@ -1,24 +1,10 @@
1
- # Casual Example: Rails + ActiveRecord + real OSM polygons
1
+ # Casual Example: Rails + ActiveRecord + GeoJSON FeatureSource
2
2
 
3
- This example shows a simple practical integration of `tg_geometry` in a Rails app.
3
+ This example shows a small Rails integration for point-in-polygon lookups with real GeoJSON data.
4
4
 
5
- Goal:
5
+ The recommended import path uses `TG::Geometry::FeatureSource` so the application does not parse the entire FeatureCollection into a Ruby Hash tree just to extract geometries.
6
6
 
7
- - store real polygon zones in a `zones` table;
8
- - import them from OpenStreetMap / Overpass Turbo as GeoJSON;
9
- - build a `TG::Geometry::Index` through a small registry;
10
- - check which zone covers a given `(lon, lat)` point;
11
- - expose that check through a Rails controller.
12
-
13
- This is intentionally casual and application-oriented. It is not a full GIS setup and does not require PostGIS.
14
-
15
- ---
16
-
17
- ## 1. Create the `zones` table
18
-
19
- For the first practical integration, store polygon geometry as raw GeoJSON text.
20
-
21
- Generate a migration and use this schema:
7
+ ## 1. Store zones
22
8
 
23
9
  ```ruby
24
10
  class CreateZones < ActiveRecord::Migration[8.1]
@@ -26,68 +12,31 @@ class CreateZones < ActiveRecord::Migration[8.1]
26
12
  create_table :zones do |t|
27
13
  t.string :code, null: false
28
14
  t.string :name
29
-
30
- # Data source: "osm", "manual", "city_open_data", etc.
31
- t.string :source, null: false, default: "manual"
32
-
33
- # For OSM imports: "way/123", "relation/456", etc.
15
+ t.string :source, null: false, default: "osm"
34
16
  t.string :source_uid
35
-
36
- # park, school, hospital, residential, commercial, etc.
37
17
  t.string :kind, null: false, default: "unknown"
38
-
39
- # Raw GeoJSON geometry only: Polygon / MultiPolygon.
40
- # Not Feature, not FeatureCollection.
41
18
  t.text :geojson, null: false
42
-
43
- # Original imported feature properties/tags.
44
19
  t.jsonb :properties, null: false, default: {}
45
-
46
20
  t.boolean :active, null: false, default: true
47
21
  t.integer :priority, null: false, default: 100
48
-
49
22
  t.datetime :imported_at
50
-
51
23
  t.timestamps
52
24
  end
53
25
 
54
26
  add_index :zones, :code, unique: true
55
27
  add_index :zones, [:source, :source_uid], unique: true, where: "source_uid IS NOT NULL"
56
28
  add_index :zones, [:active, :priority, :id]
57
- add_index :zones, :kind
58
29
  end
59
30
  end
60
31
  ```
61
32
 
62
- Run:
63
-
64
- ```bash
65
- bin/rails db:migrate
66
- ```
33
+ `geojson` stores only the raw Geometry object (`Polygon` / `MultiPolygon`), not the whole Feature and not the FeatureCollection.
67
34
 
68
- Why `text`, not `jsonb`, for `geojson`?
69
-
70
- `TG::Geometry::Index.build(..., via: :geojson)` expects a GeoJSON string. Keeping the geometry as text avoids converting `jsonb` back to JSON on every index reload.
71
-
72
- ---
73
-
74
- ## 2. Add the `Zone` model
75
-
76
- The model validates GeoJSON through `TG::Geometry.parse_geojson` and provides a small importer for a GeoJSON FeatureCollection.
35
+ ## 2. Import a FeatureCollection
77
36
 
78
37
  ```ruby
79
- # app/models/zone.rb
80
38
  class Zone < ApplicationRecord
81
- OSM_KIND_KEYS = %w[
82
- amenity
83
- leisure
84
- landuse
85
- tourism
86
- boundary
87
- natural
88
- building
89
- shop
90
- ].freeze
39
+ OSM_KIND_KEYS = %w[amenity leisure landuse tourism boundary natural building shop].freeze
91
40
 
92
41
  KIND_PRIORITY = {
93
42
  "hospital" => 10,
@@ -106,67 +55,54 @@ class Zone < ApplicationRecord
106
55
  "unknown" => 100
107
56
  }.freeze
108
57
 
109
- validates :code, presence: true, uniqueness: true
110
- validates :source, presence: true
111
- validates :kind, presence: true
112
- validates :geojson, presence: true
58
+ validates :code, :source, :kind, :geojson, presence: true
113
59
  validates :source_uid, uniqueness: { scope: :source }, allow_nil: true
114
- validates :priority, numericality: { only_integer: true }
115
-
116
60
  validate :geojson_must_be_parseable_polygon
117
61
 
118
62
  class << self
119
63
  def import_geojson_file!(path, source: "osm", replace: false)
120
- data = JSON.parse(File.read(path))
121
-
122
- unless data["type"] == "FeatureCollection"
123
- raise ArgumentError, "expected GeoJSON FeatureCollection"
124
- end
64
+ result = TG::Geometry::FeatureSource.read_features_file(
65
+ path,
66
+ id: ["properties", "@id"],
67
+ only: [:polygon, :multipolygon],
68
+ report: true,
69
+ on_invalid: :skip,
70
+ on_missing_id: :ordinal
71
+ )
125
72
 
126
73
  imported = 0
127
- skipped = 0
128
74
 
129
75
  transaction do
130
76
  where(source: source).delete_all if replace
131
77
 
132
- data.fetch("features").each_with_index do |feature, index|
133
- geometry = feature["geometry"]
134
- properties = feature["properties"] || {}
135
-
136
- unless geometry && %w[Polygon MultiPolygon].include?(geometry["type"])
137
- skipped += 1
138
- next
139
- end
140
-
141
- source_uid = osm_source_uid(feature, properties, index)
78
+ result[:features].each do |source_uid, geometry_json, properties_json|
79
+ # This is application-level parsing of one feature's properties only.
80
+ # The full FeatureCollection was not parsed through JSON.parse.
81
+ properties = JSON.parse(properties_json || "null") || {}
142
82
  kind = kind_from_properties(properties)
143
- geojson = geometry.to_json
144
-
145
- # Fail fast using the same parser that the index uses later.
146
- TG::Geometry.parse_geojson(geojson)
147
-
148
- zone = find_or_initialize_by(source: source, source_uid: source_uid)
149
83
 
84
+ zone = find_or_initialize_by(source: source, source_uid: source_uid.to_s)
150
85
  zone.assign_attributes(
151
86
  code: code_for(source, source_uid),
152
87
  name: name_from_properties(properties, kind, source_uid),
153
88
  kind: kind,
154
- geojson: geojson,
89
+ geojson: geometry_json,
155
90
  properties: properties,
156
91
  active: true,
157
92
  priority: priority_for(kind),
158
93
  imported_at: Time.current
159
94
  )
160
-
161
95
  zone.save!
162
96
  imported += 1
163
- rescue TG::Geometry::ParseError, ActiveRecord::RecordInvalid, JSON::ParserError => e
164
- skipped += 1
165
- warn "skip feature #{index}: #{e.class}: #{e.message}"
166
97
  end
167
98
  end
168
99
 
169
- { imported: imported, skipped: skipped }
100
+ {
101
+ imported: imported,
102
+ skipped: result[:skipped],
103
+ filtered: result[:filtered],
104
+ errors: result[:errors]
105
+ }
170
106
  end
171
107
 
172
108
  def kind_from_properties(properties)
@@ -174,7 +110,6 @@ class Zone < ApplicationRecord
174
110
  value = properties[key]
175
111
  return value.to_s if value.present?
176
112
  end
177
-
178
113
  "unknown"
179
114
  end
180
115
 
@@ -184,15 +119,6 @@ class Zone < ApplicationRecord
184
119
 
185
120
  private
186
121
 
187
- def osm_source_uid(feature, properties, index)
188
- uid =
189
- properties["@id"] ||
190
- feature["id"] ||
191
- properties["id"]
192
-
193
- uid.present? ? uid.to_s : "feature/#{index}"
194
- end
195
-
196
122
  def code_for(source, source_uid)
197
123
  "#{source}_#{source_uid}".parameterize(separator: "_")
198
124
  end
@@ -209,22 +135,16 @@ class Zone < ApplicationRecord
209
135
 
210
136
  def geojson_must_be_parseable_polygon
211
137
  geom = TG::Geometry.parse_geojson(geojson)
212
-
213
- unless %i[polygon multipolygon].include?(geom.type)
214
- errors.add(:geojson, "must be Polygon or MultiPolygon")
215
- end
138
+ errors.add(:geojson, "must be Polygon or MultiPolygon") unless %i[polygon multipolygon].include?(geom.type)
216
139
  rescue TG::Geometry::ParseError => e
217
140
  errors.add(:geojson, "is invalid: #{e.message}")
218
141
  end
219
142
  end
220
143
  ```
221
144
 
222
- ---
223
-
224
- ## 3. Add a rake task for import
145
+ ## 3. Import task
225
146
 
226
147
  ```ruby
227
- # lib/tasks/zones.rake
228
148
  namespace :zones do
229
149
  desc "Import zones from GeoJSON FeatureCollection"
230
150
  task import_geojson: :environment do
@@ -237,382 +157,75 @@ namespace :zones do
237
157
  replace: replace
238
158
  )
239
159
 
240
- puts "Imported: #{result[:imported]}, skipped: #{result[:skipped]}"
160
+ puts "Imported: #{result[:imported]}, filtered: #{result[:filtered]}, skipped: #{result[:skipped]}"
161
+ result[:errors].first(10).each { |error| warn error.inspect }
241
162
  end
242
163
  end
243
164
  ```
244
165
 
245
- ---
246
-
247
- ## 4. Download real polygons from Overpass Turbo
248
-
249
- For a practical first dataset, use real OpenStreetMap polygons from [Overpass Turbo](https://overpass-turbo.eu/).
250
-
251
- ### 4.1. Paste the query
252
-
253
- Open Overpass Turbo and paste this query:
254
-
255
- ```overpass
256
- [out:json][timeout:90];
257
-
258
- (
259
- way["leisure"="park"](43.12,76.75,43.40,77.20);
260
- relation["leisure"="park"](43.12,76.75,43.40,77.20);
261
-
262
- way["landuse"~"residential|commercial|industrial|retail|grass|forest|cemetery"](43.12,76.75,43.40,77.20);
263
- relation["landuse"~"residential|commercial|industrial|retail|grass|forest|cemetery"](43.12,76.75,43.40,77.20);
264
-
265
- way["amenity"~"school|university|college|kindergarten|hospital|marketplace"](43.12,76.75,43.40,77.20);
266
- relation["amenity"~"school|university|college|kindergarten|hospital|marketplace"](43.12,76.75,43.40,77.20);
267
-
268
- way["tourism"~"zoo|attraction|theme_park"](43.12,76.75,43.40,77.20);
269
- relation["tourism"~"zoo|attraction|theme_park"](43.12,76.75,43.40,77.20);
270
- );
271
-
272
- out tags geom;
273
- ```
274
-
275
- Then click **Run**.
276
-
277
- ![Overpass Turbo query step](./images/casual_example_overpass_query.png)
278
-
279
- ### 4.2. Export as GeoJSON
280
-
281
- After the query finishes, click:
282
-
283
- 1. **Export**
284
- 2. **GeoJSON**
285
- 3. **download**
286
-
287
- Save the file as:
288
-
289
- ```text
290
- db/geo/almaty_zones.geojson
291
- ```
292
-
293
- ![Overpass Turbo export step](./images/casual_example_overpass_export.png)
294
-
295
- ---
296
-
297
- ## 5. Import zones into the database
298
-
299
- Create the directory if needed:
300
-
301
- ```bash
302
- mkdir -p db/geo
303
- ```
304
-
305
- Then run:
166
+ Run:
306
167
 
307
168
  ```bash
308
169
  bundle exec rake zones:import_geojson PATH=db/geo/almaty_zones.geojson REPLACE=true
309
170
  ```
310
171
 
311
- Example result:
312
-
313
- ```text
314
- Imported: 7469, skipped: 1
315
- ```
316
-
317
- ---
318
-
319
- ## 6. Create `ZonesRegistry`
320
-
321
- For real overlapping data, ordering matters. `find_covering` returns the first matching id in index insertion order, so we build entries ordered by `priority ASC, id ASC`.
322
-
323
- ```ruby
324
- # app/services/zones_registry.rb
325
- class ZonesRegistry < TG::Geometry::Registry
326
- source do
327
- Zone
328
- .where(active: true)
329
- .order(:priority, :id)
330
- .pluck(:id, :geojson)
331
- end
332
-
333
- index_options(
334
- via: :geojson,
335
- strategy: :rtree,
336
- predicate: :covers,
337
- geometry_index: :ystripes
338
- )
339
- end
340
- ```
341
-
342
- Notes:
343
-
344
- - `via: :geojson` means the index parses GeoJSON strings.
345
- - `strategy: :rtree` is a good practical default for thousands of zones.
346
- - `predicate: :covers` is usually the right default for geofencing because boundary points count as covered.
347
- - Coordinates are passed as `(lon, lat)`, not `(lat, lon)`.
348
-
349
- ---
350
-
351
- ## 7. Check everything in `rails console`
352
-
353
- Open console:
354
-
355
- ```bash
356
- bin/rails console
357
- ```
358
-
359
- ### 7.1. Basic data checks
360
-
361
- ```ruby
362
- Zone.count
363
- Zone.group(:kind).count
364
- ```
365
-
366
- ### 7.2. Parse one geometry
367
-
368
- ```ruby
369
- zone = Zone.first
370
- geom = TG::Geometry.parse_geojson(zone.geojson)
371
-
372
- geom.type
373
- geom.bbox
374
- geom.frozen?
375
- ```
376
-
377
- Expected:
378
-
379
- - `geom.type` returns `:polygon` or `:multipolygon`;
380
- - `geom.bbox` returns `TG::Geometry::Rect`;
381
- - `geom.frozen?` returns `true`.
382
-
383
- ### 7.3. Build the index
384
-
385
- ```ruby
386
- registry = ZonesRegistry.new
387
- index = registry.reload!
388
-
389
- [index.size, index.strategy, index.predicate, index.frozen?]
390
- ```
391
-
392
- Example:
172
+ ## 4. Build an index from database rows
393
173
 
394
174
  ```ruby
395
- [7469, :rtree, :covers, true]
396
- ```
397
-
398
- ### 7.4. Check a single point
399
-
400
- Important: pass coordinates as `(lon, lat)`.
401
-
402
- ```ruby
403
- lon = 76.945
404
- lat = 43.238
405
-
406
- id = registry.find_covering(lon, lat)
407
- ids = registry.covering_ids(lon, lat)
408
-
409
- [id, ids]
410
- ```
411
-
412
- Inspect matched zones:
413
-
414
- ```ruby
415
- Zone
416
- .where(id: ids)
175
+ entries = Zone
176
+ .where(active: true)
417
177
  .order(:priority, :id)
418
- .pluck(:id, :kind, :name, :priority)
419
- ```
420
-
421
- ### 7.5. Check a rectangle
178
+ .pluck(:id, :geojson)
422
179
 
423
- ```ruby
424
- ids = registry.intersecting_rect(76.90, 43.20, 77.00, 43.30)
425
-
426
- ids.size
427
- ids.first(20)
180
+ index = TG::Geometry::Index.build(
181
+ entries,
182
+ via: :geojson,
183
+ strategy: :rtree,
184
+ predicate: :covers,
185
+ geometry_index: :ystripes
186
+ )
428
187
  ```
429
188
 
430
- If you want to inspect a small sample without a giant SQL `IN (...)` query:
189
+ `find_covering` returns the first matching id in insertion order, so sort rows in the order you want to use for priority.
431
190
 
432
- ```ruby
433
- sample_ids = ids.first(20)
434
- zones_by_id = Zone.where(id: sample_ids).index_by(&:id)
191
+ ## 5. Direct file-to-index path
435
192
 
436
- sample_ids.map do |id|
437
- zone = zones_by_id[id]
438
- [zone.id, zone.kind, zone.name, zone.priority]
439
- end
440
- ```
441
-
442
- ### 7.6. Check the packed batch API
443
-
444
- The packed API expects a flat binary buffer of native-endian doubles:
445
-
446
- ```text
447
- lon1, lat1, lon2, lat2, ...
448
- ```
449
-
450
- Do not call `points.pack("d*")` on nested arrays. Flatten first:
193
+ When you do not need to store features first, build an index directly from a FeatureCollection file:
451
194
 
452
195
  ```ruby
453
- points = [
454
- [76.945, 43.238],
455
- [76.900, 43.250],
456
- [80.000, 50.000]
457
- ]
458
-
459
- packed = points.flatten.pack("d*")
460
-
461
- registry.covering_ids_batch_packed(packed)
196
+ index = TG::Geometry::FeatureSource.build_index_file(
197
+ "db/geo/almaty_zones.geojson",
198
+ id: ["properties", "@id"],
199
+ only: [:polygon, :multipolygon],
200
+ on_missing_id: :ordinal,
201
+ strategy: :rtree,
202
+ predicate: :covers,
203
+ geometry_index: :ystripes
204
+ )
462
205
  ```
463
206
 
464
- You can compare scalar vs batch:
207
+ The direct build path is fail-fast. It does not support report mode or skip mode because it returns a ready `TG::Geometry::Index`, not an import report.
465
208
 
466
- ```ruby
467
- scalar = points.map { |lon, lat| registry.find_covering(lon, lat) }
468
- batch = registry.covering_ids_batch_packed(points.flatten.pack("d*"))
209
+ ## 6. Query points and rectangles
469
210
 
470
- [scalar, batch, scalar == batch]
471
- ```
472
-
473
- Expected final element:
474
-
475
- ```ruby
476
- true
477
- ```
478
-
479
- ---
480
-
481
- ## 8. Add a simple lookup service
482
-
483
- ```ruby
484
- # app/services/zone_lookup.rb
485
- class ZoneLookup
486
- class << self
487
- def registry
488
- @registry ||= ZonesRegistry.new.tap(&:reload!)
489
- end
490
-
491
- def reload!
492
- @registry = ZonesRegistry.new.tap(&:reload!)
493
- end
494
-
495
- def covering_zones(lon, lat)
496
- ids = registry.covering_ids(lon, lat)
497
- zones_by_id = Zone.where(id: ids).index_by(&:id)
498
-
499
- ids.filter_map { |id| zones_by_id[id] }
500
- end
501
-
502
- def first_zone(lon, lat)
503
- id = registry.find_covering(lon, lat)
504
- id && Zone.find_by(id: id)
505
- end
506
- end
507
- end
508
- ```
509
-
510
- ---
511
-
512
- ## 9. Check coordinates in a controller
513
-
514
- A minimal controller example:
211
+ Coordinates are always `(lon, lat)`.
515
212
 
516
213
  ```ruby
517
- # app/controllers/zones_controller.rb
518
- class ZonesController < ApplicationController
519
- def check
520
- lon = Float(params.require(:lon))
521
- lat = Float(params.require(:lat))
522
-
523
- zones = ZoneLookup.covering_zones(lon, lat)
524
-
525
- render json: {
526
- lon: lon,
527
- lat: lat,
528
- matched: zones.any?,
529
- first_zone: serialize_zone(zones.first),
530
- zones: zones.map { |zone| serialize_zone(zone) }
531
- }
532
- rescue KeyError, ActionController::ParameterMissing, ArgumentError
533
- render json: { error: "invalid lon/lat params" }, status: :unprocessable_entity
534
- end
535
-
536
- private
537
-
538
- def serialize_zone(zone)
539
- return nil unless zone
540
-
541
- {
542
- id: zone.id,
543
- code: zone.code,
544
- name: zone.name,
545
- kind: zone.kind,
546
- priority: zone.priority,
547
- source: zone.source,
548
- source_uid: zone.source_uid
549
- }
550
- end
551
- end
214
+ index.find_covering(76.945, 43.238)
215
+ index.covering_ids(76.945, 43.238)
216
+ index.intersecting_rect(76.90, 43.20, 77.00, 43.30)
552
217
  ```
553
218
 
554
- Add a route:
219
+ For batch point checks, pass native-endian packed doubles:
555
220
 
556
221
  ```ruby
557
- # config/routes.rb
558
- Rails.application.routes.draw do
559
- get "/zones/check", to: "zones#check"
560
- end
561
- ```
562
-
563
- Now test it:
564
-
565
- ```bash
566
- curl "http://localhost:3000/zones/check?lon=76.945&lat=43.238"
567
- ```
568
-
569
- Example response:
570
-
571
- ```json
572
- {
573
- "lon": 76.945,
574
- "lat": 43.238,
575
- "matched": true,
576
- "first_zone": {
577
- "id": 7401,
578
- "code": "osm_way_...",
579
- "name": "Айнабулак",
580
- "kind": "cemetery",
581
- "priority": 100,
582
- "source": "osm",
583
- "source_uid": "way/..."
584
- },
585
- "zones": [
586
- {
587
- "id": 7401,
588
- "code": "osm_way_...",
589
- "name": "Айнабулак",
590
- "kind": "cemetery",
591
- "priority": 100,
592
- "source": "osm",
593
- "source_uid": "way/..."
594
- }
595
- ]
596
- }
222
+ points = [[76.945, 43.238], [76.900, 43.250], [80.000, 50.000]]
223
+ packed = points.flatten.pack("d*")
224
+ index.covering_ids_batch_packed(packed)
597
225
  ```
598
226
 
599
- ---
600
-
601
- ## 10. Summary
602
-
603
- This example shows a practical end-to-end setup:
604
-
605
- 1. store polygon zones in PostgreSQL;
606
- 2. import real map polygons from OpenStreetMap / Overpass Turbo;
607
- 3. build a `TG::Geometry::Index`;
608
- 4. query by `(lon, lat)`;
609
- 5. expose the result in a Rails controller.
610
-
611
- This simple setup is enough for many point-in-polygon lookup use cases:
612
-
613
- - no PostGIS required;
614
- - no external geometry service;
615
- - no Redis;
616
- - no background processing required for the first version.
227
+ ## Notes
617
228
 
618
- For production, add your own reload strategy, monitoring, and attribution handling for OSM data.
229
+ - `FeatureSource.read_features_file` reads the full source into memory, but it does not build a Ruby Hash tree for the whole FeatureCollection.
230
+ - `properties_json` is returned as a raw JSON string. Parse it at application level only if you need those attributes.
231
+ - `TG::Geometry` uses planar XY coordinates. It does not perform geocoding, routing, projections, or geodesic distance calculations.
data/docs/CONCURRENCY.md CHANGED
@@ -20,17 +20,25 @@ A reader that already captured `old_index` can finish safely. Later readers can
20
20
 
21
21
  ## GVL policy
22
22
 
23
- The first release does not release the GVL for:
23
+ Most short operations keep the GVL:
24
24
 
25
- - parse;
25
+ - parse helpers;
26
26
  - writers;
27
27
  - `Geom` predicates;
28
28
  - Index point queries;
29
29
  - Index rect queries;
30
30
  - packed batch queries;
31
- - index build/free.
31
+ - rtree build/free and Ruby result materialization.
32
32
 
33
- This is intentional. Incorrect no-GVL code is worse than keeping GVL. Future no-GVL work requires benchmark evidence, input copying rules, and no Ruby C API calls outside the GVL.
33
+ FeatureSource bulk methods are the exception. Their heavy source-processing phase runs without the GVL:
34
+
35
+ - `_file` methods open/read the file in C outside the GVL;
36
+ - JSON validation and `tidwall/json.c` traversal run outside the GVL;
37
+ - geometry validation/parsing with TG runs outside the GVL.
38
+
39
+ That phase uses only C-owned memory. It does not create Ruby objects, call Ruby methods, raise Ruby exceptions, or call `rb_gc_adjust_memory_usage`. Ruby ids/strings/reports and Index ownership transfer happen only after the GVL is reacquired.
40
+
41
+ FeatureSource uses only Ruby VM no-GVL APIs for this phase. On Rubies exposing `RB_NOGVL_OFFLOAD_SAFE`, the heavy function is marked offload-safe for the VM. On older Rubies it uses `rb_thread_call_without_gvl`. The gem does not call `rb_fiber_scheduler_block` / `rb_fiber_scheduler_unblock` and does not run a manual scheduler worker from C. Therefore FeatureSource releases the GVL for other Ruby threads, but explicit Fiber scheduler friendliness is only claimed when the Ruby VM provides the offload-safe no-GVL API.
34
42
 
35
43
  ## Rtree owner thread-local
36
44
 
@@ -52,9 +60,7 @@ Callback rules:
52
60
 
53
61
  ## Ractor
54
62
 
55
- No Ractor support is claimed. Expansion Block I records the current boundary: frozen native wrappers are not treated as Ractor-shareable objects, and tests assert that `Ractor.shareable?` remains false for `TG::Geometry::Geom`, `TG::Geometry::Rect`, and `TG::Geometry::Index`.
56
-
57
- See `docs/RACTOR.md` for the unsupported-boundary notes and the requirements before this status can change.
63
+ No Ractor support is claimed. Frozen native wrappers are not advertised as Ractor-shareable objects. Normal Ruby thread read-only access is the supported concurrency model.
58
64
 
59
65
  ## Falcon / Async
60
66