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
@@ -11,8 +11,10 @@ module Vehicles
11
11
  # car.body_type # => :hatchback
12
12
  # car.suv? # => false
13
13
  class Model
14
- # Vehicle kinds (RDW `voertuigsoort`). Today the bundled data is all :car;
15
- # the shape already supports the rest for when those packs land.
14
+ # Vehicle kinds the predicates know (`car?`, `van?`, …). The data itself
15
+ # publishes car, motorcycle, moped, van, truck and bus (Vehicles.kinds);
16
+ # pickup/trailer/quad/trike are kept so existing predicate calls answer
17
+ # false instead of raising.
16
18
  KINDS = %i[car motorcycle van truck pickup trailer bus moped quad trike].freeze
17
19
 
18
20
  # Body types — the sub-classification within a kind. For cars these are the
@@ -31,7 +33,7 @@ module Vehicles
31
33
  # optional body_type/global_decile/availability/regions/aliases/forms/former_ids)
32
34
  # @param make [String] the parent make's display name
33
35
  # @param make_slug [String] the parent make's slug (for the composite slug)
34
- def initialize(attrs, make:, make_slug:)
36
+ def initialize(attrs, make:, make_slug:) # rubocop:disable Metrics/AbcSize -- one line per published field
35
37
  @make = make
36
38
  @make_slug = make_slug
37
39
  @name = attrs["name"]
@@ -74,9 +76,29 @@ module Vehicles
74
76
  # dataset 2026.10.1 (pipeline#255, ruling R3); nil on older snapshots
75
77
  # (including the bundled 2026.10.0) and for unranked records.
76
78
  @mass_decile = mass_decile_of(attrs)
79
+ # The first and last year this nameplate appears in official
80
+ # REGISTRATION data (dataset key `registration_years`), when published.
81
+ # Not production or model years: grey imports and late registrations
82
+ # widen it, and a register's coverage starts when its data does.
83
+ @registration_years = registration_years_of(attrs)
84
+ @id = id_of(make_slug)
77
85
  freeze
78
86
  end
79
87
 
88
+ # The canonical VehiclesDB id, "car/volkswagen/golf" (RESOLUTION.md §1):
89
+ # what to STORE. The same string the API, the SDKs and the widget use, and
90
+ # what Vehicles.by_id / Vehicles.identify read back — including after a
91
+ # dataset release renames the record (it lists the old id in former_ids).
92
+ attr_reader :id
93
+
94
+ # Years this nameplate shows up in official registrations, as
95
+ # `{ first: 1998, last: 2026 }` (the SDKs' shape), or nil when the dataset
96
+ # does not publish them.
97
+ # A HINT for ordering or pre-filling a year picker — never a validation
98
+ # bound: an import, a late registration or a register that starts in 2015
99
+ # all fall outside it legitimately. Not `years` (production years).
100
+ attr_reader :registration_years
101
+
80
102
  # "Volkswagen Golf" — the natural label for a model on its own.
81
103
  def full_name
82
104
  "#{make} #{name}"
@@ -154,20 +176,24 @@ module Vehicles
154
176
  def rare? = rarity == :rare
155
177
 
156
178
  def to_h
157
- { make: make, model: name, slug: slug, kind: kind, body_type: body_type,
179
+ { id: id, make: make, model: name, slug: slug, model_slug: model_slug, kind: kind, body_type: body_type,
158
180
  global_decile: global_decile, rarity: rarity,
159
- availability: availability, regions: regions, aliases: aliases,
160
- former_ids: former_ids, country_ranks: country_ranks, mass_decile: mass_decile }
181
+ availability: availability, regions: regions, aliases: aliases, forms: forms,
182
+ former_ids: former_ids, country_ranks: country_ranks, mass_decile: mass_decile,
183
+ registration_years: registration_years }
161
184
  end
162
185
 
