vehicles 0.9.0 → 0.9.2

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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -7
  3. data/README.md +353 -69
  4. data/data/PROVENANCE-vehicles.md +2 -2
  5. data/data/plates/PROVENANCE.md +2 -2
  6. data/data/plates/_decode/at-districts.yml +2 -2
  7. data/data/plates/_decode/cn-issuing-offices.yml +2 -2
  8. data/data/plates/_decode/cn-provinces.yml +2 -2
  9. data/data/plates/_decode/cz-kraj.yml +1 -1
  10. data/data/plates/_decode/dk-serial-blocks.yml +1 -1
  11. data/data/plates/_decode/es-provinces.yml +1 -1
  12. data/data/plates/_decode/fr-departements.yml +2 -2
  13. data/data/plates/_decode/gb-age-identifiers.yml +1 -1
  14. data/data/plates/_decode/gb-memory-tags.yml +1 -1
  15. data/data/plates/_decode/gr-prefectures.yml +1 -1
  16. data/data/plates/_decode/hr-cities.yml +3 -3
  17. data/data/plates/_decode/ie-counties.yml +3 -3
  18. data/data/plates/_decode/in-state-codes.yml +2 -2
  19. data/data/plates/_decode/it-provinces.yml +3 -3
  20. data/data/plates/_decode/jp-office-marks.yml +2 -2
  21. data/data/plates/_decode/jp-use-marks.yml +2 -2
  22. data/data/plates/_decode/kr-authority-marks.yml +1 -1
  23. data/data/plates/_decode/mx-entity-series.yml +1 -1
  24. data/data/plates/_decode/my-index-marks.yml +4 -4
  25. data/data/plates/_decode/pt-trailer-services.yml +1 -1
  26. data/data/plates/_decode/ro-counties.yml +1 -1
  27. data/data/plates/_decode/se-diplomatic.yml +1 -1
  28. data/data/plates/_decode/si-regions.yml +1 -1
  29. data/data/plates/_decode/ua-regions.yml +1 -1
  30. data/data/plates/_meta/classes.yml +1 -1
  31. data/data/plates/_meta/separators.yml +1 -1
  32. data/data/plates/ad.yml +555 -52
  33. data/data/plates/ar.yml +8 -8
  34. data/data/plates/at.yml +91 -10
  35. data/data/plates/au-act.yml +13 -13
  36. data/data/plates/au-nsw.yml +9 -9
  37. data/data/plates/au-nt.yml +13 -14
  38. data/data/plates/au-qld.yml +7 -7
  39. data/data/plates/au-sa.yml +7 -7
  40. data/data/plates/au-tas.yml +10 -10
  41. data/data/plates/au-vic.yml +6 -6
  42. data/data/plates/au-wa.yml +8 -8
  43. data/data/plates/be.yml +11 -11
  44. data/data/plates/br.yml +14 -14
  45. data/data/plates/ca-ab.yml +10 -10
  46. data/data/plates/ca-bc.yml +6 -6
  47. data/data/plates/ca-mb.yml +4 -4
  48. data/data/plates/ca-nb.yml +9 -9
  49. data/data/plates/ca-nl.yml +11 -11
  50. data/data/plates/ca-ns.yml +12 -12
  51. data/data/plates/ca-nt.yml +7 -7
  52. data/data/plates/ca-nu.yml +7 -7
  53. data/data/plates/ca-on.yml +21 -21
  54. data/data/plates/ca-pe.yml +13 -13
  55. data/data/plates/ca-qc.yml +11 -11
  56. data/data/plates/ca-sk.yml +6 -6
  57. data/data/plates/ca-yt.yml +6 -6
  58. data/data/plates/ch.yml +14 -14
  59. data/data/plates/cn.yml +9 -9
  60. data/data/plates/cy.yml +6 -6
  61. data/data/plates/cz.yml +9 -9
  62. data/data/plates/de.yml +8 -8
  63. data/data/plates/dk.yml +12 -12
  64. data/data/plates/ee.yml +13 -13
  65. data/data/plates/eg.yml +708 -0
  66. data/data/plates/es.yml +8 -8
  67. data/data/plates/fi.yml +14 -14
  68. data/data/plates/fr.yml +12 -12
  69. data/data/plates/gb.yml +16 -16
  70. data/data/plates/gr.yml +14 -14
  71. data/data/plates/hr.yml +15 -15
  72. data/data/plates/hu.yml +16 -16
  73. data/data/plates/ie.yml +7 -7
  74. data/data/plates/in.yml +7 -7
  75. data/data/plates/is.yml +3 -3
  76. data/data/plates/it.yml +21 -21
  77. data/data/plates/jp.yml +121 -121
  78. data/data/plates/kr.yml +6 -6
  79. data/data/plates/li.yml +6 -6
  80. data/data/plates/lt.yml +10 -10
  81. data/data/plates/lu.yml +4 -4
  82. data/data/plates/lv.yml +9 -9
  83. data/data/plates/mc.yml +4 -4
  84. data/data/plates/mt.yml +5 -5
  85. data/data/plates/mx.yml +7 -7
  86. data/data/plates/my.yml +18 -18
  87. data/data/plates/ng.yml +346 -0
  88. data/data/plates/nl.yml +5 -5
  89. data/data/plates/no.yml +7 -7
  90. data/data/plates/nz.yml +18 -18
  91. data/data/plates/pl.yml +4 -4
  92. data/data/plates/pt.yml +13 -5
  93. data/data/plates/ro.yml +14 -14
  94. data/data/plates/se.yml +13 -13
  95. data/data/plates/si.yml +8 -8
  96. data/data/plates/sk.yml +15 -15
  97. data/data/plates/sm.yml +7 -7
  98. data/data/plates/th.yml +10 -10
  99. data/data/plates/tr.yml +432 -0
  100. data/data/plates/ua.yml +9 -9
  101. data/data/plates/us-ak.yml +11 -11
  102. data/data/plates/us-al.yml +12 -12
  103. data/data/plates/us-ar.yml +15 -15
  104. data/data/plates/us-as.yml +10 -10
  105. data/data/plates/us-az.yml +10 -10
  106. data/data/plates/us-ca.yml +13 -13
  107. data/data/plates/us-co.yml +14 -14
  108. data/data/plates/us-ct.yml +24 -24
  109. data/data/plates/us-dc.yml +7 -7
  110. data/data/plates/us-de.yml +10 -10
  111. data/data/plates/us-fl.yml +17 -17
  112. data/data/plates/us-ga.yml +10 -10
  113. data/data/plates/us-gu.yml +11 -11
  114. data/data/plates/us-hi.yml +11 -11
  115. data/data/plates/us-ia.yml +9 -9
  116. data/data/plates/us-id.yml +13 -13
  117. data/data/plates/us-il.yml +11 -11
  118. data/data/plates/us-in.yml +9 -9
  119. data/data/plates/us-ks.yml +8 -8
  120. data/data/plates/us-ky.yml +11 -11
  121. data/data/plates/us-la.yml +11 -11
  122. data/data/plates/us-ma.yml +10 -10
  123. data/data/plates/us-md.yml +7 -7
  124. data/data/plates/us-me.yml +16 -16
  125. data/data/plates/us-mi.yml +8 -8
  126. data/data/plates/us-mn.yml +13 -13
  127. data/data/plates/us-mo.yml +13 -13
  128. data/data/plates/us-mp.yml +12 -12
  129. data/data/plates/us-ms.yml +8 -8
  130. data/data/plates/us-mt.yml +12 -12
  131. data/data/plates/us-nc.yml +12 -12
  132. data/data/plates/us-nd.yml +10 -10
  133. data/data/plates/us-ne.yml +10 -10
  134. data/data/plates/us-nh.yml +8 -8
  135. data/data/plates/us-nj.yml +8 -8
  136. data/data/plates/us-nm.yml +14 -14
  137. data/data/plates/us-nv.yml +18 -18
  138. data/data/plates/us-ny.yml +17 -17
  139. data/data/plates/us-oh.yml +13 -13
  140. data/data/plates/us-ok.yml +12 -12
  141. data/data/plates/us-or.yml +15 -15
  142. data/data/plates/us-pa.yml +14 -14
  143. data/data/plates/us-pr.yml +15 -15
  144. data/data/plates/us-ri.yml +10 -10
  145. data/data/plates/us-sc.yml +7 -7
  146. data/data/plates/us-sd.yml +12 -12
  147. data/data/plates/us-tn.yml +8 -8
  148. data/data/plates/us-tx.yml +17 -17
  149. data/data/plates/us-ut.yml +12 -12
  150. data/data/plates/us-va.yml +14 -14
  151. data/data/plates/us-vi.yml +16 -16
  152. data/data/plates/us-vt.yml +9 -9
  153. data/data/plates/us-wa.yml +18 -18
  154. data/data/plates/us-wi.yml +8 -8
  155. data/data/plates/us-wv.yml +9 -9
  156. data/data/plates/us-wy.yml +10 -10
  157. data/data/plates/va.yml +7 -7
  158. data/data/plates/za.yml +11 -11
  159. data/data/vehicles.json +121081 -62015
  160. data/lib/generators/vehicles/templates/initializer.rb +21 -12
  161. data/lib/generators/vehicles/templates/vehicles_refresh_job.rb +8 -4
  162. data/lib/vehicles/color.rb +8 -1
  163. data/lib/vehicles/colors.rb +14 -10
  164. data/lib/vehicles/configuration.rb +82 -3
  165. data/lib/vehicles/data_source.rb +220 -0
  166. data/lib/vehicles/dataset.rb +38 -6
  167. data/lib/vehicles/dataset_resolution.rb +39 -3
  168. data/lib/vehicles/dataset_slice.rb +90 -0
  169. data/lib/vehicles/hosted_result.rb +94 -0
  170. data/lib/vehicles/http.rb +75 -7
  171. data/lib/vehicles/id_lookup.rb +37 -0
  172. data/lib/vehicles/kind_rule.rb +63 -0
  173. data/lib/vehicles/labels.rb +131 -0
  174. data/lib/vehicles/make.rb +45 -19
  175. data/lib/vehicles/model.rb +66 -19
  176. data/lib/vehicles/model_match.rb +16 -2
  177. data/lib/vehicles/model_matcher.rb +6 -7
  178. data/lib/vehicles/plates/series.rb +87 -8
  179. data/lib/vehicles/plates/template_fill.rb +2 -2
  180. data/lib/vehicles/plates/templates.rb +9 -5
  181. data/lib/vehicles/plates.rb +54 -8
  182. data/lib/vehicles/providers/hosted_provider.rb +145 -32
  183. data/lib/vehicles/providers/local_provider.rb +2 -2
  184. data/lib/vehicles/refresh_result.rb +74 -0
  185. data/lib/vehicles/refresher.rb +190 -54
  186. data/lib/vehicles/release_checks.rb +72 -0
  187. data/lib/vehicles/text_matcher.rb +19 -10
  188. data/lib/vehicles/validators/vehicle_make_validator.rb +18 -1
  189. data/lib/vehicles/validators/vehicle_model_validator.rb +17 -1
  190. data/lib/vehicles/version.rb +1 -1
  191. data/lib/vehicles.rb +119 -67
  192. data/rakelib/data_refresh.rake +1 -1
  193. data/rakelib/plates_refresh.rake +1 -1
  194. data/sig/vehicles.rbs +67 -13
  195. metadata +13 -2
@@ -21,24 +21,33 @@ module Vehicles
21
21
 
22
22
  module_function
23
23
 
24
- def identify(text, kind: nil)
24
+ # `kind:` scopes the NAME rungs and only prefers a variant on the id
25
+ # rungs (an id is explicit: "bus/iveco/daily" with kind: :car is still
26
+ # that bus). `kinds:` is a hard constraint on EVERY rung: a record of
27
+ # another kind never answers, so the result is nil when it is the only
28
+ # answer (RESOLUTION.md §3, lanes B/C 2026-11-10).
29
+ def identify(text, kind: nil, kinds: nil)
25
30
  raw = text.to_s.strip
26
31
  return nil if raw.empty?
27
32
 
28
- hint = ModelMatcher.kind_hint(kind)
33
+ hint = KindRule.via(kind, kinds)
34
+ allowed = KindRule.symbols(kinds)
29
35
  ds = Vehicles.dataset
30
- by_id(ds.models_by_id(raw), :exact, kind, hint + ["exact:#{raw}"]) ||
31
- by_id(ds.models_by_former_id(raw), :former_id, kind, hint + ["former_id:#{raw}"]) ||
32
- by_make(ds, text, kind, hint)
36
+ by_id(scoped(ds.models_by_id(raw), allowed), :exact, kind, hint + ["exact:#{raw}"]) ||
37
+ by_id(scoped(ds.models_by_former_id(raw), allowed), :former_id, kind, hint + ["former_id:#{raw}"]) ||
38
+ by_make(ds, text, KindRule.scope(kind, kinds), hint)
33
39
  end
