feat(id): adaptive on-device bird ID router (BioCLIP-2, automatic)#259
feat(id): adaptive on-device bird ID router (BioCLIP-2, automatic)#259jlian wants to merge 5 commits into
Conversation
…e flag Adds an adaptive model router: on-device BioCLIP-2 (ViT-L int8) when the model is cached, server GPT otherwise. ONE shared pipeline — both models produce the same candidate shape and both use the same server-side range + confidence-gate logic, so there is no divergent per-platform codepath. Off by default behind a settings toggle. Server: - functions/api/range-adjust.ts: applies taxonomy grounding + range-prior tiering + dominance gate to on-device candidates (the shared pipeline half). Mirrors the on-device spike's winning strategy: keep top-K, trust the visual ID when the top candidate dominates, else hard-tier by range status. - functions/models/[[path]].ts: streams model assets from the MODELS R2 bucket with immutable caching + HTTP range support (files exceed the 25 MiB static asset limit). - MODELS R2 binding (prod + preview) in wrangler.toml; Env type added. Client: - src/lib/bioclip-inference.ts: onnxruntime-web (WebGPU, WASM fallback) loader with download-speed measurement + Cache API persistence; image encoder + int8 text-matmul + softmax; identifyOnDevice() calls /api/range-adjust. - src/lib/model-router.ts: identifyBirdAdaptive() + background prefetch. - Feature flag in storage-keys.ts (off by default); Settings toggle; AddPhotosFlow uses the router + warms the model on mount. - onnxruntime-web is dynamically imported so the main bundle is unaffected when the feature is off (verified: it code-splits out). Tests: 9 range-adjust endpoint tests; full suite (709) green; build + lint clean. Model assets are NOT committed (307 MB); upload once via scripts/upload-model.mjs to the wingdex-models R2 bucket. Regenerate assets with the spike scripts.
There was a problem hiding this comment.
Pull request overview
Adds an adaptive bird-ID routing path that can use an on-device BioCLIP-2 model (when enabled and cached) while keeping the existing shared server-side post-processing (taxonomy grounding, range priors, and gating) as the single pipeline.
Changes:
- Introduces
/api/range-adjustto apply the shared taxonomy + range + gating pipeline to client-produced candidate lists. - Adds
/models/*asset serving from an R2 bucket (with range support) plus an upload script for model assets. - Adds client-side on-device inference (onnxruntime-web), an adaptive router, and a Settings feature flag with background prefetch.
Reviewed changes
Copilot reviewed 11 out of 15 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| wrangler.toml | Adds MODELS R2 bucket binding (prod + preview). |
| tsconfig.json | Excludes the new range-adjust test from TS compilation. |
| src/lib/storage-keys.ts | Adds a global (non-user-scoped) feature flag for on-device ID. |
| src/lib/model-router.ts | Adds identifyBirdAdaptive() routing logic and one-time background prefetch. |
| src/lib/bioclip-inference.ts | Implements on-device BioCLIP inference, caching, and /api/range-adjust integration. |
| src/components/pages/SettingsPage.tsx | Adds Settings toggle to enable on-device ID and trigger prefetch. |
| src/components/flows/AddPhotosFlow.tsx | Switches fast identify path to the adaptive router and warms the model on mount. |
| src/tests/range-adjust.test.ts | Adds unit tests for /api/range-adjust behavior. |
| scripts/upload-model.mjs | Adds an R2 upload helper for model assets. |
| package.json | Adds onnxruntime-web dependency. |
| package-lock.json | Lockfile updates for onnxruntime-web and transitive deps. |
| functions/models/[[path]].ts | Adds /models/* route that streams R2 assets with immutable caching and range support. |
| functions/env.d.ts | Adds optional MODELS binding to Env. |
| functions/api/range-adjust.ts | Adds the range-adjust endpoint implementing dominance gate + range tiering. |
| .gitignore | Ignores functions/tsconfig.tsbuildinfo. |
UX: remove the Settings toggle and feature flag. On-device routing is now fully
automatic and invisible: the model prefetches silently in the background but
only on a suitable network (not Save-Data/cellular/slow), and identify uses
on-device when the model is ready/cached, else GPT (and warms the model for
next time). This matches the intended 'it just quietly gets better on Wi-Fi'
UX rather than an opt-in setting.
Copilot review fixes:
- isModelReady() now also checks textScale (was reporting ready with a missing
tensor, which could make loadModel() a no-op while inference throws).
- streamModel() writes into a preallocated Uint8Array when content-length is
known, avoiding the chunks+Blob+ArrayBuffer ~3x memory spike (OOM risk on
mobile); falls back to chunk assembly when size is unknown/inaccurate.
- Fixed stale comment pointing at functions/api/model/* (actual path is
functions/models/*).
- models route: parseRange() now returns undefined for absent/malformed/invalid
(end<start) ranges and handles open-ended ranges; the range option is only
passed to R2 when it parsed, removing the unsafe 'undefined as number' cast.
- Removed the misleading Settings copy ('works offline'/'no server round-trip')
along with the toggle; on-device still calls /api/range-adjust for range.
- range-adjust test: cell-aware mock bucket now returns data only for the self
cell (first requested) and null for neighbors, so neighbor behavior is
actually testable instead of returning the same blob for every key.
709 tests green; build, lint, typecheck clean.
#262) ## Problem After the Pages → Workers migration, **every PR overwrites the same preview deployment.** The CI deploy step ran `wrangler deploy --env preview`, which always targets the single Worker named `wingdex-app-preview` (from `[env.preview]` + top-level `name = "wingdex-app"`). So all open PRs deploy to the **same** URL — `wingdex-app-preview.lianguanlun.workers.dev` — and last-deploy-wins. They also shared the preview D1 (`wingdex-db-dev`) and R2 buckets, so "the preview" reflected whichever PR deployed most recently, not the PR you were looking at. Pages used to give per-branch preview URLs for free; the migration silently dropped that isolation. ## Fix Use **per-PR aliased preview URLs**: - Deploy step now runs `wrangler versions upload --env preview --preview-alias pr-<PR#>`. This creates a version with a stable alias URL of the form `pr-<PR#>-wingdex-app-preview.<subdomain>.workers.dev` (needs `preview_urls = true`, already set). Unlike `deploy`, it does **not** change the live/deployed version — each PR only gets its own isolated preview URL. - The GitHub deployment status + a **sticky PR comment** surface the per-PR URL (parsed from wrangler's output, with a deterministic fallback). ## Result - PR #258 → `pr-258-wingdex-app-preview...` - PR #259 → `pr-259-wingdex-app-preview...` - This PR → `pr-<n>-wingdex-app-preview...` No more stomping. Each in-flight PR has an independent preview. ## Notes / caveats - **Shared preview state remains.** All previews still point at the same `wingdex-db-dev` and the `wingdex-range-priors` / `wingdex-models` R2 buckets (these bindings are account-global). This PR isolates the *code/URL*, not the data. Fully isolating per-PR D1/R2 is a bigger change; flagging it but out of scope here. - Preview aliases don't need explicit cleanup — re-uploading with the same `pr-<N>` alias reuses it, and old versions age out per Cloudflare retention. The existing `cleanup-preview.yml` (deactivates GitHub deployment records on PR close) is unchanged. - Prod is unaffected: production deploys happen via `release.yml` on merge to `main`, not this step. **Priority:** merge first, then the two in-flight PRs (#258, #259) get correct isolated previews once rebased/updated onto this.
|
🔎 Preview for this PR: https://pr-259-wingdex-app-preview.lianguanlun.workers.dev Isolated per-PR alias, no longer shared/overwritten across PRs. Updates on every push. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/lib/model-router.ts:60
isNetworkSuitableForPrefetch()returnstruewhennavigator.connectionis unavailable (Safari/Firefox). That meansmaybePrefetchModel()can start a ~300 MB background download even on cellular/metered networks, which contradicts the PR's stated policy (no Save-Data/cellular/slow). Consider treating unknown network info as "not suitable" so we only prefetch when the browser can positively identify a safe connection (or when the assets are already cached).
* never warms there. Callers can still gate on isModelCached() for identify.
*/
export function isNetworkSuitableForPrefetch(): boolean {
const c = getConnection()
if (!c) return true // unknown (Safari/FF): allow; download is cached + one-time
- isNetworkSuitableForPrefetch(): on unknown networks (navigator.connection absent, notably iOS Safari + Firefox) now returns false instead of true. We can't distinguish Wi-Fi from metered cellular there, and silently pulling ~300 MB over an iPhone's LTE is user-hostile. On-device still warms on Chromium/Wi-Fi; identify falls back to GPT until cached. - models route: replaced 'immutable' Cache-Control (wrong for stable, non-versioned filenames \u2014 would pin a stale model forever) with max-age=86400 + must-revalidate + ETag, so a replaced asset is picked up via a cheap 304. - AddPhotosFlow: fixed a stale code comment referencing the removed feature flag.
) Follow-up to #262. Two cleanups + address the Node 20 deprecation notices. ## 1. Remove the preview sticky-comment step The `Comment preview URL on PR` step auto-posted (and re-posted on every push) a comment like [this one](#262 (comment)). It's redundant: the per-PR preview URL is already surfaced on the PR via the **GitHub deployment / Environments UI** (the `Branch preview deployment` step sets `environment_url`). Removing the comment cuts the noise. Also drops the now-unused `pull-requests: write` permission from the CI job (least privilege). ## 2. Fix Node 20 deprecation warnings CI was emitting: > Node 20 is being deprecated. This workflow is running with Node 24 by default... Source: actions still pinned to Node 20 runtimes. Bumped to Node 24: - `cloudflare/wrangler-action@v3` → `@v4` (ci.yml, release.yml). v4's only major change is `wranglerVersion` input flexibility (version ranges/tags); no behavior change for our exact-command usage. - `actions/github-script@v7` → `@v9` (ci.yml, cleanup-preview.yml, rotate-apple-secret.yml). v9 runs on node24. All other actions (`checkout@v6`, `setup-node@v6`, `cache@v5`) were already on Node 24. ## Verification - All 9 workflow files validated as parseable YAML. - No `@v3` wrangler-action / `@v7` github-script references remain. - Preview isolation (from #262) is unchanged: still `versions upload --preview-alias pr-<N>`. _Note: separately, the Copilot review comments on #259 are being handled on that PR._
| const [tBuf, sBuf, spBuf] = await Promise.all([ | ||
| cache ? fetchCached(cache, TEXT_EMBEDS_URL) : fetch(TEXT_EMBEDS_URL).then(r => r.arrayBuffer()), | ||
| cache ? fetchCached(cache, TEXT_SCALE_URL) : fetch(TEXT_SCALE_URL).then(r => r.arrayBuffer()), | ||
| cache ? fetchCached(cache, SPECIES_URL) : fetch(SPECIES_URL).then(r => r.arrayBuffer()), | ||
| ]) |
| const headers = new Headers() | ||
| headers.set('Content-Type', contentType) | ||
| // Cache aggressively but revalidate: the filenames are stable/non-versioned, | ||
| // so `immutable` would pin a stale model forever if an asset is ever replaced | ||
| // in R2. A long max-age keeps it fast, while ETag + must-revalidate lets | ||
| // clients pick up a new upload via a cheap 304 once the cached copy is stale. | ||
| headers.set('Cache-Control', 'public, max-age=86400, must-revalidate') | ||
| headers.set('ETag', object.httpEtag) | ||
| headers.set('Accept-Ranges', 'bytes') |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 13 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
src/lib/bioclip-inference.ts:106
- When Cache API is unavailable, the asset fetches (text embeds, scale, species.json) don't check resp.ok, so 404/500 responses get cached/parsed as bytes and later fail with confusing JSON/tensor errors. Mirror fetchCached() behavior by validating responses and throwing a clear error message.
const [tBuf, sBuf, spBuf] = await Promise.all([
cache ? fetchCached(cache, TEXT_EMBEDS_URL) : fetch(TEXT_EMBEDS_URL).then(r => r.arrayBuffer()),
cache ? fetchCached(cache, TEXT_SCALE_URL) : fetch(TEXT_SCALE_URL).then(r => r.arrayBuffer()),
cache ? fetchCached(cache, SPECIES_URL) : fetch(SPECIES_URL).then(r => r.arrayBuffer()),
])
| * - otherwise TIER by range status (present > near-range > out-of-range), | ||
| * keeping model order within each tier. |
| * Worker here with long-lived immutable caching and HTTP range support (so the | ||
| * browser can resume/stream the download). |
| it('rejects oversized candidate lists', async () => { | ||
| const many = Array.from({ length: 51 }, () => ({ commonName: 'Bald Eagle', confidence: 0.5 })) | ||
| const res = await onRequestPost(makeContext({ candidates: many }, bucketWith([]))) | ||
| expect(res.status).toBe(400) | ||
| }) | ||
| }) |
| })), | ||
| ...(rangeAdjusted ? { rangeAdjusted: true } : {}), | ||
| } | ||
| route.debug(`Range-adjusted ${result.candidates.length} on-device candidates${rangeAdjusted ? ' (range-adjusted)' : ''}`, { candidateCount: result.candidates.length, rangeAdjusted }) |
Adaptive model router for bird ID: on-device BioCLIP-2 when available, GPT otherwise. Fully automatic and invisible to the user (no setting): the model prefetches silently on a suitable network and identification transparently becomes on-device once it is cached, falling back to GPT otherwise. Backed by a spike that measured BioCLIP-2 + our range pipeline at 87% top-1 / 96% top-5 vs GPT's 83% / 87% on the 27-image benchmark.
The key design point: NOT a divergent pipeline
Both BioCLIP and GPT emit
{species, confidence}[], and the entire post-processing path is shared (taxonomy grounding → range-prior tiering → confidence gate). The router only swaps which model produces the candidate list. On-device candidates go through the same range logic as GPT via a new/api/range-adjustendpoint — this is what you already do server-side today (decision 1a), just callable with a client-produced candidate list.What's included
Server
functions/api/range-adjust.ts— shared pipeline for on-device candidates: taxonomy grounding + range-prior tiering + dominance gate (trust the visual ID when the top candidate dominates by ≥0.5; otherwise hard-tier by range status: present > near-range > out-of-range). Mirrors the spike's winning strategy. 9 unit tests.functions/models/[[path]].ts— streams model assets from theMODELSR2 bucket with immutable caching + HTTP range support (files exceed CF's 25 MiB static-asset limit, so R2 is required).MODELSR2 binding (prod + preview) +Envtype.Client
src/lib/bioclip-inference.ts— onnxruntime-web (WebGPU, WASM fallback), model loader with download-speed measurement + Cache API persistence, image encoder + int8 text-matmul + softmax,identifyOnDevice()→/api/range-adjust.src/lib/model-router.ts—identifyBirdAdaptive()+ network-gated background prefetch. Policy: model ready/cached → on-device; otherwise GPT now + silently warm the model for next time. Prefetch only runs on a suitable network (not Save-Data/cellular/slow), so we never pull 307 MB on metered data; a full download never blocks an identify.AddPhotosFlowuses the router and warms the model on mount (network-gated). No feature flag, no settings toggle — the routing is automatic.Verification
tsc -b, eslint, andnpm run build(Vite + Worker) all clean.Create + populate the R2 bucket.DONE. Thewingdex-modelsbucket is created and populated (all 4 assets uploaded + size-verified: onnx 306.9 MB, text_embeds_int8 8.6 MB, scale 44 KB, species.json 509 KB). R2 buckets are account-global, so the preview reads the same bucket. On-device ID activates automatically on the preview once the model finishes its background download and caches (network-gated; no toggle).loadModel()reports download progress; surfacing it is follow-up./api/range-adjustfor range refinement (needs network). True offline would ship a regional range table client-side — deferred.spike/bioclip/BROWSER.md). Cold-mobile is a rude first download, so realistic UX is "GPT by default, on-device once cached." A distilled <25 MB bird-only student (BioCLIP-2 is MIT, so open-weight release is possible) would remove the download problem entirely — tracked in R&D: distill a small (<25 MB) bird-only on-device model from BioCLIP-2 #260.Notes
spike/bioclip/, incl. the standalone browser demo and all measurements) lives on thespike/bioclip-birdidbranch.