Skip to content

ignaciofdezocana/nomos

Repository files navigation

Nomos

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"]
Loading
Screenshot 2026-06-11 at 8 52 02 PM

Why not just a coin-scanner app?

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:

  1. 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).
  2. 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).
  3. 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.
  4. Weigh the evidence — a reasoning model compares each candidate against everything observed (and the photos themselves), citing concrete matches and mismatches.
  5. 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.

What's in the box

  • 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).

How it works

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
Loading

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
Loading

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.

Getting started

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:3000

Then: 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.

Tips for good results

  • 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.

Confidence bands, honestly

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.

Measuring accuracy (the eval lab)

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 stop

Each 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.

Configuration

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.

Privacy & your data

  • Photos stay in ./data/uploads on 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_costs SQL view or the History page.

Data sources & licensing

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.

Development

├── 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.

About

Identify ancient Roman coins from photographs — down to the exact catalog number.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors