vehicles 0.6.1 → 0.8.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +164 -0
  3. data/CHANGELOG.md +274 -0
  4. data/README.md +55 -10
  5. data/data/PROVENANCE-vehicles.md +12 -0
  6. data/data/plates/PROVENANCE.md +5 -4
  7. data/data/plates/_decode/at-districts.yml +287 -0
  8. data/data/plates/_decode/ch-cantons.yml +110 -0
  9. data/data/plates/_decode/cn-issuing-offices.yml +463 -0
  10. data/data/plates/_decode/cn-provinces.yml +614 -0
  11. data/data/plates/_decode/cz-kraj.yml +159 -0
  12. data/data/plates/_decode/dk-serial-blocks.yml +327 -0
  13. data/data/plates/_decode/fr-departements.yml +191 -0
  14. data/data/plates/_decode/gb-age-identifiers.yml +170 -0
  15. data/data/plates/_decode/gb-memory-tags.yml +202 -0
  16. data/data/plates/_decode/gr-prefectures.yml +1408 -0
  17. data/data/plates/_decode/hr-cities.yml +315 -0
  18. data/data/plates/_decode/ie-counties.yml +227 -0
  19. data/data/plates/_decode/in-state-codes.yml +169 -0
  20. data/data/plates/_decode/is-districts-1938.yml +116 -0
  21. data/data/plates/_decode/it-provinces.yml +348 -0
  22. data/data/plates/_decode/jp-office-marks.yml +960 -0
  23. data/data/plates/_decode/jp-use-marks.yml +205 -0
  24. data/data/plates/_decode/kr-authority-marks.yml +146 -0
  25. data/data/plates/_decode/kr-use-syllables.yml +194 -0
  26. data/data/plates/_decode/lu-series-order.yml +638 -0
  27. data/data/plates/_decode/mx-entity-series.yml +380 -0
  28. data/data/plates/_decode/my-index-marks.yml +271 -0
  29. data/data/plates/_decode/no-counties-1913.yml +148 -0
  30. data/data/plates/_decode/no-districts.yml +901 -0
  31. data/data/plates/_decode/pl-powiats.yml +567 -0
  32. data/data/plates/_decode/pt-trailer-services.yml +96 -0
  33. data/data/plates/_decode/ro-counties.yml +213 -0
  34. data/data/plates/_decode/se-diplomatic.yml +220 -0
  35. data/data/plates/_decode/si-regions.yml +123 -0
  36. data/data/plates/_decode/sk-districts.yml +314 -0
  37. data/data/plates/_decode/th-registration-elements.yml +208 -0
  38. data/data/plates/_decode/ua-regions.yml +393 -0
  39. data/data/plates/_decode/za-licence-marks.yml +215 -0
  40. data/data/plates/_meta/classes.yml +1 -0
  41. data/data/plates/_meta/separators.yml +102 -0
  42. data/data/plates/ad.yml +961 -0
  43. data/data/plates/ar.yml +762 -0
  44. data/data/plates/at.yml +1424 -0
  45. data/data/plates/au-act.yml +472 -0
  46. data/data/plates/au-nsw.yml +282 -0
  47. data/data/plates/au-nt.yml +431 -0
  48. data/data/plates/au-qld.yml +272 -0
  49. data/data/plates/au-sa.yml +248 -0
  50. data/data/plates/au-tas.yml +302 -0
  51. data/data/plates/au-vic.yml +215 -0
  52. data/data/plates/au-wa.yml +309 -0
  53. data/data/plates/be.yml +1840 -0
  54. data/data/plates/br.yml +1152 -0
  55. data/data/plates/ca-ab.yml +430 -0
  56. data/data/plates/ca-bc.yml +697 -0
  57. data/data/plates/ca-mb.yml +499 -0
  58. data/data/plates/ca-nb.yml +537 -0
  59. data/data/plates/ca-nl.yml +743 -0
  60. data/data/plates/ca-ns.yml +808 -0
  61. data/data/plates/ca-nt.yml +610 -0
  62. data/data/plates/ca-nu.yml +280 -0
  63. data/data/plates/ca-on.yml +881 -0
  64. data/data/plates/ca-pe.yml +592 -0
  65. data/data/plates/ca-qc.yml +1036 -0
  66. data/data/plates/ca-sk.yml +256 -0
  67. data/data/plates/ca-yt.yml +656 -0
  68. data/data/plates/ch.yml +1273 -0
  69. data/data/plates/cn.yml +1431 -0
  70. data/data/plates/cy.yml +688 -0
  71. data/data/plates/cz.yml +1179 -0
  72. data/data/plates/de.yml +553 -118
  73. data/data/plates/dk.yml +1647 -0
  74. data/data/plates/ee.yml +1275 -0
  75. data/data/plates/es.yml +228 -32
  76. data/data/plates/fi.yml +1271 -0
  77. data/data/plates/fr.yml +1265 -0
  78. data/data/plates/gb.yml +1188 -0
  79. data/data/plates/gr.yml +721 -0
  80. data/data/plates/hr.yml +860 -0
  81. data/data/plates/hu.yml +959 -0
  82. data/data/plates/ie.yml +775 -0
  83. data/data/plates/in.yml +1172 -0
  84. data/data/plates/is.yml +667 -0
  85. data/data/plates/it.yml +1317 -0
  86. data/data/plates/jp.yml +3650 -0
  87. data/data/plates/kr.yml +1375 -0
  88. data/data/plates/li.yml +1010 -0
  89. data/data/plates/lt.yml +879 -0
  90. data/data/plates/lu.yml +842 -0
  91. data/data/plates/lv.yml +924 -0
  92. data/data/plates/mc.yml +1024 -0
  93. data/data/plates/mt.yml +854 -0
  94. data/data/plates/mx.yml +1372 -0
  95. data/data/plates/my.yml +685 -0
  96. data/data/plates/nl.yml +44 -0
  97. data/data/plates/no.yml +1018 -0
  98. data/data/plates/nz.yml +1699 -0
  99. data/data/plates/pl.yml +1296 -0
  100. data/data/plates/pt.yml +847 -0
  101. data/data/plates/ro.yml +785 -0
  102. data/data/plates/se.yml +1091 -0
  103. data/data/plates/si.yml +1105 -0
  104. data/data/plates/sk.yml +1439 -0
  105. data/data/plates/sm.yml +1119 -0
  106. data/data/plates/th.yml +1150 -0
  107. data/data/plates/ua.yml +1672 -0
  108. data/data/plates/us-ak.yml +763 -0
  109. data/data/plates/us-al.yml +457 -0
  110. data/data/plates/us-ar.yml +427 -0
  111. data/data/plates/us-as.yml +454 -0
  112. data/data/plates/us-az.yml +430 -0
  113. data/data/plates/us-ca.yml +892 -0
  114. data/data/plates/us-co.yml +452 -0
  115. data/data/plates/us-ct.yml +427 -0
  116. data/data/plates/us-dc.yml +614 -0
  117. data/data/plates/us-de.yml +551 -0
  118. data/data/plates/us-fl.yml +319 -96
  119. data/data/plates/us-ga.yml +644 -0
  120. data/data/plates/us-gu.yml +658 -0
  121. data/data/plates/us-hi.yml +585 -0
  122. data/data/plates/us-ia.yml +438 -0
  123. data/data/plates/us-id.yml +531 -0
  124. data/data/plates/us-il.yml +516 -0
  125. data/data/plates/us-in.yml +324 -0
  126. data/data/plates/us-ks.yml +351 -0
  127. data/data/plates/us-ky.yml +770 -0
  128. data/data/plates/us-la.yml +568 -0
  129. data/data/plates/us-ma.yml +379 -0
  130. data/data/plates/us-md.yml +536 -0
  131. data/data/plates/us-me.yml +445 -0
  132. data/data/plates/us-mi.yml +408 -0
  133. data/data/plates/us-mn.yml +411 -0
  134. data/data/plates/us-mo.yml +356 -0
  135. data/data/plates/us-mp.yml +402 -0
  136. data/data/plates/us-ms.yml +432 -0
  137. data/data/plates/us-mt.yml +619 -0
  138. data/data/plates/us-nc.yml +602 -0
  139. data/data/plates/us-nd.yml +419 -0
  140. data/data/plates/us-ne.yml +483 -0
  141. data/data/plates/us-nh.yml +394 -0
  142. data/data/plates/us-nj.yml +547 -0
  143. data/data/plates/us-nm.yml +574 -0
  144. data/data/plates/us-nv.yml +655 -0
  145. data/data/plates/us-ny.yml +592 -0
  146. data/data/plates/us-oh.yml +404 -0
  147. data/data/plates/us-ok.yml +513 -0
  148. data/data/plates/us-or.yml +392 -0
  149. data/data/plates/us-pa.yml +506 -0
  150. data/data/plates/us-pr.yml +507 -0
  151. data/data/plates/us-ri.yml +445 -0
  152. data/data/plates/us-sc.yml +605 -0
  153. data/data/plates/us-sd.yml +428 -0
  154. data/data/plates/us-tn.yml +471 -0
  155. data/data/plates/us-tx.yml +535 -0
  156. data/data/plates/us-ut.yml +384 -0
  157. data/data/plates/us-va.yml +567 -0
  158. data/data/plates/us-vi.yml +489 -0
  159. data/data/plates/us-vt.yml +458 -0
  160. data/data/plates/us-wa.yml +484 -0
  161. data/data/plates/us-wi.yml +509 -0
  162. data/data/plates/us-wv.yml +446 -0
  163. data/data/plates/us-wy.yml +523 -0
  164. data/data/plates/va.yml +604 -0
  165. data/data/plates/za.yml +1141 -0
  166. data/data/vehicles.json +160653 -105090
  167. data/lib/vehicles/configuration.rb +17 -3
  168. data/lib/vehicles/dataset.rb +73 -18
  169. data/lib/vehicles/fuzzy.rb +115 -0
  170. data/lib/vehicles/http.rb +48 -0
  171. data/lib/vehicles/make.rb +36 -6
  172. data/lib/vehicles/mcp_server.rb +5 -5
  173. data/lib/vehicles/model.rb +21 -2
  174. data/lib/vehicles/plates/series.rb +172 -18
  175. data/lib/vehicles/plates/template_fill.rb +188 -0
  176. data/lib/vehicles/plates/templates.rb +162 -0
  177. data/lib/vehicles/plates.rb +12 -0
  178. data/lib/vehicles/providers/hosted_provider.rb +28 -55
  179. data/lib/vehicles/validators/vehicle_make_validator.rb +3 -1
  180. data/lib/vehicles/validators/vehicle_model_validator.rb +1 -1
  181. data/lib/vehicles/version.rb +1 -1
  182. data/lib/vehicles.rb +32 -16
  183. data/rakelib/data_refresh.rake +109 -0
  184. data/rakelib/plates_refresh.rake +29 -0
  185. data/sig/vehicles.rbs +18 -0
  186. metadata +164 -2
