--- 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-`. ## 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).