34
40
 
41
+ def scoped(hits, allowed) = hits.select { |m| KindRule.allows?(allowed, m) }
42
+
35
43
  # Rungs 1–2: ids are explicit, so `kind:` only chooses among kind
36
- # variants of the same record. Two different records → ambiguous.
44
+ # variants of the same record (else the collision rule, KindRule.pick).
45
+ # Two different records → ambiguous.
37
46
  def by_id(hits, rung, kind, via)
38
47
  return nil if hits.empty?
39
48
  return ModelMatcher.ambiguous(hits, rung, via) unless hits.map { |m| record(m) }.uniq.one?
40
49
 
41
- model = (kind && hits.find { |m| m.kind == kind.to_sym }) || hits.find(&:car?) || hits.first
50
+ model = (kind && hits.find { |m| m.kind == kind.to_s.to_sym }) || KindRule.pick(hits)
42
51
  ModelMatcher.build(model, hits, [rung], via)
43
52
  end
44
53
 
@@ -89,14 +98,14 @@ module Vehicles
89
98
  # 2026-07-27; RESOLUTION.md rung 6 says "no OTHER record").
90
99
  def whole_text_alias(dataset, query, kind, hint)
91
100
  hits = dataset.models_by_alias(query)
92
- hits = hits.select { |m| m.kind == kind.to_sym } if kind
101
+ hits = scoped(hits, KindRule.symbols(kind))
93
102
  return nil if hits.empty?
94
- return nil if (dataset.records_named(query) - hits.map { |m| record(m) }).any?
103
+ return nil if (dataset.records_named(query, KindRule.symbols(kind)) - hits.map { |m| record(m) }).any?
95
104
 
96
105
  via = hint + ["alias:#{query}"]
97
106
  return ModelMatcher.ambiguous(hits, :alias, via) unless hits.map { |m| record(m) }.uniq.one?
98
107
 
99
- model = hits.find(&:car?) || hits.first
108
+ model = KindRule.pick(hits)
100
109
  ModelMatcher.build(model, hits, [:alias], [*via[0...-1], "#{via.last}→#{record(model)}"],
101
110
  confidence: WHOLE_TEXT_ALIAS)
102
111
  end
@@ -11,17 +11,34 @@
11
11
  # Pass `allow_other: true` to accept the "Other / not in the list" escape hatch
12
12
  # (see `Vehicles.other?`) — pair it with `Vehicles.make_options(include_other:)`
13
13
  # so the dropdown and the validation agree by construction.
14
+ #
15
+ # Scope it like your picker (0.9.2) so a value the picker never offered fails:
16
+ # validates :make, vehicle_make: { kind: :car, country: :es }
17
+ # `kind:` — the make builds that kind; `country:` — it has a model (of that
18
+ # kind) with evidence in that country. Country evidence is presence, not
19
+ # exhaustive eligibility: opt in only when the picker uses the same scope.
14
20
  class VehicleMakeValidator < ActiveModel::EachValidator
15
21
  def validate_each(record, attribute, value)
16
22
  return if value.blank?
17
23
  return if options[:allow_other] && Vehicles.other?(value)
24
+
18
25
  # Exact-only: a typo must fail validation, not be stored as if it were the
19
26
  # make it resembles (Vehicles.make is typo-tolerant since 0.7.8).
20
- return if Vehicles.make(value, fuzzy: false)
27
+ make = Vehicles.make(value, fuzzy: false)
28
+ return if make && in_scope?(make)
21
29
 
22
30
  record.errors.add(attribute, options[:message] || "is not a recognized vehicle make")
23
31
  rescue StandardError => e
24
32
  Vehicles.logger&.error("[vehicles] vehicle_make validation error: #{e.message}")
25
33
  record.errors.add(attribute, options[:message] || "is not a recognized vehicle make")
26
34
  end
35
+
36
+ private
37
+
38
+ def in_scope?(make)
39
+ kind = options[:kind]
40
+ return false if kind && !make.kinds.include?(kind.to_sym)
41
+
42
+ options[:country].nil? || make.available_in?(options[:country], kind: kind)
43
+ end
27
44
  end
@@ -13,6 +13,12 @@
13
13
  #
14
14
  # Pass `allow_other: true` to accept the "Other / not in the list" escape hatch
15
15
  # (see `Vehicles.other?`) — pair it with `Vehicles.model_options(include_other:)`.
16
+ #
17
+ # Scope it like your picker (0.9.2):
18
+ # validates :model, vehicle_model: { make: :make, kind: :car, country: :es }
19
+ # `kind:` / `kinds:` — only records of those kinds count; `country:` — the
20
+ # record must have evidence in that country (opt-in: presence, not
21
+ # exhaustive eligibility).
16
22
  class VehicleModelValidator < ActiveModel::EachValidator
17
23
  def validate_each(record, attribute, value)
18
24
  return if value.blank?
@@ -22,11 +28,21 @@ class VehicleModelValidator < ActiveModel::EachValidator
22
28
  make_value = record.respond_to?(make_attribute) ? record.public_send(make_attribute) : nil
23
29
  make = Vehicles.make(make_value, fuzzy: false) # exact, same rule as the make validator
24
30
  return if make.nil? # unknown make -> defer to the make validator