@@ -22,15 +22,28 @@ module Vehicles
22
22
  # works on the bundled data — the gem is standalone first, SDK second.
23
23
  attr_accessor :api_key
24
24
 
25
- # ORIGIN of the hosted VehiclesDB API — scheme + host only, no path
26
- # (endpoint paths carry their own /v1 prefix, and URI.join would drop a
27
- # path suffix here anyway). Overridable for self-hosting/testing.
25
+ # Where the hosted VehiclesDB API lives — the ORIGIN, without the /api/v1
26
+ # (endpoint paths carry it: HostedProvider::API_PREFIX). Overridable for
27
+ # self-hosting, staging or a local server: "http://localhost:3030".
28
28
  attr_accessor :api_base_url
29
29
 
30
30
  # Network timeout (seconds) for hosted API calls. Kept short so a slow/missing
31
31
  # API never blocks a request — hosted lookups degrade to the local data.
32
32
  attr_accessor :api_timeout
33
33
 
34
+ # Plate templates (Vehicles::Plates.template / .render): where fetched
35
+ # templates are kept, and for how long. Any store with read(key) and
36
+ # write(key, value, expires_in:) works — `Rails.cache` shares them across
37
+ # processes. Defaults to an in-process cache. Templates are public and
38
+ # carry nothing personal, so a long TTL is safe; the server re-renders
39
+ # them only when VehiclesDB's plate art changes.
40
+ attr_writer :plate_template_cache
41
+ attr_accessor :plate_template_ttl
42
+
43
+ def plate_template_cache
44
+ @plate_template_cache ||= Plates::TemplateCache.new
45
+ end
46
+
34
47
  # Extra make aliases, merged over the built-in ones. Keys are matched