163
- # Value-object equality — two models with the same slug are equal.
186
+ # Value-object equality by canonical id (since 0.9.2). Until 0.9.1 it
187
+ # compared the composite slug, so the car and the van of one nameplate
188
+ # (Iveco Daily, Toyota Land Cruiser) were "equal" and collapsed in sets,
189
+ # `uniq` and hash keys.
164
190
  def ==(other)
165
- other.is_a?(Model) && other.slug == slug
191
+ other.is_a?(Model) && other.id == id
166
192
  end
167
193
  alias eql? ==
168
194
 
169
195
  def hash
170
- slug.hash
196
+ id.hash
171
197
  end
172
198
 
173
199
  def inspect
@@ -180,7 +206,10 @@ module Vehicles
180
206
  # They NEVER raise — a missing/slow API just yields nil and your view renders
181
207
  # a placeholder. See Vehicles::Providers.
182
208
 
183
- # Production years as a Range, e.g. 1974..2024. nil without hosted data.
209
+ # Production years as a Range, e.g. 1974..2024. Honest status: neither
210
+ # the bundled data nor the hosted API publishes production years yet, so
211
+ # this answers nil today (the seam lights up when the API serves them).
212
+ # For a year-picker hint, see `registration_years`.
184
213
  def years
185
214
  Vehicles.resolve(:years, self)
186
215
  end
@@ -190,25 +219,43 @@ module Vehicles
190
219
  Vehicles.resolve(:segment, self)
191
220
  end
192
221
 
193
- # One rendered image URL, ready for an <img src>. `size` picks the
222
+ # One image URL, ready for an <img src>. `size` picks the
194
223
  # variant (:sm/:md/:lg), `color` a palette slug (Vehicles.colors); an
195
- # un-rendered color falls back honestly server-side rather than erroring.
196
- # `year` is accepted for forward-compatibility but not yet served.
197
- # nil without hosted data — always render a placeholder path.
198
- def image(year: nil, color: nil, size: :md)
199
- Vehicles.resolve(:image, self, year: year, color: color, size: size)
224
+ # unavailable color falls back honestly server-side (another color is
225
+ # served) unless `exact_color: true`, which answers nil instead. `year`
226
+ # is sent to the API, which rejects it until year-accurate images ship
227
+ # (→ nil; `images_result` says :invalid_request "unsupported_filter").
228
+ # nil without hosted data or on any failure — always render a
229
+ # placeholder path, and ask `images_result` when you need to know why.
230
+ def image(year: nil, color: nil, size: :md, exact_color: false)
231
+ Vehicles.resolve(:image, self, year: year, color: color, size: size, exact_color: exact_color)
200
232
  end
201
233
 
202
234
  # The full hosted images payload — every variant with dimensions, the
203
- # rendered palette for this model, served-vs-requested color, provenance.
235
+ # available palette for this model, served-vs-requested color, provenance.
204
236
  # Reach for this when one URL isn't enough (srcset, color pickers,
205
237
  # caching whole responses). nil without hosted data.
206
- def images(color: nil)
207
- Vehicles.resolve(:images, self, color: color)
238
+ def images(color: nil, year: nil, exact_color: false)
239
+ Vehicles.resolve(:images, self, color: color, year: year, exact_color: exact_color)
240
+ end
241
+
242
+ # The same call as `images`, answering a Vehicles::HostedResult that says
243
+ # WHY when there is no image (no key, 401/402/429, not available, timeout…).
244
+ # Cached like `images`; never raises.
245
+ def images_result(color: nil, year: nil, exact_color: false)
246
+ Providers::HostedProvider.images_result(self, color: color, year: year, exact_color: exact_color)
208
247
  end
209
248
 
210
249
  private
211
250
 
251
+ def id_of(make_slug) = "#{@kind}/#{make_slug}/#{@model_slug}".freeze
252
+
253
+ def registration_years_of(attrs)
254
+ years = attrs["registration_years"]
255
+ first, last = years.is_a?(Hash) ? years.values_at("first", "last") : Array(years).first(2)
256
+ first.is_a?(Integer) && last.is_a?(Integer) && first <= last ? { first: first, last: last }.freeze : nil
257
+ end
258
+
212
259
  # The flat `mass_decile` key, or the nested `popularity.mass_decile` some