25
- return if make.model(value)
31
+ return if in_scope?(make, value)
26
32
 
27
33
  record.errors.add(attribute, options[:message] || "is not a recognized #{make.name} model")
28
34
  rescue StandardError => e
29
35
  Vehicles.logger&.error("[vehicles] vehicle_model validation error: #{e.message}")
30
36
  record.errors.add(attribute, options[:message] || "is not a recognized vehicle model")
31
37
  end
38
+
39
+ private
40
+
41
+ def in_scope?(make, value)
42
+ scope = Vehicles::KindRule.scope(options[:kind], options[:kinds])
43
+ country = options[:country]
44
+ return !make.model(value, kinds: scope).nil? if country.nil?
45
+
46
+ (scope || make.kinds).any? { |k| make.model(value, kind: k)&.available_in?(country) }
47
+ end
32
48
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vehicles
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.2"
5
5
  end
data/lib/vehicles.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "vehicles/version"
4
4
  require_relative "vehicles/configuration"
5
5
  require_relative "vehicles/http"
6
+ require_relative "vehicles/kind_rule"
6
7
  require_relative "vehicles/make"
7
8
  require_relative "vehicles/model"
8
9
  require_relative "vehicles/color"
@@ -12,10 +13,17 @@ require_relative "vehicles/model_matcher"
12
13
  require_relative "vehicles/text_matcher"
13
14
  require_relative "vehicles/dataset_resolution"
14
15
  require_relative "vehicles/dataset"
16
+ require_relative "vehicles/dataset_slice"
17
+ require_relative "vehicles/data_source"
18
+ require_relative "vehicles/refresh_result"
19
+ require_relative "vehicles/release_checks"
15
20
  require_relative "vehicles/refresher"
21
+ require_relative "vehicles/hosted_result"
22
+ require_relative "vehicles/labels"
16
23
  require_relative "vehicles/providers/local_provider"
17
24
  require_relative "vehicles/providers/hosted_provider"
18
25
  require_relative "vehicles/other_option"
26
+ require_relative "vehicles/id_lookup"
19
27
  require_relative "vehicles/plates"
20
28
  require_relative "vehicles/plates/jurisdiction"
21
29
  require_relative "vehicles/plates/series"
@@ -53,7 +61,7 @@ module Vehicles
53
61
  @configuration = Configuration.new
54
62
  @providers = nil
55
63
  @data_path = nil
56
- Dataset.reset!
64
+ reload!
57
65
  end
58
66
 
59
67
  # --- data access ---------------------------------------------------------
@@ -63,35 +71,59 @@ module Vehicles
63
71
  @data_path || DATA_PATH
64
72
  end
65
73
 
66
- attr_writer :data_path, :logger
67
-
68
- # The dataset file actually in effect: an explicit override wins; otherwise a
69
- # refreshed cache (if present and `use_cache`); otherwise the bundled snapshot.
70
- # This is how a refresh reaches the running app — no gem upgrade needed.
71
- def active_data_path
72
- return @data_path if @data_path
73
- return Refresher.cached_path if configuration.use_cache && Refresher.cached?
74
+ attr_writer :logger
74
75
 
75
- DATA_PATH
76
+ # Serve this file instead (tests, a pinned snapshot). Takes effect on the
77
+ # next call; nil goes back to the cache/bundled rules.
78
+ def data_path=(path)
79
+ @data_path = path&.to_s
80
+ reload!
76
81
  end
77
82
 
78
- def dataset
79
- Dataset.load(active_data_path)
80
- end
83
+ # The explicit `data_path=` override, or nil.
84
+ def data_path_override = @data_path
81
85
 
82
- # Pull the latest published dataset into the local cache, so data fixes / new
83
- # makes land WITHOUT a gem upgrade. Returns true/false; never raises. Schedule
84
- # it (e.g. a daily job — `rails g vehicles:install` sets one up).
85
- def refresh!
86
- Refresher.refresh!
87
- end
86
+ # The dataset file actually in effect: an explicit override wins; otherwise
87
+ # a refreshed cache when usable (`use_cache`, a schema this gem reads, not
88
+ # older than the bundled data); otherwise the bundled snapshot. This is how
89
+ # a refresh reaches the running app — no gem upgrade, no restart.
90
+ def active_data_path = DataSource.active_path
91
+
92
+ # The Vehicles::Dataset in effect. Every process re-checks the file every
93
+ # `config.reload_check_interval` seconds and adopts a newer one on its own.
94
+ def dataset = DataSource.dataset
95
+
96
+ # Pull the latest published dataset into `config.cache_path`, so data
97
+ # fixes and new makes land WITHOUT a gem upgrade, safely: manifest first,
98
+ # never a downgrade, never a schema this gem cannot read, never a release
99
+ # that lost a chunk of your data (see Vehicles::Refresher). Never raises;
100
+ # returns a Vehicles::RefreshResult (`ok?`, `updated?`, `status`,
101
+ # `reason`, `message`). Schedule it daily (`rails g vehicles:install`).
102
+ # result = Vehicles.refresh
103
+ # Rails.logger.warn(result.message) unless result.ok?
104
+ def refresh = Refresher.refresh
88
105
 
89
- # Drop the in-memory dataset so the next access reloads from disk (after a
90
- # refresh, a cache clear, or a `data_path=` change).
106
+ # `refresh.ok?` — true when the app has the latest release (just written,
107
+ # or already current). Kept for callers of the pre-0.9.2 boolean API.
108
+ def refresh! = Refresher.refresh!
109
+
110
+ # Drop the in-memory dataset so the next access reloads from disk now
111
+ # (processes also notice a changed file on their own; see `dataset`).
91
112
  def reload!
113
+ DataSource.reset!
92
114
  Dataset.reset!
93
115
  end
94
116
 
117
+ # ActiveSupport::Notifications when present ("refresh.vehicles",
118
+ # "request.vehicles"); a no-op otherwise. Never raises.
119
+ def instrument(name, payload)
120
+ return unless defined?(ActiveSupport::Notifications)
121
+
122
+ ActiveSupport::Notifications.instrument(name, payload)
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
95
127
  # Version of the dataset currently in effect (refreshed cache or bundled),
