Entity resolution
How text, a VIN, or a build code becomes one entity, and how to read a shortlist.
Resolution turns something from the world (a phrase a user typed, a VIN off a windshield, a build code) into a node in the Vehicle Graph. It either succeeds or it reports how far it got. It never guesses.
Most identity bugs in automotive data come from a system that picked one answer when it had three, and every layer downstream inherited the guess without knowing it. This API is built so that cannot happen. When resolution cannot narrow to one, it says so in a typed field and returns everything that survived.
No fuzzy matching at read time
Any parameter that is not a well-formed, known ref returns a 400 naming
the value. There is no fuzzy match and no empty result for a bad ref.
Normalization happens once, in the pipeline, against closed vocabularies. It
never happens per request, and it never happens against display text.
This is why free text is only accepted at one endpoint.
Resolve, for text
curl "https://api.cardog.app/v2/entities/resolve?q=2021%20civic&domain=model-year" \
-H "x-api-key: $CARDOG_API_KEY"Candidates come back scored, best first. best is non-null only when
a candidate clears the confidence floor. A null best with three
candidates is a real answer: the input was ambiguous, so ask your user
rather than taking element zero.
Decode, for VINs
curl "https://api.cardog.app/v2/vin/1FTEX1CB0NKD00000" \
-H "x-api-key: $CARDOG_API_KEY"The decode returns identity as refs, each verified against the registry
before it is returned. A null ref means the fact is not derivable from this
VIN. It never means a guess was withheld. The decode also returns the
build code, nano:, which is where resolution gets more involved.
The build code is the resolution key
nano: (VIN positions 1–8 plus 10) identifies a configuration: cab,
drivetrain, engine, weight class. It is pure string math over the VIN,
derivable offline and stable forever, and it is the key the data endpoints
answer to:
/v2/specs/nano:{code} → the spec sheet for that build
/v2/safety/nano:{code} → the crash rating for that configuration
/v2/quotes/nano:{code} → the market price for that build
/v2/entities/nano:{code} → the build itself, with links to all of the aboveResolving to a build code is exact. Resolving from a build code to a third-party dataset, such as NHTSA's rated configurations, is a join, and a join has to report its grain.
Resolution grain
Endpoints that join a build against an external catalogue return
matchGrain: how precisely your vehicle was located in that catalogue.
matchGrain | Meaning | What to render |
|---|---|---|
configuration | Exactly one configuration survived, nothing blocking | Your vehicle's result. Safe to state as fact. |
partial | Narrowed, but more than one survived | The set. Do not pick from it. |
model-year | Nothing was eliminated | Everything for the model year, labelled as such. |
exempt | The programme does not cover this vehicle | The reason, carried in note. |
unrated | The vehicle exists; the programme never tested it | "Not rated". This is a fact, not a gap. |
Alongside it, candidates reports how many configurations existed before
narrowing, so 13 → 3 and 3 → 3 are distinguishable. The first did
some work. The second did none.
The pin rule
pinned = matchGrain === "configuration" && !response.unresolved?.lengthBoth halves are required. A single survivor is not automatically your
vehicle. unresolved names the reasons a unique match still must not
be claimed: an axis the catalogue declares that the platform does not yet
model (variant-residue:super-crew), or a decode too coarse to prove which
axle variant was rated (drive-axle-unresolved). An unmodeled axis must
never be read as satisfied. If unresolved is present, treat the result as
a shortlist of one.
Ties are reported, not broken
Where a result set carries a headline value, best is populated only when
the maximum is unique. Three configurations tied at five stars produce
best: null. Breaking that tie would mean picking alphabetically and
presenting it as a finding.
Trim is not an identity
The most common integration mistake is inferring resolution from trim text. Do not do this, even where it appears to work.
trim is display text. It is not a join key, and nothing in the platform
joins on it. "Work Truck" spans four weight classes, and "LE" spans
gas and hybrid. Where trim is carried it comes tagged with its authority
tier (derived from the VIN's own pattern, or observed from a seller's
listing). Those are different facts about different things, not two sources
for one field.
The relationship is many-to-many:
- One catalogue variant is encoded by many VINs. A King Ranch SuperCrew is thousands of vehicles.
- One build code carries many variants. Catalogue granularity runs down to interior and cosmetic packages that share a VIN pattern exactly. Fifteen to thirty per model year is ordinary on a full-size truck.
So a build code neither implies one trim nor is implied by one. If a
shortlist contains a slug that resembles your trim string, that
resemblance is a naming coincidence, not a join. When a make names things
differently, code built on it fails without warning. Wait for the grain to
say configuration.
Worked example: a 2022 F-150
curl "https://api.cardog.app/v2/safety/nano:1FTEX1CBNK" \
-H "x-api-key: $CARDOG_API_KEY"{
"matchGrain": "partial",
"candidates": 13,
"matched": [
{ "variant": "super-crew", "overallStars": 5, "frontalDriverStars": 5 },
{ "variant": "super-cab", "overallStars": 5, "frontalDriverStars": 4 },
{ "variant": "regular-cab", "overallStars": 5, "frontalDriverStars": 4 }
],
"best": null
}Thirteen rated configurations narrowed to three on drivetrain and fuel
family. The three that remain differ by cab, an axis not yet modeled as a
join column, so all three survive and the grain says partial. best
is null because all three tie at five stars overall.
The correct rendering is all three, labelled as configurations for this
model year rather than as this vehicle's rating. The incorrect rendering is
matched[1], chosen because super-cab resembles the decode's
"SuperCab".
As modeling deepens, results like this collapse toward configuration
without any change on your side. That is why you read the grain instead of
the array length.
Integration checklist
- Resolve or decode once. Store the refs, not the display strings.
- Query everything else by ref. Never reconstruct one by string
concatenation. Build it with
@cardog/entities(the ref grammar). - Branch on
matchGrainbefore rendering. Never on array length. - Check
unresolvedbefore treating a single match as a pin. - Never join on
trim,styleName, or any display text. - Treat
nullas "not known", never as "not applicable". The platform distinguishes those, andunratedversusexemptis that distinction made visible.