213
260
  # projections use.
214
261
  def mass_decile_of(attrs)
@@ -46,9 +46,23 @@ module Vehicles
46
46
  model.nil?
47
47
  end
48
48
 
49
+ # Rungs that rest on curated data: safe to apply WITHOUT asking a human.
50
+ CONFIDENT_RUNGS = %i[exact former_id fold alias form cleaned].freeze
51
+
52
+ # Safe to auto-apply (e.g. canonicalize a stored make/model, or pre-fill a
53
+ # form without asking): an answer (not ambiguous) reached through curated
54
+ # data — the id, a former id, the name, a published alias, a curated
55
+ # registry form, or one of those after peeling an echoed make or a "new"
56
+ # filler. Not :fuzzy: a typo correction is a guess ("Clase" → "Classe")
57
+ # to SHOW the user ("did you mean…"), never to write silently.
58
+ # Equivalent to `!ambiguous? && confidence >= 0.9`.
59
+ def confident?
60
+ !ambiguous? && CONFIDENT_RUNGS.include?(rung)
61
+ end
62
+
49
63
  def to_h
50
- { model: model&.to_h, method: rung.to_s, confidence: confidence, via: via,
51
- candidates: candidates.map(&:to_h), ambiguous: ambiguous? }
64
+ { model: model&.to_h, id: model&.id, method: rung.to_s, confidence: confidence, via: via,
65
+ candidates: candidates.map(&:to_h), ambiguous: ambiguous?, confident: confident? }
52
66
  end
53
67
 
54
68
  def ==(other)
@@ -44,12 +44,12 @@ module Vehicles
44
44
  module_function
45
45
 
46
46
  # The full ladder a–e. A ModelMatch, or nil when nothing matched at all.
47
- def match(make_query, model_query, kind: nil)
47
+ def match(make_query, model_query, kind: nil, kinds: nil)
48
48
  make, via, make_typo = resolve_make(make_query)
49
49
  return nil unless make
50
50
 
51
- via = kind_hint(kind) + via
52
- ladder(make, model_query, kind, via, make_query: make_query, make_typo: make_typo)
51
+ ladder(make, model_query, KindRule.scope(kind, kinds), KindRule.via(kind, kinds) + via,
52
+ make_query: make_query, make_typo: make_typo)
53
53
  end
54
54
 
55
55
  # The model half of the ladder on a resolved make: a–c, then (for
@@ -69,8 +69,6 @@ module Vehicles
69
69
  heuristic(make, query, kind, via, make_query: make_query, make_typo: make_typo)
70
70
  end
71
71
 
72
- def kind_hint(kind) = kind ? ["kind_hint:#{kind}"] : []
73
-
74
72
  # Rungs a–c only, on a resolved Make — what Vehicles.model answers. Rung a
75
73
  # is Make#model itself, so every pair that resolved before 0.9.0 resolves
76
74
  # identically; b–c are curated evidence, so on by default. A Model, or nil
@@ -189,9 +187,10 @@ module Vehicles
189
187
  def typo_hits(make, query, kind)
190
188
  limit = Fuzzy.budget(query.length)
191
189
  words = query.split
190
+ allowed = KindRule.symbols(kind)
192
191
  best = {}
193
192
  make.typo_keys.each do |key, model|
194
- next if kind && model.kind != kind.to_sym
193
+ next unless KindRule.allows?(allowed, model)
195
194
  next if (key.length - query.length).abs > limit
196
195
  next unless (dist = word_distance(words, key, limit))
197
196
 
@@ -242,7 +241,7 @@ module Vehicles
242
241
  key = Vehicles.normalize(make_query)
243
242
  if (make = ds.find_make(make_query, fuzzy: false))
244
243
  own = key.empty? || key == Vehicles.normalize(make.name) || key == Vehicles.normalize(make.slug)
