Packages

VIN and chassis-number parsing, decoding, and identity resolution. Marque-level semantics, pre-VIN chassis grammars, and ambiguity as data.

Current section

Files

Jump to
santo README.md
Raw

README.md

# 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:
```elixir
defp deps do
[
{:santo, path: "../santo"}
]
end
```
After the first Hex release:
```elixir
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
```elixir
# 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:
```elixir
{: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
- [x] Phase 0 — skeleton + corpus, `Parsed`, check digit (incl. `X`), model-year candidates
- [x] Phase 1 — WMI routing + codegen (compiled heads + long-tail map)
- [x] Phase 2 — Porsche modern adapter (US/RoW split, market-conditional check digit, serial blocks)
- [x] Phase 3 — pre-VIN grammars (ten-digit Mid, 356 Early), normalization, crossover ambiguity
- [x] Phase 5 — identity layer (`identify/1`, `Santo.Identity.key/1`, `:disputed` values)
- [x] Phase 4 — `mix vin.oracle` vPIC differential harness
([design](docs/oracle.md); initial snapshot captured 2026-07-30)
- [ ] Pre-1970 911/912 schemes ('65–'69 30xxxx/35xxxx/45xxxx blocks) — fixtures first
- [ ] More marques, only after the behaviour has survived Porsche's full weirdness
Explicit non-goals for the library: persistence, HTTP clients in the
decode path, VIN generation beyond test support.