Skip to content

Plan — /scan-lab endpoint to validate pgvector without breaking anything ​

Battle plan to validate our own scan stack (pgvector + DINOv2) without touching the tcg-vision pipeline currently used by the RN app.

Date: 2026-05-06. Status: All 6 phases shipped (A-D on 2026-05-06, E-F on 2026-05-07). RN app now scans via /scan-lab for all three TCGs (Pokémon + OP + MTG).

Why this plan ​

tcg-vision (sidecar built from Tormknd's repo, hosted on our VPS) serves the production scan path via /scan-compat. It works well, but:

  • The faiss_index.bin it uses is built offline by Tormknd; we don't know how to regenerate it ourselves
  • Adding Magic or any new TCG he hasn't covered means either asking him or building our own index
  • We already have the code to do it ourselves (embedCatalog.ts + pgvector card_embeddings table + embedding branch in /scan), but it's dormant and untested

See Card scan architecture for full context.

The risk ​

Activating the existing pgvector pipeline as-is means:

  1. Running embedCatalog.ts for ~80 minutes to populate card_embeddings
  2. Changing BACKEND_SCAN_URL in the app to point at /scan (api) instead of /scan-compat (tcg-vision)
  3. Crossing fingers that quality is equivalent

Several unknowns make that plan risky:

  • The code has never run (card_embeddings table at 0 rows)
  • Index difference: tcg-vision uses FAISS IndexFlatIP (100% recall), pgvector uses HNSW (~95-99% recall)
  • Image preprocessing potentially differs (rectification / crop / resize)
  • Parallel-art variants: card_embeddings.card_id is PK alone, so OP14-119_p1 and OP14-119_p2 collapse to one row

The plan — full isolation, zero risk ​

The idea is to add a parallel /scan-lab endpoint that queries a new card_embeddings_lab table, isolated from the prod path. We can A/B test without touching anything in production.

Before                                      After
──────                                      ─────

┌── tcg-vision :8000 ─────┐                ┌── tcg-vision :8000 ─────┐
│ /scan-compat (prod app) │                │ /scan-compat (prod app) │  ← unchanged
└─────────────────────────┘                └─────────────────────────┘

┌── api :8080 ─────┐                       ┌── api :8080 ──────────────┐
│ /scan (dormant)  │                       │ /scan (dormant)           │  ← unchanged
└──────────────────┘                       │ /scan-lab (NEW)           │
                                           └─────┬─────────────────────┘
                                                 │ query
                                                 ▼
                                           ┌── postgres ─────────────────┐
                                           │ card_embeddings (empty)     │  ← unchanged
                                           │ card_embeddings_lab (NEW)   │  ← new
                                           └─────┬───────────────────────┘
                                                 ▲
                                                 │ populated by
                                           ┌── ingest job ──────────────┐
                                           │ embedCatalogLab.ts (NEW)   │
                                           │ → embeddings: sidecar      │  ← shared, unchanged
                                           └────────────────────────────┘

No container modified. No existing endpoint touched. RN app unchanged.

Technical breakdown ​

Backend (tcg-collector-backend) — Phase A shipped (PR #103) ​

  1. ✅ Migration 0006_card_embeddings_lab.sql — schema-identical sibling of card_embeddings, distinct table name. Idempotent.

  2. ✅ src/ingest/embedCatalogLab.ts — fork of embedCatalog.ts. Differences:

    • Writes to card_embeddings_lab
    • Picks any available image quality (prefers low, falls back to full then high) to handle One Piece (single full) and Pokémon (low/high split) with the same code
    • INGEST_LAB_TCG (pokemon / onepiece) and INGEST_LAB_SETS (comma-separated) for fast smoke loops
  3. ✅ src/api/src/scan/routeLab.ts — /scan-lab endpoint:

    • Multipart photo → JPEG decode → bounds crop → DINOv2 sidecar → pgvector ANN against card_embeddings_lab
    • Pure embedding signal (no pHash / OCR / known-cards fusion) so the A/B against /scan-compat measures raw quality vs Tormknd's FAISS
    • Response shape mirrors /scan-compat (same match.id, match.name, cardDetails, etc. keys) for 1:1 diff
    • auth.ts: /scan-lab added to PUBLIC_POST_PATHS (read-only despite POST)
  4. ✅ docker/compose.yml — no new service. The embeddings: sidecar is shared (stateless, model-agnostic).

  5. Tests — at minimum a unit test on the generated SQL query. No integration test (the embedding sidecar isn't available in CI). 40/40 ingest tests passing.

Variant-aware schema (tcg-collector-backend) — Phase B shipped (PR #105) ​

Pre-requisite for distinguishing variants at scan time: break the card_set_id → 1 row collapse.

  1. ✅ Migration 0007_card_variants.sql — purely additive, two new columns:
    • cards.base_id — the logical card. Backfill base_id = id on existing rows; future variant rows share a base_id but have a distinct id (e.g. id="OP01-077_v2", base_id="OP01-077").
    • cards.variant — the print tag. 'base' for the principal print, 'v2' / 'v3' for additional prints.
    • Index on base_id + UNIQUE (base_id, variant) constraint. cards.id remains PK and unique → every existing query keeps working.
  2. Naming choice _v2 / _v3 vs Tormknd's _pN / _rN — Phase D builds our own embedding index from our own image rows, so we don't need to match the upstream FAISS suffix scheme. Sequential numbering (in OPTCG response order) is deterministic and avoids inventing a heuristic to map card names to print types.
  3. Verified post-apply — 25 294 cards backfilled (23 159 pokemon + 2 135 onepiece), all variant='base' and base_id=id. /cards/:id, /scan-lab, /scan-compat: no behaviour change.

Variant-aware OP ingest (tcg-collector-backend) — Phase C shipped (PR #107) ​

The OP ingest now leverages the new schema to store each print as its own row.

  1. ✅ assignVariantIds() in src/ingest/onepiece.ts — groups OPTCG entries by card_set_id, sorts each group by card_image URL in ASCII order (not localeCompare — see trap below), then assigns:
    • First entry → cards.id = card_set_id, variant = 'base'
    • Subsequent → cards.id = ${baseId}_v${N}, variant = 'v2' / 'v3' / ...
  2. ICU/locale trap — under Alpine + slim ICU (our Docker image), String#localeCompare ranks _ before ., the opposite of glibc. During initial smoke this caused Box-Topper variants to steal the base id. Fix: direct ASCII compare via </>. Regression test pinned in the suite (tests/optcgapi.test.mjs) with real-world OPTCG URLs (.jpg vs _p1.jpg).
  3. Disk layout keyed on variant-suffixed id — OP-01/077/full.jpg (base), OP-01/077_v2/full.jpg (variant). Existing base files don't move.
  4. Verified post-ingest — 3 330 OPTCG entries → 2 135 base + 832 variants = 2 967 OP cards (vs 2 135 collapsed before). Distribution: 2 135 base / 674 v2 / 120 v3 / 34 v4 / 4 v5. Pokémon (23 159) untouched. /cards/OP01-077 returns "Perona" with the base URL; /cards/OP01-077_v2 returns "Perona (Box Topper)". 46/46 ingest tests passing (6 new).

Catalog embed into card_embeddings_lab — Phase D shipped (with #109 preprocessing fix) ​

First real run of embedCatalogLab.ts against the prod DB.

  1. ✅ 24 539 rows embedded in 33 minutes, ~12 cards/s on the DINOv2-small sidecar (CPU only). 0 failures. Model facebook/dinov2-small, dim 384, single model name throughout.
  2. Coverage — every cards row with a card_images.status='ok' entry was processed: Pokémon (~22k) + OP base + variants (~2.5k). The embedCatalogLab selector picks the image in priority order low → full → high, so OP (single full jpg) and Pokémon (low/high split) work with the same code.
  3. Fix #109 (post-Phase D) — a self-recall test (POST the indexed image of OP01-077 to /scan-lab) returned the wrong card with sim ≈ 0.85. Diagnosis: /scan-lab was cropping the upload via detectCardBounds, but embedCatalogLab.ts sends the raw image. The two pipelines must do the same preprocessing or the vectors aren't comparable. Fix: /scan-lab defaults to no-crop, ?crop=1 is opt-in.
  4. Smoke after fix — self-recall OP01-077: 0.85 → 0.9990. The Cardmarket portgas (ST13-010) scan still mismatches because ST decks aren't indexed (revert #101) — that's a coverage gap, not a pipeline bug.

Documentation ​

This page stays as the plan's reference. Once validated or abandoned, it'll be converted into:

  • "Standalone pgvector stack (in prod)" — if successful
  • "Standalone pgvector attempt (abandoned)" with lessons learned — if failed

Smoke validation procedure ​

"Empty table" smoke (verified in Phase A) ​

bash
curl -X POST -F "photo=@portgas.jfif;type=image/jpeg" http://localhost:8080/scan-lab
# → { "ok": true, "match": null, "source": "no-match",
#     "reason": "no-match-after-filter", "similarity": 0, "ms": 723 }

DINOv2 inference (~250ms) and the pgvector query both run; no row comes back. Response shape mirrors /scan-compat's no-match for easy diffing.

Reason rename (PR #138, 2026-05-10)

The literal used to be lab-table-empty, which was misleading in production where the table holds 143k+ rows. The path is actually reached when HNSW's default 40 ANN candidates are all filtered out by the active WHERE c.tcg=$3 — typically when scanning a non-card image whose embedding lands in a region dominated by a different TCG. The new name describes what actually happens.

"Populated table" smoke (verified in Phase D) ​

bash
cd tcg-collector-backend

# 1. Embed a single OP-01 set (~154 images, ~7 minutes CPU vs ~80 min full)
sudo docker compose --env-file .env -f docker/compose.yml run --rm \
  -e INGEST_LAB_TCG=onepiece -e INGEST_LAB_SETS=OP-01 \
  --entrypoint "node dist/embedCatalogLab.js" ingest

# 2. Verify the table populates
sudo docker compose --env-file .env -f docker/compose.yml exec -T postgres \
  psql -U tcg -d tcg -c "SELECT COUNT(*) FROM card_embeddings_lab;"
# Expected: ~150-154 (depending on collapsed variants)

# 3. Compare 5-10 cards: /scan-lab (our stack) vs /scan-compat (tcg-vision)
for img in /tmp/op-test-*.jpg; do
  echo "=== $img ==="
  curl -s -X POST -F "photo=@$img;type=image/jpeg" http://localhost:8080/scan-lab \
    | python3 -c "import json,sys;d=json.load(sys.stdin);m=d.get('match');print(f'lab     : {m[\"id\"] if m else None} sim={d[\"similarity\"]:.4f}')"
  curl -s -X POST -F "photo=@$img" http://localhost:8000/scan-compat \
    | python3 -c "import json,sys;d=json.load(sys.stdin);m=d.get('match');print(f'compat  : {m[\"id\"] if m else None} sim={d[\"similarity\"]:.4f}')"
done

Success criteria:

  • 100% of tested scans return the same card_id as /scan-compat
  • Comparable latency (~250ms inference + a few ms ANN, total <500ms)
  • No crash on edge inputs (blurry, partial, wrong rotation)

If KO: dig in to identify the gap (preprocessing? HNSW recall? image format?), fix, retry the smoke. The full embed of all 25k cards waits for validation.

PR workflow ​

Once implemented:

RepoBranchContents
tcg-collector-backendfeat/scan-lab-pgvectorMigration 0006 + embedCatalogLab.ts + routeLab.ts + tests
tcg-collector-backenddocs/scan-lab-changelog (separate)CHANGELOG.md entry under [Unreleased]
documentationdocs/scan-lab-resultsUpdate this page with smoke test results

See also ​

  • Card scan architecture — pipeline overview
  • Rollback — One Piece integration — prior rollback, same patterns
  • tcg-collector-backend/src/ingest/src/embedCatalog.ts — existing script to fork
  • tcg-collector-backend/src/api/src/scan/embeddings.ts — existing embedding branch to fork
  • tcg-collector-backend/src/ingest/migrations/0004_card_embeddings.sql — reference schema for the new table