---
name: tighten-scan-identify-hot-path
classification: feature
success_metric: |
  After ship, a new scan_attempts window shows L1 escalate ≤40% of L1
  (was 76.7%), L1 matched ≥25% of L1 (was 5.8%), and first identify
  attempt starts in ≤1.2s of tracking (was 3.5s). L2 p50 stays ≤1.8s.
skip:
  - ia
  - ui-design
  - visual
  - flag
status: shipped
created: 2026-08-14
depends_on:
  - add-real-ocr-layer
umbrella: scanner-identify-upgrade
model_policy:
  default_session: auto
  roles:
    role-conductor: composer-2.5-fast
    role-architect: composer-2.5
    role-ia-architect: composer-2.5-fast
    role-ux-reviewer: composer-2.5-fast
    role-ui-designer: composer-2.5-fast
    role-implementer: composer-2.5-fast
    role-reviewer: cursor-grok-4.5-high
    role-security-auditor: gpt-5.6-terra-medium
    role-design-system-auditor: cursor-grok-4.5-high
    role-a11y-auditor: cursor-grok-4.5-high
    role-doc-writer: auto
  escalate_to: claude-sonnet-5-thinking-medium
  escalate_to_premium: claude-4.6-opus-high-thinking
  never_premium:
    - role-reviewer
    - role-security-auditor
    - role-design-system-auditor
    - role-a11y-auditor
    - role-ui-designer
    - role-doc-writer
---

# Convoy: tighten-scan-identify-hot-path

Phase 1 of `scanner-identify-upgrade`. Make the **existing** two-layer
pipeline faster and more unique-match capable. No new vendors, no
detection rewrite, no embeddings.

## Why

Live telemetry (see umbrella) says Layer-1 is fast (p50 158ms) and
almost useless as an identifier (5.8% auto-match, 76.7% escalate). The
user still waits 3.5s of hold-still before that cheap path runs. L2
Gemini is 1.7s p50 and spends a second token on every disambiguation
refine. Collector number — the unique printing key L2 already uses —
is never read on L1.

## Scope

### In scope

- **Hold-still constants** in `lib/scanner-card-detection.js`:
  `MIN_FIRST_SEEN_MS_FOR_VERIFY` 2500 → ~800;
  `MIN_STABLE_COUNT_FOR_VERIFY` 6 → 3. Keep tests in
  `test/lib/scanner-card-detection.test.js` in lockstep.
- **Name-strip OCR mode:** Tesseract PSM 7 (single line), JPEG quality
  0.92 on the crop used for OCR (`lib/ocr-worker.js`,
  `captureCardRegionFromVideo` quality if shared).
- **Collector-number strip:** second crop of the bottom ~18% of the
  card; pass number (+ optional set hint) into `matchTextInCatalog`.
  When name + number uniquely match a `cards` row, auto-match even if
  many printings share the name.
- **Structured Gemini output** in `lib/scan-vision.js`:
  `response_format` JSON schema (or Gateway equivalent). Delete the
  confidence-30 regex fallback as the success path; keep a hard parse
  error → `needs_input`.
- **Model A/B:** default stays `google/gemini-2.5-flash-lite`. Document
  and allow `SCAN_VISION_MODEL=google/gemini-3.1-flash-lite` (GA on
  Vercel AI Gateway, ~2.5× input price). Do not flip prod default in
  this convoy unless Architect + a short identify bake-off say so.
- **Stop automatic L2 refine** in `use-scanner-identification.js` when
  L1 already returned a printing list. User picks; Gemini refine is
  opt-in or only when L1 had no set/number hint.
- **`docs/SCHEMA_MAP.md`:** `scan_attempts.layer` is 1 = Tesseract
  (shipped), not "Tesseract future".

### Out of scope

- Detector / OpenCV / YOLO / perspective warp →
  `improve-scan-card-detection`.
- pgvector / CLIP / catalog embeddings →
  `scan-visual-catalog-search`.
- Changing `checkScanRateLimit` (5/min). Revisit after re-measure.
- Scanner chrome, cart, checkout (`scanner-mobile-checkout`).
- Replacing `tesseract.js`.

## Roles invoked

1. `role-ux-reviewer` — hold-still feel; when the picker still appears;
   no new screens.
2. `role-architect` — ratify constant values, L1 match rules when a
   collector number is present, JSON schema shape, whether the model
   default flips. Write briefs + `slice_dependencies`.
3. `role-implementer` — per brief.
4. Audit: `role-reviewer` + `role-security-auditor`. Skip design-system
   + a11y unless a brief unexpectedly touches `components/`.

## Todos

- [x] UX: confirm 800ms / 3-frame gate does not cause double-scans
- [x] Architect: briefs + match-rule when number is present
- [x] Brief A — detection constants + tests
- [x] Brief B — OCR strips (name PSM 7 + collector number) +
      `card-text-match.js` / `identify-by-text`