245
- return [make, own ? [] : ["make_alias:#{key}→#{make.slug}"], false]
244
+ return [make, own ? [] : [ds.make_step(make, key)], false]
246
245
  end
247
246
  make = fuzzy ? ds.find_make(make_query, fuzzy: true) : nil
248
247
  make ? [make, ["make_typo:#{key}→#{make.slug}"], true] : [nil, [], false]
@@ -11,11 +11,16 @@ module Vehicles
11
11
  attr_reader :id, :klass, :categories, :period, :period_evidence,
12
12
  :pattern, :display_pattern, :regex, :regex_strict, :design,
13
13
  :variants, :sources, :notes, :validation_regexp, :lenient_regexp,
14
- :issued_regexp, :issued_separators
14
+ :issued_regexp, :issued_separators, :matching, :recall_lenient_regexp,
15
+ :letters
15
16
 
16
17
  def initialize(entry)
17
18
  @id = entry["id"]
18
19
  @klass = entry["class"] || "standard"
20
+ # the plates spec §2.7 (normative): a series says whether a regex hit is a
21
+ # validity claim (`strict`) or only a shape hit (`recall-only`).
22
+ # Absent => strict, the spec's backward-compatible default.
23
+ @matching = entry["matching"] || "strict"
19
24
  @categories = Array(entry["categories"]).freeze
20
25
  @period = (entry["period"] || {}).freeze
21
26
  @period_evidence = entry["period_evidence"]
@@ -25,6 +30,11 @@ module Vehicles
25
30
  @display_pattern = @pattern&.gsub(/\\(.)/) { $1 }
26
31
  @regex = entry.dig("format", "regex")
27
32
  @regex_strict = entry.dig("format", "regex_strict")
33
+ # The letters this series issues, when the dataset enumerates them
34
+ # (es-national-2000: the 20 consonants). Nil when it does not; a few
35
+ # series carry prose there ("A-Z, no exclusions published"), not a set.
36
+ letters = entry.dig("format", "charset", "letters")
37
+ @letters = letters.is_a?(Array) ? letters.map(&:to_s).freeze : nil
28
38
  @design = (entry["design"] || {}).freeze
29
39
  @variants = Array(entry["variants"]).freeze
30
40
  @sources = Array(entry["sources"]).freeze
@@ -32,18 +42,48 @@ module Vehicles
32
42
  # Compiled eagerly — the object is frozen, so no lazy memoization.
33
43
  @validation_regexp = compile(@regex_strict || @regex)
34
44
  @lenient_regexp = compile(self.class.strip_separators(@regex_strict || @regex))
45
+ # The series' SHAPE without its issuing alphabet, only where the two
46
+ # differ (a `regex_strict` exists): what Match#near_misses tests.
47
+ @recall_lenient_regexp = @regex_strict && compile(self.class.strip_separators(@regex))
35
48
  segments, separators = self.class.segment_separators(@regex_strict || @regex)
36
49
  @issued_separators = (separators || []).freeze
37
50
  @issued_regexp = segments && compile("\\A(#{segments.join(')(')})\\z")
51
+ # The same segmentation with every quantifier lazy. When the greedy
52
+ # and lazy readings of a serial split it differently, the groups are
53
+ # not recoverable from the bare serial ("CD12345" is CD-123-45 or
54
+ # CD-1234-5): format_serial must not pick one.
55
+ @issued_lazy_regexp = segments && compile("\\A(#{segments.map { |seg| self.class.lazy(seg) }.join(')(')})\\z")
38
56
  freeze
39
57
  end
40
58
 
41
- # A strict-validated series has an authority-alphabet regex twin; a
59
+ # A strict series' regex hit is a validity claim for that series; a
42
60
  # recall-only series (export/dark-blue catch-alls that accept any
43
- # current shape) matches loosely by design. Consumers validating user
44
- # input should weight strict hits above recall-only ones — Match does.
61
+ # current shape, the ES 1900-1971 numeric era) matches loosely by
62
+ # design. Read from the dataset's `matching:` key, never inferred from
63
+ # `regex_strict` being present: The plates spec §2.7 forbids that inference
64
+ # because most strict series need no strict twin (their `regex` already
65
+ # IS the issuing grammar — Portugal's AA-00-AA, the Spanish CD/CC/OI/TA
66
+ # series). Until 0.9.2 the gem inferred it, so 668 strict series
67
+ # (Portugal's whole national range among them) answered "not strict".
68
+ # Consumers validating user input should weight strict hits above
69
+ # recall-only ones — Match does.
45
70
  def strict?
46
- !regex_strict.nil?
71
+ matching != "recall-only"
72
+ end
73
+
74
+ # Vehicle categories that carry people (dataset `categories`
75
+ # vocabulary). A series issued only to trailers, semitrailers,
76
+ # caravans, tractors, machines or trucks plates nothing a passenger
77
+ # rides in.
78
+ PASSENGER_CATEGORIES = %w[car van bus coach motorhome motorcycle moped scooter quadricycle tricycle
79
+ ambulance].freeze
80
+
81
+ # Is this series issued to at least one passenger-carrying vehicle
82
+ # category? (es-national-2000: yes; es-trailer-1999 and the
83
+ # es-special-vehicles-1999 tractor plates: no.) Series without
84
+ # categories answer true: unknown is not "no".
85
+ def passenger?
86
+ categories.empty? || categories.intersect?(PASSENGER_CATEGORIES)
47
87
  end
48
88
 
49
89
  # period_evidence values on which the recorded START is not (or not
@@ -90,10 +130,15 @@ module Vehicles
90
130
  def format_serial(serial)
91
131
  return nil if serial.nil?
92
132
 
133
+ # A serial whose groups the series' own regex can split more than one
134
+ # way is returned as stored, before anything else: the bare serial
135
+ # cannot say which split was issued ("CD123456" is CD-123-456 or
136
+ # CD-12-3456; the dataset calls CD-999-999 representative), and a
137
+ # display pattern of one shape must not decide it.
138
+ return serial.dup.freeze if ambiguous_split?(serial)
139
+
93
140
  # The display pattern is authoritative when the serial fills it
94
- # exactly — it knows splits the regex cannot (CC-999-999 over
95
- # `\d+[- ]\d+` is ambiguous to a greedy match, unambiguous to the
96
- # pattern).
141
+ # exactly and the regex has only one reading of it.
97
142
  slots = display_pattern&.each_char&.count { |ch| ![ "-", " ", "·", "." ].include?(ch) }
98
143
  if slots && serial.length == slots
99
144
  idx = 0
@@ -274,6 +319,40 @@ module Vehicles
274
319
  out.gsub(REDUNDANT_CHARACTER_CLASS_REPEAT) { "#{Regexp.last_match(1)}+" }
275
320
  end
276
321
 
322
+ # True when the greedy and lazy readings of the segmented regex split
323
+ # this serial differently.
324
+ def ambiguous_split?(serial)
325
+ greedy = issued_regexp&.match(serial) or return false
326
+ lazy = @issued_lazy_regexp&.match(serial) or return false
327
+ greedy.captures != lazy.captures
328
+ end
329
+
330
+ # A regex segment with its quantifiers made lazy (+ *, ?, {m,n}), for
331
+ # the ambiguity check in format_serial. Escapes and character classes
332
+ # are copied untouched.
333
+ def self.lazy(src)
334
+ out = +""
335
+ chars = src.chars
336
+ i = 0
337
+ while i < chars.size
338
+ ch = chars[i]
339
+ if ch == "\\"
340
+ out << ch << chars[i + 1].to_s
341
+ i += 2
342
+ elsif ch == "["
343
+ closing = i + 1
344
+ closing += 1 while closing < chars.size && chars[closing] != "]"
345
+ out << chars[i..closing].join
346
+ i = closing + 1
347
+ else
348
+ out << ch
349
+ out << "?" if [ "+", "*", "}" ].include?(ch) && chars[i + 1] != "?"
350
+ i += 1
351
+ end
352
+ end
353
+ out
354
+ end
355
+
277
356
  def inspect = %(#<Vehicles::Plates::Series #{id} "#{pattern}">)
278
357
 
279
358
  private
@@ -6,14 +6,14 @@ module Vehicles
6
6
  module Plates
7
7
  # The REFERENCE FILLER for VehiclesDB plate templates — the client half of
8
8
  # the privacy-safe template endpoint
9
- # (GET /plates/:code/:series_id/template.svg, VehiclesDB PRD-PLATE-ART §9).
9
+ # (GET /plates/:code/:series_id/template.svg, VehiclesDB plate-art spec §9).
10
10
  # A client downloads a template once (it carries nothing personal, so it
11
11
  # caches publicly) and writes the user's registration into it HERE, on its
12
12
  # own machine — a registration is personal data and never travels:
13
13
  #
14
14
  # svg = Vehicles::Plates::TemplateFill.fill(template_svg, "1234 BCD")
15
15
  #
16
- # Moved here from vehiclesdb-web (app/models/plates/template_fill.rb; same logic,
16
+ # Moved here from the VehiclesDB web app (app/models/plates/template_fill.rb; same logic,
17
17
  # plus shape_of and label:), whose renderer will call `fit_size` /
18
18
  # `recentered_baseline` from this gem
19
19
  # so the fit formula lives once. Deliberately dependency-free (Ruby stdlib
@@ -3,7 +3,7 @@
3
3
  module Vehicles
4
4
  module Plates
5
5
  # VehiclesDB's real plate art for a registration, WITHOUT sending the
6
- # registration anywhere (VehiclesDB PRD-PLATE-ART §9: a plate number is
6
+ # registration anywhere (VehiclesDB plate-art spec §9: a plate number is
7
7
  # personal data). The plate is fetched as a BLANK template — the series,
8
8
  # the registration's shape ("NNNN LLL", character classes only) and an
9
9
  # optional region — and filled locally by TemplateFill:
@@ -79,13 +79,17 @@ module Vehicles
79
79
  nil
80
80
  end
81
81
 
82
- # The one series a match's plate is drawn as: the only standard-class
83
- # series among the hits, else the only hit. Nil when it is ambiguous.
82
+ # The one series a match's plate is drawn as. Strict hits outrank
83
+ # recall-only ones (a Spanish "CD-123-456" is the diplomatic series, not
84
+ # the 1900-1971 numeric catch-all it also fits); among them, the only
85
+ # standard-class series, else the only hit. Nil when it is ambiguous.
84
86
  def template_series(found)
85
- standard = found.series.select { |series| series.klass == "standard" }
87
+ strict = found.series.select(&:strict?)
88
+ candidates = strict.empty? ? found.series : strict
89
+ standard = candidates.select { |series| series.klass == "standard" }
86
90
  return standard.first if standard.one?
87
91
 
88
- found.series.first if found.series.one?
92
+ candidates.first if candidates.one?
89
93
  end
90
94
 
91
95
  def fetch_template(code, series, shape, region)
@@ -3,7 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  module Vehicles
6
- # License-plate & registration-mark knowledge (the VehiclesDB PRD-PLATES
6
+ # License-plate & registration-mark knowledge (the VehiclesDB the plates spec
7
7
  # dataset, bundled offline like everything else in this gem). Ask it two
8
8
  # kinds of question:
9
9
  #
@@ -67,15 +67,20 @@ module Vehicles
67
67
  canon = typed.gsub(SEPARATORS, "")
68
68
  return Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: []) if juris.nil? || canon.empty?
69
69
 
70
- hits = juris.series.filter_map do |series|
71
- next nil unless series.validation_regexp
70
+ hits = []
71
+ near_misses = []
72
+ juris.series.each do |series|
73
+ next unless series.validation_regexp
72
74
 
73
75
  exact = series.validation_regexp.match?(typed)