35
48
  # forgivingly (case/diacritics-insensitive); values are canonical make names.
36
49
  attr_reader :aliases
@@ -73,6 +86,7 @@ module Vehicles
73
86
  @cache_path = default_cache_path
74
87
  @use_cache = true
75
88
  @refresh_timeout = 5
89
+ @plate_template_ttl = 86_400 # a day, the server's own shared-cache lifetime
76
90
  end
77
91
 
78
92
  # Normalize alias keys at assignment time so lookups stay O(1) and forgiving.
@@ -78,7 +78,11 @@ module Vehicles
78
78
  end
79
79
 
80
80
  # Resolve a make from a String/Symbol/Make via aliases, slug, or name.
81
- def find_make(query)
81
+ # With `fuzzy:` (the default), a query that matches nothing exactly falls
82
+ # back to the closest make by edit distance — "marcedes" → Mercedes-Benz —
83
+ # but only when ONE make is strictly closest; equally close makes → nil.
84
+ # Pass `fuzzy: false` where a typo must NOT count as a match (validators).
85
+ def find_make(query, fuzzy: true)
82
86
  return query if query.is_a?(Make)
83
87
 
84
88
  q = Vehicles.normalize(query)
@@ -95,25 +99,31 @@ module Vehicles
95
99
  end
