Santo

An Elixir library for VIN and chassis-number parsing, decoding, and identity resolution — the decode layer beneath a provenance registry.

Generic VIN decoders (ISO 3779 split + an NHTSA API call) are commodity. Santo's value is the layer they skip: marque-level semantics, pre-VIN chassis grammars, and chassis identity resolution. Porsche is the first marque adapter, chosen because it exercises every hard case: pre-1981 chassis numbering, US/RoW VDS divergence, serial-range-encoded model semantics, and the 30-year model-year ambiguity.

Installation

Santo requires Elixir 1.14 or later and has no runtime dependencies.

For local service development, keep Santo as a sibling project:

defp deps do
[
{:santo, path: "../santo"}
]
end

After the first Hex release:

defp deps do
[
{:santo, "~> 0.2.0"}
]
end

The package contains only the runtime library, its compiled reference tables, license, changelog, and package metadata. The corpus and vPIC Oracle remain maintainer tooling and are not shipped to consumers.

Design commitments

  1. Pure functions over compiled data. No processes, no ETS, no HTTP in the decode path. Reference tables (priv/data/*.csv, every row with a source column) compile into function heads and map literals. Works in a script, a test, or a Livebook with zero setup.
  2. Binary pattern matching is the dispatch mechanism. Marque routing, market detection, and era detection are function heads over binary prefixes and shapes.
  3. Ambiguity is a first-class return value. The library never guesses. A VIN that admits two model years returns both. A chassis number that could be two eras returns both, with the evidence that would settle it (:kardex, :coa, :engine_number) named in notes.
  4. VIN ≠ identity.Santo.Parsed describes a string; Santo.Identity names a physical chassis. Re-stamps, replicas, pre-VIN cars, and factory crossover-era cars make identity a superset of VIN.

Usage

# Modern US-market: real check digit, model code + plant disambiguation
{:ok, d} = Santo.decode("WP0CA298X5L001502")
d.model #=> {:carrera_gt, "980"}
d.years #=> [2005]
d.check_digit #=> :valid (position 9 is literally "X" — remainder 10)
# Modern RoW: ZZZ filler *is* the market signal; no check digit applies
{:ok, d} = Santo.decode("WP0ZZZ95ZJS905016")
d.model #=> {:"959", "959"}
d.attributes.variant #=> :sport (905xxx serial block — semantics in the serial range)
d.check_digit #=> :not_applicable
# Pre-VIN ten-digit grammar (and marque-aware normalization)
{:ok, d} = Santo.decode("911.360.0471")
d.model #=> {:carrera_rs_27, "911"}
d.years #=> [1973]
d.notes #=> [{:evidence_required, :option_package, [:kardex, :coa]}]
# (M471 Lightweight vs M472 Touring is *not* in the chassis number)
# 356-era crossover: ambiguity is data
{:ambiguous, [pre_a, a]} = Santo.decode("81192")
Santo.Identity.key("81192")
#=> {:ok, {:disputed,
# [{:chassis, :porsche, :"356_pre_a", "81192"},
# {:chassis, :porsche, :"356_a", "81192"}],
# [:kardex, :engine_number]}}
# Dirty sources: repairs are proposed, never applied
{:error, invalid} = Santo.decode("WP0ZZZ95ZJS90015") # 16 chars, as printed by Bonhams
invalid.repaired #=> nil (multiple plausible repairs — never guess)
invalid.candidates #=> ["WP0ZZZ95ZJS900015", ...] (all decode as in-block 959s)
{:error, invalid} = Santo.decode("WP0CA29845L0O1561") # OCR'd O for 0
invalid.repaired #=> "WP0CA29845L001561" (unique repair passes the check digit)

Return convention throughout:

{:ok, %Santo.Decoded{}}
| {:ambiguous, [%Santo.Decoded{}]} # multiple valid readings
| {:error, %Santo.Invalid{}} # reasons + repair proposals

Layout

lib/santo.ex # Public API: parse/1, decode/1, validate/1, identify/1, normalize/1
lib/santo/
parsed.ex # positional decomposition (no semantics)
decoded.ex # semantic decode struct
invalid.ex # diagnosis + repair proposals
check_digit.ex # transliteration, weights; market-conditional at the call site
model_year.ex # 30-year cycle; candidate lists; pos-7 rule (NA post-1981 only)
wmi.ex # marque routing: compiled heads (hot) + map literal (long tail)
normalize.ex # lossless canonicalization
identity.ex # VIN ⊃ chassis; :disputed registry keys
marque.ex # adapter behaviour (:pass falls through to generic)
marque/porsche.ex # dispatcher: routes by shape/era
marque/porsche/ # modern.ex (17-char) / mid.ex (ten-digit) / early.ex (356)
priv/data/ # vendored tables; every row carries a source column
test/fixtures/corpus.exs # golden corpus of real, documented cars
oracle/ # maintainer-only vPIC differential harness

Test corpus

The golden corpus (test/fixtures/corpus.exs) landed before the decoder did — frozen benchmark first. Real, publicly documented cars: six Carrera GTs sharing a VDS stem with distinct check digits, 959 Komfort vs Sport serial blocks, the H/J year-code pair, 1973 Carrera RS 2.7 ten-digit chassis numbers, and 356 Speedsters spanning the Pre-A/A factory transition.

mix test # zero dependencies

Status / roadmap

Explicit non-goals for the library: persistence, HTTP clients in the decode path, VIN generation beyond test support.