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        vehicle
130    }
131
132    /// Apply the longest-matching curated `wmi_rules` row for this VIN.
133    /// Non-empty `make`/`model` fields overwrite whatever was previously set;
134    /// empty fields are left alone.
135    fn apply_wmi_rules(&self, vin: &Vin, vehicle: &mut Vehicle) {
136        let Some(wmi_rules) = &self.wmi_rules else {
137            return;
138        };
139        let Some(rules) = wmi_rules.get(vin.wmi()) else {
140            return;
141        };
142        let after_wmi = &vin.as_str()[3..];
143        for rule in rules {
144            if !after_wmi.starts_with(&rule.remainder) {
145                continue;
146            }
147            if !rule.make.is_empty() {
148                vehicle.make = Some(rule.make.clone());
149            }
150            if !rule.model.is_empty() {
151                vehicle.model = Some(rule.model.clone());
152            }
153            return;
154        }
155    }
156
157    fn fill_pattern_attrs(&self, vin: &Vin, vehicle: &mut Vehicle) {
158        let Some(schemas) = self.wmi_schema.get(vin.wmi()) else {
159            return;
160        };
161        let mut groups: HashMap<Element, Vec<(LookupRow, f64)>> = HashMap::new();
162        for sch in &schemas {
163            let Some(lookups) = self.schema_lookup.get(&sch.id) else {
164                continue;
165            };
166            for lk in lookups {
167                let Some(elem) = Element::from_str(&lk.element) else {
168                    continue;
169                };
170                let conf = pattern::confidence(&lk.pattern, vin.vds(), vin.vis());
171                if conf > elem.confidence_cutoff() {
172                    groups.entry(elem).or_default().push((lk, conf));
173                }
174            }
175        }
176        for (elem, mut group) in groups {
177            group.sort_by(rank_candidates);
178            if let Some((top, _)) = group.into_iter().next() {
179                elem.apply(vehicle, top.value);
180            }
181        }
182    }
183}
184
185/// Order two competing pattern rows for the same element, best first.
186///
187/// Primary keys are the vPIC element weight then the pattern-match confidence
188/// — the real signal. But some vPIC snapshots ship no weight column (every
189/// `weight` is 0) and equally specific patterns tie on confidence too, so the
190/// top two keys collapse and the winner would otherwise fall to data row order
191/// — which the SQL dump does not pin down, making decode nondeterministic.
192///
193/// Two further keys keep it deterministic AND correct: a resolved human name
194/// always beats a still-numeric foreign-key id (`"Model 3"` over `"29000005"`),
195/// and the value string itself breaks any final tie.
196fn rank_candidates(a: &(LookupRow, f64), b: &(LookupRow, f64)) -> std::cmp::Ordering {
197    use std::cmp::Ordering;
198    b.0.weight
199        .cmp(&a.0.weight)
200        .then(b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal))
201        .then_with(|| looks_like_fk(&a.0.value).cmp(&looks_like_fk(&b.0.value)))
202        .then_with(|| a.0.value.cmp(&b.0.value))
203}
204
205/// A value that is entirely ASCII digits is an unresolved foreign-key id rather
206/// than a human-readable attribute — rank it below any real name.
207fn looks_like_fk(value: &str) -> bool {
208    !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit())
209}
210
211/// Canonicalise a make string for catalog lookups: uppercase + collapse
212/// hyphens to spaces + ASCII-fold diacritics. Aligns `wmi_make` rows
213/// (`"MERCEDES-BENZ"`, `"CITROËN"`) with `eu_brand_models` rows
214/// (`"MERCEDES BENZ"`, `"CITROEN"`).
215pub(crate) fn normalize_make(s: &str) -> String {
216    let mut out = String::with_capacity(s.len());
217    for ch in s.chars() {
218        if ch == '-' || ch == '_' {
219            out.push(' ');
220        } else {
221            for u in ch.to_uppercase() {
222                out.push(ascii_fold_char(u));
223            }
224        }
225    }
226    out
227}
228
229/// Strip diacritics from a string by ASCII-folding common Latin accents.
230/// Used to clean upstream sources that ship `CITROËN`, `ŠKODA`, `BJØRN`, etc.
231/// Non-foldable characters are passed through unchanged.
232pub(crate) fn ascii_fold(s: &str) -> String {
233    s.chars().map(ascii_fold_char).collect()
234}
235
236fn ascii_fold_char(c: char) -> char {
237    match c {
238        'À'..='Å' | 'à'..='å' => {
239            if c.is_ascii_uppercase() || c.is_uppercase() {
240                'A'
241            } else {
242                'a'
243            }
244        }
245        'Ç' => 'C',
246        'ç' => 'c',
247        'È'..='Ë' => 'E',
248        'è'..='ë' => 'e',
249        'Ì'..='Ï' => 'I',
250        'ì'..='ï' => 'i',
251        'Ñ' => 'N',
252        'ñ' => 'n',
253        'Ò'..='Ö' | 'Ø' => 'O',
254        'ò'..='ö' | 'ø' => 'o',
255        'Ù'..='Ü' => 'U',
256        'ù'..='ü' => 'u',
257        'Ý' | 'Ÿ' => 'Y',
258        'ý' | 'ÿ' => 'y',
259        'Š' => 'S',
260        'š' => 's',
261        'Ž' => 'Z',
262        'ž' => 'z',
263        _ => c,
264    }
265}
266
267#[cfg(test)]
268mod rank_tests {
269    use super::{looks_like_fk, rank_candidates};
270    use crate::data::LookupRow;
271
272    fn cand(value: &str, weight: u32, conf: f64) -> (LookupRow, f64) {
273        (
274            LookupRow {
275                pattern: "*".into(),
276                element: "Model".into(),
277                value: value.into(),
278                weight,
279            },
280            conf,
281        )
282    }
283
284    #[test]
285    fn fk_detection() {
286        assert!(looks_like_fk("29000005"));
287        assert!(!looks_like_fk("Model 3"));
288        assert!(!looks_like_fk("")); // empty is not a numeric FK
289        assert!(!looks_like_fk("A4"));
290    }
291
292    /// The regression that took the cron down: equal weight (0) + equal
293    /// confidence, resolved name must beat the raw FK regardless of input order.
294    #[test]
295    fn resolved_name_beats_raw_fk_either_order() {
296        for mut g in [
297            [cand("29000005", 0, 1.0), cand("Model 3", 0, 1.0)],
298            [cand("Model 3", 0, 1.0), cand("29000005", 0, 1.0)],
299        ] {
300            g.sort_by(rank_candidates);
301            assert_eq!(g[0].0.value, "Model 3");
302        }
303    }
304
305    #[test]
306    fn weight_then_confidence_dominate() {
307        let mut g = [cand("Model 3", 0, 1.0), cand("29000005", 5, 0.1)];
308        g.sort_by(rank_candidates);
309        assert_eq!(g[0].0.value, "29000005", "a weighted row wins outright");
310
311        let mut g = [cand("low", 0, 0.2), cand("high", 0, 0.9)];
312        g.sort_by(rank_candidates);
313        assert_eq!(g[0].0.value, "high", "higher confidence breaks weight tie");
314    }
315
316    #[test]
317    fn fully_tied_is_order_independent() {
318        let mut a = [cand("Zeta", 0, 1.0), cand("Alpha", 0, 1.0)];
319        let mut b = [cand("Alpha", 0, 1.0), cand("Zeta", 0, 1.0)];
320        a.sort_by(rank_candidates);
321        b.sort_by(rank_candidates);
322        assert_eq!(a[0].0.value, "Alpha");
323        assert_eq!(a[0].0.value, b[0].0.value);
324    }
325}