96
100
 
97
101
  # 3. direct slug / normalized name / make alias
98
- @by_slug[q] || @index[q]
102
+ # 4. bounded edit-distance fallback over names/slugs/aliases (Fuzzy);
103
+ # user-configured aliases are not fuzzy-matched
104
+ # — never for the "Other" escape hatch's label ("Otra" is one edit
105
+ # from the make Ora; Vehicles.models("Otra", include_other: true)
106
+ # must stay [other_label])
107
+ # — and never for id/slug/path-shaped input (Fuzzy.eligible_input?)
108
+ fuzzy &&= Fuzzy.eligible_input?(query) && !Vehicles.other?(query)
109
+ @by_slug[q] || @index[q] || (fuzzy ? Fuzzy.unique_closest(q, fuzzy_keys) : nil)
99
110
  end
100
111
 
101
112
  # Resolve a free-text "make + model" string into one Model. Tries the longest
102
113
  # leading make prefix first ("land rover defender"), then the remainder as the
103
114
  # model. Returns nil if nothing matches.
104
- def find_model(query)
115
+ #
116
+ # The exact pass runs first over EVERY split; only if nothing matches is
117
+ # the make prefix retried with typo tolerance, so no answer that resolved
118
+ # before 0.7.8 can change ("volkswagon golf" now resolves; "vw golf" is
119
+ # answered by the exact pass exactly as before).
120
+ def find_model(query, fuzzy: true)
105
121
  q = Vehicles.normalize(query)
106
122
  tokens = q.split
107
123
  return nil if tokens.empty?
108
124
 
109
- (tokens.length - 1).downto(1) do |i|
110
- make = find_make(tokens[0, i].join(" "))
111
- next unless make
112
-
113
- model = make.model(tokens[i..].join(" "))
114
- return model if model
115
- end
116
- nil
125
+ fuzzy &&= Fuzzy.eligible_input?(query) # ids/slugs/paths resolve exactly or not at all
126
+ model_by_split(tokens, fuzzy: false) || (fuzzy ? model_by_split(tokens, fuzzy: true) : nil)
117
127
  end
118
128
 
119
129
  # Every model whose name (or full name) matches the query, ranked: exact name,
@@ -148,20 +158,43 @@ module Vehicles
148
158
  @kinds ||= @makes.flat_map(&:kinds).uniq.sort.freeze
149
159
  end
150
160
 
151
- # Ranked models by popularity: global decile first, then breadth of
152
- # availability as the tiebreaker, then name. Unranked models (nil decile —
153
- # catalog-only evidence) never appear: "unknown" must not outrank "known".
154
- # top_models(kind: :car, country: :nl, limit: 10)
161
+ # Ranked models by popularity. Unranked models (nil decile — catalog-only
162
+ # evidence) never appear: "unknown" must not outrank "known".
163
+ #
164
+ # Without `country:` — global decile, then breadth of availability, then
165
+ # name. WITH `country:` — that country's OWN rank (Model#rank_in) first,
166
+ # so a UK list reads in UK order, not in the presence-weighted global
167
+ # order (until 0.7.7 a filtered list kept the global sort). Models
168
+ # available there but without a rank there follow, in global order; a
169
+ # snapshot without `country_ranks` (data before 2026.10) degrades to the
170
+ # 0.7.7 order. Ranks are per KIND, so pass `kind:` for a clean ranking —
171
+ # without it, kinds interleave by rank (car #1, motorcycle #1, …).
172
+ #
173
+ # `by:` picks the decile: :global_decile (default — presence average,
174
+ # "popular in many places") or :mass_decile (per-kind registration mass,
175
+ # "how many exist"; data 2026.10.1+). Records without the chosen decile
176
+ # never appear, so `by: :mass_decile` on an older snapshot returns [].
177
+ # top_models(kind: :car, country: :gb, limit: 10)
178
+ # With `country:`, the country's own rank still sorts first and the
179
+ # chosen decile only breaks ties among models ranked equally there.
180
+ # top_models(kind: :car, by: :mass_decile, limit: 10)
155
181
  # top_models(kind: :motorcycle, region: :as, limit: 10) # by continent
156
- def top_models(kind: nil, country: nil, region: nil, limit: 20)
182
+ def top_models(kind: nil, country: nil, region: nil, limit: 20, by: :global_decile)
183
+ raise ArgumentError, "by: must be :global_decile or :mass_decile" unless RANKINGS.include?(by)
184
+
157
185
  c = country&.to_s&.downcase
158
- list = all_models.select(&:global_decile)
186
+ list = all_models.select(&by)
159
187
  list = list.select { |m| m.kind == kind.to_sym } if kind
160
188
  list = list.select { |m| m.availability.include?(c) } if c
161
189
  list = list.select { |m| m.available_in_region?(region) } if region
162
- list.sort_by { |m| [m.global_decile, -m.availability.size, m.name] }.first(limit)
190
+ list.sort_by do |m|
191
+ rank = c && m.rank_in(c)
192
+ [rank ? 0 : 1, rank || 0, m.public_send(by), -m.availability.size, m.name]
193
+ end.first(limit)
163
194
  end
164
195
 
196
+ RANKINGS = %i[global_decile mass_decile].freeze
197
+
165
198
  # Every model matching optional kind/region/rarity filters, ranked by
166
199
  # popularity. The "give me a sensible slice" entry point.
167
200
  def all_models_filtered(kind: nil, region: nil, rarity: nil, max_decile: nil)
@@ -186,6 +219,28 @@ module Vehicles
186
219
  @index[Vehicles.normalize(key)] ||= make
187
220
  end
188
221
 
222
+ # The longest-make-prefix split behind #find_model, one pass.
223
+ def model_by_split(tokens, fuzzy:)
224
+ (tokens.length - 1).downto(1) do |i|
225
+ make = find_make(tokens[0, i].join(" "), fuzzy: fuzzy)
226
+ next unless make
227
+
228
+ model = make.model(tokens[i..].join(" "))
229
+ return model if model
230
+ end
231
+ nil
232
+ end
233
+
234
+ # Every normalized key a make answers to (name, slug, published aliases,
235
+ # built-in aliases) — the haystack for #fuzzy_make. Built once.
236
+ def fuzzy_keys
237
+ @fuzzy_keys ||= begin
238
+ keys = @index.to_a
239
+ BUILTIN_ALIASES.each { |key, slug| keys << [key, @by_slug[slug]] if @by_slug[slug] }
240
+ keys.reject { |k, _m| k.empty? }.freeze
241
+ end
242
+ end
243
+
189
244
  def region_match?(region)
190
245
  Vehicles.normalize(@region) == "global" ||
191
246
  Vehicles.normalize(region) == Vehicles.normalize(@region)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ # Typo tolerance for make lookups (0.7.8): bounded Levenshtein distance,
5
+ # stdlib only. "marcedes" → Mercedes-Benz, "volkswagon" → Volkswagen.
6
+ #
7
+ # Deliberately conservative — a confident WRONG make is worse than none:
8
+ # - queries under MIN_LENGTH characters never fuzzy-match;
9
+ # - the edit budget is 1 for 4-5 characters and 2 from 6 up (never more);
10
+ # - the answer must be UNIQUE: if two different makes tie at the best
11
+ # distance, the result is nil ("acra": one edit from Acura AND Acma);
12
+ # - a candidate may never have FEWER words than the query: deleting a
13
+ # whole word is how a make prefix swallows a model token ("volvo v" is
14
+ # two edits from "volvo", which turned "Volvo V 60" into Volvo 60);
15
+ # - every word keeps its first letter (word for word when the counts
16
+ # match, else the first letter of the query): typos rarely hit the
17
+ # first letter, model tokens almost always differ there ("citroen e"
18
+ # is two edits from "citroen ds" — that is the e-C3, not Citroën DS).
19
+ module Fuzzy
20
+ MIN_LENGTH = 4
21
+
22
+ module_function
23
+
24
+ # Only HUMAN-typed names are fuzzy-matched. Anything shaped like an id, a
25
+ # path or a slug — any "/" ("car/mercedes-benz/c-class", "/makes/marcedes",
26
+ # "marcedes-benz/c-class") or all-lowercase hyphenated ("marcedes-benz") —
27
+ # is machine input: it must resolve exactly or not at all, never to a
28
+ # guessed make (web routing and API ids rely on it; web-session ruling).
29
+ # Symbols are always machine input; so is anything with / _ + or a
30
+ # backslash, a leading/trailing/double hyphen, or a single-case
31
+ # hyphenated token ("marcedes-benz", "MARCEDES-BENZ"). Mixed case
32
+ # ("Marcedes-Benz") and plain words ("marcedes") are human. Verifier
33
+ # finding (0.7.8): each of those machine shapes fuzzed for every typo.
34
+ def eligible_input?(raw)
35
+ return false if raw.is_a?(Symbol)
36
+
37
+ s = raw.to_s.strip
38
+ return false if s.match?(%r{[/_+\\]}) || s.start_with?("-") || s.end_with?("-") || s.include?("--")
39
+
40
+ !s.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)+\z/) && !s.match?(/\A[A-Z0-9]+(?:-[A-Z0-9]+)+\z/)
41
+ end
42
+
43
+ # Maximum edit distance allowed for a query of this many characters.
44
+ def budget(length)
45
+ length >= 6 ? 2 : 1
46
+ end
47
+
48
+ # The single Make closest to `query` among `keys` ([[normalized key, Make],
49
+ # …]), or nil when nothing is within budget or two makes tie.
50
+ def unique_closest(query, keys)
51
+ return nil if query.length < MIN_LENGTH
52
+
53
+ limit = budget(query.length)
54
+ words = query.count(" ") + 1
55
+ best = limit + 1
56
+ hits = {}
57
+ keys.each do |key, make|
58
+ next if (key.length - query.length).abs > limit # cannot be within budget
59
+ next if key.count(" ") + 1 < words # would swallow a word
60
+ next unless initials_match?(query, key)
61
+
62
+ dist = distance(query, key, limit)
63
+ # `distance` reports out-of-budget as limit + 1, the same value `best`
64
+ # starts at — test the LIMIT, or a lone length-compatible key would be
65
+ # accepted at any distance (codex review, 0.7.8: "z" * 28 → a make).
66
+ next if dist > limit || dist > best
67
+
68
+ hits = {} if dist < best
69
+ best = dist
70
+ hits[make.slug] = make
71
+ end
72
+ hits.size == 1 ? hits.values.first : nil
73
+ end
74
+
75
+ # First letters agree: word for word when both have the same number of
76
+ # words, otherwise (the query has fewer words, "delorean" vs
77
+ # "de lorean") just the first letter.
78
+ def initials_match?(query, key)
79
+ qw = query.split
80
+ kw = key.split
81
+ return qw.first[0] == kw.first[0] unless qw.size == kw.size
82
+
83
+ qw.zip(kw).all? { |q, k| q[0] == k[0] }
84
+ end
85
+
86
+ # Edit distance (optimal string alignment: Levenshtein plus adjacent
87
+ # transposition = 1 edit, so "telsa" is ONE edit from "tesla" — plain
88
+ # Levenshtein counted it as two and let the one-substitution "temsa" win).
89
+ # Gives up early: returns `limit + 1` once two consecutive rows exceed
90
+ # `limit` (a transposition reads two rows back, so one row is not enough).
91
+ def distance(left, right, limit)
92
+ prev2 = nil
93
+ prev = (0..right.length).to_a
94
+ left.each_char.with_index(1) do |lch, row|
95
+ cur = [row]
96
+ right.each_char.with_index(1) do |rch, col|
97
+ swap = prev2 && col > 1 && lch == right[col - 2] && left[row - 2] == rch
98
+ cur << cell(prev, cur, col, lch == rch, swap ? prev2[col - 2] : nil)
99
+ end
100
+ return limit + 1 if cur.min > limit && prev.min > limit
101
+
102
+ prev2 = prev
103
+ prev = cur
104
+ end
105
+ [prev.last, limit + 1].min
106
+ end
107
+
108
+ # One DP cell: deletion, insertion, substitution, and — when the two
109
+ # characters are swapped neighbours — transposition from two rows back.
110
+ def cell(prev, cur, col, same, swap_from)
111
+ best = [prev[col] + 1, cur[col - 1] + 1, prev[col - 1] + (same ? 0 : 1)].min
112
+ swap_from ? [best, swap_from + 1].min : best
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ # The one way this gem talks to a VehiclesDB server: a single GET, capped by
5
+ # `config.api_timeout`, never retried, never raising. Shared by the hosted
6
+ # images provider (keyed, JSON) and the plate templates (public, SVG), so
7
+ # the latency promise and the no-silent-retry rule live in one place.
8
+ module HTTP
9
+ module_function
10
+
11
+ # `config.api_base_url` + path + query. Plain concatenation, not
12
+ # URI.join: an origin ("http://localhost:3030") and an origin behind a
13
+ # path prefix ("https://proxy.example/vdb") both keep everything they say.
14
+ def url(path, params = {})
15
+ # Lazy-required: the gem is standalone-first, and a key-less install
16
+ # that renders no plates never touches the network.
17
+ require "uri"
18
+
19
+ uri = URI("#{Vehicles.configuration.api_base_url.to_s.chomp("/")}#{path}")
20
+ uri.query = URI.encode_www_form(params) unless params.empty?
21
+ uri
22
+ end
23
+
24
+ # The response body of a 2xx, or nil on ANY failure (network, timeout,
25
+ # non-2xx). Callers treat nil as "fall back".
26
+ def get(uri, headers = {})
27
+ require "net/http"
28
+
29
+ config = Vehicles.configuration
30
+ http = Net::HTTP.new(uri.host, uri.port)
31
+ http.use_ssl = uri.scheme == "https"
32
+ http.open_timeout = config.api_timeout
33
+ http.read_timeout = config.api_timeout
34
+ # Net::HTTP silently re-sends an idempotent GET once after a read
35
+ # timeout. That would double the latency we promise to cap AND bill a
36
+ # served image twice, so one request means one request.
37
+ http.max_retries = 0
38
+
39
+ request = Net::HTTP::Get.new(uri)
40
+ headers.each { |name, value| request[name] = value }
41
+ response = http.request(request)
42
+ response.body if response.is_a?(Net::HTTPSuccess)
43
+ rescue StandardError => e
44
+ warn "[vehicles] request to VehiclesDB failed (#{e.class}); falling back" if $DEBUG
45
+ nil
46
+ end
47
+ end
48
+ end
data/lib/vehicles/make.rb CHANGED
@@ -9,6 +9,10 @@ module Vehicles
9
9
  # make.models # => ["A3", "A4", "Q3", ...]