96
128
  # e.g. "2026.06.0".
97
129
  def data_version
@@ -108,8 +140,9 @@ module Vehicles
108
140
  # Make display names. => ["Abarth", "Alfa Romeo", ...]
109
141
  # Vehicles.makes(kind: :motorcycle, region: :as) # continent filter
110
142
  # Vehicles.makes(include_other: true) # ... + the "Other" escape hatch
111
- def makes(kind: nil, region: nil, include_other: false)
112
- names = dataset.makes(kind: kind, region: region || configuration.region).map(&:name)
143
+ # Vehicles.makes(kind: :car, country: :es) # makes with a car evidenced in Spain
144
+ def makes(kind: nil, region: nil, country: nil, include_other: false)
145
+ names = dataset.makes(kind: kind, region: region || configuration.region, country: country).map(&:name)
113
146
  append_other_name(names, include_other)
114
147
  end
115
148
 
@@ -119,10 +152,15 @@ module Vehicles
119
152
  # With `include_other:`, an unknown make (e.g. the "Other" make itself) yields
120
153
  # just `[other_label]`, so a make→model picker never dead-ends.
121
154
  # `fuzzy:` is forwarded to `make` (typo-tolerant by default since 0.7.8).
122
- def models(make, kind: nil, body_type: nil, region: nil, rarity: nil, max_decile: nil, include_other: false,
123
- fuzzy: true)
155
+ #
156
+ # With `country:` (ISO alpha-2), only models evidenced there, in that
157
+ # country's own popularity order:
158
+ # Vehicles.models("SEAT", kind: :car, country: :es) # => ["Ibiza", "Arona", "León", ...]
159
+ # A name can repeat when the make sells it as several kinds (pass `kind:`).
160
+ def models(make, kind: nil, body_type: nil, region: nil, rarity: nil, max_decile: nil, country: nil,
161
+ include_other: false, fuzzy: true)
124
162
  names = make(make, fuzzy: fuzzy)&.models(kind: kind, body_type: body_type,
125
- region: region || configuration.region,
163
+ region: region || configuration.region, country: country,
126
164
  rarity: rarity, max_decile: max_decile)&.map(&:name) || []
127
165
  append_other_name(names, include_other)
128
166
  end
@@ -159,8 +197,14 @@ module Vehicles
159
197
  # data, not guessing: a spelling claimed by two different models answers
160
198
  # nil. `fuzzy:` governs the MAKE typo tolerance only, as before. Need to
161
199
  # know how a pair matched, or want cleanup/typo tolerance? `match_model`.
162
- def model(make_name, model_name, fuzzy: true, kind: nil)
163
- (found = make(make_name, fuzzy: fuzzy)) && ModelMatcher.exact(found, model_name, kind: kind)
200
+ #
201
+ # Kind collisions (one nameplate as several kinds) follow the one rule
202
+ # every VehiclesDB surface shares (Vehicles::KindRule): "Iveco Daily"
203
+ # is the van. `kind:` picks a kind; `kinds:` allows only some (nil when
204
+ # none of them has that name).
205
+ def model(make_name, model_name, fuzzy: true, kind: nil, kinds: nil)
206
+ (found = make(make_name, fuzzy: fuzzy)) &&
207
+ ModelMatcher.exact(found, model_name, kind: KindRule.scope(kind, kinds))
164
208
  end
165
209
 
166
210
  # Resolve a stored make + model pair and say HOW: a Vehicles::ModelMatch
@@ -176,8 +220,13 @@ module Vehicles
176
220
  # Vehicles.match_model("VW", "Sirocco").rung # => :fuzzy
177
221
  # Vehicles.match_model("Honda", "Honda") # => nil
178
222
  # See Vehicles::ModelMatcher for the rungs and their rules.
179
- def match_model(make_name, model_name, kind: nil)
180
- ModelMatcher.match(make_name, model_name, kind: kind)
223
+ #
224
+ # Safe to write back without asking? `match.confident?` (curated rungs
225
+ # only, never a typo guess, never ambiguous). Scope by kind with `kind:`
226
+ # (one kind) or `kinds:` (a hard list: a record of another kind never
227
+ # answers).
228
+ def match_model(make_name, model_name, kind: nil, kinds: nil)
229
+ ModelMatcher.match(make_name, model_name, kind: kind, kinds: kinds)
181
230
  end
182
231
 
183
232
  # Resolve ONE free-text string ("vw golf", "MERCEDES-BENZ SPRINTER",
@@ -188,9 +237,12 @@ module Vehicles
188
237
  # Nothing resolves without a make except ids and whole-text aliases.
189
238
  # Vehicles.identify("vw tuareg").model # => Volkswagen Touareg (:fuzzy)
190
239
  # Vehicles.identify("golf") # => nil
191
- def identify(text, kind: nil) = TextMatcher.identify(text, kind: kind)
240
+ # `kind:` scopes the name rungs (an explicit id still answers whatever
241
+ # its kind); `kinds:` is a hard filter on every rung, ids included:
242
+ # Vehicles.identify("bus/iveco/daily", kinds: [:car]) # => nil
243
+ def identify(text, kind: nil, kinds: nil) = TextMatcher.identify(text, kind: kind, kinds: kinds)
192
244
 
193
- # License plates (the bundled PRD-PLATES dataset). No argument: every
245
+ # License plates (the bundled plates dataset). No argument: every
194
246
  # jurisdiction. With a code: that jurisdiction (or nil — forgiving).
195
247
  # Vehicles.plates # => [#<Jurisdiction nl …>, …]
196
248
  # Vehicles.plates(:nl) # => the Netherlands entry (33 series)
@@ -223,6 +275,7 @@ module Vehicles
223
275
  # Vehicles.top_models(kind: :car, country: :nl, limit: 10).map(&:name)
224
276
  # Vehicles.top_models(kind: :motorcycle, region: :as, limit: 10)
225
277
  # Vehicles.top_models(kind: :car, by: :mass_decile) # by registration mass (data 2026.10.1+)
278
+ # `limit: nil` returns every match (for a per-country picker, prefer `catalog(country:)`).
226
279
  def top_models(kind: nil, country: nil, region: nil, limit: 20, by: :global_decile)
