Identify ancient Roman and Greek coins from photographs — down to the exact catalog number.
Upload photos of a coin's two faces and Nomos tells you what it is: not just "a coin of Gallienus", but RIC V Gallienus 283 — the precise entry in the standard scholarly catalog, with the evidence for and against each candidate laid out so you can confirm it like an expert would. Runs entirely on your machine (plus AI model calls), with a local database of 78,000+ Roman and Hellenistic Greek coin types (Imperial, Republican, Macedonian, Seleucid, Ptolemaic, Antigonid — see Data sources & licensing).
flowchart LR
A["📷 obverse + reverse<br/>photos"] --> B["🔍 AI reads legends,<br/>design, metal"]
B --> C["🗂 search 78k<br/>catalog types"]
C --> D["⚖️ ranked candidates<br/>with evidence"]
D --> E["✅ you confirm<br/>(or correct)"]
E --> F["📝 optional EN/ES<br/>sale listing"]
Consumer apps (CoinSnap, Google Lens…) match your photo against other photos. That works for modern machine-struck coins and fails on ancients: every ancient coin was hand-struck from hand-cut dies, so wear, strike and lighting defeat picture-matching. Nomos instead mimics what a numismatist actually does:
- Read the legend — the inscription around the face, transcribed letter
by letter in whichever script is actually inscribed (Latin for Roman
coins, Greek for Hellenistic ones), with explicit
*wildcards for worn letters (never guessed). - Constrain the search — that legend narrows 78,000 types to the issues that actually carry it (sometimes still hundreds — emperors and kings reused inscriptions heavily).
- Flip the coin — the reverse design ("Sol standing left, raising right hand, holding globe") separates the remaining candidates, using semantic and keyword matching against the catalog descriptions.
- Weigh the evidence — a reasoning model compares each candidate against everything observed (and the photos themselves), citing concrete matches and mismatches.
- Stay honest — a deterministic confidence band (
high/medium/low) is computed from the evidence, never from AI enthusiasm. A low band gets you "needs a closer look" plus what would help, not a confident wrong answer.
- Attribution pipeline — photo upload → live progress → "what we read" card → ranked candidate cards with per-field evidence and score breakdowns.
- Human-in-the-loop tools — confirm a candidate, fix a misread legend and
re-run, or fall back to manual search (wildcard legend search +
filters:
GALLIENVSA*G, metal, denomination, authority). - Listing generator — one click turns a confirmed coin into a marketplace-ready description in English and Spanish (catalog facts only; no value or authenticity claims).
- History — every coin, run, decision and cost, browsable.
- Plausibility checks — if you enter weight/diameter, they're compared against published averages for the top candidate ("3.1 g vs typical ~7.6 g — worth a closer look").
- A built-in accuracy lab — label coins with their true type and measure exactly how well the pipeline performs as you tune it (see Measuring accuracy below).
Everything runs locally except the AI calls (via a single OpenRouter key) and the one-time catalog download.
flowchart TB
subgraph Local["Your machine"]
UI["Web UI · localhost:3000"]
API["Next.js API routes<br/>(pipeline orchestration)"]
PG[("Postgres 16<br/>+ pgvector + pg_trgm<br/>78k types · pgvector embeddings")]
FS[("./data/uploads<br/>your photos")]
ETL["Python ETL<br/>(one-time setup)"]
end
subgraph Net["Network"]
OR["OpenRouter<br/>vision · ranking · embeddings"]
ANS["numismatics.org<br/>open catalog data<br/>(OCRE/CRRO/PELLA/SCO/PCO/AGCO)"]
end
UI --> API --> PG
API --> FS
API --> OR
ETL --> ANS
ETL --> PG
ETL --> OR
An attribution, end to end:
sequenceDiagram
actor U as You
participant W as Nomos
participant V as Vision model
participant DB as Type database
participant R as Ranking model
U->>W: upload photos (+ weight/diameter, optional)
W->>V: read the coin (3 independent samples)
V-->>W: legends, descriptions, metal — disagreements become wildcards
W->>DB: legend-constrained search, scored by reverse design + evidence checklist
W->>R: top 20 candidates + observations + the photos
R-->>W: ranking with per-candidate matches / mismatches
W-->>U: results + confidence band (live progress via SSE throughout)
U->>W: confirm · edit & retry · manual search
A few design choices worth knowing:
- Legends are read conservatively. The vision model samples three times;
letters the samples agree on are kept, disagreements become
*wildcards. It is explicitly forbidden from guessing an emperor's name from the portrait — only legible letters count. - The legend is a constraint, not a vote. Search happens inside the set of types carrying a compatible legend (with fuzzy fallbacks for misreads), so the right answer can't be crowded out by sheer tie volume — a real problem when one Antoninus Pius inscription is shared by 1,300+ types.
- The ranker can't invent coins. It may only judge the candidates it was given; anything else is discarded. Strict JSON schemas with validation and one self-correcting retry on every model call.
- Every step is traced — latency, token usage and cost per stage land in Postgres, queryable with plain SQL.
You'll need: Node 20+, pnpm, Python 3.11+, Docker, and an OpenRouter API key with a few dollars of credit.
cp .env.example .env # paste your OPENROUTER_API_KEY — the only secret
docker compose up -d # Postgres with pgvector
pnpm install && pnpm migrate
cd etl && python3 -m venv .venv && .venv/bin/pip install -e . && cd ..
# one-time catalog setup (~60 min total, ~$0.10 in embedding cost)
pnpm etl:verify # sanity-check one live catalog record
pnpm etl:download # fetch open catalog dumps, all 6 corpora (~185 MB)
pnpm etl:ingest # load 78k+ types into Postgres
pnpm etl:embed # build semantic search vectors
pnpm dev # → http://localhost:3000Then: drop an obverse and reverse photo on the home page (JPEG/PNG/WebP/HEIC — iPhone photos work directly), add weight/diameter if you have them, and hit Attribute. Results arrive in ~20–30 seconds.
- Always include the reverse. It's usually what pins the exact type; without it, confidence is capped by design.
- Sharp, evenly lit, fills the frame. The legend letters are what matter most.
- If the AI misread a legend you can read yourself, use Edit & retry — a corrected legend re-runs the whole search and routinely rescues a miss.
- None of these? → manual search with
*wildcards is the expert escape hatch; picking a type there records it as the attribution.
| band | meaning |
|---|---|
| high | strong legend match, clear margin over the runner-up — trust but verify |
| medium | good evidence with real alternatives — read the per-candidate evidence |
| low | "needs a closer look": evidence doesn't single out one type; the UI tells you what would help (sharper reverse, weight, a legend correction) |
On a benchmark of museum-photographed specimens, the correct type appears in the top 3 suggestions roughly half the time overall — and ~70% of the time when the legend was read accurately, at ~$0.03–0.05 of model cost per coin. Worn coins are genuinely hard; the design goal is that when Nomos is unsure, it says so rather than bluffing.
Nomos ships with the harness used to develop it. Confirm coins in the app and
click Add to eval set (or seed from photographed museum specimens with
etl/seed_specimen_eval.py), then:
pnpm eval -- --note "my first benchmark"
# useful flags:
# --filter difficulty=legible | source=user
# --reuse-extraction <run_id> # replay prior photo-readings: cheaper, and
# # isolates search/ranking changes cleanly
# --max-cost-usd 5 # hard budget stopEach run records top-1/3/5 accuracy, where the truth ranked, legend
character-error-rate, confidence calibration, cost and latency — visible in
the Eval dashboard with side-by-side run comparison. Change one thing
(a prompt, a model slug, a retrieval weight — all in .env), run again,
keep what measures better. Development notes and measured experiment history
live in docs/IMPROVEMENT-PLAN.md.
Everything tunable lives in .env:
| variable | default | what it does |
|---|---|---|
EXTRACTION_MODEL |
google/gemini-3.5-flash |
vision model that reads the coin |
EXTRACTION_SELF_CONSISTENCY |
3 |
photo-reading samples to merge (1 = single read, cheaper) |
EXTRACTION_REASONING |
low |
reasoning effort for reading (high reads slightly better, ~2.5× cost) |
RANKING_MODEL |
anthropic/claude-haiku-4.5 |
model that judges candidates |
EMBEDDING_MODEL |
openai/text-embedding-3-small |
semantic search vectors (changing it requires re-embedding) |
RETR_W_LEGEND / _SEMANTIC / _FILTER |
0.45 / 0.30 / 0.25 | evidence weights |
RETR_CANDIDATES_TO_LLM |
20 | candidates sent to the ranker |
MAX_COST_PER_RUN_USD |
0.25 | hard abort if a run would exceed this |
Model slugs are pinned on purpose — swap them deliberately, then re-run the eval to see what it did.
- Photos stay in
./data/uploadson your machine (EXIF/GPS stripped on ingest) and are sent to the AI providers via OpenRouter only to be read — that's the one place your images travel. - The only secret is your OpenRouter key in
.env, which is gitignored. Nothing sensitive is committed to this repo. - Every model call's cost is recorded; check spending anytime with the
v_run_costsSQL view or the History page.
Coin-type data comes from six open Linked-Data catalogs published by the American Numismatic Society and partners (all on the shared Numishare/ Nomisma platform — same ontology, same bulk-dump format), used under the Open Database License (ODbL), with metrology lookups from nomisma.org:
| corpus | coverage | types |
|---|---|---|
| OCRE | Roman Imperial | 56,113 |
| CRRO | Roman Republican | 2,602 |
| SCO | Seleucid | 8,694 |
| PELLA | Macedonian / Alexander the Great | 7,229 |
| PCO | Ptolemaic | 3,650 |
| AGCO | Antigonid | 182 |
Nomos downloads their bulk dumps once, queries politely (descriptive User-Agent, cached lookups), and redistributes nothing. Catalog images are institution-copyright and are never downloaded in bulk; the photos in your Nomos are your own.
Hellenistic legends are read in Greek script (transcription, normalization
and fuzzy matching all support the Greek alphabet alongside Latin), but this
path is new and hasn't been measured against a dedicated Hellenistic eval set
the way the Roman path has — treat retrieval/ranking quality on Greek-legend
coins as unproven until it's run against real specimens. The four Hellenistic
corpora also need pnpm etl:embed re-run to get semantic-search vectors
(their type_embeddings rows aren't populated by default in every checkout);
until then they're still fully searchable by legend, authority, mint,
denomination and date, just without the semantic-similarity signal.
Not yet supported: RPC (Roman Provincial) is a Numishare corpus too, but its license (CC BY-NC-SA 4.0, non-commercial) needs a separate gate before ingestion. Moneda Ibérica (Iberian coinage) has no machine-readable bulk-data path today — its "Open Data" export has been a placeholder for years — so it isn't ingestable at all yet.
├── db/migrations/ # plain SQL schema, applied by `pnpm migrate`
├── etl/ # Python: download → verify → ingest → embed the catalog
├── scripts/ # eval runner, eval seeding, failure diagnostics
├── src/lib/ # the core: pipeline, retrieval, confidence, LLM client
├── src/app/ # Next.js pages + API routes (SSE attribution endpoint)
└── tests/ # 78 tests on an isolated DB; LLM calls replayed from
# fixtures — `pnpm test` needs no network and no key
Stack: Next.js + TypeScript, Postgres 16 (pgvector for semantic search,
pg_trgm for fuzzy legend matching), raw SQL (no ORM), Python for the
offline ETL, and plain fetch + zod against OpenRouter's OpenAI-compatible
API. Run pnpm test before sending changes; the eval harness is the
integration test that actually matters.