74
- lenient = exact || series.lenient_regexp&.match?(canon)
75
- Match::Hit.new(series: series, exact: exact) if lenient
76
+ if exact || series.lenient_regexp&.match?(canon)
77
+ hits << Match::Hit.new(series: series, exact: exact)
78
+ elsif series.recall_lenient_regexp&.match?(canon)
79
+ near_misses << series
80
+ end
76
81
  end
77
82
 
78
- Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits)
83
+ Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits, near_misses: near_misses)
79
84
  end
80
85
 
81
86
  # The answer to "is this a plate, and which series issues it?".
@@ -84,12 +89,17 @@ module Vehicles
84
89
  def exact? = exact
85
90
  end
86
91
 
87
- attr_reader :input, :jurisdiction, :canon, :hits
92
+ attr_reader :input, :jurisdiction, :canon, :hits, :near_misses
88
93
 
89
- def initialize(input:, jurisdiction:, canon:, hits:)
94
+ def initialize(input:, jurisdiction:, canon:, hits:, near_misses: [])
90
95
  @input = input
91
96
  @jurisdiction = jurisdiction
92
97
  @canon = canon
98
+ # Series whose SHAPE the plate has but whose issuing ALPHABET it
99
+ # breaks: "1234 ABC" is the Spanish 2000 shape with vowels Spain never
100
+ # issues. Lets a form say WHY ("no vowels, Ñ or Q") instead of only
101
+ # "not a plate". Empty whenever the plate hits a strict series.
102
+ @near_misses = (hits.any? { |h| h.series.strict? } ? [] : near_misses).freeze
93
103
  # Exact hits outrank lenient ones, strict-validated series outrank
94
104
  # recall-only catch-alls; series order (standard first) breaks ties.
95
105
  @hits = hits.sort_by { |h| [ h.exact? ? 0 : 1, h.series.strict? ? 0 : 1 ] }.freeze
@@ -117,6 +127,42 @@ module Vehicles
117
127
  def suggestion
118
128
  formatted if valid? && !exact?
119
129
  end
130
+
131
+ # WHY the plate is (not) valid, as one Symbol — the same codes the
132
+ # VehiclesDB API answers (as strings):
133
+ # :ok a strict series issues it (the authority's real alphabet)
134
+ # :unverifiable only a recall-only series has the shape (an era
135
+ # or catch-all whose alphabet is not published)
136
+ # :excluded_letters a series has this shape but never issues these
137
+ # letters ("1234 ABC": Spain issues no vowels, Ñ or Q)
138
+ # :bad_shape no series has this shape
139
+ # :empty nothing but separators was typed
140
+ # :unknown_jurisdiction no plate data for that code
141
+ # Validity only: whether a passenger vehicle may carry it is `passenger?`.
142
+ def reason
143
+ return :unknown_jurisdiction if jurisdiction.nil?
144
+ return :empty if canon.empty?
145
+ return :ok if strict?
146
+ return :unverifiable if valid?
147
+
148
+ excluded_letters.empty? ? :bad_shape : :excluded_letters
149
+ end
150
+
151
+ # The letters of the plate that a near-miss series (same shape, an
152
+ # enumerated alphabet) never issues, e.g. ["A", "E"] for "1234 ABE" in
153
+ # Spain. [] when that is not why it failed.
154
+ def excluded_letters
155
+ letters = canon.scan(/[[:alpha:]]/).uniq
156
+ near_misses.select(&:letters).flat_map { |series| letters - series.letters }.uniq.sort
157
+ end
158
+
159
+ # Does a STRICT hit come from a series issued to passenger-carrying
160
+ # vehicles (Series#passenger?)? false for a valid trailer or tractor
161
+ # plate, and for anything not strictly valid. For apps that move
162
+ # people (ride sharing, rentals).
163
+ def passenger?
164
+ hits.any? { |hit| hit.series.strict? && hit.series.passenger? }
165
+ end
120
166
  end
121
167
  end
122
168
  end