10
10
  # make.model("a3")# => #<Vehicles::Model "Audi A3">
11
11
  class Make
12
+ # "Serie 3" — how BMW sells the 3 Series in Spain and Italy (and "Série"
13
+ # in France, which normalizes the same) — read as "3 Series".
14
+ LOCALIZED_SERIES = /\Aserie (\w+)\z/
15
+
12
16
  attr_reader :name, :slug, :aliases, :kinds
13
17
 
14
18
  def initialize(attrs)
@@ -58,15 +62,26 @@ module Vehicles
58
62
  models(**filters).map { |m| [m.name, m.model_slug] }
59
63
  end
60
64
 
61
- # Find one model within this make by exact (normalized) name or slug.
62
- # "a3" / "A3" / "Q 3" resolve; partial input does NOT (so `model("a")`
63
- # returns nil, not "A3" — important, since the vehicle_model validator relies
64
- # on this). Fuzzy/partial matching lives in `Vehicles.search`.
65
- def model(query)
65
+ # Find one model within this make by name or slug. Forgiving about case,
66
+ # accents and spacing — "a3", "A 3", "XCeed" (the dataset's "X Ceed") and
67
+ # BMW's Spanish "Serie 3" (→ "3 Series") all resolve — but partial input
68
+ # does NOT (so `model("a")` returns nil, not "A3" — important, since the
69
+ # vehicle_model validator relies on this). Fuzzy/partial matching lives in
70
+ # `Vehicles.search`.
71
+ #
72
+ # Some names exist as several kinds (Toyota "Land Cruiser" is a car, a
73
+ # van, a truck and a bus). Without `kind:` the car wins — it's what people
74
+ # mean — and pass `kind:` to pick another:
75
+ # toyota.model("Land Cruiser") # => the car
76
+ # toyota.model("Land Cruiser", kind: :bus) # => the bus
77
+ def model(query, kind: nil)
66
78
  q = Vehicles.normalize(query)
67
79
  return nil if q.empty?
68
80
 
69
- models.find { |m| Vehicles.normalize(m.name) == q || m.model_slug == q }
81
+ pool = models(kind: kind)
82
+ series = q.sub(LOCALIZED_SERIES, '\\1 series')
83
+ matches = named(pool, q) || (named(pool, series) unless series == q)
84
+ matches && (matches.find(&:car?) || matches.first)
70
85
  end
71
86
 
72
87
  def to_h
@@ -89,5 +104,20 @@ module Vehicles
89
104
  def inspect