- [x] Brief C — `scan-vision.js` JSON schema + optional model id
- [x] Brief D — skip automatic disambiguation Gemini refine
- [x] SCHEMA_MAP layer-1 wording
- [x] Re-query `scan_attempts` after preview traffic (2026-08-15 — metrics unvalidated; see umbrella § Post-ship telemetry)

## Post-ship (PR #156, 2026-08-14)

**Shipped:** faster verify gates, collector-number L1, structured Gemini JSON,
skip L1 disambiguation refine.

**Telemetry (n=250 all-time):** L1 matched 5.4% (target ≥25%); L1 escalate
78.4% (target ≤40%). Gate latency goal met (~158 ms L1 p50). Aug 15 session
(n=26) showed 0% L1 match — OCR strips got noisier post-warp, not better.

**Verdict:** Machinery shipped; L1 text path remains insufficient for the
umbrella 50% auto-match goal. Phase 3 visual kNN is the intended fix.

## Likely file ownership (Architect will lock)

| Area | Files |
| --- | --- |
| Gates | `lib/scanner-card-detection.js`, `test/lib/scanner-card-detection.test.js` |
| OCR | `lib/ocr-worker.js` |
| L1 match | `lib/card-text-match.js`, `pages/api/cards/identify-by-text.js` |
| L2 vision | `lib/scan-vision.js`, `pages/api/scan/identify.js` |
| Refine | `lib/use-scanner-identification.js`, `lib/scanner-card-identify.js` |
| Docs | `docs/SCHEMA_MAP.md` |

Briefs A and C are file-disjoint and can run in parallel. B depends on
nothing if it does not retouch detection constants. D depends on B
(needs L1 payload shape).

## Multitask dispatch

Planning: UX → Architect (serial).

After Architect, if `slice_dependencies` marks A+C `depends_on: []`:

```
/multitask role-implementer briefs A, C
```

Then B, then D.

Audit group id: `audit-tighten-scan-identify-hot-path-<pr>`.

## CI impact

| Workflow / job | Behavior |
| --- | --- |
| `ci.yml` test | Fires — detection + identify unit tests change |
| `visual-diff.yml` | Should **not** fire if `components/` / `pages/` / `styles/` untouched |
| `preview-smoke.yml` | Fires |
| `forbidden-client-side-llm-keys` | Must stay green — no client Gemini URLs |

## Operator action

Optional: set `SCAN_VISION_MODEL=google/gemini-3.1-flash-lite` on a
preview to bake off against 2.5 Flash Lite. Leave prod on 2.5 until
the bake-off.

## Conductor notes