227
280
  dataset.top_models(kind: kind, country: country, region: region, limit: limit, by: by)
228
281
  end
@@ -238,43 +291,22 @@ module Vehicles
238
291
 
239
292
  # Continent codes present in the dataset. => [:af, :as, :eu, :na, :oc, :sa]
240
293
  def regions
241
- @regions ||= dataset.all_models.flat_map(&:regions).uniq.sort
242
- end
243
-
244
- # --- colors (canonical reference palette) --------------------------------
245
-
246
- # The canonical color palette, frequency-ordered. => [Vehicles::Color, ...]
247
- def colors
248
- Colors::ALL
249
- end
250
-
251
- # [[name, slug], ...] for a Rails `select`. Names are English — localize the
252
- # labels in your app; the slug is the stable value you store.
253
- def color_options
254
- Colors::ALL.map { |c| [c.name, c.slug] }
255
- end
256
-
257
- # Resolve a color by slug or name (forgiving: case, diacritics, synonyms like
258
- # "gray"→grey, "navy"→blue). Returns a Vehicles::Color, or nil.
259
- def color(query)
260
- q = normalize(query)
261
- return nil if q.empty?
262
-
263
- Colors::BY_SLUG[q] || Colors::BY_NAME[q] || Colors::BY_SLUG[Colors::SYNONYMS[q]]
294
+ dataset.regions
264
295
  end
265
296
 
266
297
  # [[label, value], ...] of makes for a Rails `select`.
267
298
  # Vehicles.make_options(include_other: true) # ... + [other_label, "other"]
268
- def make_options(kind: nil, region: nil, include_other: false)
269
- opts = dataset.makes(kind: kind, region: region || configuration.region).map { |m| [m.name, m.slug] }
299
+ def make_options(kind: nil, region: nil, country: nil, include_other: false)
300
+ opts = dataset.makes(kind: kind, region: region || configuration.region, country: country)
301
+ .map { |m| [m.name, m.slug] }
270
302
  append_other_option(opts, include_other)
271
303
  end
272
304
 
273
305
  # [[label, value], ...] of a make's models for a Rails `select`. Unknown => [].
274
306
  # Vehicles.model_options("audi", include_other: true) # ... + [other_label, "other"]
275
- def model_options(make, kind: nil, body_type: nil, include_other: false, fuzzy: true)
307
+ def model_options(make, kind: nil, body_type: nil, country: nil, include_other: false, fuzzy: true)
276
308
  found = make(make, fuzzy: fuzzy)
277
- opts = found ? found.model_options(kind: kind, body_type: body_type) : []
309
+ opts = found ? found.model_options(kind: kind, body_type: body_type, country: country) : []
278
310
  append_other_option(opts, include_other)
279
311
  end
280
312
 
@@ -284,14 +316,27 @@ module Vehicles
284
316
 
285
317
  # A { make => [model names] } map for the given filters — everything you need
286
318
  # to build a dependent make → model picker entirely on the client: embed it
287
- # once (`Vehicles.catalog(kind: :car).to_json`) and switch the model list in a
288
- # few lines of JS. No endpoint, no extra request, instant. The whole car
289
- # catalog is small (tens of KB), so this is the simplest path for most apps.
290
- # Vehicles.catalog(kind: :car) # => { "Audi" => ["A3", "A4", ...], ... }
291
- def catalog(kind: nil, region: nil)
292
- makes(kind: kind, region: region).to_h do |name|
293
- [name, models(name, kind: kind, region: region)]
294
- end
319
+ # once and switch the model list in a few lines of JS (or hand it to the
320
+ # make-model-dropdown widget). No endpoint, no extra request, instant.
321
+ # Makes are alphabetical.
322
+ #
323
+ # Scope it to your market with `country:` (ISO alpha-2): only models with
324
+ # official evidence there, each make's models in that country's popularity
325
+ # order. That is the picker list a one-country app wants — Spain's cars are
326
+ # ~140 makes / ~1,300 models, ~14 KB of JSON — where the whole global car
327
+ # catalog is ~330 makes / ~6,000 models (~63 KB).
328
+ # Vehicles.catalog(kind: :car, country: :es)["SEAT"] # => ["Ibiza", "Arona", "León", ...]
329
+ # Vehicles.catalog(kind: :car) # => every car, worldwide
330
+ # Pass `kind:` for a picker: without it a nameplate sold as a car and a
331
+ # van appears twice.
332
+ # `kinds:` allows several kinds (a car-and-van picker); `[]` is empty.
333
+ def catalog(kind: nil, region: nil, country: nil, kinds: nil)
334
+ scope = KindRule.scope(kind, kinds)
335
+ region ||= configuration.region
336
+ dataset.makes(kind: kind, region: region, country: country).filter_map do |make|
337
+ names = make.models(region: region, country: country).select { |m| KindRule.allows?(scope, m) }.map(&:name)
338
+ [make.name, names.uniq] unless names.empty?
339
+ end.to_h
295
340
  end
296
341
 
297
342
  # --- provider resolution (hosted enrichment, optional) -------------------
@@ -363,6 +408,13 @@ module Vehicles
363
408
  # they become Vehicles singleton methods with access to `configuration` /
364
409
  # `normalize`.
365
410
  extend OtherOption
411
+ # Canonical-id lookups (`by_id`, `successor`, `live?`) and the en/es label
412
+ # helpers (`kind_label`, `kind_options`, `body_type_label`,
413
+ # `body_type_options`), extended the same way.
414
+ extend IdLookup
415
+ extend LabelOptions
416
+ # `Vehicles.dataset_slice` (lib/vehicles/dataset_slice.rb).
417
+ extend DatasetSliceExport
366
418
  end
367
419
 
368
420
  # Loaded after the module so its lookup tables can use Vehicles.normalize.
@@ -14,7 +14,7 @@ require "json"
14
14
  # are copied from the SAME tag's catalog/<kind>/models.json
15
15
  # (popularity.by_country[cc].rank — open CC-BY data), keyed by the full
16
16
  # kind/make/model id, so the merged file is what the pipeline's own projection