90
105
  %(#<Vehicles::Make "#{name}">)
91
106
  end
107
+
108
+ private
109
+
110
+ # The models answering to a normalized query: by exact name or slug; failing
111
+ # that, by the same letters and digits with spacing ignored ("xceed" is
112
+ # "X Ceed", "a 5" is "A5") — but only when that lands on ONE name, never a
113
+ # guess between two. nil when nothing answers.
114
+ def named(pool, query)
115
+ exact = pool.select { |m| Vehicles.normalize(m.name) == query || m.model_slug == query }
116
+ return exact if exact.any?
117
+
118
+ squeezed = query.delete(" ")
119
+ loose = pool.select { |m| Vehicles.normalize(m.name).delete(" ") == squeezed }
120
+ loose if loose.map { |m| Vehicles.normalize(m.name) }.uniq.one?
121
+ end
92
122
  end
93
123
  end
@@ -162,7 +162,7 @@ module Vehicles
162
162
  makes = Vehicles.dataset.makes(kind: kind)
163
163
  unless q.strip.empty?
164
164
  n = Vehicles.normalize(q)
165
- exact = Vehicles.dataset.find_make(q)
165
+ exact = Vehicles.dataset.find_make(q, fuzzy: false) # MCP tools stay exact (agents pass ids/slugs)
166
166
  makes = makes.select { |m| Vehicles.normalize(m.name).include?(n) }
167
167
  makes = [exact, *makes].uniq if exact && (kind.nil? || exact.kinds.include?(kind))
168
168
  end
@@ -178,8 +178,8 @@ module Vehicles
178
178
  end
179
179
 
180
180
  def get_model(args)
181
- m = Vehicles.model(args.fetch("make"), args.fetch("model")) ||
182
- Vehicles.find("#{args.fetch("make")} #{args.fetch("model")}")
181
+ m = Vehicles.model(args.fetch("make"), args.fetch("model"), fuzzy: false) ||
182
+ Vehicles.find("#{args.fetch("make")} #{args.fetch("model")}", fuzzy: false)
183
183
  if m
184
184
  model_json(m)
185
185
  else
@@ -194,8 +194,8 @@ module Vehicles
194
194
  list = Vehicles.dataset.top_models(kind: args["kind"]&.to_sym,
195
195
  country: args["country"], limit: limit)
196
196
  { models: list.map { |m| model_json(m) },
197
- note: "Ranked by global popularity decile from official registration counts; " \
198
- "country filters by evidenced availability." }
197
+ note: "Ranked from official registration counts: with a country, by that country's own " \
198
+ "rank (per kind — pass kind for a clean list); without one, by global popularity decile." }
199
199
  end
200
200
 
201
201
  def model_json(model)
@@ -25,7 +25,7 @@ module Vehicles
25
25
  REGIONS = %i[eu na as sa oc af].freeze
26
26
 
27
27
  attr_reader :make, :make_slug, :name, :kind, :body_type, :global_decile,
28
- :availability, :regions, :aliases, :former_ids
28
+ :availability, :regions, :aliases, :former_ids, :country_ranks, :mass_decile
29
29
 
30
30
  # @param attrs [Hash] one model entry from the dataset (name/slug/kind +
31
31
  # optional body_type/global_decile/availability/regions/aliases/former_ids)
@@ -55,6 +55,18 @@ module Vehicles
55
55
  # the dataset's append-only migration contract (SCHEMA.md: renames
56
56
  # alias, nothing is silently deleted). Empty for never-renamed records.
57
57
  @former_ids = (attrs["former_ids"] || []).freeze
58
+ # { "gb" => 2, "nl" => 1, ... } — the model's rank among every model of
59
+ # its KIND in that country, from official registration counts (dataset
60
+ # projection key `country_ranks`, data 2026.10+). Empty when unranked or
61
+ # when the snapshot predates the key; `rank_in` then returns nil.
62
+ @country_ranks = (attrs["country_ranks"] || {}).freeze
63
+ # Per-KIND mass decile 1 (heaviest 10% by registrations summed across
64
+ # measured countries) … 10 — "how many exist", where global_decile
65
+ # (a presence average) answers "popular in many places". Published from
66
+ # dataset 2026.10.1 (pipeline#255, ruling R3); nil on older snapshots
67
+ # (including the bundled 2026.10.0) and for unranked records.
68
+ pop = attrs["popularity"]
69
+ @mass_decile = attrs["mass_decile"] || (pop.is_a?(Hash) ? pop["mass_decile"] : nil)
58
70
  freeze
59
71
  end
60
72
 
@@ -90,6 +102,13 @@ module Vehicles
90
102
  availability.include?(country.to_s.downcase)
91
103
  end
92
104
 
105
+ # Rank in one country among all models of the same kind (1 = most
106
+ # registered there), or nil when that country has no ranking for it.
107
+ # Vehicles.find("vw golf").rank_in(:gb) # => 2
108
+ def rank_in(country)
109
+ country_ranks[country.to_s.downcase]
110
+ end
111
+
93
112
  # Evidence of presence on a continent (:eu/:na/:sa/:as/:oc/:af):
94
113
  # Vehicles.find("perodua myvi").available_in_region?(:as) # => true
95
114
  def available_in_region?(region)
@@ -131,7 +150,7 @@ module Vehicles
131
150
  { make: make, model: name, slug: slug, kind: kind, body_type: body_type,
132
151
  global_decile: global_decile, rarity: rarity,
133
152
  availability: availability, regions: regions, aliases: aliases,
134
- former_ids: former_ids }
153
+ former_ids: former_ids, country_ranks: country_ranks, mass_decile: mass_decile }
135
154
  end
136
155
 
137
156
  # Value-object equality — two models with the same slug are equal.