deckhearth/.convoys/tighten-scan-identify-hot-path.md
Randall Stillwell c6a0225e54 Document post-ship scan_attempts telemetry and close Phase 1–2 convoys.
Record Aug 15 measurement showing Phases 1–2 missed success targets, Phase 3 code is live with 15% embedding backfill and zero L0 traffic yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 16:19:30 -05:00

13 KiB
Raw Blame History

name classification success_metric skip status created depends_on umbrella model_policy
tighten-scan-identify-hot-path feature 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.
ia
ui-design
visual
flag
shipped 2026-08-14
add-real-ocr-layer
scanner-identify-upgrade
default_session roles escalate_to escalate_to_premium never_premium
auto
role-conductor role-architect role-ia-architect role-ux-reviewer role-ui-designer role-implementer role-reviewer role-security-auditor role-design-system-auditor role-a11y-auditor role-doc-writer
composer-2.5-fast composer-2.5 composer-2.5-fast composer-2.5-fast composer-2.5-fast composer-2.5-fast cursor-grok-4.5-high gpt-5.6-terra-medium cursor-grok-4.5-high cursor-grok-4.5-high auto
claude-sonnet-5-thinking-medium claude-4.6-opus-high-thinking
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

  • UX: confirm 800ms / 3-frame gate does not cause double-scans
  • Architect: briefs + match-rule when number is present
  • Brief A — detection constants + tests
  • Brief B — OCR strips (name PSM 7 + collector number) + card-text-match.js / identify-by-text
  • Brief C — scan-vision.js JSON schema + optional model id
  • Brief D — skip automatic disambiguation Gemini refine
  • SCHEMA_MAP layer-1 wording
  • 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

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