17
- # emits once it ships the key (vehiclesdb-pipeline emit.rb write_dist_json!,
17
+ # emits once it ships the key (the VehiclesDB data pipeline,
18
18
  # 2026-10-10). When the projection DOES carry the key, the task asserts every
19
19
  # rank equals the catalog's and aborts on any mismatch. Output formatting is
20
20
  # the pipeline's (JSON.pretty_generate + "\n"); stripping `country_ranks`
@@ -13,7 +13,7 @@ namespace :plates do
13
13
  rm_rf "#{root}/data/plates/_art"
14
14
  commit = `git -C #{ds} rev-parse origin/main`.strip
15
15
  File.write("#{root}/data/plates/PROVENANCE.md", <<~MD)
16
- # Bundled plates dataset (PRD-PLATES, full corpus)
16
+ # Bundled plates dataset (full corpus)
17
17
 
18
18
  Copy of `plates/` from the public data repo (EXCLUDING `_art/`),
19
19
  refreshed on gem releases:
data/sig/vehicles.rbs CHANGED
@@ -13,26 +13,35 @@ module Vehicles
13
13
  def self.data_version: () -> String?
14
14
  def self.region: () -> Symbol
15
15
 
16
- def self.makes: (?kind: Symbol?, ?region: Symbol?) -> Array[String]
17
- def self.models: (untyped make, ?kind: Symbol?, ?body_type: Symbol?, ?region: Symbol?) -> Array[String]
16
+ def self.makes: (?kind: Symbol?, ?region: Symbol?, ?country: untyped, ?include_other: bool) -> Array[String]
17
+ def self.models: (untyped make, ?kind: Symbol?, ?body_type: Symbol?, ?region: Symbol?, ?country: untyped) -> Array[String]
18
18
  def self.make: (untyped query) -> Make?
19
19
  def self.find: (untyped query) -> Model?
20
- def self.model: (untyped make_name, untyped model_name, ?fuzzy: bool, ?kind: Symbol?) -> Model?
21
- def self.match_model: (untyped make_name, untyped model_name, ?kind: Symbol?) -> ModelMatch?
22
- def self.identify: (untyped text, ?kind: Symbol?) -> ModelMatch?
20
+ def self.model: (untyped make_name, untyped model_name, ?fuzzy: bool, ?kind: Symbol?, ?kinds: Array[Symbol]?) -> Model?
21
+ def self.match_model: (untyped make_name, untyped model_name, ?kind: Symbol?, ?kinds: Array[Symbol]?) -> ModelMatch?
22
+ def self.identify: (untyped text, ?kind: Symbol?, ?kinds: Array[Symbol]?) -> ModelMatch?
23
+ def self.by_id: (untyped id, ?follow: bool, ?kinds: Array[Symbol]?) -> Model?
24
+ def self.successor: (untyped id) -> String?
25
+ def self.live?: (untyped id) -> bool
23
26
  def self.search: (untyped query) -> Array[Model]
24
- def self.make_options: (?kind: Symbol?, ?region: Symbol?) -> Array[[String, String]]
25
- def self.model_options: (untyped make, ?kind: Symbol?, ?body_type: Symbol?) -> Array[[String, String]]
26
- def self.catalog: (?kind: Symbol?, ?region: Symbol?) -> Hash[String, Array[String]]
27
+ def self.make_options: (?kind: Symbol?, ?region: Symbol?, ?country: untyped) -> Array[[String, String]]
28
+ def self.model_options: (untyped make, ?kind: Symbol?, ?body_type: Symbol?, ?country: untyped) -> Array[[String, String]]
29
+ def self.catalog: (?kind: Symbol?, ?region: Symbol?, ?country: untyped, ?kinds: Array[Symbol]?) -> Hash[String, Array[String]]
30
+ def self.dataset_slice: (?kind: Symbol?, ?region: Symbol?, ?country: untyped, ?kinds: Array[Symbol]?) -> Hash[String, untyped]
27
31
 
28
- def self.colors: () -> Array[Color]
29
- def self.color: (untyped query) -> Color?
30
- def self.color_options: () -> Array[[String, String]]
32
+ def self.colors: (?locale: untyped) -> Array[Color]
33
+ def self.color: (untyped query, ?locale: untyped) -> Color?
34
+ def self.color_options: (?locale: untyped) -> Array[[String, String]]
35
+ def self.kind_label: (untyped kind, ?locale: untyped) -> String?
36
+ def self.kind_options: (?locale: untyped, ?kinds: Array[Symbol]) -> Array[[String, String]]
37
+ def self.body_type_label: (untyped body_type, ?locale: untyped) -> String?
38
+ def self.body_type_options: (?locale: untyped) -> Array[[String, String]]
31
39
 
32
40
  def self.dataset: () -> Dataset
33
41
  def self.data_path: () -> String
34
42
  def self.data_path=: (String) -> void
35
43
  def self.active_data_path: () -> String
44
+ def self.refresh: () -> RefreshResult
36
45
  def self.refresh!: () -> bool
37
46
  def self.reload!: () -> void
38
47
  def self.normalize: (untyped str) -> String
@@ -53,6 +62,43 @@ module Vehicles
53
62
  attr_accessor refresh_timeout: Integer
54
63
  attr_accessor plate_template_cache: untyped
55
64
  attr_accessor plate_template_ttl: Integer
65
+ attr_accessor manifest_url: String?
66
+ attr_accessor cache_dir: String
67
+ attr_accessor reload_check_interval: Numeric?
68
+ attr_accessor refresh_min_ratio: Float?
69
+ attr_accessor refresh_guard: (^(Dataset, Dataset) -> boolish)?
70
+ attr_accessor on_hosted_error: (^(HostedResult) -> void)?
71
+ attr_accessor image_cache: untyped
72
+ attr_accessor image_cache_ttl: Integer
73
+ attr_accessor image_miss_ttl: Integer
74
+ end
75
+
76
+ class RefreshResult
77
+ attr_reader status: Symbol
78
+ attr_reader reason: Symbol?
79
+ attr_reader version: String?
80
+ attr_reader previous_version: String?
81
+ attr_reader detail: String?
82
+ def ok?: () -> bool
83
+ def updated?: () -> bool
84
+ def message: () -> String
85
+ def to_h: () -> Hash[Symbol, untyped]
86
+ end
87
+
88
+ class HostedResult
89
+ attr_reader reason: Symbol
90
+ attr_reader status: Integer?
91
+ attr_reader error: String?
92
+ attr_reader payload: Hash[String, untyped]?
93
+ attr_reader path: String?
94
+ def ok?: () -> bool
95
+ def cached?: () -> bool
96
+ def url: (?Symbol size) -> String?
97
+ def to_error: () -> HostedError
98
+ end
99
+
100
+ class HostedError < StandardError
101
+ attr_accessor result: HostedResult
56
102
  end