Do not treat Tesseract replacement as in-scope if L1 escalate stays
high after collector-number matching — that is a crop/detection
problem (#2) or a visual-search problem (#3), not an OCR-engine
problem. The six historical L1 matches already show Tesseract can
read a name when the crop is clean; it cannot pick a printing.

## UX

No new routes or screens. Changes are timing + fewer automatic Gemini
calls during disambiguation.

### Existing components to reuse

- `ScannerDisambiguation` (`components/scanner/ScannerDisambiguation.js`) —
  printing picker when L1/L2 cannot unique-match.
- `ScannerToast` / status copy around tracked card (`components/scanner/ScannerCamera.js`)
  — continue showing detecting → verifying → found without new chrome.
- `ScanDisambiguationDialog` pattern if still referenced — do not fork a
  second picker.

### Design direction alignment

Liquid Glass tokens unchanged. No new overlays. Faster identify should
feel like the camera "wakes up" sooner (H1 visibility of system status).

### Patterns to follow

- Disambiguation stays a bottom sheet / modal pause (`verificationPausedRef`
  already wired in `use-scanner-identification.js`).
- Error toasts debounced via `reportScannerError` (4s gap) — keep that
  when tightening gates so double-failures do not spam.

### A11y constraints

- No new interactive controls in this convoy.
- Existing disambiguation list must remain keyboard-selectable (hand off
  unchanged to a11y auditor if Brief D touches the sheet).

### Interaction patterns

| Pattern | Requirement | Heuristic |
| --- | --- | --- |
| Hold-still gate | **required** — 800ms + 3 stable frames; user sees brackets sooner | H1 feedback |
| Auto-match | **required** — collector number should skip picker for multi-printing names | H6 recognition vs recall |
| Disambiguation refine | **required** — stop silent second Gemini call when L1 already opened picker; user tap only | H3 user control |
| Rate-limit toast | **required** — unchanged copy when L2 throttled | H9 error recovery |
| Loading during verify | **nice-to-have** — optional subtle "Reading…" on card status if trivial | H1 |

### Anti-patterns

- Adding a setup screen or settings toggle for gate timing (H4 consistency).
- Auto-picking a printing without number evidence when multiple exact-name
  rows exist (H6).
- Blocking the camera on L1 OCR worker load (H1 — worker stays async).

### Mobile / responsive

Gate timing applies equally on phone and desktop; no viewport-specific
branches in this convoy.

## Architecture

### File plan

| File | Action | Purpose |
| --- | --- | --- |
| `lib/scanner-card-detection.js` | modified | 800ms / 3-frame verify gates |
| `test/lib/scanner-card-detection.test.js` | modified | Lock new constants |
| `lib/ocr-worker.js` | modified | PSM 7 name strip + bottom collector strip |
| `lib/card-text-match.js` | modified | Name + collector number unique match |
| `pages/api/cards/identify-by-text.js` | modified | Accept `cardNumber` body field |
| `test/lib/card-text-match.test.js` | new | Unit tests for number path (mock-free helpers) |
| `lib/scanner-card-identify.js` | modified | Pass number to L1; JPEG 0.92; `fromLayer1` flag |
| `lib/scan-vision.js` | modified | JSON schema response; stricter parse |
| `pages/api/scan/identify.js` | modified | Handle vision parse failures → needs_input |
| `lib/use-scanner-identification.js` | modified | Skip auto Gemini refine when `fromLayer1` |
| `docs/SCHEMA_MAP.md` | modified | Layer 1 = Tesseract (shipped) |

### API surface

**POST `/api/cards/identify-by-text`** (modified)

- Auth: `getUserFromRequest` → 401
- Body: `{ ocrText, ocrConfidence?, cardNumber?, game? }`
- Response: unchanged envelope + optional `cardNumber` echo in `ocr` meta
- Rate limit: none (unchanged)

**POST `/api/scan/identify`** (modified behavior only)

- On vision JSON parse failure: 200 with `needsUserInput: true` instead of
  silent regex fallback at confidence 30

### Schema diff

No migration. `scan_attempts.layer` semantics documented only.

### Test plan

- Update `scanner-card-detection.test.js` thresholds (800ms, stable 3).
- New `card-text-match.test.js` for `extractCollectorNumberCandidate` and
  collector-aware disambiguation resolution (pure functions exported for test).
- Extend `scanner-card-identify.test.js` for `fromLayer1` disambiguation
  payload if added to pure helpers.
- Run `npm run test:run` + `npm run lint`.

### Risks

| Risk | Mitigation |
| --- | --- |
| Faster gate double-scans same card | `scanAttempts < 1` + tracker negative state unchanged |
| OCR number strip reads set symbol garbage | Normalize with `extractCollectorNumberCandidate`; fall back to name-only |
| Gateway rejects `response_format` | Catch 400, log once, fall back to prose prompt (Architect: implementer may feature-detect) |
| L1 skip-refine hides catalog-gap auto-submit | Refine still runs for L2 disambiguation only |

### Decomposition

| Brief # | Title | Files | Depends on | Size |
| --- | --- | --- | --- | --- |
| 1 | Faster verify gates | detection + test | — | S |
| 2 | Collector number L1 | ocr-worker, card-text-match, identify-by-text, scanner-card-identify, test | — | M |
| 3 | Structured vision JSON | scan-vision, identify API | — | S |
| 4 | Skip L1 disambiguation refine | use-scanner-identification, SCHEMA_MAP | 2 | S |

### Slice dependencies

```yaml
slice_dependencies:
  - brief: 1
    depends_on: []
    files:
      - lib/scanner-card-detection.js
      - test/lib/scanner-card-detection.test.js
  - brief: 2
    depends_on: []
    files:
      - lib/ocr-worker.js
      - lib/card-text-match.js
      - pages/api/cards/identify-by-text.js
      - lib/scanner-card-identify.js
      - test/lib/card-text-match.test.js
  - brief: 3
    depends_on: []
    files:
      - lib/scan-vision.js
      - pages/api/scan/identify.js
  - brief: 4
    depends_on: [2]
    files:
      - lib/use-scanner-identification.js
      - docs/SCHEMA_MAP.md
```

### Decisions (post-UX)

| # | Decision |
| --- | --- |
| D1 | Gate values: `MIN_FIRST_SEEN_MS_FOR_VERIFY = 800`, `MIN_STABLE_COUNT_FOR_VERIFY = 3`. |
| D2 | Collector strip: bottom 18% of crop; PSM 7 for both strips. |
| D3 | When exact-name multiple printings AND parsed collector number matches exactly one row → auto-match. |
| D4 | `SCAN_VISION_MODEL` default stays `google/gemini-2.5-flash-lite`; structured JSON either way. |
| D5 | Auto Gemini refine skipped when disambiguation opened from L1 (`result.layer === 1`). |

Human gate 1: **approved** (operator requested full pipeline run 2026-08-14).
