Skip to main content

vin_decode/
decoder.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::data::{LookupRow, MakeRow, SchemaRow, VinRuleRow};
5use crate::element::Element;
6use crate::maps::{FstMap, data_dir};
7use crate::types::{Vehicle, Vin};
8use crate::{Error, check_digit, pattern};
9
10#[cfg(feature = "parallel")]
11use rayon::prelude::*;
12
13/// VIN decoder backed by mmap'd FST/rkyv lookup maps.
14///
15/// Construct via [`Decoder::new`] (uses default data dir / env var) or
16/// [`Decoder::open`] (explicit path).
17pub struct Decoder {
18    wmi_make: FstMap<MakeRow>,
19    wmi_schema: FstMap<SchemaRow>,
20    schema_lookup: FstMap<LookupRow>,
21    wmi_rules: Option<FstMap<VinRuleRow>>,
22}
23
24impl Decoder {
25    /// Open the decoder using the default data directory.
26    ///
27    /// Resolution order:
28    /// 1. `VIN_DECODE_DATA_DIR` environment variable
29    /// 2. `$HOME/.vin-decode-cache`
30    /// 3. `./.vin-decode-cache`
31    ///
32    /// With the `embedded` feature, this also auto-installs bundled data into
33    /// the resolved directory on first run.
34    pub fn new() -> crate::Result<Self> {
35        let dir = data_dir();
36        #[cfg(feature = "embedded")]
37        crate::embedded::ensure_installed(&dir).ok();
38        Self::open(&dir).map_err(|e| match e {
39            Error::MissingData(path) => Error::MissingData(format!(
40                "{path} — set VIN_DECODE_DATA_DIR or install embedded data"
41            )),
42            other => other,
43        })
44    }
45
46    /// Open the decoder against an explicit data directory. The `wmi_rules`
47    /// curated table is optional — only loaded if `wmi_rules.fst` exists.
48    pub fn open(dir: &Path) -> crate::Result<Self> {
49        let wmi_rules = if dir.join("wmi_rules.fst").exists() {
50            Some(FstMap::open(dir)?)
51        } else {
52            None
53        };
54        Ok(Decoder {
55            wmi_make: FstMap::open(dir)?,
56            wmi_schema: FstMap::open(dir)?,
57            schema_lookup: FstMap::open(dir)?,
58            wmi_rules,
59        })
60    }
61
62    /// Decode a VIN with full validation (length, charset, check digit).
63    pub fn decode(&self, raw: &str) -> crate::Result<Vehicle> {
64        let vin = Vin::new(raw)?;
65        check_digit::validate(&vin)?;
66        Ok(self.decode_inner(vin))
67    }
68
69    /// Decode a VIN, skipping the check-digit step.
70    ///
71    /// Useful for VINs from non-NHTSA jurisdictions where the check digit isn't enforced.
72    pub fn decode_unchecked(&self, raw: &str) -> crate::Result<Vehicle> {
73        let vin = Vin::new(raw)?;
74        Ok(self.decode_inner(vin))
75    }
76
77    /// Decode a slice of VINs (parallelized with `parallel` feature, sequential otherwise).
78    pub fn decode_batch(&self, vins: &[&str]) -> Vec<crate::Result<Vehicle>>
79    where
80        Self: Sync,
81    {
82        #[cfg(feature = "parallel")]
83        {
84            vins.par_iter().map(|v| self.decode(v)).collect()
85        }
86        #[cfg(not(feature = "parallel"))]
87        {
88            vins.iter().map(|v| self.decode(v)).collect()
89        }
90    }
91
92    fn decode_inner(&self, vin: Vin) -> Vehicle {
93        let wmi = vin.wmi().to_string();
94        let mut make_row = self.wmi_make.get(&wmi).and_then(|mut rows| rows.pop());
95
96        let mut vehicle = Vehicle {
97            vin: vin.as_str().to_string(),
98            wmi: wmi.clone(),
99            make: make_row.as_ref().map(|r| ascii_fold(&r.name)),
100            ..Default::default()
101        };
102
103        if let Some(row) = make_row.take() {
104            if !row.country.is_empty() {
105                vehicle.plant_country = Some(row.country);
106            }
107            if !row.region.is_empty() {
108                vehicle.region = Some(row.region);
109            }
110        }
111        if vehicle.region.is_none() {
112            if let Some(region) = crate::wmi::region(vin.as_str().chars().next().unwrap_or('\0')) {
113                vehicle.region = Some(region.to_string());
114            }
115        }
116
117        // Curated VIN rules first — they can override make (e.g. UU1 → DACIA
118        // on HSD prefix vs RENAULT default) and supply model when vPIC has no
119        // pattern coverage. Pattern decode runs after and can refine model.
120        self.apply_wmi_rules(&vin, &mut vehicle);
121
122        // model_year is intentionally never filled here. SAE-J853 year codes
123        // map to TWO candidate years 30y apart and brands disagree on which
124        // VIN position carries the year. Returning a guessed year was wrong
125        // more often than helpful on real corpora — consumers who want raw
126        // candidates can call `Vin::year_candidates()` and decide themselves.
127
128        self.fill_pattern_attrs(&vin, &mut vehicle);
129
130        // plant_country: the VIN's ISO 3779 country code (positions 1-2) is the
131        // authoritative source. The WMI metadata table mixes scraped sources
132        // (some plain wrong — e.g. a Sunderland Nissan tagged Switzerland) and
133        // vPIC pattern data is US-centric and FK-leaky, so the country range
134        // encoded in the VIN itself wins whenever it maps. Unmapped prefixes
135        // fall back to whatever the table/pattern supplied (already guarded
136        // against unresolved numeric FKs).
137        if let Some(country) = crate::country::country_from_code(vin.country_code()) {
138            vehicle.plant_country = Some(country.to_string());
139        }
140
141        vehicle
142    }
143
144    /// Apply the longest-matching curated `wmi_rules` row for this VIN.
145    /// Non-empty `make`/`model` fields overwrite whatever was previously set;
146    /// empty fields are left alone.
147    fn apply_wmi_rules(&self, vin: &Vin, vehicle: &mut Vehicle) {
148        let Some(wmi_rules) = &self.wmi_rules else {
149            return;
150        };
151        let Some(rules) = wmi_rules.get(vin.wmi()) else {
152            return;
153        };
154        let after_wmi = &vin.as_str()[3..];
155        for rule in rules {
156            if !after_wmi.starts_with(&rule.remainder) {
157                continue;
158            }
159            if !rule.make.is_empty() {
160                vehicle.make = Some(rule.make.clone());
161            }
162            if !rule.model.is_empty() {
163                vehicle.model = Some(rule.model.clone());
164            }
165            return;
166        }
167    }
168
169    fn fill_pattern_attrs(&self, vin: &Vin, vehicle: &mut Vehicle) {
170        let Some(schemas) = self.wmi_schema.get(vin.wmi()) else {
171            return;
172        };
173        let mut groups: HashMap<Element, Vec<(LookupRow, f64)>> = HashMap::new();
174        for sch in &schemas {
175            let Some(lookups) = self.schema_lookup.get(&sch.id) else {
176                continue;
177            };
178            for lk in lookups {
179                let Some(elem) = Element::from_str(&lk.element) else {
180                    continue;
181                };
182                let conf = pattern::confidence(&lk.pattern, vin.vds(), vin.vis());
183                if conf > elem.confidence_cutoff() {
184                    groups.entry(elem).or_default().push((lk, conf));
185                }
186            }
187        }
188        for (elem, mut group) in groups {
189            group.sort_by(rank_candidates);
190            if let Some((top, _)) = group.into_iter().next() {
191                elem.apply(vehicle, top.value);
192            }
193        }
194    }
195}
196
197/// Order two competing pattern rows for the same element, best first.
198///
199/// Primary keys are the vPIC element weight then the pattern-match confidence
200/// — the real signal. But some vPIC snapshots ship no weight column (every
201/// `weight` is 0) and equally specific patterns tie on confidence too, so the
202/// top two keys collapse and the winner would otherwise fall to data row order
203/// — which the SQL dump does not pin down, making decode nondeterministic.
204///
205/// Two further keys keep it deterministic AND correct: a resolved human name
206/// always beats a still-numeric foreign-key id (`"Model 3"` over `"29000005"`),
207/// and the value string itself breaks any final tie.
208fn rank_candidates(a: &(LookupRow, f64), b: &(LookupRow, f64)) -> std::cmp::Ordering {
209    use std::cmp::Ordering;
210    b.0.weight
211        .cmp(&a.0.weight)
212        .then(b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal))
213        .then_with(|| looks_like_fk(&a.0.value).cmp(&looks_like_fk(&b.0.value)))
214        .then_with(|| a.0.value.cmp(&b.0.value))
215}
216
217/// A value that is entirely ASCII digits is an unresolved foreign-key id rather
218/// than a human-readable attribute — rank it below any real name.
219fn looks_like_fk(value: &str) -> bool {
220    !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit())
221}
222
223/// Canonicalise a make string for catalog lookups: uppercase + collapse
224/// hyphens to spaces + ASCII-fold diacritics. Aligns `wmi_make` rows
225/// (`"MERCEDES-BENZ"`, `"CITROËN"`) with `eu_brand_models` rows
226/// (`"MERCEDES BENZ"`, `"CITROEN"`).
227pub(crate) fn normalize_make(s: &str) -> String {
228    let mut out = String::with_capacity(s.len());
229    for ch in s.chars() {
230        if ch == '-' || ch == '_' {
231            out.push(' ');
232        } else {
233            for u in ch.to_uppercase() {
234                out.push(ascii_fold_char(u));
235            }
236        }
237    }
238    out
239}
240
241/// Strip diacritics from a string by ASCII-folding common Latin accents.
242/// Used to clean upstream sources that ship `CITROËN`, `ŠKODA`, `BJØRN`, etc.
243/// Non-foldable characters are passed through unchanged.
244pub(crate) fn ascii_fold(s: &str) -> String {
245    s.chars().map(ascii_fold_char).collect()
246}
247
248fn ascii_fold_char(c: char) -> char {
249    match c {
250        'À'..='Å' | 'à'..='å' => {
251            if c.is_ascii_uppercase() || c.is_uppercase() {
252                'A'
253            } else {
254                'a'
255            }
256        }
257        'Ç' => 'C',
258        'ç' => 'c',
259        'È'..='Ë' => 'E',
260        'è'..='ë' => 'e',
261        'Ì'..='Ï' => 'I',
262        'ì'..='ï' => 'i',
263        'Ñ' => 'N',
264        'ñ' => 'n',
265        'Ò'..='Ö' | 'Ø' => 'O',
266        'ò'..='ö' | 'ø' => 'o',
267        'Ù'..='Ü' => 'U',
268        'ù'..='ü' => 'u',
269        'Ý' | 'Ÿ' => 'Y',
270        'ý' | 'ÿ' => 'y',
271        'Š' => 'S',
272        'š' => 's',
273        'Ž' => 'Z',
274        'ž' => 'z',
275        _ => c,
276    }
277}
278
279#[cfg(test)]
280mod rank_tests {
281    use super::{looks_like_fk, rank_candidates};
282    use crate::data::LookupRow;
283
284    fn cand(value: &str, weight: u32, conf: f64) -> (LookupRow, f64) {
285        (
286            LookupRow {
287                pattern: "*".into(),
288                element: "Model".into(),
289                value: value.into(),
290                weight,
291            },
292            conf,
293        )
294    }
295
296    #[test]
297    fn fk_detection() {
298        assert!(looks_like_fk("29000005"));
299        assert!(!looks_like_fk("Model 3"));
300        assert!(!looks_like_fk("")); // empty is not a numeric FK
301        assert!(!looks_like_fk("A4"));
302    }
303
304    /// The regression that took the cron down: equal weight (0) + equal
305    /// confidence, resolved name must beat the raw FK regardless of input order.
306    #[test]
307    fn resolved_name_beats_raw_fk_either_order() {
308        for mut g in [
309            [cand("29000005", 0, 1.0), cand("Model 3", 0, 1.0)],
310            [cand("Model 3", 0, 1.0), cand("29000005", 0, 1.0)],
311        ] {
312            g.sort_by(rank_candidates);
313            assert_eq!(g[0].0.value, "Model 3");
314        }
315    }
316
317    #[test]
318    fn weight_then_confidence_dominate() {
319        let mut g = [cand("Model 3", 0, 1.0), cand("29000005", 5, 0.1)];
320        g.sort_by(rank_candidates);
321        assert_eq!(g[0].0.value, "29000005", "a weighted row wins outright");
322
323        let mut g = [cand("low", 0, 0.2), cand("high", 0, 0.9)];
324        g.sort_by(rank_candidates);
325        assert_eq!(g[0].0.value, "high", "higher confidence breaks weight tie");
326    }
327
328    #[test]
329    fn fully_tied_is_order_independent() {
330        let mut a = [cand("Zeta", 0, 1.0), cand("Alpha", 0, 1.0)];
331        let mut b = [cand("Alpha", 0, 1.0), cand("Zeta", 0, 1.0)];
332        a.sort_by(rank_candidates);
333        b.sort_by(rank_candidates);
334        assert_eq!(a[0].0.value, "Alpha");
335        assert_eq!(a[0].0.value, b[0].0.value);
336    }
337}