57
103
 
58
104
  module Plates
@@ -77,7 +123,8 @@ module Vehicles
77
123
  attr_reader aliases: Array[String]
78
124
  attr_reader kinds: Array[Symbol]
79
125
  def models: (?kind: Symbol?, ?body_type: Symbol?) -> Array[Model]
80
- def model: (untyped query, ?kind: Symbol?) -> Model?
126
+ attr_reader former_slugs: Array[String]
127
+ def model: (untyped query, ?kind: Symbol?, ?kinds: Array[Symbol]?) -> Model?
81
128
  def model_names: (?kind: Symbol?, ?body_type: Symbol?) -> Array[String]
82
129
  def model_options: (?kind: Symbol?, ?body_type: Symbol?) -> Array[[String, String]]
83
130
  def to_h: () -> Hash[Symbol, untyped]
@@ -91,6 +138,7 @@ module Vehicles
91
138
  attr_reader via: Array[String]
92
139
  attr_reader candidates: Array[Model]
93
140
  def ambiguous?: () -> bool
141
+ def confident?: () -> bool
94
142
  def to_h: () -> Hash[Symbol, untyped]
95
143
  end
96
144
 
@@ -98,6 +146,7 @@ module Vehicles
98
146
  attr_reader slug: String
99
147
  attr_reader name: String
100
148
  attr_reader hex: String
149
+ def label: (?untyped locale) -> String
101
150
  def to_h: () -> Hash[Symbol, String]
102
151
  end
103
152
 
@@ -111,6 +160,9 @@ module Vehicles
111
160
  attr_reader body_type: Symbol
112
161
  attr_reader aliases: Array[String]
113
162
  attr_reader forms: Array[String]
163
+ attr_reader id: String
164
+ attr_reader former_ids: Array[String]
165
+ attr_reader registration_years: { first: Integer, last: Integer }?
114
166
  def full_name: () -> String
115
167
  def to_s: () -> String
116
168
  def slug: () -> String
@@ -122,6 +174,8 @@ module Vehicles
122
174
  def hatchback?: () -> bool
123
175
  def years: () -> Range[Integer]?
124
176
  def segment: () -> Symbol?
125
- def image: (?year: Integer?, ?color: untyped) -> String?
177
+ def image: (?year: Integer?, ?color: untyped, ?size: Symbol, ?exact_color: bool) -> String?
178
+ def images: (?color: untyped, ?year: Integer?, ?exact_color: bool) -> Hash[String, untyped]?
179
+ def images_result: (?color: untyped, ?year: Integer?, ?exact_color: bool) -> HostedResult
126
180
  end
127
181
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vehicles
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-11-09 00:00:00.000000000 Z
10
+ date: 2026-11-11 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: vehicles ships a curated, bundled dataset of car makes and models (with
13
13
  kind and body type) and a delightful, Rails-friendly API for vehicle dropdowns,
@@ -96,6 +96,7 @@ files:
96
96
  - data/plates/de.yml
97
97
  - data/plates/dk.yml
98
98
  - data/plates/ee.yml
99
+ - data/plates/eg.yml
99
100
  - data/plates/es.yml
100
101
  - data/plates/fi.yml
101
102
  - data/plates/fr.yml
@@ -117,6 +118,7 @@ files:
117
118
  - data/plates/mt.yml
118
119
  - data/plates/mx.yml
119
120
  - data/plates/my.yml
121
+ - data/plates/ng.yml
120
122
  - data/plates/nl.yml
121
123
  - data/plates/no.yml
122
124
  - data/plates/nz.yml
@@ -128,6 +130,7 @@ files:
128
130
  - data/plates/sk.yml
129
131
  - data/plates/sm.yml
130
132
  - data/plates/th.yml
133
+ - data/plates/tr.yml
131
134
  - data/plates/ua.yml
132
135
  - data/plates/us-ak.yml
133
136
  - data/plates/us-al.yml
@@ -196,10 +199,16 @@ files:
196
199
  - lib/vehicles/color.rb
197
200
  - lib/vehicles/colors.rb
198
201
  - lib/vehicles/configuration.rb
202
+ - lib/vehicles/data_source.rb
199
203
  - lib/vehicles/dataset.rb
200
204
  - lib/vehicles/dataset_resolution.rb
205
+ - lib/vehicles/dataset_slice.rb
201
206
  - lib/vehicles/fuzzy.rb
207
+ - lib/vehicles/hosted_result.rb
202
208
  - lib/vehicles/http.rb
209
+ - lib/vehicles/id_lookup.rb
210
+ - lib/vehicles/kind_rule.rb
211
+ - lib/vehicles/labels.rb
203
212
  - lib/vehicles/make.rb
204
213
  - lib/vehicles/mcp_server.rb
205
214
  - lib/vehicles/model.rb
@@ -214,7 +223,9 @@ files:
214
223
  - lib/vehicles/providers/hosted_provider.rb
215
224
  - lib/vehicles/providers/local_provider.rb
216
225
  - lib/vehicles/railtie.rb
226
+ - lib/vehicles/refresh_result.rb
217
227
  - lib/vehicles/refresher.rb
228
+ - lib/vehicles/release_checks.rb
218
229
  - lib/vehicles/text_matcher.rb
219
230
  - lib/vehicles/validators/vehicle_make_validator.rb
220
231
  - lib/vehicles/validators/vehicle_model_validator.rb